From 5f098d5d49649e0827d950df74de5e1eec5c78f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 12:44:12 +0000 Subject: [PATCH 1/4] Expose host process memory from the misc plugin Add a get_process_memory_bytes Tauri command (sysinfo RSS of the current process) with regenerated bindings and permissions, for the developer status bar's MEM readout. Co-authored-by: John Jeong --- plugins/misc/build.rs | 1 + plugins/misc/js/bindings.gen.ts | 8 ++++++ .../commands/get_process_memory_bytes.toml | 13 +++++++++ .../permissions/autogenerated/reference.md | 27 +++++++++++++++++++ plugins/misc/permissions/default.toml | 1 + plugins/misc/permissions/schemas/schema.json | 16 +++++++++-- plugins/misc/src/commands.rs | 8 ++++++ plugins/misc/src/ext.rs | 17 ++++++++++++ plugins/misc/src/lib.rs | 1 + 9 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 plugins/misc/permissions/autogenerated/commands/get_process_memory_bytes.toml 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) From 7df785343ff598bfb6cae0bd8be7bc7969920bb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 12:44:12 +0000 Subject: [PATCH 2/4] Add VS Code-style developer status bar to dev and staging builds Mount a slim bottom bar in the main window when show_devtool is true. It shows the build channel, version and git hash (click opens the Devtools panel), FPS, Tauri IPC traffic (invokes/callbacks per second), host process memory, and, under the Vite dev server, React Scan render counts with toggles for re-render outlines and the React Scan toolbar. Move React Scan bootstrapping into devtools-bar/react-scan.ts and hide its floating toolbar by default now that the bar embeds its metrics. Co-authored-by: John Jeong --- apps/desktop/src/devtools-bar/index.test.tsx | 164 +++++++++ apps/desktop/src/devtools-bar/index.tsx | 326 ++++++++++++++++++ apps/desktop/src/devtools-bar/metrics.test.ts | 166 +++++++++ apps/desktop/src/devtools-bar/metrics.ts | 209 +++++++++++ apps/desktop/src/devtools-bar/react-scan.ts | 71 ++++ apps/desktop/src/main.tsx | 13 +- apps/desktop/src/main/shell-frame.test.tsx | 16 + apps/desktop/src/main/shell-frame.tsx | 8 +- 8 files changed, 959 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/devtools-bar/index.test.tsx create mode 100644 apps/desktop/src/devtools-bar/index.tsx create mode 100644 apps/desktop/src/devtools-bar/metrics.test.ts create mode 100644 apps/desktop/src/devtools-bar/metrics.ts create mode 100644 apps/desktop/src/devtools-bar/react-scan.ts 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..b46f9224d6e --- /dev/null +++ b/apps/desktop/src/devtools-bar/index.test.tsx @@ -0,0 +1,164 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +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", () => ({ + 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"; + 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("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..28183ae7cf7 --- /dev/null +++ b/apps/desktop/src/devtools-bar/index.tsx @@ -0,0 +1,326 @@ +import { useQuery } from "@tanstack/react-query"; +import { getIdentifier, getVersion } from "@tauri-apps/api/app"; +import { useEffect, 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, + isReactScanAvailable, + isReactScanToolbarVisible, + setReactScanOutlinesEnabled, + setReactScanToolbarVisible, + subscribeReactScanAvailability, +} from "./react-scan"; + +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", +}; + +/** + * 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() { + const enabledQuery = useQuery({ + queryKey: ["devtools-panel", "enabled"], + queryFn: commands.showDevtool, + staleTime: Infinity, + }); + + if (enabledQuery.data !== true) { + return null; + } + + return ; +} + +function DevtoolsStatusBarContent() { + useEffect(() => startDevtoolsMetrics(), []); + + const metrics = useDevtoolsMetrics(); + const build = useBuildInfo(); + const reactScanAvailable = useSyncExternalStore( + subscribeReactScanAvailability, + isReactScanAvailable, + ); + const [, refresh] = useReducer((tick: number) => tick + 1, 0); + + const channel = build?.channel ?? "stable"; + 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(); + + return ( +
+ void openDevtoolsPanel()} + > + + {channel} + + {build ? ( + + {build.version} + {build.hash ? ` ${build.hash}` : ""} + + ) : null} + + + + {fps ?? "–"} + + + + + + ↑{invokes} ↓{callbacks} + + value + (metrics.callbacks[index] ?? 0), + )} + /> + + + {reactScanAvailable ? ( + { + setReactScanOutlinesEnabled(!outlinesEnabled); + refresh(); + }} + > + + {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({ + children, + label, + title, +}: { + children: React.ReactNode; + label: string; + title: string; +}) { + return ( +
+ + {children} +
+ ); +} + +function BarButton({ + children, + onClick, + title, +}: { + children: React.ReactNode; + onClick: () => void; + title: string; +}) { + return ( + + ); +} + +function Label({ children }: { children: React.ReactNode }) { + return {children}; +} + +function Value({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return {children}; +} + +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({ + values, + floor = 1, + relative = false, +}: { + values: number[]; + floor?: number; + relative?: boolean; +}) { + 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..e86fa111f11 --- /dev/null +++ b/apps/desktop/src/devtools-bar/react-scan.ts @@ -0,0 +1,71 @@ +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; +} + +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}
+
); } From f60c556a7282ea2809feaf04c8753e15f44b490f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 13:30:16 +0000 Subject: [PATCH 3/4] Keep the developer status bar out of React Scan The bar re-renders once a second, so React Scan outlined its own segments and counted them as renders. Mark each bar component's props as ignored so outlines and the RENDERS counter only reflect the rest of the app. Co-authored-by: John Jeong --- apps/desktop/src/devtools-bar/index.test.tsx | 1 + apps/desktop/src/devtools-bar/index.tsx | 82 +++++++++----------- apps/desktop/src/devtools-bar/react-scan.ts | 9 +++ 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/devtools-bar/index.test.tsx b/apps/desktop/src/devtools-bar/index.test.tsx index b46f9224d6e..bf52753042f 100644 --- a/apps/desktop/src/devtools-bar/index.test.tsx +++ b/apps/desktop/src/devtools-bar/index.test.tsx @@ -34,6 +34,7 @@ vi.mock("@anlg/plugin-windows", () => ({ })); vi.mock("./react-scan", () => ({ + ignoreReactScan: vi.fn(), isReactScanAvailable: () => mocks.reactScanAvailable, subscribeReactScanAvailability: () => () => {}, areReactScanOutlinesEnabled: () => mocks.outlinesEnabled, diff --git a/apps/desktop/src/devtools-bar/index.tsx b/apps/desktop/src/devtools-bar/index.tsx index 28183ae7cf7..d5f3b65a4b2 100644 --- a/apps/desktop/src/devtools-bar/index.tsx +++ b/apps/desktop/src/devtools-bar/index.tsx @@ -14,6 +14,7 @@ import { } from "./metrics"; import { areReactScanOutlinesEnabled, + ignoreReactScan, isReactScanAvailable, isReactScanToolbarVisible, setReactScanOutlinesEnabled, @@ -37,11 +38,16 @@ const CHANNEL_CLASSES: Record = { 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() { +export function DevtoolsStatusBar(props: Record) { + ignoreReactScan(props); + const enabledQuery = useQuery({ queryKey: ["devtools-panel", "enabled"], queryFn: commands.showDevtool, @@ -55,7 +61,8 @@ export function DevtoolsStatusBar() { return ; } -function DevtoolsStatusBarContent() { +function DevtoolsStatusBarContent(props: Record) { + ignoreReactScan(props); useEffect(() => startDevtoolsMetrics(), []); const metrics = useDevtoolsMetrics(); @@ -104,7 +111,7 @@ function DevtoolsStatusBarContent() { label="FPS" title={`Frames per second (avg ${average(metrics.fps)} over ${metrics.fps.length}s)`} > - {fps ?? "–"} + {fps ?? "–"} @@ -112,9 +119,9 @@ function DevtoolsStatusBarContent() { label="IPC" title="Tauri IPC per second: ↑ invokes, ↓ callbacks (responses, events, channels)" > - + ↑{invokes} ↓{callbacks} - + value + (metrics.callbacks[index] ?? 0), @@ -134,8 +141,10 @@ function DevtoolsStatusBarContent() { refresh(); }} > - - {outlinesEnabled ? renders : "off"} + renders + + {outlinesEnabled ? renders : "off"} + ) : null} @@ -144,9 +153,9 @@ function DevtoolsStatusBarContent() { label="MEM" title="Host process resident memory (WebKit content process is separate on macOS)" > - + {memoryBytes === undefined ? "–" : formatBytes(memoryBytes)} - + @@ -160,9 +169,9 @@ function DevtoolsStatusBarContent() { refresh(); }} > - + {toolbarVisible ? "◉" : "○"} react scan - + ) : null} @@ -196,64 +205,46 @@ async function openDevtoolsPanel() { } } -function Segment({ - children, - label, - title, -}: { +function Segment(props: { children: React.ReactNode; label: string; title: string; }) { + ignoreReactScan(props); + return (
- - {children} + {props.label} + {props.children}
); } -function BarButton({ - children, - onClick, - title, -}: { +function BarButton(props: { children: React.ReactNode; onClick: () => void; title: string; }) { + ignoreReactScan(props); + return ( ); } -function Label({ children }: { children: React.ReactNode }) { - return {children}; -} - -function Value({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return {children}; -} - const SPARKLINE_WIDTH = 36; const SPARKLINE_HEIGHT = 10; @@ -262,15 +253,14 @@ const SPARKLINE_HEIGHT = 10; * `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({ - values, - floor = 1, - relative = false, -}: { +function Sparkline(props: { values: number[]; floor?: number; relative?: boolean; }) { + ignoreReactScan(props); + const { values, floor = 1, relative = false } = props; + if (values.length < 2) { return ( Date: Thu, 3 Sep 2026 01:37:40 +0900 Subject: [PATCH 4/4] Prevent status bar channel flash Wait for build metadata before rendering and use the mount-only metrics hook. --- apps/desktop/src/devtools-bar/index.test.tsx | 24 ++++++++++++++++++++ apps/desktop/src/devtools-bar/index.tsx | 22 ++++++++++-------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/devtools-bar/index.test.tsx b/apps/desktop/src/devtools-bar/index.test.tsx index bf52753042f..8da0a72e7bf 100644 --- a/apps/desktop/src/devtools-bar/index.test.tsx +++ b/apps/desktop/src/devtools-bar/index.test.tsx @@ -1,4 +1,5 @@ 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"; @@ -71,6 +72,9 @@ function renderBar() { 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; @@ -114,6 +118,26 @@ describe("DevtoolsStatusBar", () => { 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"; diff --git a/apps/desktop/src/devtools-bar/index.tsx b/apps/desktop/src/devtools-bar/index.tsx index d5f3b65a4b2..0aa32ffa017 100644 --- a/apps/desktop/src/devtools-bar/index.tsx +++ b/apps/desktop/src/devtools-bar/index.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { getIdentifier, getVersion } from "@tauri-apps/api/app"; -import { useEffect, useReducer, useSyncExternalStore } from "react"; +import { useReducer, useSyncExternalStore } from "react"; import { commands as miscCommands } from "@anlg/plugin-misc"; import { commands as windowsCommands } from "@anlg/plugin-windows"; @@ -22,6 +22,7 @@ import { subscribeReactScanAvailability, } from "./react-scan"; +import { useMountEffect } from "~/shared/hooks/useMountEffect"; import { commands } from "~/types/tauri.gen"; export type BuildChannel = "dev" | "staging" | "stable"; @@ -63,7 +64,7 @@ export function DevtoolsStatusBar(props: Record) { function DevtoolsStatusBarContent(props: Record) { ignoreReactScan(props); - useEffect(() => startDevtoolsMetrics(), []); + useMountEffect(() => startDevtoolsMetrics()); const metrics = useDevtoolsMetrics(); const build = useBuildInfo(); @@ -73,7 +74,6 @@ function DevtoolsStatusBarContent(props: Record) { ); const [, refresh] = useReducer((tick: number) => tick + 1, 0); - const channel = build?.channel ?? "stable"; const fps = last(metrics.fps); const invokes = last(metrics.invokes) ?? 0; const callbacks = last(metrics.callbacks) ?? 0; @@ -82,6 +82,12 @@ function DevtoolsStatusBarContent(props: Record) { const outlinesEnabled = reactScanAvailable && areReactScanOutlinesEnabled(); const toolbarVisible = reactScanAvailable && isReactScanToolbarVisible(); + if (!build) { + return null; + } + + const channel = build.channel; + return (
) {
{channel} - {build ? ( - - {build.version} - {build.hash ? ` ${build.hash}` : ""} - - ) : null} + + {build.version} + {build.hash ? ` ${build.hash}` : ""} +