feat: ship a web app #218

Merged
talksik merged 5 commits from web-app into master 2026-05-26 22:37:35 +00:00
51 changed files with 1260 additions and 238 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Deploy llink-web
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options: [dev, prod]
concurrency:
group: deploy-llink-web-${{ inputs.environment }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
defaults:
run:
working-directory: js/desktop
steps:
- uses: actions/checkout@v4
- name: Resolve environment settings
id: env
run: |
case "${{ inputs.environment }}" in
dev)
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
;;
prod)
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
;;
*)
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
;;
esac
echo "region=us-west2" >> "$GITHUB_OUTPUT"
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- name: Install gke-gcloud-auth-plugin
run: gcloud components install gke-gcloud-auth-plugin --quiet
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
- name: Get GKE credentials
run: |
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
--region "${{ steps.env.outputs.region }}" \
--project "${{ steps.env.outputs.project }}"
- name: Install skaffold
run: |
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
sudo install skaffold /usr/local/bin/
skaffold version
- name: Deploy
env:
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
run: skaffold run -p ${{ inputs.environment }}
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
@@ -173,6 +174,9 @@ func main() {
// Particles
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
// Link metadata
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
// Depot
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
@@ -185,8 +189,17 @@ func main() {
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
// nil = allow all origins (Electron app needs it).
muxWithCors := middleware.CORS(nil)(mux)
// CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client.
// Empty / unset = allow all
var allowedOrigins []string
if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" {
for _, o := range strings.Split(raw, ",") {
if o = strings.TrimSpace(o); o != "" {
allowedOrigins = append(allowedOrigins, o)
}
}
}
muxWithCors := middleware.CORS(allowedOrigins)(mux)
addr := fmt.Sprintf("0.0.0.0:%s", port)
slog.Info("running server", "addr", addr)
+209
View File
@@ -0,0 +1,209 @@
package handler
import (
"context"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`.
// All clients (web and Electron) call this endpoint — the Electron main-process
// fetcher was retired so both clients share one parser and the server-side cache.
type LinkMetadata struct {
URL string `json:"url"`
Title *string `json:"title"`
Description *string `json:"description"`
Image *string `json:"image"`
Favicon *string `json:"favicon"`
Domain string `json:"domain"`
}
const (
metadataFetchTimeout = 5 * time.Second
metadataMaxBytes = 50 * 1024
metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)"
)
// In-process cache, unbounded but only successful results are stored.
// URL space is bounded in practice.
var metadataCache sync.Map // map[string]LinkMetadata
func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("url")
if raw == "" {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
http.Error(w, "invalid url", http.StatusBadRequest)
return
}
if cached, ok := metadataCache.Load(raw); ok {
writeJSON(w, cached)
return
}
if err := guardSSRF(parsed.Hostname()); err != nil {
// Treat unresolvable / private hosts as "no metadata" rather than 4xx — the
// client treats a null body as a progressive-enhancement miss.
writeJSON(w, nil)
return
}
meta, err := fetchLinkMetadata(r.Context(), parsed)
if err != nil {
slog.Warn("link metadata fetch failed", "url", raw, "error", err)
writeJSON(w, nil)
return
}
metadataCache.Store(raw, *meta)
writeJSON(w, meta)
}
// guardSSRF resolves the host and rejects loopback, private, link-local, and
// unspecified addresses so the endpoint can't be turned into an internal-network
// probe.
func guardSSRF(host string) error {
ips, err := net.LookupIP(host)
if err != nil {
return err
}
if len(ips) == 0 {
return errors.New("no addresses for host")
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return errors.New("private or local address")
}
}
return nil
}
func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) {
ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", metadataUserAgent)
req.Header.Set("Accept", "text/html")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errors.New("upstream non-2xx")
}
body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes))
if err != nil {
return nil, err
}
html := string(body)
domain := strings.TrimPrefix(target.Hostname(), "www.")
title := firstNonNil(metaContent(html, "og:title"), parseTitle(html))
description := firstNonNil(
metaContent(html, "og:description"),
metaContent(html, "description"),
)
image := resolveURL(metaContent(html, "og:image"), target)
favicon := parseFavicon(html, target)
return &LinkMetadata{
URL: target.String(),
Title: title,
Description: description,
Image: image,
Favicon: favicon,
Domain: domain,
}, nil
}
var titleRe = regexp.MustCompile(`(?i)<title[^>]*>([^<]*)</title>`)
func parseTitle(html string) *string {
m := titleRe.FindStringSubmatch(html)
if len(m) < 2 {
return nil
}
s := strings.TrimSpace(m[1])
if s == "" {
return nil
}
return &s
}
// metaContent finds <meta property|name="<prop>" content="..."> in either attribute order.
func metaContent(html, prop string) *string {
escaped := regexp.QuoteMeta(prop)
re := regexp.MustCompile(
`(?i)<meta[^>]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`,
)
m := re.FindStringSubmatch(html)
if len(m) == 0 {
return nil
}
for _, g := range m[1:] {
if g != "" {
return &g
}
}
return nil
}
var (
faviconRe1 = regexp.MustCompile(`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`)
faviconRe2 = regexp.MustCompile(`(?i)<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`)
)
func parseFavicon(html string, base *url.URL) *string {
for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} {
if m := re.FindStringSubmatch(html); len(m) >= 2 {
return resolveURL(&m[1], base)
}
}
// Fall back to /favicon.ico
fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String()
return &fallback
}
func resolveURL(src *string, base *url.URL) *string {
if src == nil || *src == "" {
return nil
}
parsed, err := url.Parse(*src)
if err != nil {
return src
}
resolved := base.ResolveReference(parsed).String()
return &resolved
}
func firstNonNil(values ...*string) *string {
for _, v := range values {
if v != nil && *v != "" {
return v
}
}
return nil
}
+3
View File
@@ -79,6 +79,9 @@ spec:
value: "llink://billing/success"
- name: "BILLING_CANCEL_URL"
value: "llink://billing/cancel"
# Web client origins
- name: "CORS_ALLOWED_ORIGINS"
value: "http://localhost:5173,http://localhost:5174,http://localhost:5175,https://llink.dev.flowy.live"
---
+3
View File
@@ -76,6 +76,9 @@ spec:
value: "llink://billing/success"
- name: "BILLING_CANCEL_URL"
value: "llink://billing/cancel"
# Web client origins
- name: "CORS_ALLOWED_ORIGINS"
value: "https://llink.flowy.live"
---
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist-web
.vite
out
.git
.gitignore
.DS_Store
*.log
+17
View File
@@ -0,0 +1,17 @@
# Two-stage build for the llink web SPA. APP_ENV is baked into the bundle
# at build time via vite's `define` (see vite.env.ts).
FROM node:22-alpine AS builder
WORKDIR /app
ARG APP_ENV=prod
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN APP_ENV=$APP_ENV yarn web:build:ci
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist-web /usr/share/nginx/html
EXPOSE 80
+98
View File
@@ -0,0 +1,98 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: llink-web
spec:
selector:
matchLabels:
app: llink-web
replicas: 1
template:
metadata:
labels:
app: llink-web
spec:
serviceAccountName: default-service-account
nodeSelector:
cloud.google.com/gke-spot: "true"
terminationGracePeriodSeconds: 15
containers:
- name: llink-web
image: "llink-web"
ports:
- containerPort: 80
resources:
requests:
memory: "64Mi"
cpu: 20m
limits:
memory: "64Mi"
cpu: 50m
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 2
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: llink-web
spec:
selector:
app: llink-web
ports:
- port: 80
targetPort: 80
protocol: TCP
---
kind: HTTPRoute
apiVersion: gateway.networking.k8s.io/v1beta1
metadata:
name: llink-web
spec:
parentRefs:
- kind: Gateway
name: external-gateway
hostnames:
- llink.dev.flowy.live
rules:
- backendRefs:
- name: llink-web
port: 80
---
apiVersion: networking.gke.io/v1
kind: HealthCheckPolicy
metadata:
name: llink-web-service-health-check
spec:
default:
checkIntervalSec: 15
timeoutSec: 15
healthyThreshold: 1
unhealthyThreshold: 2
logConfig:
enabled: true
config:
type: HTTP
httpHealthCheck:
portSpecification: USE_FIXED_PORT
port: 80
requestPath: /health
targetRef:
group: ""
kind: Service
name: llink-web
+95
View File
@@ -0,0 +1,95 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: llink-web
spec:
selector:
matchLabels:
app: llink-web
replicas: 2
template:
metadata:
labels:
app: llink-web
spec:
serviceAccountName: default-service-account
containers:
- name: llink-web
image: "llink-web"
ports:
- containerPort: 80
resources:
requests:
memory: "64Mi"
cpu: 20m
limits:
memory: "64Mi"
cpu: 50m
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 2
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: llink-web
spec:
selector:
app: llink-web
ports:
- port: 80
targetPort: 80
protocol: TCP
---
kind: HTTPRoute
apiVersion: gateway.networking.k8s.io/v1beta1
metadata:
name: llink-web
spec:
parentRefs:
- kind: Gateway
name: external-gateway
hostnames:
- llink.flowy.live
rules:
- backendRefs:
- name: llink-web
port: 80
---
apiVersion: networking.gke.io/v1
kind: HealthCheckPolicy
metadata:
name: llink-web-service-health-check
spec:
default:
checkIntervalSec: 15
timeoutSec: 15
healthyThreshold: 1
unhealthyThreshold: 2
logConfig:
enabled: true
config:
type: HTTP
httpHealthCheck:
portSpecification: USE_FIXED_PORT
port: 80
requestPath: /health
targetRef:
group: ""
kind: Service
name: llink-web
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /health {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
+7 -1
View File
@@ -14,7 +14,11 @@
"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:build:ci": "vite build --config vite.web.config.mts",
"web:preview": "vite preview --config vite.web.config.mts"
},
"keywords": [],
"author": {
@@ -43,6 +47,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 +59,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",
+38
View File
@@ -0,0 +1,38 @@
apiVersion: skaffold/v4beta11
kind: Config
metadata:
name: llink-web
build:
local: {}
tagPolicy:
gitCommit:
variant: AbbrevCommitSha
profiles:
- name: dev
build:
artifacts:
- image: llink-web
context: .
docker:
dockerfile: Dockerfile
buildArgs:
APP_ENV: dev
manifests:
rawYaml:
- k8s/dev/llink-web.yaml
deploy:
kubectl: {}
- name: prod
build:
artifacts:
- image: llink-web
context: .
docker:
dockerfile: Dockerfile
buildArgs:
APP_ENV: prod
manifests:
rawYaml:
- k8s/prod/llink-web.yaml
deploy:
kubectl: {}
+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 }),
}));
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/icon.png" />
<title>llink</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./renderer.tsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+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"