diff --git a/src/App.tsx b/src/App.tsx index 6e6dfb7..a3994f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -333,6 +333,7 @@ export function App() { const [actions, setActions] = useSearchResults(window.nvm.search, query, refreshNonce) const [iconUrls, setIconUrls] = useState>({}) const [runningAppPaths, setRunningAppPaths] = useState>({}) + const [runningAppPathsRefreshNonce, setRunningAppPathsRefreshNonce] = useState(0) const [selectedValue, setSelectedValue] = useState('') const selectedValueRef = useRef('') const [optionsFor, setOptionsFor] = useState(null) @@ -665,6 +666,10 @@ export function App() { } }, [actions]) + useEffect(() => window.nvm.onRunningAppPathsChanged(() => { + setRunningAppPathsRefreshNonce((nonce) => nonce + 1) + }), []) + useEffect(() => { const appPaths = Array.from(new Set(actions.map(appPathForRunningStatus).filter(Boolean) as string[])) if (!appPaths.length) return @@ -685,7 +690,7 @@ export function App() { return changed ? next : current }) }).catch(() => {}) - }, [actions]) + }, [actions, runningAppPathsRefreshNonce]) const selectedAction = useMemo( () => actions.find((action) => action.id === selectedValue), diff --git a/src/docs/solutions/running-app-status-background-refresh.md b/src/docs/solutions/running-app-status-background-refresh.md new file mode 100644 index 0000000..80772e4 --- /dev/null +++ b/src/docs/solutions/running-app-status-background-refresh.md @@ -0,0 +1,57 @@ +# Running app status blocked palette responsiveness + +## Problem or symptoms + +The palette felt laggy or unusable while opening, searching, or rendering app results. Performance logs showed slow decorative running-app status checks: + +- `apps.running.snapshot` around 800–1460ms +- `apps.running.get` / `ipc.apps:running-paths` waiting on that snapshot +- palette `showPalette.later` callbacks delayed by roughly 1.2–3.5s + +## Context + +Root app results render a running/open indicator via `runningAppClassName` in `src/App.tsx`. That status is visual decoration; it must never gate user-visible search, input, command execution, or palette reveal. + +Relevant paths: + +- `src/App.tsx` +- `src/electron/main.ts` +- `src/electron/os.ts` +- `src/electron/preload.ts` +- `src/preload-api.ts` + +## What did not work + +Only making the macOS running-app detector faster was a symptom patch. Replacing a slow native call with a faster native call still left a decorative UI affordance on the renderer-to-main request path, so future regressions could again make palette interactions wait on OS process inspection. + +## Root cause + +`apps:running-paths` awaited `runningAppPathSnapshot()`. When the cached snapshot was missing or stale, the IPC handler synchronously waited for native running-app detection before responding to the renderer. + +On macOS, the detector used AppleScript through System Events, which could take around a second. Multiple renderer requests then piled up behind the same snapshot work, making the palette feel blocked even though the running indicator was not required for primary results. + +## Fix + +Treat running-app status as stale-while-revalidate decoration: + +- `apps:running-paths` returns immediately from the last cached snapshot, or an empty snapshot if none exists yet. +- A stale or missing snapshot schedules background refresh and does not block the IPC response. +- The host dedupes in-flight refreshes. +- When the refreshed snapshot changes, main sends `apps:running-paths-changed` to the renderer. +- The renderer listens for that event and re-queries only to update row decoration. +- macOS detection uses bounded `ps` process inspection instead of AppleScript, so the background refresh is cheaper too. + +## Verification + +Run: + +```sh +mise exec -- pnpm typecheck +mise exec -- pnpm build +``` + +Both passed after the change. + +## Notes for future searches + +Keywords: running app indicator, open app marker, `apps:running-paths`, `apps.running.snapshot`, `apps.running.get`, `ipc.apps:running-paths`, stale-while-revalidate, background refresh, decorative status, AppleScript, System Events, `ps -axo comm=`, palette lag, user actions should be instant. diff --git a/src/electron/main.ts b/src/electron/main.ts index 622ecbc..f88d168 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -699,14 +699,24 @@ function normalizedRunningPath(value) { return process.platform === 'darwin' || process.platform === 'win32' ? text.toLowerCase() : text } -async function runningAppPathSnapshot() { - const now = Date.now() - if (runningAppsSnapshot && now - runningAppsSnapshot.updatedAt < RUNNING_APPS_SNAPSHOT_TTL_MS) return runningAppsSnapshot.paths +function runningSnapshotIsFresh() { + return Boolean(runningAppsSnapshot && Date.now() - runningAppsSnapshot.updatedAt < RUNNING_APPS_SNAPSHOT_TTL_MS) +} + +function sameRunningPathSet(a: Set | undefined, b: Set) { + if (!a || a.size !== b.size) return false + for (const item of a) if (!b.has(item)) return false + return true +} + +function refreshRunningAppPathSnapshot(reason: string) { if (runningAppsRefresh) return runningAppsRefresh - runningAppsRefresh = measureDebugPerformance('apps.running.snapshot', { indexedCount: appIndex.length, alwaysLog: true }, async () => { + const previousPaths = runningAppsSnapshot?.paths + runningAppsRefresh = measureDebugPerformance('apps.running.snapshot', { indexedCount: appIndex.length, reason, alwaysLog: true }, async () => { const paths = await detectRunningAppPaths(appIndex) runningAppsSnapshot = { updatedAt: Date.now(), paths } - markDebugPerformance('apps.running.snapshot.result', { count: paths.size }) + markDebugPerformance('apps.running.snapshot.result', { count: paths.size, reason }) + if (!sameRunningPathSet(previousPaths, paths)) paletteWindow.win?.webContents.send('apps:running-paths-changed') return paths }).finally(() => { runningAppsRefresh = null @@ -714,11 +724,17 @@ async function runningAppPathSnapshot() { return runningAppsRefresh } +function scheduleRunningAppPathSnapshotRefresh(reason: string) { + if (runningSnapshotIsFresh() || runningAppsRefresh) return + void refreshRunningAppPathSnapshot(reason).catch((error) => logWarn('apps.running.snapshot.failed', error, { source: 'host', scope: 'apps' })) +} + async function runningAppPathsForRenderer(appPaths) { - return measureDebugPerformance('apps.running.get', { requestedCount: Array.isArray(appPaths) ? appPaths.length : 0, alwaysLog: true }, async () => { + return measureDebugPerformance('apps.running.get', { requestedCount: Array.isArray(appPaths) ? appPaths.length : 0, cached: Boolean(runningAppsSnapshot), alwaysLog: true }, async () => { const requestedPaths = Array.from(new Set((Array.isArray(appPaths) ? appPaths : []).map((item) => String(item || '').trim()).filter(Boolean))).slice(0, 30) if (!requestedPaths.length) return [] - const runningPaths = await runningAppPathSnapshot() + scheduleRunningAppPathSnapshotRefresh('renderer-request') + const runningPaths = runningAppsSnapshot?.paths || new Set() return requestedPaths.filter((appPath) => runningPaths.has(normalizedRunningPath(appPath))) }) } @@ -4791,6 +4807,7 @@ async function indexApplications() { runningAppsSnapshot = null markDebugPerformance('apps.index.result', { scannedCount: apps.length, indexedCount: appIndex.length }) paletteWindow.win?.webContents.send('apps:indexed', appIndex.length) + scheduleRunningAppPathSnapshotRefresh('apps-indexed') } catch (error) { logError('applications.index.failed', error, { source: 'host', scope: 'apps' }) } diff --git a/src/electron/os.ts b/src/electron/os.ts index 1431577..f4669d9 100644 --- a/src/electron/os.ts +++ b/src/electron/os.ts @@ -214,22 +214,30 @@ function normalizedRunningPath(value: unknown) { return process.platform === 'darwin' || process.platform === 'win32' ? text.toLowerCase() : text } -async function runningMacAppPaths() { - const script = `set outputLines to {} -tell application "System Events" -repeat with appProcess in (application processes whose background only is false) -set appPath to "" -try -set appPath to POSIX path of (file of appProcess as alias) -end try -if appPath is not "" then set end of outputLines to appPath -end repeat -end tell -set AppleScript's text item delimiters to linefeed -return outputLines as text` - const result = await runAppleScript(script, 5_000) - if (result.exitCode !== 0) return new Set() - return new Set(result.stdout.split(/\r?\n/).map((item) => normalizedRunningPath(item)).filter(Boolean)) +async function macProcessExecutablePaths() { + return new Promise((resolve) => { + execFile('ps', ['-axo', 'comm='], { timeout: 1_000, maxBuffer: 1024 * 1024 }, (error, stdout) => { + if (error) return resolve([]) + resolve(stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) + }) + }) +} + +function macExecutableBelongsToApp(executablePath: string, appPath: string) { + const normalizedExecutable = normalizedRunningPath(executablePath) + const normalizedAppPath = normalizedRunningPath(appPath).replace(/\/+$/, '') + return normalizedExecutable.startsWith(`${normalizedAppPath}/contents/`) +} + +async function runningMacAppPaths(apps: RunningAppCandidate[] = []) { + const candidates = Array.from(new Set(apps.map((item) => item.path || item.id).filter((item): item is string => Boolean(item?.endsWith('.app'))))) + if (!candidates.length) return new Set() + const executables = await macProcessExecutablePaths() + const running = new Set() + for (const appPath of candidates) { + if (executables.some((executablePath) => macExecutableBelongsToApp(executablePath, appPath))) running.add(normalizedRunningPath(appPath)) + } + return running } function shellWords(command: string) { @@ -274,7 +282,7 @@ async function runningLinuxAppPaths(apps: RunningAppCandidate[] = []) { } export async function runningAppPaths(apps: RunningAppCandidate[] = []) { - return osFunction({ darwin: runningMacAppPaths, linux: () => runningLinuxAppPaths(apps) }, async () => new Set())() + return osFunction({ darwin: () => runningMacAppPaths(apps), linux: () => runningLinuxAppPaths(apps) }, async () => new Set())() } export function watchApps(onChange: () => void) { diff --git a/src/electron/preload.ts b/src/electron/preload.ts index f890370..1cd0e5b 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -93,6 +93,11 @@ const api: NevermindApi = { ipcRenderer.on('apps:indexed', listener) return () => ipcRenderer.removeListener('apps:indexed', listener) }, + onRunningAppPathsChanged: (callback) => { + const listener = () => callback() + ipcRenderer.on('apps:running-paths-changed', listener) + return () => ipcRenderer.removeListener('apps:running-paths-changed', listener) + }, onClipboardChanged: (callback) => { const listener = () => callback() ipcRenderer.on('clipboard:changed', listener) diff --git a/src/preload-api.ts b/src/preload-api.ts index accf38e..a72cb1e 100644 --- a/src/preload-api.ts +++ b/src/preload-api.ts @@ -134,6 +134,7 @@ export type NevermindApi = { onShortcutShown: (callback: () => void) => () => void onHidden: (callback: () => void) => () => void onAppsIndexed: (callback: (count: number) => void) => () => void + onRunningAppPathsChanged: (callback: () => void) => () => void onClipboardChanged: (callback: () => void) => () => void onRootItemsChanged: (callback: () => void) => () => void onOpenActionView: (callback: (payload?: OpenActionViewPayload) => void) => () => void