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
14 changes: 6 additions & 8 deletions apps/desktop/src/renderer/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@linkcode/workbench';
import { useEffect } from 'foxact/use-abortable-effect';
import { useState } from 'react';
import useSWRImmutable from 'swr/immutable';
import { DesktopAutomationsView } from './automations/automations-view';
import { cloudDataBridge } from './cloud-auth/bridges';
import { desktopDaemonConnectionSource } from './daemon-connection-source';
Expand Down Expand Up @@ -109,15 +110,12 @@ function DesktopConnectionFallback(): React.ReactNode {

/** Whether main supervises the daemon (packaged, no override) — picks the failure copy. */
function useDaemonIsManaged(): boolean {
// Managed-ness only moves with the override, so the override is the cache key: changing it
// re-fetches, and everything else serves the cached answer.
const daemonUrlOverride = useDesktopSettingsStore((state) => state.daemonUrlOverride);
const [managed, setManaged] = useState(false);
useEffect(
(signal) => {
void systemBridge.daemon.isManaged().then((value) => {
if (!signal.aborted) setManaged(value);
});
},
[daemonUrlOverride],
const { data: managed = false } = useSWRImmutable(
['desktop:daemon-managed', daemonUrlOverride],
() => systemBridge.daemon.isManaged(),
);
return managed;
}
14 changes: 5 additions & 9 deletions apps/desktop/src/renderer/src/settings/about-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import type { UpdaterStatus } from '@linkcode/ipc';
import { Button } from 'coss-ui/components/button';
import { Field, FieldLabel } from 'coss-ui/components/field';
import { Progress, ProgressIndicator, ProgressTrack } from 'coss-ui/components/progress';
import { useEffect } from 'foxact/use-abortable-effect';
import { useState } from 'react';
import useSWRImmutable from 'swr/immutable';
import { useTranslations } from 'use-intl';
import { systemBridge } from '../ipc';
import { useUpdaterState } from '../updater';
Expand All @@ -19,15 +18,12 @@ const STATUS_KEYS = {

export function AboutTab(): React.ReactNode {
const t = useTranslations('settings.about');
const [version, setVersion] = useState('');
// The app version is constant for the process lifetime — fetch once, cache forever.
const { data: version } = useSWRImmutable('desktop:app-version', () =>
systemBridge.app.version(),
);
const { progress, status } = useUpdaterState();

useEffect((signal) => {
void systemBridge.app.version().then((value) => {
if (!signal.aborted) setVersion(value);
});
}, []);

const statusKey = status === 'idle' ? null : STATUS_KEYS[status];
const progressPercent = progress === null ? null : Math.round(progress);

Expand Down
143 changes: 81 additions & 62 deletions apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import type { BrowserFindState } from '@linkcode/ui/shell/browser';
import { BrowserPane } from '@linkcode/ui/shell/browser';
import type { WebviewTag } from 'electron';
import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect';
import { useSingleton } from 'foxact/use-singleton';
import { noop } from 'foxts/noop';
import { useEffectEvent, useRef, useState } from 'react';
import { useCallback, useEffectEvent, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslations } from 'use-intl';
import { useDesktopShellStore } from '../store/store';
import {
advanceBrowserWebviewGeneration,
isBrowserWebviewReady,
markBrowserWebviewReady,
markBrowserWebviewUnready,
registerBrowserWebview,
Expand All @@ -19,21 +19,38 @@ import {
/** All in-app pages share one persisted session (cookies/storage survive restarts). */
const BROWSER_PARTITION = 'persist:linkcode-browser';

interface WebviewNavState {
isLoading: boolean;
canGoBack: boolean;
canGoForward: boolean;
failure: string | null;
guestReady: boolean;
/** Guest events after which the nav/readiness snapshots must be re-read. */
const GUEST_NAV_EVENTS = [
'did-start-loading',
'did-stop-loading',
'dom-ready',
'did-start-navigation',
'did-navigate',
'did-navigate-in-page',
] as const;

/** An absent or still-attaching guest (methods throw until dom-ready) reads as idle. */
function readGuest(webview: WebviewTag | null, read: (view: WebviewTag) => boolean): boolean {
if (webview === null) return false;
try {
return read(webview);
} catch {
return false;
}
}

const IDLE_NAV: WebviewNavState = {
isLoading: false,
canGoBack: false,
canGoForward: false,
failure: null,
guestReady: false,
};
/** Forward every nav-affecting guest event to `onStoreChange`; returns the detach. */
function subscribeGuestNav(webview: WebviewTag | null, onStoreChange: () => void): () => void {
if (webview === null) return noop;
for (let i = 0, len = GUEST_NAV_EVENTS.length; i < len; i++) {
webview.addEventListener(GUEST_NAV_EVENTS[i], onStoreChange);
}
return () => {
for (let i = 0, len = GUEST_NAV_EVENTS.length; i < len; i++) {
webview.removeEventListener(GUEST_NAV_EVENTS[i], onStoreChange);
}
};
}

function whenNotLocal(event: KeyboardEvent): boolean {
return !isKeyboardShortcutLocalTarget(event.target);
Expand Down Expand Up @@ -92,79 +109,51 @@ export function BrowserWebviewPane({
);
const rootRef = useRef<HTMLDivElement | null>(null);
const [webview, setWebview] = useState<WebviewTag | null>(null);
const [nav, setNav] = useState<WebviewNavState>(IDLE_NAV);
const guestReady = webview !== null && nav.guestReady;
const [find, setFind] = useState<BrowserFindState | null>(null);
// React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement;
// in Electron (webviewTag enabled) the live element is always the full WebviewTag.
const { current: captureWebview } = useSingleton(() => (element: HTMLWebViewElement | null) => {
setWebview(element as WebviewTag | null);
setNav((prev) => (prev.guestReady ? { ...prev, guestReady: false } : prev));
});
const captureWebview = useCallback(
(element: HTMLWebViewElement | null) => setWebview(element as WebviewTag | null),
[],
);

useLayoutEffect(() => {
if (webview === null) return;
registerBrowserWebview(tabId, webview);
return () => registerBrowserWebview(tabId, null);
}, [tabId, webview]);

const [failureError, setFailureError] = useState<string | null>(null);
const [find, setFind] = useState<BrowserFindState | null>(null);

const syncDocumentState = useEffectEvent((currentUrl: string, currentTitle: string) => {
if (currentUrl.length > 0) setBrowserTabUrl(tabId, currentUrl);
if (currentTitle.length > 0) setBrowserTabTitle(tabId, currentTitle);
});

// Command side: guest events drive the registry marks, the shell store's url/title, and the
// event-payload-only states (load failure, find matches) that no webview getter can serve.
useLayoutEffect(() => {
if (webview === null) return;
let ready = false;
const sync = (): void => {
if (!ready) return;
setNav((prev) => ({
...prev,
isLoading: webview.isLoading(),
canGoBack: webview.canGoBack(),
canGoForward: webview.canGoForward(),
}));
};
const syncDocument = (): void => {
ready = true;
markBrowserWebviewReady(tabId);
/* `nav` mirrors an external event target (the guest webview). This setter normally runs
* from webview events, but the effect also invokes it once synchronously via the
* post-subscribe probe below, closing the race where a cached page finished loading
* before the listeners attached. There is no earlier call site: the webview element
* itself arrives via state, so the probe must live in the effect that subscribes. */
// eslint-disable-next-line vibe-proof/react-no-use-effect-watching -- see above
setNav((prev) => ({
...prev,
isLoading: webview.isLoading(),
canGoBack: webview.canGoBack(),
canGoForward: webview.canGoForward(),
guestReady: true,
}));
syncDocumentState(webview.getURL(), webview.getTitle());
};
const onNavigate = (event: Electron.DidNavigateEvent): void => {
advanceBrowserWebviewGeneration(tabId);
syncDocumentState(event.url, '');
setNav((prev) => ({ ...prev, failure: null }));
sync();
setFailureError(null);
};
const onStartNavigation = (event: Electron.DidStartNavigationEvent): void => {
if (!event.isMainFrame || event.isInPlace) return;
ready = false;
markBrowserWebviewUnready(tabId);
setNav((prev) => (prev.guestReady ? { ...prev, guestReady: false } : prev));
};
const onTitleUpdated = (event: Electron.PageTitleUpdatedEvent): void => {
syncDocumentState('', event.title);
};
const onFail = (event: Electron.DidFailLoadEvent): void => {
// -3 = ERR_ABORTED: fired for cancelled loads (e.g. quick re-navigation), not real failures.
if (event.errorCode === -3 || !event.isMainFrame) return;
setNav((prev) => ({
...prev,
failure: t('loadFailed', { error: event.errorDescription }),
}));
setFailureError(event.errorDescription);
};
const onFoundInPage = (event: Electron.FoundInPageEvent): void => {
setFind((prev) =>
Expand All @@ -176,8 +165,7 @@ export function BrowserWebviewPane({
},
);
};
webview.addEventListener('did-start-loading', sync);
// `dom-ready` can fire before React's layout effects subscribe on very fast pages. The later
// `dom-ready` can fire before this effect subscribes on very fast pages. The later
// `did-stop-loading` is an equivalent safe point for guest methods and closes that race.
webview.addEventListener('did-stop-loading', syncDocument);
webview.addEventListener('dom-ready', syncDocument);
Expand All @@ -195,7 +183,6 @@ export function BrowserWebviewPane({
noop();
}
return () => {
webview.removeEventListener('did-start-loading', sync);
webview.removeEventListener('did-stop-loading', syncDocument);
webview.removeEventListener('dom-ready', syncDocument);
webview.removeEventListener('did-start-navigation', onStartNavigation);
Expand All @@ -205,7 +192,39 @@ export function BrowserWebviewPane({
webview.removeEventListener('did-fail-load', onFail);
webview.removeEventListener('found-in-page', onFoundInPage);
};
}, [webview, t, tabId]);
}, [webview, tabId]);

// Query side: the webview itself is the store. Subscribing just forwards guest events to
// React, and each snapshot reads the element (or the registry's readiness mark) directly —
// the post-subscribe snapshot re-read makes a pre-subscription load impossible to miss.
const isLoading = useSyncExternalStore(
useCallback(
(onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange),
[webview],
),
() => readGuest(webview, (view) => view.isLoading()),
);
const canGoBack = useSyncExternalStore(
useCallback(
(onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange),
[webview],
),
() => readGuest(webview, (view) => view.canGoBack()),
);
const canGoForward = useSyncExternalStore(
useCallback(
(onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange),
[webview],
),
() => readGuest(webview, (view) => view.canGoForward()),
);
const guestReady = useSyncExternalStore(
useCallback(
(onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange),
[webview],
),
() => webview !== null && isBrowserWebviewReady(tabId),
);

// Pause playing media when the pane is hidden; gated on dom-ready to avoid pre-attachment throws.
useLayoutEffect(() => {
Expand Down Expand Up @@ -318,10 +337,10 @@ export function BrowserWebviewPane({
<div ref={rootRef} className="h-full min-h-0">
<BrowserPane
url={url}
isLoading={nav.isLoading}
canGoBack={nav.canGoBack}
canGoForward={nav.canGoForward}
failure={nav.failure}
isLoading={isLoading}
canGoBack={canGoBack}
canGoForward={canGoForward}
failure={failureError === null ? null : t('loadFailed', { error: failureError })}
find={find}
onNavigate={(next) => setBrowserTabUrl(tabId, next)}
onBack={() => guestReady && webview?.goBack()}
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/renderer/src/shell/browser/webview-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { noop } from 'foxts/noop';
interface WebviewEntry {
webview: WebviewTag;
generation: number;
readyNow: boolean;
ready: Promise<void>;
resolveReady: () => void;
}
Expand All @@ -21,7 +22,7 @@ function unreadyEntry(webview: WebviewTag, generation: number): WebviewEntry {
const ready = new Promise<void>((resolve) => {
resolveReady = resolve;
});
return { webview, generation, ready, resolveReady };
return { webview, generation, readyNow: false, ready, resolveReady };
}

export function registerBrowserWebview(tabId: string, webview: WebviewTag | null): void {
Expand All @@ -48,7 +49,16 @@ export function markBrowserWebviewUnready(tabId: string): void {
}

export function markBrowserWebviewReady(tabId: string): void {
webviews.get(tabId)?.resolveReady();
const entry = webviews.get(tabId);
if (entry) {
entry.readyNow = true;
entry.resolveReady();
}
}

/** Synchronous readiness read, for `useSyncExternalStore` snapshots. */
export function isBrowserWebviewReady(tabId: string): boolean {
return webviews.get(tabId)?.readyNow ?? false;
}

export function advanceBrowserWebviewGeneration(tabId: string): void {
Expand Down
22 changes: 13 additions & 9 deletions apps/desktop/src/renderer/src/shell/chrome/window-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ShellIconButton } from '@linkcode/ui';
import { systemBridge } from '@renderer/ipc';
import { useEffect } from 'foxact/use-abortable-effect';
import { CopyIcon, MinusIcon, SquareIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
import { useEffect } from 'react';
import useSWRImmutable from 'swr/immutable';
import type { DesktopChromeMetricsStyle } from './metrics';
import { DESKTOP_CHROME_METRICS_STYLE } from './metrics';

Expand Down Expand Up @@ -34,13 +34,17 @@ export function DesktopWindowControls(): React.ReactNode {
* `systemBridge.window` IPC; maximize state comes from the main-pushed `onMaximizedChange`.
*/
function WindowControls(): React.ReactNode {
const [maximized, setMaximized] = useState(false);
useEffect((signal) => {
void systemBridge.window.isMaximized().then((value) => {
if (!signal.aborted) setMaximized(value);
});
return systemBridge.window.onMaximizedChange(setMaximized);
}, []);
const { data: maximized = false, mutate } = useSWRImmutable('desktop:window-maximized', () =>
systemBridge.window.isMaximized(),
);
useEffect(
() =>
systemBridge.window.onMaximizedChange((value) => {
// update value directly and avoid re-fetch
mutate(value, { revalidate: false });
}),
[mutate],
);

return (
<div className="pointer-events-auto flex h-full items-center gap-(--lc-chrome-control-gap)">
Expand Down
Loading