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
36 changes: 24 additions & 12 deletions src/main/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,39 @@ function clear(): void {
function rescan(): HydratePayload {
const config = getConfig()

// Demo mode short-circuits the disk read. The synthetic dataset is
// reproducible, so callers can toggle it on and off safely without
// losing or mixing real-data state.
if (config.demoMode) {
// Demo data is served in two cases:
// 1. `config.demoMode` is on (user opted in via Settings).
// 2. No folder is configured yet (first-launch fallback so the
// welcome modal renders against a populated app, not a blank
// shell — picking a folder swaps in real data).
// Either case short-circuits the disk read. The synthetic dataset is
// deterministic, so callers can toggle it on and off without losing
// or mixing real-data state. We track the *persisted* demoMode flag
// in `lastDemoMode` (not the fact we're rendering demo) so the
// hydrate() change-detection stays correct: when the user later
// picks a folder OR toggles demo on, the path/flag mismatch triggers
// a fresh rescan.
const usingDemoFallback = !config.superwhisperPath
if (config.demoMode || usingDemoFallback) {
const t0 = Date.now()
recordings = buildDemoRecordings(new Date(), config.fillerWords)
aggregates = computeAll(recordings, new Date())
indexedAt = new Date().toISOString()
lastScannedPath = null
lastDemoMode = true
lastScannedPath = config.superwhisperPath
lastDemoMode = config.demoMode
scanErrors = 0
scanSkipped = 0
console.log(`[cache] generated ${recordings.length} demo recordings in ${Date.now() - t0}ms`)
const reason = config.demoMode ? 'demo mode' : 'no folder configured'
console.log(
`[cache] generated ${recordings.length} demo recordings in ${Date.now() - t0}ms (${reason})`
)
return buildPayload(null)
}

const path = config.superwhisperPath
if (!path) {
clear()
return buildPayload('No recordings folder configured.')
}
// `superwhisperPath` is guaranteed non-null here: the demo fallback
// branch above catches the null case. Narrow via assertion so the
// `scan(path, …)` call below doesn't trip the type checker.
const path = config.superwhisperPath as string
if (!isPathValid(path)) {
clear()
return buildPayload(`Path not found: ${path}`)
Expand Down
31 changes: 31 additions & 0 deletions src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export function setConfig(patch: Partial<Config>): Config {
return merged
}

/**
* Wipe the persisted config back to defaults. Used by the "Reset app"
* affordance in Settings so the welcome flow can be re-tested without
* hand-editing `~/Library/Application Support/me.cyzr.superwhisper-
* analytics/config.json`. Writes the defaults explicitly so the
* file's mtime updates and the renderer's next `config:status` call
* sees the cleared state.
*/
export function resetConfig(): Config {
const fresh = defaultConfig()
const file = configFilePath()
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(fresh, null, 2), 'utf-8')
return fresh
}

/**
* Probe known SuperWhisper recordings paths. Returns the first that
* exists, or `null` if neither does (user must pick manually).
Expand All @@ -99,6 +115,21 @@ export function defaultPath(): string | null {
return null
}

/**
* Resolve a picked path to the actual recordings directory. The user
* might select the SuperWhisper parent folder (e.g. `.../com.super
* duper.superwhisper`) rather than its `recordings/` subdirectory.
* If the picked path isn't itself a valid recordings folder but has a
* `recordings/` child that is, promote to the child — saves the user
* having to re-navigate through the picker.
*/
export function resolveRecordingsPath(picked: string): string {
if (isPathValid(picked)) return picked
const sub = join(picked, 'recordings')
if (isPathValid(sub)) return sub
return picked
}

/**
* Cheap validity check: directory exists and at least one of its first
* five children contains a `meta.json`. Avoids walking all 11k entries
Expand Down
28 changes: 26 additions & 2 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import type { HydratePayload } from '@shared/types'
import { defaultPath, getConfig, isPathInsideHome, isPathValid, setConfig } from './config'
import {
defaultPath,
getConfig,
isPathInsideHome,
isPathValid,
resetConfig,
resolveRecordingsPath,
setConfig
} from './config'
import { hydrate, reindex, setFillerWords } from './cache'
import { checkForUpdatesManually, getUpdaterStatus, type UpdaterStatus } from './updater'
import { disableWatch, enableWatch } from './watcher'
Expand Down Expand Up @@ -46,7 +54,12 @@ export function registerIpcHandlers(): void {
ipcMain.handle('config:setPath', (_, path: unknown): ConfigStatus => {
// Accept string or null; ignore anything else.
if (path !== null && !validString(path)) return buildStatus()
setConfig({ superwhisperPath: path })
// Auto-promote a SuperWhisper parent-folder pick to its `recordings/`
// child if the parent itself isn't a valid recordings dir. Saves
// users having to re-navigate via the picker when they grabbed the
// SuperWhisper container by mistake.
const resolved = path === null ? null : resolveRecordingsPath(path)
setConfig({ superwhisperPath: resolved })
syncWatcher()
return buildStatus()
})
Expand Down Expand Up @@ -82,6 +95,17 @@ export function registerIpcHandlers(): void {
return buildStatus()
})

// Reset everything — wipes config.json back to defaults so the
// welcome flow shows again on next hydrate. Used by the "Reset app"
// affordance in Settings → About.
ipcMain.handle('config:reset', (): ConfigStatus => {
resetConfig()
// Watch is keyed off path, which is now null — kill any active
// watcher so we don't leak a handle pointing at the old folder.
disableWatch()
return buildStatus()
})

ipcMain.handle('dialog:pickFolder', async (event): Promise<string | null> => {
const win = BrowserWindow.fromWebContents(event.sender)
const opts = {
Expand Down
7 changes: 6 additions & 1 deletion src/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ export const api = {
* (≤900px) hide the sidebar automatically; when off the user controls
* it entirely via Cmd-B / the navbar icon. */
setAutoHideSidebar: (enabled: boolean): Promise<ConfigStatus> =>
ipcRenderer.invoke('config:setAutoHideSidebar', enabled)
ipcRenderer.invoke('config:setAutoHideSidebar', enabled),
/** Wipe the persisted config back to defaults — clears the saved
* folder, demo flag, custom filler dictionary, etc. The renderer
* follows up by triggering a fresh hydrate so the dataStore picks
* up the cleared state. Used by Settings → About → Reset app. */
reset: (): Promise<ConfigStatus> => ipcRenderer.invoke('config:reset')
},
data: {
hydrate: (): Promise<HydratePayload> => ipcRenderer.invoke('data:hydrate'),
Expand Down
29 changes: 18 additions & 11 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,21 @@ function App(): React.JSX.Element {
void hydrateConfig()
}, [hydrateConfig])

// Once config is hydrated, drive the data store off the path's
// validity. Valid path → hydrate (cache rescans transparently if the
// path has changed). Invalid path → clear data (so stale aggregates
// from a previous folder don't linger after the user breaks the
// path).
// Once config is hydrated, ask main for data. Main decides what to
// serve:
// • configured + valid path → real recordings
// • demo mode on → synthetic dataset
// • no folder configured → demo fallback (so screens look
// populated behind the welcome modal on a fresh install)
// clearData() is intentionally not called for the "no folder" path
// any more — the demo fallback covers that case and avoids a brief
// empty-screen flash before the modal animates in.
useEffect(() => {
if (!configHydrated) return
if (configValid) {
void hydrateData()
} else {
clearData()
}
void hydrateData()
// `clearData` is referenced here purely so React's lint rule for
// exhaustive deps stays happy when other branches re-introduce it.
void clearData
}, [configHydrated, configValid, hydrateData, clearData])

// Subscribe to fs.watch invalidation pushes from main — when the user
Expand All @@ -77,7 +80,11 @@ function App(): React.JSX.Element {
return (
<>
<RouterProvider router={router} />
{dataLoading && <LoadingOverlay />}
{/* LoadingOverlay only covers the screen for real-data scans
(configured + valid path). On a fresh install we serve demo
data behind the welcome modal — no need for a loading curtain
there too. */}
{configValid && dataLoading && <LoadingOverlay />}
</>
)
}
Expand Down
77 changes: 0 additions & 77 deletions src/renderer/src/components/FirstRunModal.tsx

This file was deleted.

Loading
Loading