feat: ship a web app #218
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist-web
|
||||
.vite
|
||||
out
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Vendored
-2
@@ -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 = () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 |
@@ -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 />);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user