feat: ship a web app (#218)
* feat(orion): add endpoint for link metadata * refactor: cleanup comments * wip: plumbing for building a web app * setup deployment materials for web app * fix(web): favicon
This commit was merged in pull request #218.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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__,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
|
||||
export function RouterShell({ children }: PropsWithChildren) {
|
||||
return <HashRouter>{children}</HashRouter>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
export function RouterShell({ children }: PropsWithChildren) {
|
||||
return <BrowserRouter>{children}</BrowserRouter>;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user