diff --git a/apps/desktop/src/devtools-bar/index.test.tsx b/apps/desktop/src/devtools-bar/index.test.tsx new file mode 100644 index 00000000000..8da0a72e7bf --- /dev/null +++ b/apps/desktop/src/devtools-bar/index.test.tsx @@ -0,0 +1,189 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getIdentifier } from "@tauri-apps/api/app"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + identifier: "com.hyprnote.staging", + reactScanAvailable: false, + outlinesEnabled: true, + toolbarVisible: false, + setReactScanOutlinesEnabled: vi.fn(), + setReactScanToolbarVisible: vi.fn(), + devtoolsPanelShow: vi.fn().mockResolvedValue({ status: "ok", data: null }), + startDevtoolsMetrics: vi.fn(() => vi.fn()), +})); + +vi.mock("@tauri-apps/api/app", () => ({ + getIdentifier: vi.fn(() => Promise.resolve(mocks.identifier)), + getVersion: vi.fn(() => Promise.resolve("1.2.3")), +})); + +vi.mock("@anlg/plugin-misc", () => ({ + commands: { + getGitHash: vi + .fn() + .mockResolvedValue({ status: "ok", data: "abcdef1234567890" }), + getProcessMemoryBytes: vi.fn(), + }, +})); + +vi.mock("@anlg/plugin-windows", () => ({ + commands: { + devtoolsPanelShow: mocks.devtoolsPanelShow, + }, +})); + +vi.mock("./react-scan", () => ({ + ignoreReactScan: vi.fn(), + isReactScanAvailable: () => mocks.reactScanAvailable, + subscribeReactScanAvailability: () => () => {}, + areReactScanOutlinesEnabled: () => mocks.outlinesEnabled, + isReactScanToolbarVisible: () => mocks.toolbarVisible, + setReactScanOutlinesEnabled: mocks.setReactScanOutlinesEnabled, + setReactScanToolbarVisible: mocks.setReactScanToolbarVisible, +})); + +vi.mock("./metrics", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startDevtoolsMetrics: mocks.startDevtoolsMetrics, + }; +}); + +import { DevtoolsStatusBar } from "./index"; +import { useDevtoolsMetrics } from "./metrics"; + +import { commands } from "~/types/tauri.gen"; + +function renderBar() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + , + ); +} + +describe("DevtoolsStatusBar", () => { + beforeEach(() => { + mocks.identifier = "com.hyprnote.staging"; + vi.mocked(getIdentifier).mockImplementation(() => + Promise.resolve(mocks.identifier), + ); + mocks.reactScanAvailable = false; + mocks.outlinesEnabled = true; + mocks.toolbarVisible = false; + vi.mocked(commands.showDevtool).mockResolvedValue(true); + useDevtoolsMetrics.setState({ + fps: [58, 60], + invokes: [3, 12], + callbacks: [4, 15], + renders: [10, 41], + memoryBytes: [300 * 1024 ** 2, 312 * 1024 ** 2], + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders nothing when devtools are disabled", async () => { + vi.mocked(commands.showDevtool).mockResolvedValue(false); + + renderBar(); + + await vi.waitFor(() => expect(commands.showDevtool).toHaveBeenCalled()); + expect(screen.queryByTestId("devtools-status-bar")).toBeNull(); + expect(mocks.startDevtoolsMetrics).not.toHaveBeenCalled(); + }); + + it("shows the build channel, version, hash and live metrics", async () => { + renderBar(); + + const bar = await screen.findByTestId("devtools-status-bar"); + await screen.findByText("1.2.3 abcdef1"); + + expect(bar.textContent).toContain("staging"); + expect(bar.className).toContain("bg-amber-900"); + expect(bar.textContent).toContain("FPS60"); + expect(bar.textContent).toContain("↑12 ↓15"); + expect(bar.textContent).toContain("MEM312MB"); + expect(screen.queryByText("renders")).toBeNull(); + expect(mocks.startDevtoolsMetrics).toHaveBeenCalledTimes(1); + }); + + it("waits for build info before rendering the channel", async () => { + let resolveIdentifier: (identifier: string) => void = () => {}; + vi.mocked(getIdentifier).mockReturnValue( + new Promise((resolve) => { + resolveIdentifier = resolve; + }), + ); + + renderBar(); + + await vi.waitFor(() => expect(getIdentifier).toHaveBeenCalled()); + expect(screen.queryByTestId("devtools-status-bar")).toBeNull(); + + resolveIdentifier("com.hyprnote.staging"); + + const bar = await screen.findByTestId("devtools-status-bar"); + expect(bar.textContent).toContain("staging"); + expect(bar.className).toContain("bg-amber-900"); + }); + + it("uses the dev palette for local builds", async () => { + mocks.identifier = "com.hyprnote.dev"; + + renderBar(); + + const bar = await screen.findByTestId("devtools-status-bar"); + await vi.waitFor(() => expect(bar.className).toContain("bg-blue-900")); + expect(bar.textContent).toContain("dev"); + }); + + it("opens the devtools panel from the channel badge", async () => { + renderBar(); + + fireEvent.click(await screen.findByTitle("Open Devtools panel")); + + expect(mocks.devtoolsPanelShow).toHaveBeenCalledTimes(1); + }); + + it("exposes react-scan controls when it is running", async () => { + mocks.reactScanAvailable = true; + + renderBar(); + + const bar = await screen.findByTestId("devtools-status-bar"); + expect(bar.textContent).toContain("renders41"); + + fireEvent.click( + screen.getByTitle("React Scan: outlining re-renders (click to pause)"), + ); + expect(mocks.setReactScanOutlinesEnabled).toHaveBeenCalledWith(false); + + fireEvent.click( + screen.getByTitle( + "Toggle the React Scan toolbar (inspector, slowdown notifications)", + ), + ); + expect(mocks.setReactScanToolbarVisible).toHaveBeenCalledWith(true); + }); + + it("shows renders as off while outlines are paused", async () => { + mocks.reactScanAvailable = true; + mocks.outlinesEnabled = false; + + renderBar(); + + const bar = await screen.findByTestId("devtools-status-bar"); + expect(bar.textContent).toContain("rendersoff"); + }); +}); diff --git a/apps/desktop/src/devtools-bar/index.tsx b/apps/desktop/src/devtools-bar/index.tsx new file mode 100644 index 00000000000..0aa32ffa017 --- /dev/null +++ b/apps/desktop/src/devtools-bar/index.tsx @@ -0,0 +1,320 @@ +import { useQuery } from "@tanstack/react-query"; +import { getIdentifier, getVersion } from "@tauri-apps/api/app"; +import { useReducer, useSyncExternalStore } from "react"; + +import { commands as miscCommands } from "@anlg/plugin-misc"; +import { commands as windowsCommands } from "@anlg/plugin-windows"; +import { cn } from "@anlg/utils"; + +import { + formatBytes, + HISTORY_LENGTH, + startDevtoolsMetrics, + useDevtoolsMetrics, +} from "./metrics"; +import { + areReactScanOutlinesEnabled, + ignoreReactScan, + isReactScanAvailable, + isReactScanToolbarVisible, + setReactScanOutlinesEnabled, + setReactScanToolbarVisible, + subscribeReactScanAvailability, +} from "./react-scan"; + +import { useMountEffect } from "~/shared/hooks/useMountEffect"; +import { commands } from "~/types/tauri.gen"; + +export type BuildChannel = "dev" | "staging" | "stable"; + +export function resolveBuildChannel(identifier: string): BuildChannel { + if (identifier.endsWith(".staging")) return "staging"; + if (identifier.endsWith(".dev")) return "dev"; + return "stable"; +} + +const CHANNEL_CLASSES: Record = { + dev: "bg-blue-900 text-blue-50", + staging: "bg-amber-900 text-amber-50", + stable: "bg-neutral-800 text-neutral-100", +}; + +const LABEL_CLASS = "uppercase opacity-60"; +const VALUE_CLASS = "tabular-nums"; + +/** + * VS Code-style status bar for dev and staging builds. Stable builds never + * render it (`show_devtool` is false there), so copy stays English-only. + */ +export function DevtoolsStatusBar(props: Record) { + ignoreReactScan(props); + + const enabledQuery = useQuery({ + queryKey: ["devtools-panel", "enabled"], + queryFn: commands.showDevtool, + staleTime: Infinity, + }); + + if (enabledQuery.data !== true) { + return null; + } + + return ; +} + +function DevtoolsStatusBarContent(props: Record) { + ignoreReactScan(props); + useMountEffect(() => startDevtoolsMetrics()); + + const metrics = useDevtoolsMetrics(); + const build = useBuildInfo(); + const reactScanAvailable = useSyncExternalStore( + subscribeReactScanAvailability, + isReactScanAvailable, + ); + const [, refresh] = useReducer((tick: number) => tick + 1, 0); + + const fps = last(metrics.fps); + const invokes = last(metrics.invokes) ?? 0; + const callbacks = last(metrics.callbacks) ?? 0; + const renders = last(metrics.renders) ?? 0; + const memoryBytes = last(metrics.memoryBytes); + const outlinesEnabled = reactScanAvailable && areReactScanOutlinesEnabled(); + const toolbarVisible = reactScanAvailable && isReactScanToolbarVisible(); + + if (!build) { + return null; + } + + const channel = build.channel; + + return ( +
+ void openDevtoolsPanel()} + > + + {channel} + + + {build.version} + {build.hash ? ` ${build.hash}` : ""} + + + + + {fps ?? "–"} + + + + + + ↑{invokes} ↓{callbacks} + + value + (metrics.callbacks[index] ?? 0), + )} + /> + + + {reactScanAvailable ? ( + { + setReactScanOutlinesEnabled(!outlinesEnabled); + refresh(); + }} + > + renders + + {outlinesEnabled ? renders : "off"} + + + + ) : null} + + + + {memoryBytes === undefined ? "–" : formatBytes(memoryBytes)} + + + + +
+ + {reactScanAvailable ? ( + { + setReactScanToolbarVisible(!toolbarVisible); + refresh(); + }} + > + + {toolbarVisible ? "◉" : "○"} react scan + + + ) : null} +
+ ); +} + +function useBuildInfo() { + return useQuery({ + queryKey: ["devtools-bar", "build"], + staleTime: Infinity, + queryFn: async () => { + const [identifier, version, hash] = await Promise.all([ + getIdentifier(), + getVersion(), + miscCommands.getGitHash(), + ]); + + return { + channel: resolveBuildChannel(identifier), + version, + hash: hash.status === "ok" ? hash.data.slice(0, 7) : null, + }; + }, + }).data; +} + +async function openDevtoolsPanel() { + const result = await windowsCommands.devtoolsPanelShow(); + if (result.status === "error") { + console.error("Failed to show Devtools panel:", result.error); + } +} + +function Segment(props: { + children: React.ReactNode; + label: string; + title: string; +}) { + ignoreReactScan(props); + + return ( +
+ {props.label} + {props.children} +
+ ); +} + +function BarButton(props: { + children: React.ReactNode; + onClick: () => void; + title: string; +}) { + ignoreReactScan(props); + + return ( + + ); +} + +const SPARKLINE_WIDTH = 36; +const SPARKLINE_HEIGHT = 10; + +/** + * `floor` pins the top of the chart to at least that value (e.g. 60 fps). + * `relative` scales between the window's min and max so slow drifts such as + * memory growth stay visible instead of flattening against zero. + */ +function Sparkline(props: { + values: number[]; + floor?: number; + relative?: boolean; +}) { + ignoreReactScan(props); + const { values, floor = 1, relative = false } = props; + + if (values.length < 2) { + return ( + + ); + } + + const min = relative ? Math.min(...values) : 0; + const max = relative ? Math.max(...values) : Math.max(floor, ...values); + const range = max - min; + const step = SPARKLINE_WIDTH / (HISTORY_LENGTH - 1); + const offset = SPARKLINE_WIDTH - (values.length - 1) * step; + const points = values + .map((value, index) => { + const x = offset + index * step; + const ratio = range === 0 ? 0.5 : (value - min) / range; + const y = SPARKLINE_HEIGHT - 0.5 - ratio * (SPARKLINE_HEIGHT - 1); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + + return ( + + + + ); +} + +function last(values: number[]): number | undefined { + return values.length ? values[values.length - 1] : undefined; +} + +function average(values: number[]): number { + if (!values.length) return 0; + return Math.round( + values.reduce((sum, value) => sum + value, 0) / values.length, + ); +} diff --git a/apps/desktop/src/devtools-bar/metrics.test.ts b/apps/desktop/src/devtools-bar/metrics.test.ts new file mode 100644 index 00000000000..1a38b393b33 --- /dev/null +++ b/apps/desktop/src/devtools-bar/metrics.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./react-scan", () => ({ + drainReactScanRenders: vi.fn(() => 0), +})); + +vi.mock("@anlg/plugin-misc", () => ({ + commands: { + getProcessMemoryBytes: vi.fn(), + }, +})); + +import { commands as miscCommands } from "@anlg/plugin-misc"; + +import { + formatBytes, + HISTORY_LENGTH, + installIpcCounters, + isTauriIpcUrl, + pushSample, + startDevtoolsMetrics, + useDevtoolsMetrics, +} from "./metrics"; + +const internals = () => + (window as unknown as { __TAURI_INTERNALS__: Record }) + .__TAURI_INTERNALS__; + +describe("pushSample", () => { + it("appends and keeps only the most recent samples", () => { + const history = Array.from({ length: HISTORY_LENGTH }, (_, i) => i); + + const next = pushSample(history, 99); + + expect(next).toHaveLength(HISTORY_LENGTH); + expect(next[0]).toBe(1); + expect(next[next.length - 1]).toBe(99); + expect(history).toHaveLength(HISTORY_LENGTH); + }); + + it("grows until the limit is reached", () => { + expect(pushSample([], 1)).toEqual([1]); + expect(pushSample([1], 2)).toEqual([1, 2]); + }); +}); + +describe("formatBytes", () => { + it("formats megabytes and gigabytes", () => { + expect(formatBytes(36 * 1024 ** 2)).toBe("36MB"); + expect(formatBytes(1.5 * 1024 ** 3)).toBe("1.50GB"); + }); +}); + +describe("isTauriIpcUrl", () => { + it("matches the ipc scheme on every platform", () => { + expect(isTauriIpcUrl("ipc://localhost/plugin%3Amisc%7Cget_git_hash")).toBe( + true, + ); + expect(isTauriIpcUrl("http://ipc.localhost/plugin%3Amisc")).toBe(true); + expect(isTauriIpcUrl("https://api.anarlog.so/v1")).toBe(false); + }); +}); + +describe("installIpcCounters", () => { + let originalFetch: typeof fetch; + let callbacks: Map void>; + + beforeEach(() => { + originalFetch = window.fetch; + window.fetch = vi.fn().mockResolvedValue(undefined) as typeof fetch; + callbacks = new Map(); + internals().callbacks = callbacks; + }); + + afterEach(() => { + window.fetch = originalFetch; + delete internals().callbacks; + }); + + it("counts ipc invokes and delivered callbacks", () => { + const counters = installIpcCounters(); + + void fetch("ipc://localhost/plugin%3Amisc%7Cget_git_hash", { + method: "POST", + }); + void fetch("https://api.anarlog.so/v1"); + callbacks.set(1, () => {}); + callbacks.get(1); + callbacks.get(2); + + expect(counters.drain()).toEqual({ invokes: 1, callbacks: 2 }); + expect(counters.drain()).toEqual({ invokes: 0, callbacks: 0 }); + + counters.restore(); + }); + + it("ignores traffic issued inside ignoring()", () => { + const counters = installIpcCounters(); + + counters.ignoring(() => { + void fetch("ipc://localhost/plugin%3Amisc%7Cget_process_memory_bytes"); + callbacks.set(10, () => {}); + callbacks.set(11, () => {}); + }); + callbacks.get(10); + callbacks.delete(11); + callbacks.set(12, () => {}); + callbacks.get(12); + + expect(counters.drain()).toEqual({ invokes: 0, callbacks: 1 }); + + counters.restore(); + }); + + it("restores the original fetch and map methods", () => { + const patchedFetch = window.fetch; + const counters = installIpcCounters(); + + expect(window.fetch).not.toBe(patchedFetch); + expect(Object.getOwnPropertyNames(callbacks)).toContain("get"); + + counters.restore(); + + expect(window.fetch).toBe(patchedFetch); + expect(Object.getOwnPropertyNames(callbacks)).not.toContain("get"); + }); +}); + +describe("startDevtoolsMetrics", () => { + beforeEach(() => { + vi.useFakeTimers(); + useDevtoolsMetrics.setState({ + fps: [], + invokes: [], + callbacks: [], + renders: [], + memoryBytes: [], + }); + vi.mocked(miscCommands.getProcessMemoryBytes).mockResolvedValue({ + status: "ok", + data: 512 * 1024 ** 2, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("samples once per second and polls memory", async () => { + const stop = startDevtoolsMetrics(); + + await vi.advanceTimersByTimeAsync(2000); + + const state = useDevtoolsMetrics.getState(); + expect(state.fps).toHaveLength(2); + expect(state.invokes).toHaveLength(2); + expect(state.callbacks).toHaveLength(2); + expect(state.renders).toHaveLength(2); + expect(state.memoryBytes).toEqual([512 * 1024 ** 2, 512 * 1024 ** 2]); + expect(miscCommands.getProcessMemoryBytes).toHaveBeenCalledTimes(2); + + stop(); + await vi.advanceTimersByTimeAsync(2000); + expect(useDevtoolsMetrics.getState().fps).toHaveLength(2); + }); +}); diff --git a/apps/desktop/src/devtools-bar/metrics.ts b/apps/desktop/src/devtools-bar/metrics.ts new file mode 100644 index 00000000000..501419c0309 --- /dev/null +++ b/apps/desktop/src/devtools-bar/metrics.ts @@ -0,0 +1,209 @@ +import { create } from "zustand"; + +import { commands as miscCommands } from "@anlg/plugin-misc"; + +import { drainReactScanRenders } from "./react-scan"; + +export const HISTORY_LENGTH = 30; +const TICK_MS = 1000; +const MEMORY_POLL_EVERY_TICKS = 2; + +export type DevtoolsMetrics = { + fps: number[]; + invokes: number[]; + callbacks: number[]; + renders: number[]; + memoryBytes: number[]; +}; + +export const useDevtoolsMetrics = create(() => ({ + fps: [], + invokes: [], + callbacks: [], + renders: [], + memoryBytes: [], +})); + +export function pushSample( + history: number[], + value: number, + limit = HISTORY_LENGTH, +): number[] { + const next = history.slice(Math.max(0, history.length - limit + 1)); + next.push(value); + return next; +} + +export function formatBytes(bytes: number): string { + if (bytes >= 1024 ** 3) { + return `${(bytes / 1024 ** 3).toFixed(2)}GB`; + } + return `${Math.round(bytes / 1024 ** 2)}MB`; +} + +export function isTauriIpcUrl(input: string): boolean { + return /^(?:ipc:\/\/localhost|https?:\/\/ipc\.localhost)\//.test(input); +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +type TauriCallbacks = Map; + +function getTauriCallbacks(): TauriCallbacks | null { + const internals = (window as unknown as Record) + .__TAURI_INTERNALS__ as { callbacks?: unknown } | undefined; + return internals?.callbacks instanceof Map + ? (internals.callbacks as TauriCallbacks) + : null; +} + +/** + * Tauri freezes `__TAURI_INTERNALS__`, so IPC traffic is observed from the + * outside: invokes go through `fetch` against the `ipc` scheme, and every + * Rust→JS delivery (invoke responses, events, channel messages) resolves its + * callback via `callbacks.get`, which is a plain Map we can shadow. + * + * Calls made while `ignoring()` runs are excluded so the bar's own memory + * polling does not show up as traffic. + */ +export function installIpcCounters() { + const counters = { invokes: 0, callbacks: 0 }; + const ignoredIds = new Set(); + let ignoreDepth = 0; + const cleanups: Array<() => void> = []; + + const originalFetch = window.fetch; + if (typeof originalFetch === "function") { + window.fetch = function countingFetch( + this: unknown, + input: RequestInfo | URL, + init?: RequestInit, + ) { + if (ignoreDepth === 0 && isTauriIpcUrl(requestUrl(input))) { + counters.invokes += 1; + } + return originalFetch.call(this, input, init); + } as typeof fetch; + cleanups.push(() => { + window.fetch = originalFetch; + }); + } + + const callbacks = getTauriCallbacks(); + if (callbacks) { + Object.defineProperty(callbacks, "set", { + configurable: true, + value(this: TauriCallbacks, id: number, callback: unknown) { + if (ignoreDepth > 0) { + ignoredIds.add(id); + } + return Map.prototype.set.call(this, id, callback); + }, + }); + Object.defineProperty(callbacks, "get", { + configurable: true, + value(this: TauriCallbacks, id: number) { + if (!ignoredIds.delete(id)) { + counters.callbacks += 1; + } + return Map.prototype.get.call(this, id); + }, + }); + Object.defineProperty(callbacks, "delete", { + configurable: true, + value(this: TauriCallbacks, id: number) { + ignoredIds.delete(id); + return Map.prototype.delete.call(this, id); + }, + }); + cleanups.push(() => { + delete (callbacks as Partial).set; + delete (callbacks as Partial).get; + delete (callbacks as Partial).delete; + }); + } + + return { + drain() { + const snapshot = { ...counters }; + counters.invokes = 0; + counters.callbacks = 0; + return snapshot; + }, + ignoring(run: () => T): T { + ignoreDepth += 1; + try { + return run(); + } finally { + ignoreDepth -= 1; + } + }, + restore() { + cleanups.forEach((cleanup) => cleanup()); + }, + }; +} + +export function startDevtoolsMetrics(): () => void { + const ipc = installIpcCounters(); + let frames = 0; + let frameHandle: number | null = null; + if (typeof requestAnimationFrame === "function") { + frameHandle = requestAnimationFrame(function countFrame() { + frames += 1; + frameHandle = requestAnimationFrame(countFrame); + }); + } + let lastTick = performance.now(); + let ticks = 0; + let stopped = false; + + const sampleMemory = () => { + void ipc + .ignoring(() => miscCommands.getProcessMemoryBytes()) + .then((result) => { + if (stopped || result.status !== "ok") return; + useDevtoolsMetrics.setState((state) => ({ + memoryBytes: pushSample(state.memoryBytes, result.data), + })); + }) + .catch(() => {}); + }; + + const interval = setInterval(() => { + const now = performance.now(); + const elapsedSeconds = Math.max((now - lastTick) / 1000, 0.001); + lastTick = now; + const fps = Math.round(frames / elapsedSeconds); + frames = 0; + const traffic = ipc.drain(); + const renders = drainReactScanRenders(); + + useDevtoolsMetrics.setState((state) => ({ + fps: pushSample(state.fps, fps), + invokes: pushSample(state.invokes, traffic.invokes), + callbacks: pushSample(state.callbacks, traffic.callbacks), + renders: pushSample(state.renders, renders), + })); + + ticks += 1; + if (ticks % MEMORY_POLL_EVERY_TICKS === 0) { + sampleMemory(); + } + }, TICK_MS); + + sampleMemory(); + + return () => { + stopped = true; + clearInterval(interval); + if (frameHandle !== null) { + cancelAnimationFrame(frameHandle); + } + ipc.restore(); + }; +} diff --git a/apps/desktop/src/devtools-bar/react-scan.ts b/apps/desktop/src/devtools-bar/react-scan.ts new file mode 100644 index 00000000000..5ba6ff780fb --- /dev/null +++ b/apps/desktop/src/devtools-bar/react-scan.ts @@ -0,0 +1,80 @@ +type ReactScanModule = typeof import("react-scan"); + +let reactScan: ReactScanModule | null = null; +let pendingRenders = 0; +const availabilityListeners = new Set<() => void>(); + +/** + * React Scan piggybacks on the devtools hook that React Refresh installs, so it + * only works under the Vite dev server. Production bundles (staging included) + * evaluate React before any hook exists, and React Scan cannot attach late. + */ +export async function startReactScanInDev(): Promise { + if (!import.meta.env.DEV || reactScan) { + return; + } + + try { + const module = await import("react-scan"); + module.scan({ + enabled: true, + showToolbar: false, + onRender: (_fiber, renders) => { + pendingRenders += renders.length; + }, + }); + reactScan = module; + availabilityListeners.forEach((listener) => listener()); + } catch (error) { + console.warn("Failed to start React Scan:", error); + } +} + +export function isReactScanAvailable(): boolean { + return reactScan !== null; +} + +export function subscribeReactScanAvailability(listener: () => void) { + availabilityListeners.add(listener); + return () => { + availabilityListeners.delete(listener); + }; +} + +/** Returns renders observed since the previous call and resets the counter. */ +export function drainReactScanRenders(): number { + const renders = pendingRenders; + pendingRenders = 0; + return renders; +} + +/** + * Call with a component's props object during render to keep that component + * out of React Scan outlines and render counts. The bar re-renders every + * second, so without this it would outline and count itself. + */ +export function ignoreReactScan(props: object): void { + reactScan?.ignoredProps.add(props); +} + +export function areReactScanOutlinesEnabled(): boolean { + const instrumentation = reactScan?.ReactScanInternals.instrumentation; + return instrumentation ? !instrumentation.isPaused.value : false; +} + +// React Scan skips its onRender hook entirely while outlines are paused, so +// the render counter only advances when outlines are on. +export function setReactScanOutlinesEnabled(enabled: boolean): void { + const instrumentation = reactScan?.ReactScanInternals.instrumentation; + if (instrumentation) { + instrumentation.isPaused.value = !enabled; + } +} + +export function isReactScanToolbarVisible(): boolean { + return reactScan?.getOptions().value.showToolbar === true; +} + +export function setReactScanToolbarVisible(visible: boolean): void { + reactScan?.setOptions({ showToolbar: visible }); +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b9e203dd013..1273809fe74 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -17,6 +17,7 @@ import { Toaster } from "@anlg/ui/components/ui/toast"; import { AITaskWindowSyncBridge } from "./ai/task-window-sync"; import { trackAnalyticsEvent } from "./analytics"; import { createToolRegistry } from "./contexts/tool-registry/core"; +import { startReactScanInDev } from "./devtools-bar/react-scan"; import { captureOperationalError, initializeErrorReporting, @@ -156,25 +157,19 @@ if (isMainWindow) { const rootElement = document.getElementById("root")!; -async function enableReactScanInDev() { +async function enableDevInstrumentation() { if (!import.meta.env.DEV) { return; } - try { - const { scan } = await import("react-scan"); - scan({ enabled: true }); - } catch (error) { - console.warn("Failed to start React Scan:", error); - } - + await startReactScanInDev(); startInteractionProfiler(); } async function renderApp() { await Promise.all([ bootstrapThemeFromSettings(), - enableReactScanInDev(), + enableDevInstrumentation(), initializeAppStoreBuild(), ]); const root = ReactDOM.createRoot(rootElement); diff --git a/apps/desktop/src/main/shell-frame.test.tsx b/apps/desktop/src/main/shell-frame.test.tsx index 3d893764df6..84b645ee531 100644 --- a/apps/desktop/src/main/shell-frame.test.tsx +++ b/apps/desktop/src/main/shell-frame.test.tsx @@ -54,6 +54,10 @@ vi.mock("~/contexts/shell", () => ({ }), })); +vi.mock("~/devtools-bar", () => ({ + DevtoolsStatusBar: () =>
, +})); + vi.mock("~/sidebar/toast", () => ({ ToastNotifications: () =>
, })); @@ -101,6 +105,18 @@ describe("ClassicMainShellFrame", () => { expect(screen.getByTestId("main-shell-scaffold")).toBeTruthy(); }); + it("places the devtools status bar below the shell", () => { + render(); + + const scaffold = screen.getByTestId("main-shell-scaffold"); + const statusBar = screen.getByTestId("devtools-status-bar"); + + expect(scaffold.compareDocumentPosition(statusBar)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(statusBar.parentElement?.className).toContain("flex-col"); + }); + it("uses left-edge main surface chrome while the sidebar timeline is expanded", () => { render(); diff --git a/apps/desktop/src/main/shell-frame.tsx b/apps/desktop/src/main/shell-frame.tsx index de2d791096d..eb61b276043 100644 --- a/apps/desktop/src/main/shell-frame.tsx +++ b/apps/desktop/src/main/shell-frame.tsx @@ -5,6 +5,7 @@ import { resolveMainSurfaceChrome } from "./main-surface-chrome"; import { WindowsTitleBar } from "./windows-title-bar"; import { useShell } from "~/contexts/shell"; +import { DevtoolsStatusBar } from "~/devtools-bar"; import { usesWindowsStyleTitleBar } from "~/shared/hooks/useWindowControlsGutter"; import { MainShellBodyFrame, MainShellScaffold } from "~/shared/main"; import { ToastNotifications } from "~/sidebar/toast"; @@ -45,14 +46,11 @@ export function ClassicMainShellFrame() { ); - if (!usesWindowsStyleTitleBar()) { - return shell; - } - return (
- + {usesWindowsStyleTitleBar() ? : null}
{shell}
+
); } diff --git a/plugins/misc/build.rs b/plugins/misc/build.rs index 667535939b8..dd522a33cf2 100644 --- a/plugins/misc/build.rs +++ b/plugins/misc/build.rs @@ -2,6 +2,7 @@ const COMMANDS: &[&str] = &[ "get_git_hash", "get_fingerprint", "get_device_info", + "get_process_memory_bytes", "opinionated_md_to_html", "delete_session_folder", "audio_open", diff --git a/plugins/misc/js/bindings.gen.ts b/plugins/misc/js/bindings.gen.ts index 0a15037b39b..51c870b0d4a 100644 --- a/plugins/misc/js/bindings.gen.ts +++ b/plugins/misc/js/bindings.gen.ts @@ -30,6 +30,14 @@ async getDeviceInfo(locale: string | null) : Promise> else return { status: "error", error: e as any }; } }, +async getProcessMemoryBytes() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:misc|get_process_memory_bytes") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async opinionatedMdToHtml(text: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("plugin:misc|opinionated_md_to_html", { text }) }; diff --git a/plugins/misc/permissions/autogenerated/commands/get_process_memory_bytes.toml b/plugins/misc/permissions/autogenerated/commands/get_process_memory_bytes.toml new file mode 100644 index 00000000000..1188f815f85 --- /dev/null +++ b/plugins/misc/permissions/autogenerated/commands/get_process_memory_bytes.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-process-memory-bytes" +description = "Enables the get_process_memory_bytes command without any pre-configured scope." +commands.allow = ["get_process_memory_bytes"] + +[[permission]] +identifier = "deny-get-process-memory-bytes" +description = "Denies the get_process_memory_bytes command without any pre-configured scope." +commands.deny = ["get_process_memory_bytes"] diff --git a/plugins/misc/permissions/autogenerated/reference.md b/plugins/misc/permissions/autogenerated/reference.md index f293ba332dd..e4f33511794 100644 --- a/plugins/misc/permissions/autogenerated/reference.md +++ b/plugins/misc/permissions/autogenerated/reference.md @@ -7,6 +7,7 @@ Default permissions for the plugin - `allow-get-git-hash` - `allow-get-fingerprint` - `allow-get-device-info` +- `allow-get-process-memory-bytes` - `allow-opinionated-md-to-html` ## Permission Table @@ -255,6 +256,32 @@ Denies the get_git_hash command without any pre-configured scope. +`misc:allow-get-process-memory-bytes` + + + + +Enables the get_process_memory_bytes command without any pre-configured scope. + + + + + + + +`misc:deny-get-process-memory-bytes` + + + + +Denies the get_process_memory_bytes command without any pre-configured scope. + + + + + + + `misc:allow-opinionated-md-to-html` diff --git a/plugins/misc/permissions/default.toml b/plugins/misc/permissions/default.toml index 4b01d90f3f6..acdccb47475 100644 --- a/plugins/misc/permissions/default.toml +++ b/plugins/misc/permissions/default.toml @@ -4,5 +4,6 @@ permissions = [ "allow-get-git-hash", "allow-get-fingerprint", "allow-get-device-info", + "allow-get-process-memory-bytes", "allow-opinionated-md-to-html", ] diff --git a/plugins/misc/permissions/schemas/schema.json b/plugins/misc/permissions/schemas/schema.json index 4fa1e2adb8e..ec78af33227 100644 --- a/plugins/misc/permissions/schemas/schema.json +++ b/plugins/misc/permissions/schemas/schema.json @@ -402,6 +402,18 @@ "const": "deny-get-git-hash", "markdownDescription": "Denies the get_git_hash command without any pre-configured scope." }, + { + "description": "Enables the get_process_memory_bytes command without any pre-configured scope.", + "type": "string", + "const": "allow-get-process-memory-bytes", + "markdownDescription": "Enables the get_process_memory_bytes command without any pre-configured scope." + }, + { + "description": "Denies the get_process_memory_bytes command without any pre-configured scope.", + "type": "string", + "const": "deny-get-process-memory-bytes", + "markdownDescription": "Denies the get_process_memory_bytes command without any pre-configured scope." + }, { "description": "Enables the opinionated_md_to_html command without any pre-configured scope.", "type": "string", @@ -427,10 +439,10 @@ "markdownDescription": "Denies the reveal_session_in_finder command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-opinionated-md-to-html`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-get-process-memory-bytes`\n- `allow-opinionated-md-to-html`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-opinionated-md-to-html`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-get-git-hash`\n- `allow-get-fingerprint`\n- `allow-get-device-info`\n- `allow-get-process-memory-bytes`\n- `allow-opinionated-md-to-html`" } ] } diff --git a/plugins/misc/src/commands.rs b/plugins/misc/src/commands.rs index 0fd45f22a42..d00da526c74 100644 --- a/plugins/misc/src/commands.rs +++ b/plugins/misc/src/commands.rs @@ -23,6 +23,14 @@ pub async fn get_device_info( Ok(app.misc().get_device_info(locale)) } +#[tauri::command] +#[specta::specta] +pub async fn get_process_memory_bytes( + app: tauri::AppHandle, +) -> Result { + app.misc().get_process_memory_bytes() +} + #[tauri::command] #[specta::specta] pub async fn opinionated_md_to_html( diff --git a/plugins/misc/src/ext.rs b/plugins/misc/src/ext.rs index a1fa255fd6b..59debf50b46 100644 --- a/plugins/misc/src/ext.rs +++ b/plugins/misc/src/ext.rs @@ -42,6 +42,23 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Misc<'a, R, M> { } } + /// Resident set size of the host process. WebKit renders in a separate + /// process on macOS, so this reflects the Rust side, not the JS heap. + pub fn get_process_memory_bytes(&self) -> Result { + let pid = sysinfo::get_current_pid()?; + let mut system = sysinfo::System::new(); + system.refresh_processes_specifics( + sysinfo::ProcessesToUpdate::Some(&[pid]), + false, + sysinfo::ProcessRefreshKind::nothing().with_memory(), + ); + + system + .process(pid) + .map(|process| process.memory()) + .ok_or_else(|| "current process not found".to_string()) + } + pub fn opinionated_md_to_html(&self, text: impl AsRef) -> Result { anlg_buffer::opinionated_md_to_html(text.as_ref()).map_err(|e| e.to_string()) } diff --git a/plugins/misc/src/lib.rs b/plugins/misc/src/lib.rs index 229d40d6d9c..da08772cb60 100644 --- a/plugins/misc/src/lib.rs +++ b/plugins/misc/src/lib.rs @@ -12,6 +12,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::get_git_hash::, commands::get_fingerprint::, commands::get_device_info::, + commands::get_process_memory_bytes::, commands::opinionated_md_to_html::, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result)