wip: plumbing for building a web app

This commit is contained in:
Arjun Patel
2026-05-26 14:37:28 -07:00
parent 1e992abf41
commit 578ebdbd1e
39 changed files with 677 additions and 236 deletions
+6 -1
View File
@@ -14,7 +14,10 @@
"publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64",
"invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES",
"lint": "eslint --ext .ts,.tsx .",
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'"
"compile": "npx tsc --noEmit 2>&1 | grep '^src/'",
"web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts",
"web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts",
"web:preview": "vite preview --config vite.web.config.mts"
},
"keywords": [],
"author": {
@@ -43,6 +46,7 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^5.1.4",
"cross-env": "^10.1.0",
"electron": "40.6.0",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.32.0",
@@ -54,6 +58,7 @@
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@sentry/electron": "^7.11.0",
"@sentry/react": "^10.54.0",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+8 -4
View File
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route, useNavigate } from "react-router-dom";
import { Routes, Route, useNavigate } from "react-router-dom";
import { RouterShell } from "@/lib/router-shell";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
@@ -19,6 +20,8 @@ import {
TopLevelErrorBoundary,
} from "@/components/app-error-boundary";
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider";
import { platform } from "@/lib/platform";
import { InAppAutoplayCard } from "@/components/in-app-autoplay-card";
const queryClient = createQueryClient();
@@ -53,7 +56,7 @@ function AutoplayNavigationListener() {
const navigate = useNavigate();
useEffect(() => {
return window.electronAutoplay.onNavigate((data) => {
return platform.autoplay.onNavigate((data) => {
navigate(`/${data.networkId}/${data.streamId}`);
});
}, [navigate]);
@@ -63,8 +66,9 @@ function AutoplayNavigationListener() {
function AuthenticatedApp() {
return (
<HashRouter>
<RouterShell>
<AutoplayNavigationListener />
<InAppAutoplayCard />
<RouteErrorBoundary>
<Routes>
<Route path="settings" element={<SettingsPage />} />
@@ -80,7 +84,7 @@ function AuthenticatedApp() {
</Route>
</Routes>
</RouteErrorBoundary>
</HashRouter>
</RouterShell>
);
}
+11
View File
@@ -27,6 +27,7 @@ import type {
RevokeInvitationRequest,
SignInRequest,
} from "./types";
import type { LinkMetadata } from "@/lib/link-metadata";
interface ApiClientConfig {
baseUrl: string;
@@ -256,6 +257,16 @@ class ApiClient {
`/networks/${networkId}/usage`,
);
}
// --- Link metadata ---
async getLinkMetadata(url: string): Promise<LinkMetadata | null> {
const response = await this.fetch(
"GET",
`/metadata?url=${encodeURIComponent(url)}`,
);
return (await response.json()) as LinkMetadata | null;
}
}
export const apiClient = new ApiClient({
+20 -79
View File
@@ -1,102 +1,43 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import type { AutoplayPayload } from '@/lib/autoplay-ipc';
import { useCallback, useEffect, useState } from "react";
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
import { AutoplayCardContent } from "@/components/autoplay-card-content";
export function AutoplayApp() {
const [payload, setPayload] = useState<AutoplayPayload | null>(null);
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
useEffect(() => {
return window.electronAutoplay.onPlay((p) => setPayload(p));
}, []);
useEffect(() => {
return window.electronAutoplay.onStop(() => {
mediaRef.current?.pause();
setPayload(null);
});
return window.electronAutoplay.onStop(() => setPayload(null));
}, []);
const stop = useCallback(() => {
mediaRef.current?.pause();
const handleDismiss = useCallback(() => {
setPayload(null);
window.electronAutoplay.dismiss();
}, []);
const handleNavigate = useCallback(() => {
if (!payload) return;
window.electronAutoplay.navigate({
networkId: payload.networkId,
streamId: payload.streamId,
});
}, [payload]);
if (!payload) {
return <div className="h-screen w-screen" />;
}
const isVideo = payload.mimeType.startsWith('video/');
const handleClick = () => {
mediaRef.current?.pause();
window.electronAutoplay.navigate({
networkId: payload.networkId,
streamId: payload.streamId,
});
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
stop();
};
return (
<div
className="relative h-screen w-screen cursor-pointer overflow-hidden bg-black"
onClick={handleClick}
>
{isVideo ? (
<>
<video
ref={mediaRef as React.Ref<HTMLVideoElement>}
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
playsInline
onEnded={stop}
className="block h-full w-full object-cover"
/>
<button
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
onClick={handleClose}
>
<X className="size-3.5" />
</button>
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 py-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
{payload.senderInitials}
</div>
<p className="truncate text-xs text-white/80">{payload.senderName}</p>
</div>
</>
) : (
<>
<button
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
onClick={handleClose}
>
<X className="size-3.5" />
</button>
<div className="flex h-full w-full items-center gap-2 bg-card px-3 py-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{payload.senderInitials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-card-foreground">{payload.senderName}</p>
<p className="text-xs text-muted-foreground">Playing audio...</p>
</div>
</div>
<audio
ref={mediaRef as React.Ref<HTMLAudioElement>}
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
onEnded={stop}
/>
</>
)}
<div className="h-screen w-screen">
<AutoplayCardContent
payload={payload}
onDismiss={handleDismiss}
onNavigate={handleNavigate}
onEnded={handleDismiss}
/>
</div>
);
}
@@ -0,0 +1,90 @@
import { useRef } from "react";
import { X } from "lucide-react";
import type { AutoplayPayload } from "@/lib/autoplay-ipc";
interface AutoplayCardContentProps {
payload: AutoplayPayload;
onDismiss: () => void;
onNavigate: () => void;
onEnded: () => void;
}
export function AutoplayCardContent({
payload,
onDismiss,
onNavigate,
onEnded,
}: AutoplayCardContentProps) {
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
const isVideo = payload.mimeType.startsWith("video/");
const handleClick = () => {
mediaRef.current?.pause();
onNavigate();
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
mediaRef.current?.pause();
onDismiss();
};
return (
<div
className="relative h-full w-full cursor-pointer overflow-hidden bg-black"
onClick={handleClick}
>
{isVideo ? (
<>
<video
ref={mediaRef as React.Ref<HTMLVideoElement>}
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
playsInline
onEnded={onEnded}
className="block h-full w-full object-cover"
/>
<button
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
onClick={handleClose}
>
<X className="size-3.5" />
</button>
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 py-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white">
{payload.senderInitials}
</div>
<p className="truncate text-xs text-white/80">{payload.senderName}</p>
</div>
</>
) : (
<>
<button
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
onClick={handleClose}
>
<X className="size-3.5" />
</button>
<div className="flex h-full w-full items-center gap-2 bg-card px-3 py-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{payload.senderInitials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-card-foreground">{payload.senderName}</p>
<p className="text-xs text-muted-foreground">Playing audio...</p>
</div>
</div>
<audio
ref={mediaRef as React.Ref<HTMLAudioElement>}
key={payload.particleId}
src={payload.downloadUrl}
autoPlay
onEnded={onEnded}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import { useCallback } from "react";
import { platform } from "@/lib/platform";
import { useAutoplayPayloadStore } from "@/stores/autoplay-payload-store";
import { AutoplayCardContent } from "@/components/autoplay-card-content";
/**
* Bottom-right floating autoplay card used on the web client. The desktop
* client uses a separate BrowserWindow (`src/autoplay_window/`), so this
* component renders nothing on Electron.
*/
export function InAppAutoplayCard() {
const payload = useAutoplayPayloadStore((s) => s.payload);
const setPayload = useAutoplayPayloadStore((s) => s.setPayload);
const setPendingNav = useAutoplayPayloadStore((s) => s.setPendingNav);
const handleDismiss = useCallback(() => setPayload(null), [setPayload]);
const handleNavigate = useCallback(() => {
if (!payload) return;
setPayload(null);
setPendingNav({ networkId: payload.networkId, streamId: payload.streamId });
}, [payload, setPayload, setPendingNav]);
if (platform.kind !== "web") return null;
if (!payload) return null;
const isVideo = payload.mimeType.startsWith("video/");
return (
<div
className="fixed bottom-4 right-4 z-50 w-80 overflow-hidden rounded-lg shadow-lg"
style={{ height: isVideo ? 180 : 64 }}
>
<AutoplayCardContent
payload={payload}
onDismiss={handleDismiss}
onNavigate={handleNavigate}
onEnded={handleDismiss}
/>
</div>
);
}
@@ -3,6 +3,7 @@ import type { LinkMetadata } from "@/lib/link-metadata";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { platform } from "@/lib/platform";
interface LinkPreviewCardProps {
metadata: LinkMetadata;
@@ -12,7 +13,7 @@ interface LinkPreviewCardProps {
export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
window.electronLink.openExternal(metadata.url);
platform.link.openExternal(metadata.url);
};
const handleCopy = (e: React.MouseEvent) => {
@@ -3,20 +3,23 @@ import { Copy, Minus, Square, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { platform } from "@/lib/platform";
export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
return window.electronWindow.onMaximizeChange(setIsMaximized);
return platform.window.onMaximizeChange(setIsMaximized);
}, []);
if (platform.kind !== "electron") return null;
const minimize = (
<Button
key="minimize"
variant="ghost"
size="icon-sm"
onClick={() => window.electronWindow.minimize()}
onClick={() => platform.window.minimize()}
aria-label="Minimize"
className="dark:hover:bg-white/10 rounded text-white/50"
>
@@ -29,7 +32,7 @@ export function WindowControls() {
key="maximize"
variant="ghost"
size="icon-sm"
onClick={() => window.electronWindow.maximize()}
onClick={() => platform.window.maximize()}
aria-label={isMaximized ? "Restore" : "Maximize"}
className="dark:hover:bg-white/10 rounded text-white/50"
>
@@ -42,7 +45,7 @@ export function WindowControls() {
key="close"
variant="ghost"
size="icon-sm"
onClick={() => window.electronWindow.close()}
onClick={() => platform.window.close()}
aria-label="Close"
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
>
-2
View File
@@ -1,4 +1,3 @@
import type { LinkMetadata } from './lib/link-metadata';
import type { AutoplayPayload } from './lib/autoplay-ipc';
declare global {
@@ -42,7 +41,6 @@ declare global {
onInit: (callback: () => void) => () => void;
};
electronLink: {
fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
openExternal: (url: string) => Promise<void>;
};
electronAttachment: {
@@ -12,6 +12,7 @@ import {
import { useDownloadUrl } from "@/hooks/use-download-url";
import { Button } from "@/components/ui/button";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { platform } from "@/lib/platform";
export interface AttachmentItem {
id: string;
@@ -95,7 +96,7 @@ export function AttachmentLightbox({
const handleDownload = () => {
if (!current || !url || current.source.kind !== "remote") return;
window.electronAttachment.download(url, current.filename);
platform.attachment.download(url, current.filename);
};
const handleRemove = () => {
+3 -2
View File
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { H3, Muted } from "@/components/ui/typography";
import { PRIVACY_URL, TERMS_URL } from "@/lib/constants";
import { useAuthStore } from "@/stores/auth-store";
import { platform } from "@/lib/platform";
interface EmailStepProps {
onCodeSent: (email: string) => void;
@@ -62,7 +63,7 @@ export function EmailStep({ onCodeSent }: EmailStepProps) {
By continuing, you agree to our{" "}
<button
type="button"
onClick={() => window.electronLink.openExternal(TERMS_URL)}
onClick={() => platform.link.openExternal(TERMS_URL)}
className="underline underline-offset-2 hover:text-foreground"
>
Terms of Service
@@ -70,7 +71,7 @@ export function EmailStep({ onCodeSent }: EmailStepProps) {
and{" "}
<button
type="button"
onClick={() => window.electronLink.openExternal(PRIVACY_URL)}
onClick={() => platform.link.openExternal(PRIVACY_URL)}
className="underline underline-offset-2 hover:text-foreground"
>
Privacy Policy
@@ -9,6 +9,7 @@ import {
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
import { platform } from "@/lib/platform";
export interface PendingAttachment {
id: string;
@@ -120,7 +121,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
return (
<button
type="button"
onClick={() => window.electronLink.openExternal(metadata.url)}
onClick={() => platform.link.openExternal(metadata.url)}
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
>
<div className="flex items-center gap-1 text-[10px] text-white/40">
@@ -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({
<ScreenSourcePicker
title="Record your screen"
confirmLabel="Record"
getSources={window.electronScreen.getScreenSources}
getSources={platform.screenRecord.getScreenSources}
onSelect={handleScreenSourceSelected}
onCancel={cancel}
/>
@@ -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]);
+3 -2
View File
@@ -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),
});
};
@@ -10,6 +10,7 @@ import {
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
import { platform } from "@/lib/platform";
type FileParticle = Extract<Particle, { type: "file" }>;
@@ -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 (
@@ -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 });
});
};
@@ -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<string, string[]> | 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]);
+4 -3
View File
@@ -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<string>();
useEffect(() => {
window.electronApp.getVersion().then(setVersion);
platform.app.getVersion().then(setVersion);
}, []);
const handleToggleEmailNotifications = async (checked: boolean) => {
@@ -190,12 +191,12 @@ export default function SettingsPage() {
<SettingsRow
icon={<Shield className="size-4" />}
label="Privacy Policy"
onClick={() => window.electronLink.openExternal(PRIVACY_URL)}
onClick={() => platform.link.openExternal(PRIVACY_URL)}
/>
<SettingsRow
icon={<FileText className="size-4" />}
label="Terms of Service"
onClick={() => window.electronLink.openExternal(TERMS_URL)}
onClick={() => platform.link.openExternal(TERMS_URL)}
/>
</SettingsGroup>
+3 -2
View File
@@ -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]);
}
+3 -2
View File
@@ -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<LinkMetadata | null>({
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,
+2 -1
View File
@@ -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,
-5
View File
@@ -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";
@@ -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;
}
+57
View File
@@ -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(),
},
};
+8
View File
@@ -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";
+8
View File
@@ -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";
+63
View File
@@ -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<ScreenSource[]>;
};
screenRecord: {
isSupported: boolean;
start: () => void;
stop: () => void;
cancel: () => void;
onStopRequested: (cb: () => void) => () => void;
getScreenSources: () => Promise<ScreenSource[]>;
};
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<LinkMetadata | null>;
openExternal: (url: string) => Promise<void>;
};
attachment: {
download: (url: string, filename?: string) => void;
};
app: {
setDockBadge: (count: number) => void;
getVersion: () => Promise<string>;
};
}
export const DESKTOP_DOWNLOAD_URL = "https://flowylabs.ai/llink/download";
+107
View File
@@ -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__,
},
};
+6
View File
@@ -0,0 +1,6 @@
import type { PropsWithChildren } from "react";
import { HashRouter } from "react-router-dom";
export function RouterShell({ children }: PropsWithChildren) {
return <HashRouter>{children}</HashRouter>;
}
+6
View File
@@ -0,0 +1,6 @@
import type { PropsWithChildren } from "react";
import { BrowserRouter } from "react-router-dom";
export function RouterShell({ children }: PropsWithChildren) {
return <BrowserRouter>{children}</BrowserRouter>;
}
+25
View File
@@ -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,
}),
});
}
-111
View File
@@ -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<string, LinkMetadata>();
function getMetaContent(html: string, property: string): string | null {
// Match both property="..." and name="..." attributes
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']|<meta[^>]*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[^>]*>([^<]*)<\/title>/i);
return match?.[1]?.trim() ?? null;
}
function getFavicon(html: string, baseUrl: string): string | null {
const match = html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']/i)
?? html.match(/<link[^>]*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<LinkMetadata | null> {
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 <head> 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
-1
View File
@@ -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),
});
@@ -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<AutoplayPayloadState>((set) => ({
payload: null,
pendingNav: null,
setPayload: (payload) => set({ payload }),
setPendingNav: (d) =>
set({ pendingNav: d ? { ...d, nonce: Date.now() } : null }),
}));
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>llink</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./renderer.tsx"></script>
</body>
</html>
+9
View File
@@ -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(<App />);
+40
View File
@@ -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,
},
});
+67
View File
@@ -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"