diff --git a/js/desktop/src/features/compose/compose-overlay.tsx b/js/desktop/src/features/compose/compose-overlay.tsx
index ef5157b..8657b44 100644
--- a/js/desktop/src/features/compose/compose-overlay.tsx
+++ b/js/desktop/src/features/compose/compose-overlay.tsx
@@ -23,6 +23,8 @@ import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { useComposeIntentStore } from "@/stores/compose-intent-store";
+import { platform } from "@/lib/platform";
+import { requireDesktop } from "@/lib/platform/desktop-only";
export type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
@@ -512,6 +514,7 @@ export function ComposeOverlay({
} else if (e.key === "s" || e.key === "S") {
e.preventDefault();
if (!guardIdle()) break;
+ if (!requireDesktop("Screen recording")) break;
setRecordingSource("screen");
setStepSync("picking");
} else if (e.key === "t" || e.key === "T") {
@@ -594,7 +597,7 @@ export function ComposeOverlay({
diff --git a/js/desktop/src/features/compose/use-screen-recorder.ts b/js/desktop/src/features/compose/use-screen-recorder.ts
index 259243f..a512c01 100644
--- a/js/desktop/src/features/compose/use-screen-recorder.ts
+++ b/js/desktop/src/features/compose/use-screen-recorder.ts
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef } from "react";
+import { platform } from "@/lib/platform";
+import { requireDesktop } from "@/lib/platform/desktop-only";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
@@ -75,6 +77,7 @@ export function useScreenRecorder({
const startRecording = useCallback(
async (sourceId: string) => {
+ if (!requireDesktop("Screen recording")) return;
try {
// 1. Screen video
const screenStream = await navigator.mediaDevices.getUserMedia({
@@ -113,7 +116,7 @@ export function useScreenRecorder({
const durationMs = Date.now() - startTimeRef.current;
const blob = new Blob(chunksRef.current, { type: mime });
stopAllTracks();
- window.electronScreen.stopRecordingWindow();
+ platform.screenRecord.cancel();
if (blob.size > 0) {
onFinishRef.current(blob, durationMs, mime);
@@ -123,17 +126,17 @@ export function useScreenRecorder({
recorder.start(1000);
// 4. Show floating control window
- window.electronScreen.startRecordingWindow();
+ platform.screenRecord.start();
// 5. Listen for stop from floating window
- cleanupIpcRef.current = window.electronScreen.onStopRequested(() => {
+ cleanupIpcRef.current = platform.screenRecord.onStopRequested(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
});
} catch (err) {
stopAllTracks();
- window.electronScreen.stopRecordingWindow();
+ platform.screenRecord.cancel();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start screen recording",
);
@@ -157,14 +160,14 @@ export function useScreenRecorder({
}
}
stopAllTracks();
- window.electronScreen.stopRecordingWindow();
+ platform.screenRecord.cancel();
}, [stopAllTracks]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopAllTracks();
- window.electronScreen.stopRecordingWindow();
+ platform.screenRecord.cancel();
};
}, [stopAllTracks]);
diff --git a/js/desktop/src/features/network-billing.tsx b/js/desktop/src/features/network-billing.tsx
index 2e37ed1..7180712 100644
--- a/js/desktop/src/features/network-billing.tsx
+++ b/js/desktop/src/features/network-billing.tsx
@@ -18,6 +18,7 @@ import {
import { useNetworkUsage } from "@/hooks/use-network-usage";
import { useIsNetworkAdmin } from "@/hooks/use-networks";
import type { BillingCadence, BillingStatus } from "@/api/types";
+import { platform } from "@/lib/platform";
function formatCents(cents: number): string {
if (cents % 100 === 0) return `$${cents / 100}`;
@@ -171,7 +172,7 @@ function FreeBilling({
const handleUpgrade = () => {
createCheckout.mutate(cadence, {
- onSuccess: ({ url }) => window.electronLink.openExternal(url),
+ onSuccess: ({ url }) => platform.link.openExternal(url),
});
};
@@ -228,7 +229,7 @@ function ProBilling({
const handleManage = () => {
createPortal.mutate(undefined, {
- onSuccess: ({ url }) => window.electronLink.openExternal(url),
+ onSuccess: ({ url }) => platform.link.openExternal(url),
});
};
diff --git a/js/desktop/src/features/particles/particle-attachments.tsx b/js/desktop/src/features/particles/particle-attachments.tsx
index 2555fc5..bdcee8b 100644
--- a/js/desktop/src/features/particles/particle-attachments.tsx
+++ b/js/desktop/src/features/particles/particle-attachments.tsx
@@ -10,6 +10,7 @@ import {
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
+import { platform } from "@/lib/platform";
type FileParticle = Extract
;
@@ -46,7 +47,7 @@ function openParticle(
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
onPreview(index);
} else if (url) {
- window.electronLink.openExternal(url);
+ platform.link.openExternal(url);
}
}
@@ -65,7 +66,7 @@ function ImageAttachment({
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
- window.electronAttachment.download(url, particle.properties.filename);
+ platform.attachment.download(url, particle.properties.filename);
};
return (
@@ -113,7 +114,7 @@ function FileAttachment({
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
if (!url) return;
- window.electronAttachment.download(url, particle.properties.filename);
+ platform.attachment.download(url, particle.properties.filename);
};
return (
diff --git a/js/desktop/src/features/particles/stream-top-bar.tsx b/js/desktop/src/features/particles/stream-top-bar.tsx
index f028032..5c9cb13 100644
--- a/js/desktop/src/features/particles/stream-top-bar.tsx
+++ b/js/desktop/src/features/particles/stream-top-bar.tsx
@@ -25,6 +25,8 @@ import { WindowControls } from "@/components/window-controls";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useStreamPresence } from "@/features/particles/stream-presence-context";
import { resolveHumanDisplay } from "@/lib/humans";
+import { platform } from "@/lib/platform";
+import { requireDesktop } from "@/lib/platform/desktop-only";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
@@ -71,8 +73,9 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
const hasActiveHuddle = huddleParticipants.length > 0;
const handleJoinHuddle = () => {
+ if (!requireDesktop("Huddle")) return;
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
- window.electronWindow.openHuddle({ token, serverUrl: server_url });
+ platform.huddle.open({ token, serverUrl: server_url });
});
};
diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx
index ad941df..af079ba 100644
--- a/js/desktop/src/features/particles/stream-view.tsx
+++ b/js/desktop/src/features/particles/stream-view.tsx
@@ -30,7 +30,8 @@ import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-s
import { usePlaybackKeys } from "@/hooks/use-playback-keys";
import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys";
import { useStreamActionKeys } from "@/hooks/use-stream-action-keys";
-import { c } from "vite/dist/node/types.d-aGj9QkWt";
+import { platform } from "@/lib/platform";
+import { requireDesktop } from "@/lib/platform/desktop-only";
function getReactions(particle: Particle): Record | undefined {
if (isParticleDeleted(particle)) return undefined;
@@ -150,7 +151,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const navigate = useNavigate();
useMount(() => {
- window.electronAutoplay.dismiss();
+ platform.autoplay.dismiss();
});
const {
@@ -220,8 +221,9 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
});
const handleOpenHuddle = useCallback(() => {
+ if (!requireDesktop("Huddle")) return;
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
- window.electronWindow.openHuddle({ token, serverUrl: server_url });
+ platform.huddle.open({ token, serverUrl: server_url });
});
navigate(`/${networkId}`);
}, [networkId, streamParticle.id, navigate]);
diff --git a/js/desktop/src/features/settings-page.tsx b/js/desktop/src/features/settings-page.tsx
index fb6b9f0..bcacacb 100644
--- a/js/desktop/src/features/settings-page.tsx
+++ b/js/desktop/src/features/settings-page.tsx
@@ -16,6 +16,7 @@ import { logError, toUserMessage } from "@/lib/errors";
import { toast } from "sonner";
import { PRIVACY_URL, SUPPORT_EMAIL, TERMS_URL } from "@/lib/constants";
import { ArrowLeft } from "lucide-react";
+import { platform } from "@/lib/platform";
interface SettingsRowProps {
icon: React.ReactNode;
@@ -79,7 +80,7 @@ export default function SettingsPage() {
const [version, setVersion] = useState();
useEffect(() => {
- window.electronApp.getVersion().then(setVersion);
+ platform.app.getVersion().then(setVersion);
}, []);
const handleToggleEmailNotifications = async (checked: boolean) => {
@@ -190,12 +191,12 @@ export default function SettingsPage() {
}
label="Privacy Policy"
- onClick={() => window.electronLink.openExternal(PRIVACY_URL)}
+ onClick={() => platform.link.openExternal(PRIVACY_URL)}
/>
}
label="Terms of Service"
- onClick={() => window.electronLink.openExternal(TERMS_URL)}
+ onClick={() => platform.link.openExternal(TERMS_URL)}
/>
diff --git a/js/desktop/src/hooks/use-dock-badge.ts b/js/desktop/src/hooks/use-dock-badge.ts
index f09de64..c58948c 100644
--- a/js/desktop/src/hooks/use-dock-badge.ts
+++ b/js/desktop/src/hooks/use-dock-badge.ts
@@ -4,6 +4,7 @@ import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
+import { platform } from "@/lib/platform";
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
@@ -49,7 +50,7 @@ export function useDockBadge(networkId: string | undefined) {
}, [children, userId]);
useEffect(() => {
- window.electronApp.setDockBadge(unseenCount);
- return () => window.electronApp.setDockBadge(0);
+ platform.app.setDockBadge(unseenCount);
+ return () => platform.app.setDockBadge(0);
}, [unseenCount]);
}
diff --git a/js/desktop/src/hooks/use-link-metadata.ts b/js/desktop/src/hooks/use-link-metadata.ts
index 0f66b7b..c76ac8a 100644
--- a/js/desktop/src/hooks/use-link-metadata.ts
+++ b/js/desktop/src/hooks/use-link-metadata.ts
@@ -1,10 +1,11 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
+import { platform } from "@/lib/platform";
export function useLinkMetadata(url: string | null) {
return useQuery({
queryKey: ["link-metadata", url],
- queryFn: () => window.electronLink.fetchMetadata(url!),
+ queryFn: () => platform.link.fetchMetadata(url!),
enabled: !!url,
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
@@ -30,7 +31,7 @@ export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
const results = useQueries({
queries: urls.map((url) => ({
queryKey: ["link-metadata", url],
- queryFn: () => window.electronLink.fetchMetadata(url),
+ queryFn: () => platform.link.fetchMetadata(url),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
diff --git a/js/desktop/src/hooks/use-stream-autoplay.ts b/js/desktop/src/hooks/use-stream-autoplay.ts
index 05abeee..2026242 100644
--- a/js/desktop/src/hooks/use-stream-autoplay.ts
+++ b/js/desktop/src/hooks/use-stream-autoplay.ts
@@ -6,6 +6,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAutoplayStore } from "@/stores/autoplay-store";
import { resolveHumanDisplay } from "@/lib/humans";
import { logError } from "@/lib/errors";
+import { platform } from "@/lib/platform";
/**
* Triggers autoplay when a stream's latest child changes to a new media particle.
@@ -55,7 +56,7 @@ export function useStreamAutoplay(
);
apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
- window.electronAutoplay.play({
+ platform.autoplay.play({
particleId: particle.id,
streamId: streamParticle.id,
networkId,
diff --git a/js/desktop/src/lib/platform.ts b/js/desktop/src/lib/platform.ts
deleted file mode 100644
index 8782f64..0000000
--- a/js/desktop/src/lib/platform.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const isMac = window.electronWindow?.platform === "darwin";
-
-// Symbol to show in keyboard hints for the primary modifier
-// (Cmd on macOS, Ctrl on Windows/Linux).
-export const metaKey = isMac ? "⌘" : "Ctrl";
diff --git a/js/desktop/src/lib/platform/desktop-only.ts b/js/desktop/src/lib/platform/desktop-only.ts
new file mode 100644
index 0000000..d14b3dd
--- /dev/null
+++ b/js/desktop/src/lib/platform/desktop-only.ts
@@ -0,0 +1,18 @@
+import { toast } from "sonner";
+import { platform, DESKTOP_DOWNLOAD_URL } from "@/lib/platform";
+
+/**
+ * Gate desktop-only features (huddle, screen recording). On Electron this
+ * always returns true. On web it shows a toast linking to the desktop app
+ * download and returns false — callers should bail.
+ */
+export function requireDesktop(feature: string): boolean {
+ if (platform.kind === "electron") return true;
+ toast.message(`${feature} is only available in the desktop app`, {
+ action: {
+ label: "Download",
+ onClick: () => window.open(DESKTOP_DOWNLOAD_URL, "_blank", "noopener,noreferrer"),
+ },
+ });
+ return false;
+}
diff --git a/js/desktop/src/lib/platform/electron.ts b/js/desktop/src/lib/platform/electron.ts
new file mode 100644
index 0000000..e6b2cd2
--- /dev/null
+++ b/js/desktop/src/lib/platform/electron.ts
@@ -0,0 +1,57 @@
+import { apiClient } from "@/api/client";
+import type { Platform } from "./types";
+
+export const electronPlatform: Platform = {
+ kind: "electron",
+
+ window: {
+ minimize: () => window.electronWindow.minimize(),
+ maximize: () => window.electronWindow.maximize(),
+ fullscreen: () => window.electronWindow.fullscreen(),
+ close: () => window.electronWindow.close(),
+ onMaximizeChange: (cb) => window.electronWindow.onMaximizeChange(cb),
+ get platform() {
+ const p = window.electronWindow?.platform;
+ return p === "darwin" || p === "win32" || p === "linux" ? p : "linux";
+ },
+ },
+
+ huddle: {
+ isSupported: true,
+ open: (args) => window.electronWindow.openHuddle(args),
+ close: () => window.electronWindow.closeHuddle(),
+ getScreenSources: () => window.electronHuddle.getScreenSources(),
+ },
+
+ screenRecord: {
+ isSupported: true,
+ start: () => window.electronScreen.startRecordingWindow(),
+ stop: () => window.electronScreenRecord.stop(),
+ cancel: () => window.electronScreen.stopRecordingWindow(),
+ onStopRequested: (cb) => window.electronScreen.onStopRequested(cb),
+ getScreenSources: () => window.electronScreen.getScreenSources(),
+ },
+
+ autoplay: {
+ play: (payload) => window.electronAutoplay.play(payload),
+ dismiss: () => window.electronAutoplay.dismiss(),
+ navigate: (d) => window.electronAutoplay.navigate(d),
+ onPlay: (cb) => window.electronAutoplay.onPlay(cb),
+ onStop: (cb) => window.electronAutoplay.onStop(cb),
+ onNavigate: (cb) => window.electronAutoplay.onNavigate(cb),
+ },
+
+ link: {
+ fetchMetadata: (url) => apiClient.getLinkMetadata(url).catch(() => null),
+ openExternal: (url) => window.electronLink.openExternal(url),
+ },
+
+ attachment: {
+ download: (url, filename) => window.electronAttachment.download(url, filename),
+ },
+
+ app: {
+ setDockBadge: (count) => window.electronApp.setDockBadge(count),
+ getVersion: () => window.electronApp.getVersion(),
+ },
+};
diff --git a/js/desktop/src/lib/platform/index.ts b/js/desktop/src/lib/platform/index.ts
new file mode 100644
index 0000000..4b9437a
--- /dev/null
+++ b/js/desktop/src/lib/platform/index.ts
@@ -0,0 +1,8 @@
+import { electronPlatform } from "./electron";
+
+export const platform = electronPlatform;
+export type { Platform, ScreenSource } from "./types";
+export { DESKTOP_DOWNLOAD_URL } from "./types";
+
+export const isMac = platform.window.platform === "darwin";
+export const metaKey = isMac ? "⌘" : "Ctrl";
diff --git a/js/desktop/src/lib/platform/index.web.ts b/js/desktop/src/lib/platform/index.web.ts
new file mode 100644
index 0000000..19c8aec
--- /dev/null
+++ b/js/desktop/src/lib/platform/index.web.ts
@@ -0,0 +1,8 @@
+import { webPlatform } from "./web";
+
+export const platform = webPlatform;
+export type { Platform, ScreenSource } from "./types";
+export { DESKTOP_DOWNLOAD_URL } from "./types";
+
+export const isMac = platform.window.platform === "darwin";
+export const metaKey = isMac ? "⌘" : "Ctrl";
diff --git a/js/desktop/src/lib/platform/types.ts b/js/desktop/src/lib/platform/types.ts
new file mode 100644
index 0000000..52c2b2c
--- /dev/null
+++ b/js/desktop/src/lib/platform/types.ts
@@ -0,0 +1,63 @@
+import type { AutoplayPayload } from "@/lib/autoplay-ipc";
+import type { LinkMetadata } from "@/lib/link-metadata";
+
+export interface ScreenSource {
+ id: string;
+ name: string;
+ thumbnailDataUrl: string;
+ appIconDataUrl: string | null;
+}
+
+export interface Platform {
+ kind: "electron" | "web";
+
+ window: {
+ minimize: () => void;
+ maximize: () => void;
+ fullscreen: () => void;
+ close: () => void;
+ onMaximizeChange: (cb: (m: boolean) => void) => () => void;
+ platform: "darwin" | "win32" | "linux" | "web";
+ };
+
+ huddle: {
+ isSupported: boolean;
+ open: (args: { token: string; serverUrl: string }) => void;
+ close: () => void;
+ getScreenSources: () => Promise;
+ };
+
+ screenRecord: {
+ isSupported: boolean;
+ start: () => void;
+ stop: () => void;
+ cancel: () => void;
+ onStopRequested: (cb: () => void) => () => void;
+ getScreenSources: () => Promise;
+ };
+
+ autoplay: {
+ play: (payload: AutoplayPayload) => void;
+ dismiss: () => void;
+ navigate: (d: { networkId: string; streamId: string }) => void;
+ onPlay: (cb: (payload: AutoplayPayload) => void) => () => void;
+ onStop: (cb: () => void) => () => void;
+ onNavigate: (cb: (d: { networkId: string; streamId: string }) => void) => () => void;
+ };
+
+ link: {
+ fetchMetadata: (url: string) => Promise;
+ openExternal: (url: string) => Promise;
+ };
+
+ attachment: {
+ download: (url: string, filename?: string) => void;
+ };
+
+ app: {
+ setDockBadge: (count: number) => void;
+ getVersion: () => Promise;
+ };
+}
+
+export const DESKTOP_DOWNLOAD_URL = "https://flowylabs.ai/llink/download";
diff --git a/js/desktop/src/lib/platform/web.ts b/js/desktop/src/lib/platform/web.ts
new file mode 100644
index 0000000..71a3347
--- /dev/null
+++ b/js/desktop/src/lib/platform/web.ts
@@ -0,0 +1,107 @@
+import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store";
+import type { AutoplayPayload } from "@/lib/autoplay-ipc";
+import { apiClient } from "@/api/client";
+import type { Platform } from "./types";
+
+declare const __APP_VERSION__: string;
+
+function detectWebPlatform(): "darwin" | "win32" | "linux" | "web" {
+ if (typeof navigator === "undefined") return "web";
+ const ua = navigator.userAgent;
+ if (/Mac|iPhone|iPad|iPod/i.test(ua)) return "darwin";
+ if (/Win/i.test(ua)) return "win32";
+ if (/Linux|X11/i.test(ua)) return "linux";
+ return "web";
+}
+
+function downloadCrossOrigin(url: string, filename?: string) {
+ const a = document.createElement("a");
+ a.href = url;
+ if (filename) a.download = filename;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+}
+
+const baseTitle = typeof document !== "undefined" ? document.title : "llink";
+
+function applyDockBadge(count: number) {
+ if (typeof document === "undefined") return;
+ document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
+}
+
+const NOT_SUPPORTED = "Not supported in the web app";
+
+export const webPlatform: Platform = {
+ kind: "web",
+
+ window: {
+ minimize: () => {},
+ maximize: () => {},
+ fullscreen: () => {},
+ close: () => {},
+ onMaximizeChange: () => () => {},
+ platform: detectWebPlatform(),
+ },
+
+ huddle: {
+ isSupported: false,
+ open: () => { throw new Error(NOT_SUPPORTED); },
+ close: () => {},
+ getScreenSources: async () => { throw new Error(NOT_SUPPORTED); },
+ },
+
+ screenRecord: {
+ isSupported: false,
+ start: () => { throw new Error(NOT_SUPPORTED); },
+ stop: () => {},
+ cancel: () => {},
+ onStopRequested: () => () => {},
+ getScreenSources: async () => { throw new Error(NOT_SUPPORTED); },
+ },
+
+ autoplay: {
+ play: (payload: AutoplayPayload) => {
+ useAutoplayPayloadStore.getState().setPayload(payload);
+ },
+ dismiss: () => {
+ useAutoplayPayloadStore.getState().setPayload(null);
+ },
+ navigate: (d) => {
+ useAutoplayPayloadStore.getState().setPayload(null);
+ useAutoplayPayloadStore.getState().setPendingNav(d);
+ },
+ onPlay: (cb) =>
+ useAutoplayPayloadStore.subscribe((state, prev) => {
+ if (state.payload && state.payload !== prev.payload) cb(state.payload);
+ }),
+ onStop: (cb) =>
+ useAutoplayPayloadStore.subscribe((state, prev) => {
+ if (!state.payload && prev.payload) cb();
+ }),
+ onNavigate: (cb) =>
+ useAutoplayPayloadStore.subscribe((state, prev) => {
+ if (state.pendingNav && state.pendingNav !== prev.pendingNav) {
+ cb({ networkId: state.pendingNav.networkId, streamId: state.pendingNav.streamId });
+ }
+ }),
+ },
+
+ link: {
+ fetchMetadata: (url) => apiClient.getLinkMetadata(url).catch(() => null),
+ openExternal: async (url) => {
+ window.open(url, "_blank", "noopener,noreferrer");
+ },
+ },
+
+ attachment: {
+ download: downloadCrossOrigin,
+ },
+
+ app: {
+ setDockBadge: applyDockBadge,
+ getVersion: async () => __APP_VERSION__,
+ },
+};
diff --git a/js/desktop/src/lib/router-shell.tsx b/js/desktop/src/lib/router-shell.tsx
new file mode 100644
index 0000000..c77a8d4
--- /dev/null
+++ b/js/desktop/src/lib/router-shell.tsx
@@ -0,0 +1,6 @@
+import type { PropsWithChildren } from "react";
+import { HashRouter } from "react-router-dom";
+
+export function RouterShell({ children }: PropsWithChildren) {
+ return {children};
+}
diff --git a/js/desktop/src/lib/router-shell.web.tsx b/js/desktop/src/lib/router-shell.web.tsx
new file mode 100644
index 0000000..00a060d
--- /dev/null
+++ b/js/desktop/src/lib/router-shell.web.tsx
@@ -0,0 +1,6 @@
+import type { PropsWithChildren } from "react";
+import { BrowserRouter } from "react-router-dom";
+
+export function RouterShell({ children }: PropsWithChildren) {
+ return {children};
+}
diff --git a/js/desktop/src/lib/sentry.web.ts b/js/desktop/src/lib/sentry.web.ts
new file mode 100644
index 0000000..3adf7c5
--- /dev/null
+++ b/js/desktop/src/lib/sentry.web.ts
@@ -0,0 +1,25 @@
+import * as Sentry from "@sentry/react";
+import { appConfig, appEnv } from "@/config/env";
+import { installErrorSinks } from "@/lib/errors";
+
+export function initSentryRenderer(): void {
+ if (!appConfig.sentryDsn) return;
+
+ Sentry.init({
+ dsn: appConfig.sentryDsn,
+ environment: appEnv,
+ tracesSampleRate: 0,
+ });
+
+ installErrorSinks({
+ capture: (err, context) =>
+ Sentry.captureException(err, { extra: context }),
+ breadcrumb: (err, context) =>
+ Sentry.addBreadcrumb({
+ category: "error",
+ level: "error",
+ message: err instanceof Error ? err.message : String(err),
+ data: context,
+ }),
+ });
+}
diff --git a/js/desktop/src/main.ts b/js/desktop/src/main.ts
index a8784a9..8f0f5e3 100644
--- a/js/desktop/src/main.ts
+++ b/js/desktop/src/main.ts
@@ -3,7 +3,6 @@ import path from 'node:path';
import started from 'electron-squirrel-startup';
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
-import type { LinkMetadata } from './lib/link-metadata';
import { appConfig } from './config/env';
import { logError } from './lib/errors';
import { safeHandle } from './main/ipc-utils';
@@ -370,106 +369,6 @@ ipcMain.on('autoplay:navigate', (_event, data) => {
}
});
-// --- Link metadata ---
-
-const metadataCache = new Map();
-
-function getMetaContent(html: string, property: string): string | null {
- // Match both property="..." and name="..." attributes
- const regex = new RegExp(
- `]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`,
- 'i',
- );
- const match = html.match(regex);
- return match?.[1] ?? match?.[2] ?? null;
-}
-
-function getTitle(html: string): string | null {
- const match = html.match(/]*>([^<]*)<\/title>/i);
- return match?.[1]?.trim() ?? null;
-}
-
-function getFavicon(html: string, baseUrl: string): string | null {
- const match = html.match(/]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']/i)
- ?? html.match(/]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']/i);
- if (!match?.[1]) {
- // Fall back to /favicon.ico
- try {
- const url = new URL(baseUrl);
- return `${url.protocol}//${url.host}/favicon.ico`;
- } catch {
- return null;
- }
- }
- try {
- return new URL(match[1], baseUrl).href;
- } catch {
- return match[1];
- }
-}
-
-function resolveUrl(src: string | null, baseUrl: string): string | null {
- if (!src) return null;
- try {
- return new URL(src, baseUrl).href;
- } catch {
- return src;
- }
-}
-
-async function fetchLinkMetadata(url: string): Promise {
- const cached = metadataCache.get(url);
- if (cached) return cached;
-
- try {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 5000);
-
- const response = await fetch(url, {
- signal: controller.signal,
- headers: {
- 'User-Agent': 'Mozilla/5.0 (compatible; llink/1.0)',
- 'Accept': 'text/html',
- },
- redirect: 'follow',
- });
- clearTimeout(timeout);
-
- if (!response.ok) return null;
-
- // Only read the first ~50KB to get content
- const reader = response.body?.getReader();
- if (!reader) return null;
-
- let html = '';
- const decoder = new TextDecoder();
- while (html.length < 50_000) {
- const { done, value } = await reader.read();
- if (done) break;
- html += decoder.decode(value, { stream: true });
- }
- reader.cancel();
-
- const domain = new URL(url).hostname.replace(/^www\./, '');
- const metadata: LinkMetadata = {
- url,
- title: getMetaContent(html, 'og:title') ?? getTitle(html),
- description: getMetaContent(html, 'og:description') ?? getMetaContent(html, 'description'),
- image: resolveUrl(getMetaContent(html, 'og:image'), url),
- favicon: getFavicon(html, url),
- domain,
- };
-
- metadataCache.set(url, metadata);
- return metadata;
- } catch (err) {
- // Metadata is a progressive enhancement — keep the null contract, but log
- // so upstream failures (DNS, TLS, aborted fetches) aren't invisible.
- logError(err, { scope: 'link.fetchMetadata', url });
- return null;
- }
-}
-
// --- Dock badge ---
ipcMain.on('app:set-dock-badge', (_event, count: number) => {
@@ -480,16 +379,6 @@ ipcMain.on('app:set-dock-badge', (_event, count: number) => {
safeHandle('app:get-version', () => app.getVersion());
-safeHandle('link:fetch-metadata', async (_event, url) => {
- if (typeof url !== 'string') return null;
- try {
- new URL(url);
- } catch {
- return null;
- }
- return fetchLinkMetadata(url);
-});
-
safeHandle('link:open-external', async (_event, url) => {
if (typeof url !== 'string') return;
// Only allow http(s) URLs for security
diff --git a/js/desktop/src/preload.ts b/js/desktop/src/preload.ts
index 5274f02..8f18db1 100644
--- a/js/desktop/src/preload.ts
+++ b/js/desktop/src/preload.ts
@@ -63,7 +63,6 @@ contextBridge.exposeInMainWorld('electronScreenRecord', {
});
contextBridge.exposeInMainWorld('electronLink', {
- fetchMetadata: (url: string) => ipcRenderer.invoke('link:fetch-metadata', url),
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
});
diff --git a/js/desktop/src/stores/autoplay-payload-store.ts b/js/desktop/src/stores/autoplay-payload-store.ts
new file mode 100644
index 0000000..13943ef
--- /dev/null
+++ b/js/desktop/src/stores/autoplay-payload-store.ts
@@ -0,0 +1,17 @@
+import { create } from "zustand";
+import type { AutoplayPayload } from "@/lib/autoplay-ipc";
+
+interface AutoplayPayloadState {
+ payload: AutoplayPayload | null;
+ pendingNav: { networkId: string; streamId: string; nonce: number } | null;
+ setPayload: (payload: AutoplayPayload | null) => void;
+ setPendingNav: (d: { networkId: string; streamId: string } | null) => void;
+}
+
+export const useAutoplayPayloadStore = create((set) => ({
+ payload: null,
+ pendingNav: null,
+ setPayload: (payload) => set({ payload }),
+ setPendingNav: (d) =>
+ set({ pendingNav: d ? { ...d, nonce: Date.now() } : null }),
+}));
diff --git a/js/desktop/src/web/index.html b/js/desktop/src/web/index.html
new file mode 100644
index 0000000..a1819a4
--- /dev/null
+++ b/js/desktop/src/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ llink
+
+
+
+
+
+
diff --git a/js/desktop/src/web/renderer.tsx b/js/desktop/src/web/renderer.tsx
new file mode 100644
index 0000000..c875e2e
--- /dev/null
+++ b/js/desktop/src/web/renderer.tsx
@@ -0,0 +1,9 @@
+import { createRoot } from "react-dom/client";
+import App from "@/App";
+import { initSentryRenderer } from "@/lib/sentry";
+import "@/styles/globals.css";
+
+initSentryRenderer();
+
+const root = createRoot(document.getElementById("root")!);
+root.render();
diff --git a/js/desktop/vite.web.config.mts b/js/desktop/vite.web.config.mts
new file mode 100644
index 0000000..07870d1
--- /dev/null
+++ b/js/desktop/vite.web.config.mts
@@ -0,0 +1,40 @@
+import path from "path";
+import { createRequire } from "module";
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { defineEnv } from "./vite.env";
+
+const require = createRequire(import.meta.url);
+const pkg = require("./package.json") as { version: string };
+
+// Web build target for the browser. Aliases swap the platform adapter, the
+// router shell, and Sentry over to web variants. Electron build is untouched
+// — see vite.renderer.config.mts.
+export default defineConfig({
+ root: path.resolve(__dirname, "src/web"),
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ // Exact-match regexes for the file-level swaps — `@/lib/platform` points at
+ // a file (`index.web.ts`), so a string-prefix alias would also swallow
+ // deeper paths like `@/lib/platform/desktop-only`. The generic `@` alias
+ // handles everything else.
+ alias: [
+ { find: /^@\/lib\/platform$/, replacement: path.resolve(__dirname, "./src/lib/platform/index.web.ts") },
+ { find: /^@\/lib\/sentry$/, replacement: path.resolve(__dirname, "./src/lib/sentry.web.ts") },
+ { find: /^@\/lib\/router-shell$/, replacement: path.resolve(__dirname, "./src/lib/router-shell.web.tsx") },
+ { find: "@", replacement: path.resolve(__dirname, "./src") },
+ ],
+ },
+ define: {
+ ...defineEnv,
+ __APP_VERSION__: JSON.stringify(pkg.version),
+ },
+ server: {
+ port: 5174,
+ },
+ build: {
+ outDir: path.resolve(__dirname, "dist-web"),
+ emptyOutDir: true,
+ },
+});
diff --git a/js/desktop/yarn.lock b/js/desktop/yarn.lock
index f67f16a..3eafafa 100644
--- a/js/desktop/yarn.lock
+++ b/js/desktop/yarn.lock
@@ -734,6 +734,11 @@
dependencies:
tslib "^2.4.0"
+"@epic-web/invariant@^1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813"
+ integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==
+
"@esbuild/aix-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
@@ -2970,6 +2975,13 @@
dependencies:
"@sentry/core" "10.47.0"
+"@sentry-internal/browser-utils@10.54.0":
+ version "10.54.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.54.0.tgz#0aee09715307e271d9387cc1ddd43b9fed3e992a"
+ integrity sha512-Cz6NzYFmWJlHh1tvtltKsmLl+1jlseQaPXk18Z0P1g6lXAwhT3aJ99x7vDm4jwCzcJ12qAa8Oga8T3C23Ihijw==
+ dependencies:
+ "@sentry/core" "10.54.0"
+
"@sentry-internal/feedback@10.47.0":
version "10.47.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.47.0.tgz#2a847b821f60802c4ed3d0da980ef57f593afb26"
@@ -2977,6 +2989,13 @@
dependencies:
"@sentry/core" "10.47.0"
+"@sentry-internal/feedback@10.54.0":
+ version "10.54.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.54.0.tgz#dddf105bbe5396748e0bd6fc2b2a954dd5e8fa6f"
+ integrity sha512-14D+TPgi75zogGQ/EWwtIm34FVWP34gso4SfJZRAoHiQrRfd907q8/7MTXNItxi81x79cH9vweu/o55LBml6MA==
+ dependencies:
+ "@sentry/core" "10.54.0"
+
"@sentry-internal/replay-canvas@10.47.0":
version "10.47.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.47.0.tgz#157256195f71592cd462fe5a57e71ddbd66e9cff"
@@ -2985,6 +3004,14 @@
"@sentry-internal/replay" "10.47.0"
"@sentry/core" "10.47.0"
+"@sentry-internal/replay-canvas@10.54.0":
+ version "10.54.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.54.0.tgz#612f668b9de55102a223c693520b3572aa8fa39a"
+ integrity sha512-CGsH019npxnU5cocVDoZKod7JaQtaM6JiR6e2fI8tDwssohJAxP616UQTmoTtBLe3yLG18P4e1BxMxYZFalZEQ==
+ dependencies:
+ "@sentry-internal/replay" "10.54.0"
+ "@sentry/core" "10.54.0"
+
"@sentry-internal/replay@10.47.0":
version "10.47.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.47.0.tgz#33bb78457ee9731056d2b5a4805328e922b28886"
@@ -2993,6 +3020,14 @@
"@sentry-internal/browser-utils" "10.47.0"
"@sentry/core" "10.47.0"
+"@sentry-internal/replay@10.54.0":
+ version "10.54.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.54.0.tgz#5519d6d60f1d315d7b6e5ac1b649210ab6a182ff"
+ integrity sha512-B7eicNhAomJ7bGihJO7mCw7pZ8FFo/THQgGPo85VR3FaJVCCot20WxVgvhjc7IVBQVlaaxSrnlUFvA+yHjszqQ==
+ dependencies:
+ "@sentry-internal/browser-utils" "10.54.0"
+ "@sentry/core" "10.54.0"
+
"@sentry/browser@10.47.0":
version "10.47.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.47.0.tgz#286f5051ca82706c03e7a499b9464453225f3648"
@@ -3004,11 +3039,27 @@
"@sentry-internal/replay-canvas" "10.47.0"
"@sentry/core" "10.47.0"
+"@sentry/browser@10.54.0":
+ version "10.54.0"
+ resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.54.0.tgz#9d8f32912cd3eb7ccf2825785f62bdeaabd745bf"
+ integrity sha512-XYuAA2E4Hf6NOJiP3PqczPgBhFUEsEAh+avgxcYTjTwYdr+Nh5XmDxXATr6RxXUvRASTiYN9zNWyK2o9kEDloA==
+ dependencies:
+ "@sentry-internal/browser-utils" "10.54.0"
+ "@sentry-internal/feedback" "10.54.0"
+ "@sentry-internal/replay" "10.54.0"
+ "@sentry-internal/replay-canvas" "10.54.0"
+ "@sentry/core" "10.54.0"
+
"@sentry/core@10.47.0":
version "10.47.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.47.0.tgz#175d1865f0d762ebe7be3b2a6ec3ece4e5a76a5a"
integrity sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==
+"@sentry/core@10.54.0":
+ version "10.54.0"
+ resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.54.0.tgz#b716c0cd7005ec94abf8e3a7d29fa87109038d93"
+ integrity sha512-yC/bc8N5ut6vk9X/ugTnIFAbzaSZ2uGoKiHRGzt7VseDIrjXk5ENDJP0m7Rbchuozr41kBv2QB3mPcHUhfB43w==
+
"@sentry/electron@^7.11.0":
version "7.11.0"
resolved "https://registry.yarnpkg.com/@sentry/electron/-/electron-7.11.0.tgz#39a21578d3a92524748ed7b0574dde0aad65b2dc"
@@ -3075,6 +3126,14 @@
dependencies:
"@sentry/core" "10.47.0"
+"@sentry/react@^10.54.0":
+ version "10.54.0"
+ resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.54.0.tgz#2f8dd953882aa9dcc2dfe623812269a83483ad9e"
+ integrity sha512-P9x2oJwm0LpJC3HUFfvFMcMZt3qW+PFznDk0hl+QI3BO/In07IvzpdQ/nWO81SHt0uwglwGs3bAjnN84YVzXIw==
+ dependencies:
+ "@sentry/browser" "10.54.0"
+ "@sentry/core" "10.54.0"
+
"@sindresorhus/is@^4.0.0":
version "4.6.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
@@ -4616,6 +4675,14 @@ cross-dirname@^0.1.0:
resolved "https://registry.yarnpkg.com/cross-dirname/-/cross-dirname-0.1.0.tgz#b899599f30a5389f59e78c150e19f957ad16a37c"
integrity sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==
+cross-env@^10.1.0:
+ version "10.1.0"
+ resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783"
+ integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==
+ dependencies:
+ "@epic-web/invariant" "^1.0.0"
+ cross-spawn "^7.0.6"
+
cross-spawn@^6.0.0:
version "6.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57"