Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions components/DesktopAppBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

import { useState } from "react";
import { X } from "lucide-react";
import {
downloadUrlFor,
isElectron,
PLATFORM_LABEL,
type Platform,
} from "@/lib/platform";
import { usePlatform } from "@/hooks/usePlatform";

const DISMISSED_STORAGE_KEY = "rescript:desktop-banner-dismissed";

/** localStorage throws in private-mode Safari and when storage is full. */
function readDismissed(): boolean {
try {
return window.localStorage.getItem(DISMISSED_STORAGE_KEY) === "1";
} catch {
return false;
}
}

function writeDismissed() {
try {
window.localStorage.setItem(DISMISSED_STORAGE_KEY, "1");
} catch {
// Ignore — the banner simply comes back next session.
}
}

function AppleIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M17.05 12.54c-.03-2.5 2.04-3.7 2.13-3.76-1.16-1.7-2.97-1.93-3.61-1.96-1.54-.15-3 .9-3.78.9-.78 0-1.98-.88-3.25-.86-1.67.03-3.21.97-4.07 2.46-1.73 3-.44 7.45 1.25 9.89.82 1.19 1.8 2.53 3.08 2.48 1.24-.05 1.71-.8 3.2-.8 1.5 0 1.92.8 3.23.78 1.33-.02 2.18-1.21 3-2.41.94-1.38 1.33-2.72 1.35-2.79-.03-.01-2.59-1-2.62-3.93M14.6 4.6c.68-.83 1.14-1.98 1.02-3.13-.98.04-2.17.65-2.88 1.48-.63.73-1.19 1.9-1.04 3.02 1.1.09 2.21-.55 2.9-1.37" />
</svg>
);
}

function WindowsIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M3 5.48 10.2 4.5v6.94H3zm0 13.04 7.2.98v-6.85H3zM11.05 4.38 21 3v8.44h-9.95zm0 15.24L21 21v-8.35h-9.95z" />
</svg>
);
}

function LinuxIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M12 1.5c-2.3 0-3.6 1.7-3.6 4.1 0 1 .13 1.83.1 2.6-.04.83-.5 1.5-1.1 2.4-.72 1.05-1.35 2.1-1.6 3.35-.16.8-.5 1.4-.94 1.98-.4.53-.44 1.1-.1 1.43.36.36 1 .28 1.5.5.45.2.72.6 1.22.9.5.3 1.2.44 2.2.24.72-.15 1.4-.15 2.5-.02 1.05.13 1.83.1 2.5-.1.6-.18 1-.55 1.5-.83.5-.28 1.1-.24 1.45-.6.33-.35.24-.9-.17-1.43-.45-.58-.75-1.2-.9-2-.24-1.24-.87-2.3-1.6-3.34-.6-.9-1.05-1.57-1.1-2.4-.03-.77.1-1.6.1-2.6 0-2.4-1.3-4.1-3.6-4.1zm-1.35 3.1c.4 0 .72.42.72.94 0 .52-.32.94-.72.94-.4 0-.72-.42-.72-.94 0-.52.32-.94.72-.94zm2.75 0c.4 0 .72.42.72.94 0 .52-.32.94-.72.94-.4 0-.72-.42-.72-.94 0-.52.32-.94.72-.94zM12 7.35c.85 0 1.63.35 2 .8.2.24.14.5-.14.72l-1.4 1.08c-.3.23-.62.23-.92 0l-1.4-1.08c-.28-.22-.34-.48-.14-.72.37-.45 1.15-.8 2-.8z" />
</svg>
);
}

/** Nothing for an unrecognised desktop browser — its button reads "Download". */
function PlatformIcon({ platform }: { platform: Platform }) {
if (platform === "mac-arm" || platform === "mac-intel")
return <AppleIcon size={14} />;
if (platform === "windows") return <WindowsIcon size={13} />;
if (platform === "linux") return <LinuxIcon size={14} />;
return null;
}

/**
* Web-only nudge toward the desktop build, which runs transcription and
* exports natively instead of through WebAssembly. Hidden inside the Electron
* shell, on phones and tablets (there is no mobile build to pitch), and once
* the user dismisses it.
*/
export default function DesktopAppBanner() {
const platform = usePlatform();
const [dismissed, setDismissed] = useState(
() => isElectron || readDismissed(),
);

if (isElectron || dismissed || platform === "mobile") return null;

const label = PLATFORM_LABEL[platform];

return (
<div className="relative flex h-11 shrink-0 items-center justify-center gap-3 border-b border-blue-200/60 bg-gradient-to-b from-blue-50/70 to-blue-100/50 pr-11 pl-3 text-[13px] text-blue-900 dark:border-blue-400/15 dark:from-blue-950/40 dark:to-blue-900/25 dark:text-blue-100">
<p className="truncate">
<span className="hidden sm:inline">
Want faster transcription and exports?{" "}
</span>
Get the Rescript desktop app
</p>
<a
href={downloadUrlFor(platform)}
target="_blank"
rel="noopener noreferrer"
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full bg-white/70 pr-3.5 pl-3 text-[12px] font-medium text-blue-700 ring-1 ring-blue-300/60 transition hover:bg-white hover:ring-blue-400/70 dark:bg-white/10 dark:text-blue-100 dark:ring-white/15 dark:hover:bg-white/15 dark:hover:ring-white/25"
>
<PlatformIcon platform={platform} />
{label ? `Download for ${label}` : "Download"}
</a>
<button
onClick={() => {
setDismissed(true);
writeDismissed();
}}
title="Dismiss"
aria-label="Dismiss"
className="absolute right-2.5 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-blue-500 transition hover:bg-blue-500/10 hover:text-blue-800 dark:text-blue-300 dark:hover:bg-white/10 dark:hover:text-blue-100"
>
<X size={14} />
</button>
</div>
);
}
13 changes: 13 additions & 0 deletions components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEditorStore } from "@/lib/store";
import { getCutRanges, isWordCutOut } from "@/lib/edits";
import { extractAudio, getFFmpeg, releaseFFmpeg } from "@/lib/ffmpeg";
import { VAD_SAMPLE_RATE } from "@/lib/vad";
import { isNetworkError } from "@/lib/network";
import { isElectron } from "@/lib/platform";
import { reportError } from "@/lib/sentry";
import { startSessionReporting } from "@/lib/telemetry";
Expand All @@ -15,6 +16,7 @@ import { useIsDesktopLayout } from "@/hooks/useIsDesktopLayout";
import { useTranscriber } from "@/hooks/useTranscriber";
import { detectMediaKind, MEDIA_ACCEPT } from "@/lib/media";
import TopBar from "./TopBar";
import DesktopAppBanner from "./DesktopAppBanner";
import UploadScreen from "./UploadScreen";
import TranscriptPanel from "./TranscriptPanel";
import MediaPreview from "./MediaPreview";
Expand Down Expand Up @@ -229,6 +231,16 @@ export default function Editor() {
}
} catch (err) {
console.error("Processing pipeline failed:", err);
// Same reasoning as the worker's error path: a dropped connection while
// pulling the media engine is the user's network, not a bug, and
// "Failed to fetch" tells them nothing about what to do next.
if (isNetworkError(err)) {
s.setError(
"Couldn't load the media engine — the connection dropped. " +
"Check your internet and try again."
);
return;
}
reportError(err, "media-pipeline");
s.setError(err instanceof Error ? err.message : "Failed to process this file.");
}
Expand Down Expand Up @@ -333,6 +345,7 @@ export default function Editor() {

return (
<div className="relative flex h-dvh flex-col overflow-hidden bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<DesktopAppBanner />
{status === "idle" ? (
<>
{isElectron && <TopBar>
Expand Down
2 changes: 1 addition & 1 deletion components/UploadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export default function UploadScreen({
</a>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<SettingsMenu />
<div className="h-5 w-px bg-zinc-200 dark:bg-zinc-700" />
<div className="h-5 w-px bg-zinc-200 dark:bg-zinc-700 mr-1" />
<ModelSelector groupLabel="Transcript source">
{MODEL_ORDER.map((id) => (
<ModelOption key={id} id={id} />
Expand Down
17 changes: 17 additions & 0 deletions hooks/usePlatform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import { useSyncExternalStore } from "react";
import { detectPlatform, type Platform } from "@/lib/platform";

// The OS never changes mid-session, so there is nothing to subscribe to.
const subscribe = () => () => {};
const getServerSnapshot = (): Platform => "unknown";

/**
* Reads the visitor's platform without a hydration mismatch: the server (and
* the hydrating client render) sees "unknown", then React swaps in the real
* value. `detectPlatform` returns a string, so the snapshot is stable by value.
*/
export function usePlatform(): Platform {
return useSyncExternalStore(subscribe, detectPlatform, getServerSnapshot);
}
11 changes: 8 additions & 3 deletions hooks/useTranscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,14 @@ export function useTranscriber() {
break;
case "error":
s.setError(msg.message);
// Worker errors cross a postMessage boundary, so the original stack is
// already gone by here — send the message with a stage tag instead.
reportError(new Error(msg.message), "transcription");
// A connection that dropped mid-download is the user's network, and
// the worker already retried it. There is no stack to act on, so
// reporting it only spends quota on an issue we cannot fix.
if (msg.cause !== "network") {
// Worker errors cross a postMessage boundary, so the original stack
// is already gone by here — send the message with a stage tag.
reportError(new Error(msg.message), "transcription");
}
break;
}
};
Expand Down
111 changes: 111 additions & 0 deletions lib/network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Network-failure detection and download retries.
*
* Model weights are fetched from the Hub the first time a model runs — up to
* 1.3 GB for Parakeet fp16 — so a single dropped connection anywhere in that
* transfer used to abort the whole transcription with a bare "Failed to fetch".
*/

/**
* True for failures that mean the request never completed — the network
* dropped, DNS failed, the connection reset — as opposed to a response that
* arrived and was unwelcome (those surface as an `ok: false` Response, not a
* throw).
*
* Matching on the message is the only option: fetch rejects with a bare
* `TypeError` carrying no code, and the wording is engine-specific — Chromium
* and Electron say "Failed to fetch", Firefox "NetworkError when attempting to
* fetch resource", WebKit just "Load failed". Chromium sometimes appends the
* underlying `net::ERR_*` instead, and onnxruntime / transformers.js wrap the
* original message in their own, so these are substring tests.
*/
export function isNetworkError(err: unknown): boolean {
const raw = err instanceof Error ? err.message : String(err ?? "");
const msg = raw.toLowerCase().trim();
if (!msg) return false;
// WebKit's entire message. Left as an exact match because "load failed" as a
// substring also describes plenty of non-network failures ("model load
// failed"), and mislabelling those as offline sends the user chasing their
// router instead of reporting a bug.
if (msg === "load failed") return true;
return (
msg.includes("failed to fetch") ||
msg.includes("networkerror when attempting to fetch") ||
msg.includes("network request failed") ||
msg.includes("the network connection was lost") ||
msg.includes("err_internet_disconnected") ||
msg.includes("err_network_changed") ||
msg.includes("err_name_not_resolved") ||
msg.includes("err_connection_") ||
msg.includes("err_timed_out") ||
msg.includes("err_address_unreachable")
);
}

/** Backoff before each retry; length also caps the number of attempts. */
const RETRY_DELAYS_MS = [500, 2_000, 6_000];

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/**
* Wrap a scope's `fetch` so requests that fail at the transport layer are
* retried with backoff.
*
* Only network-class rejections are retried. An HTTP error is a real answer and
* is returned untouched; an abort is a decision, not a failure. Retries are
* limited to GET/HEAD, which are the only requests here (weights, ORT WASM) and
* the only ones safe to replay without a body to re-send.
*
* Idempotent per scope, so importing this from more than one place is harmless.
* Returns the wrapped function, for libraries that snapshot `globalThis.fetch`
* at import time and so never see the replacement (transformers.js keeps its own
* `env.fetch` this way).
*/
export function installFetchRetry(scope: {
fetch: typeof fetch;
__fetchRetryInstalled?: boolean;
}): typeof fetch {
if (scope.__fetchRetryInstalled) return scope.fetch;
scope.__fetchRetryInstalled = true;

const original = scope.fetch.bind(scope) as typeof fetch;

scope.fetch = async (input, init) => {
const method = (
init?.method ??
(typeof Request !== "undefined" && input instanceof Request
? input.method
: "GET")
).toUpperCase();
const replayable = method === "GET" || method === "HEAD";

for (let attempt = 0; ; attempt++) {
try {
return await original(input, init);
} catch (err) {
const signal =
init?.signal ??
(typeof Request !== "undefined" && input instanceof Request
? input.signal
: undefined);
if (
signal?.aborted ||
!replayable ||
!isNetworkError(err) ||
attempt >= RETRY_DELAYS_MS.length
) {
throw err;
}
const wait = RETRY_DELAYS_MS[attempt];
console.warn(
`Network request failed; retrying in ${wait}ms ` +
`(attempt ${attempt + 2} of ${RETRY_DELAYS_MS.length + 1}).`,
err
);
await sleep(wait);
}
}
};

return scope.fetch;
}
59 changes: 59 additions & 0 deletions lib/platform.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,61 @@
export const isElectron =
typeof navigator !== "undefined" && /electron/i.test(navigator.userAgent);

/**
* Desktop platforms the app ships builds for. Ported from the marketing site
* (getrescript.com `src/lib/platform.ts`) so the two agree on what `/download`
* expects in its `platform` query param. "mobile" is local to this app: the
* site never needs it, but the in-app banner must not pitch a desktop build to
* a phone.
*/
export type Platform =
| "mac-arm"
| "mac-intel"
| "windows"
| "linux"
| "mobile"
| "unknown";

/** Phones and tablets, including iPadOS, which reports a desktop Mac UA. */
function isMobile(ua: string): boolean {
if (/android|iphone|ipod|ipad|windows phone|mobile/.test(ua)) return true;
// iPadOS 13+ masquerades as macOS; a touch-capable "Mac" is really an iPad.
return (
ua.includes("mac") &&
typeof navigator !== "undefined" &&
navigator.maxTouchPoints > 1
);
}

export function detectPlatform(): Platform {
if (typeof navigator === "undefined") return "unknown";
const ua = navigator.userAgent.toLowerCase();
const platform = (navigator.platform || "").toLowerCase();

if (isMobile(ua)) return "mobile";
if (ua.includes("win") || platform.includes("win")) return "windows";
if (ua.includes("linux") || platform.includes("linux")) return "linux";
if (ua.includes("mac") || platform.includes("mac")) {
// Apple Silicon is the common default for recent Macs; the download page
// offers Intel as the alternate.
return "mac-arm";
}
return "unknown";
}

export const DOWNLOAD_PAGE_URL = "https://www.getrescript.com/#download";

/** The site's `/download` route reads this param (see its parsePlatformParam). */
export function downloadUrlFor(platform: Platform): string {
if (platform === "mobile" || platform === "unknown") return DOWNLOAD_PAGE_URL;
return `https://www.getrescript.com/download?platform=${platform}`;
}

export const PLATFORM_LABEL: Record<Platform, string> = {
"mac-arm": "Mac",
"mac-intel": "Mac",
windows: "Windows",
linux: "Linux",
mobile: "",
unknown: "",
};
Loading
Loading