Files
cinny/src/app/pages/App.tsx
T
Lotus CIandClaude Opus 5.5 7d7a379ce0
CI / Build & Quality Checks (pull_request) Successful in 1m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m24s
feat(desktop): call page on its own loopback origin, opt-in (#43)
The desktop app loads the bundled Element Call page from its own origin
(http://localhost:<port>), so the call frame can read the app's storage
(login token) and DOM — the hole #43 closed on the web by moving the page
to call.chat.lotusguild.org.

The desktop's local server can also answer on http://127.0.0.1:<port>: the
same server and bundle, but a different origin (and still a secure
context). resolveDesktopCallPageUrl loads the bundled page from there when
the desktop config sets `desktopCallOrigin`:
- only a loopback http origin on the SAME port as the app, no path, query
  or credentials;
- only when the app itself runs on http://localhost (release builds; debug
  builds on tauri:// keep the same-origin page);
- unset (every desktop build until cinny-desktop opts in, together with the
  server bind, CSP and permission changes it needs): unchanged.

The web app is unchanged (elementCallUrl as before).

Tested in a simulated desktop app (Tauri bridge stub + the desktop
config.json, served on localhost and 127.0.0.1) against a local Synapse +
LiveKit, two users: call page from http://127.0.0.1:<port>, parentUrl =
the app origin; the frame gets SecurityError on parent.localStorage and
parent.document (same-origin control: readable); join, speaking indicator,
mic off/on, screenshare start/stop, layout switch and hang-up all work, no
page errors — 12/12 in 5 of 6 runs, like the same-origin control (3 of 4;
the misses on both sides were the local LiveKit connection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:09:27 -04:00

268 lines
11 KiB
TypeScript

import React, { ReactNode, useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import {
Box,
Button,
config,
OverlayContainerProvider,
PopOutContainerProvider,
Text,
toRem,
TooltipContainerProvider,
} from 'folds';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ClientConfigLoader } from '../components/ClientConfigLoader';
import { ClientConfigProvider } from '../hooks/useClientConfig';
import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useSessionAppeared } from '../hooks/useSessionAppeared';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
import { isTauri } from '../hooks/useTauri';
import { TitleBar } from '../features/desktop/TitleBar';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
import {
resolveCallPageUrl,
resolveDesktopCallPageUrl,
setCallPageUrl,
} from '../plugins/call/callPageUrl';
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
// chosen font's stack silently drops them.
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
const FONT_MAP: Record<string, string> = {
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`,
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
};
function AppearanceEffects() {
const settings = useAtomValue(settingsAtom);
useEffect(() => {
const color = settings.mentionHighlightColor;
if (color) {
document.body.style.setProperty('--mention-highlight-bg', color);
// WCAG 2.1 relative luminance with gamma linearization
const toLinear = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const lum = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
document.body.style.setProperty('--mention-highlight-text', lum > 0.179 ? '#000' : '#fff');
// Derive a visible border: same hue, reduced alpha
document.body.style.setProperty('--mention-highlight-border', `rgba(${r},${g},${b},0.5)`);
} else {
document.body.style.removeProperty('--mention-highlight-bg');
document.body.style.removeProperty('--mention-highlight-text');
document.body.style.removeProperty('--mention-highlight-border');
}
}, [settings.mentionHighlightColor]);
useEffect(() => {
// Custom accent color applies only to non-TDS themes. When Lotus Terminal
// (TDS) is active it has its own fixed palette, so we remove any overrides.
const accent = settings.customAccentColor;
if (accent && !settings.lotusTerminal && applyCustomAccent(accent)) {
return () => removeCustomAccent();
}
removeCustomAccent();
return undefined;
}, [settings.customAccentColor, settings.lotusTerminal]);
useEffect(() => {
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
document.body.style.setProperty('--font-secondary', font);
}, [settings.fontFamily]);
return null;
}
function TauriEffects() {
useTauriNotificationBadge();
return null;
}
// P5-47 — opt-in TDS window chrome. `useTauriWindowChrome` keeps the native OS
// window decorations in sync with the setting; when a desktop user enables
// custom chrome we replace the OS titlebar with <TitleBar/>. When off (the
// default, and always in the browser) this returns children unchanged, so there
// is zero layout impact for everyone else.
function DesktopChrome({ children }: { children: ReactNode }) {
const customChrome = useAtomValue(customWindowChromeAtom);
useTauriWindowChrome();
const useChrome = isTauri() && customChrome;
// Keep the wrapper element structure STABLE across the toggle so flipping the
// setting never changes the element type in `children`'s ancestry — otherwise
// React would unmount/remount the whole RouterProvider subtree (losing scroll,
// menus, unsaved composer state). When off, both wrappers use `display:contents`
// so they generate no box → zero layout impact (also the browser default path).
return (
<div
style={
useChrome
? // Match html/#root (100dvh), NOT 100vh — in the Tauri webview 100vh
// can exceed the visible height after decorations are stripped, which
// makes the timeline's scroll container taller than the viewport and
// sends the virtual paginator into a runaway back-pagination loop.
{ display: 'flex', flexDirection: 'column', height: '100dvh' }
: { display: 'contents' }
}
>
{useChrome && <TitleBar />}
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
{children}
</div>
</div>
);
}
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
const scheduled = settings.nightLightEnabled && settings.nightLightSchedule;
// When scheduled, re-evaluate the active window every minute so the overlay
// turns on/off automatically at the start/end times without a reload.
const [, forceTick] = useState(0);
useEffect(() => {
if (!scheduled) return undefined;
const id = setInterval(() => forceTick((n) => n + 1), 60_000);
return () => clearInterval(id);
}, [scheduled]);
if (!settings.nightLightEnabled) return null;
if (
settings.nightLightSchedule &&
!isWithinTimeWindow(settings.nightLightStart, settings.nightLightEnd)
) {
return null;
}
return (
<div
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: zIndices.nightLight,
backgroundColor: `rgba(255, 140, 0, ${(settings.nightLightOpacity ?? 30) / 100})`,
}}
/>
);
}
const queryClient = new QueryClient();
function App() {
const screenSize = useScreenSize();
useCompositionEndTracking();
// #69 — cross-tab session sync (useSessionSync) only mounts inside the
// authenticated ClientRoot shell, so a tab still on the unauthenticated
// routes (login/register/OIDC callback) never learns that another tab just
// signed in. This lighter watcher covers that gap by reloading once a
// session appears where there was none.
useSessionAppeared();
const portalContainer = document.getElementById('portalContainer') ?? undefined;
// OIDC/next-gen-auth callback is a real (non-hash) path: OAuth redirect_uris
// can't contain a fragment, so it must be handled OUTSIDE the router. Render
// the standalone callback page before the RouterProvider mounts. It needs no
// app providers (it only touches the SDK + localStorage).
if (window.location.pathname.endsWith(OIDC_CALLBACK_PATH)) {
return <OidcCallback />;
}
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
style={{ height: '100vh', padding: config.space.S700, textAlign: 'center' }}
>
<Text size="H2">Something went wrong</Text>
<Text size="T300" priority="300" style={{ maxWidth: toRem(400) }}>
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
</Text>
<Button variant="Primary" onClick={resetErrorBoundary}>
<Text as="span" size="B400">
Try again
</Text>
</Button>
</Box>
)}
>
<TooltipContainerProvider value={portalContainer}>
<PopOutContainerProvider value={portalContainer}>
<OverlayContainerProvider value={portalContainer}>
<ScreenSizeProvider value={screenSize}>
<FeatureCheck>
<ClientConfigLoader
fallback={() => <ConfigConfigLoading />}
error={(err, retry, ignore) => (
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
)}
>
{(clientConfig) => {
// [Gitea #43] Idempotent: where the call page is loaded from.
setCallPageUrl(
isTauri()
? resolveDesktopCallPageUrl(
clientConfig.desktopCallOrigin,
window.location.origin,
import.meta.env.BASE_URL,
)
: resolveCallPageUrl(clientConfig.elementCallUrl, false),
);
return (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<AppearanceEffects />
<TauriEffects />
<DesktopChrome>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</DesktopChrome>
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ClientConfigProvider>
);
}}
</ClientConfigLoader>
</FeatureCheck>
</ScreenSizeProvider>
</OverlayContainerProvider>
</PopOutContainerProvider>
</TooltipContainerProvider>
</ErrorBoundary>
);
}
export default App;