Files
openchamber/packages/ui/src/lib/runtimeSurface.ts
T

118 lines
4.2 KiB
TypeScript
Raw Normal View History

import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { isCapacitorApp } from '@/lib/platform';
2026-05-25 01:39:47 +03:00
export type HostedSurface = 'desktop' | 'mobile';
declare global {
interface Window {
__OPENCHAMBER_SURFACE__?: HostedSurface;
}
}
const MOBILE_SURFACE_MAX_WIDTH = 768;
const SURFACE_SWITCH_DEBOUNCE_MS = 800;
2026-05-25 01:39:47 +03:00
const isTouchOrCoarsePointer = (): boolean => {
if (typeof window === 'undefined') return false;
const coarsePointer = typeof window.matchMedia === 'function'
? window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches
: false;
const touchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
return coarsePointer || touchPoints > 0;
};
const hasSurfaceUrlOverride = (): boolean => {
if (typeof window === 'undefined') return false;
const override = new URLSearchParams(window.location.search).get('surface');
return override === 'mobile' || override === 'desktop';
};
/** Viewport half of the surface decision; re-evaluated on resize by the watcher. */
const isPhoneViewport = (): boolean => {
if (typeof window === 'undefined') return false;
const width = Math.min(
window.innerWidth || Number.POSITIVE_INFINITY,
window.screen?.width || Number.POSITIVE_INFINITY,
);
return Number.isFinite(width)
&& width <= MOBILE_SURFACE_MAX_WIDTH
&& isTouchOrCoarsePointer();
};
/**
* Single authority for the mobile-vs-desktop surface decision.
*
* Priority: explicit stamp (set once at boot) → URL override → Capacitor
* shell (always the mobile surface) → desktop shells → phone heuristic.
*/
2026-08-13 15:30:54 +03:00
const detectHostedSurface = (): HostedSurface => {
2026-05-25 01:39:47 +03:00
if (typeof window === 'undefined') return 'desktop';
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
if (explicitSurface === 'mobile' || explicitSurface === 'desktop') {
return explicitSurface;
}
const override = new URLSearchParams(window.location.search).get('surface');
if (override === 'mobile' || override === 'desktop') {
return override;
}
if (isCapacitorApp()) return 'mobile';
if (isDesktopShell() || isVSCodeRuntime()) return 'desktop';
2026-05-25 01:39:47 +03:00
return isPhoneViewport() ? 'mobile' : 'desktop';
};
/**
* Decides the surface once and stamps it on `window` so every later
* `isMobileSurfaceRuntime()` call (perf tuning, sync paging, device info)
* reads the same stable answer instead of re-running viewport heuristics.
*/
export const resolveHostedSurface = (): HostedSurface => {
const surface = detectHostedSurface();
if (typeof window !== 'undefined') {
window.__OPENCHAMBER_SURFACE__ = surface;
}
return surface;
2026-05-25 01:39:47 +03:00
};
export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile';
/**
* The surface is stamped once at boot, so a browser window that crosses the
* phone threshold after load would otherwise keep the wrong app shell (the
* app trees, stores, and sync bootstrap differ, so an in-place switch is not
* safe). Watch for the viewport heuristic disagreeing with the stamp and
* reload — the same mechanism a surface change has always used — once the
* resize settles. Fixed shells never switch: Capacitor is always mobile,
* desktop/VS Code shells are always desktop, and an explicit ?surface=
* override wins over the heuristic.
*/
export const watchHostedSurfaceViewport = (): (() => void) => {
if (typeof window === 'undefined') return () => {};
if (isCapacitorApp() || isDesktopShell() || isVSCodeRuntime()) return () => {};
if (hasSurfaceUrlOverride()) return () => {};
let timeout: ReturnType<typeof setTimeout> | null = null;
const handleResize = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
const stamped = window.__OPENCHAMBER_SURFACE__;
const desired: HostedSurface = isPhoneViewport() ? 'mobile' : 'desktop';
if (stamped && stamped !== desired) {
window.__OPENCHAMBER_SURFACE__ = undefined;
window.location.reload();
}
}, SURFACE_SWITCH_DEBOUNCE_MS);
};
window.addEventListener('resize', handleResize);
return () => {
if (timeout) clearTimeout(timeout);
window.removeEventListener('resize', handleResize);
};
};