🚀 Features
🛠 Technical Learnings
• Building Spotlight-like apps with Electron framework
• Publishing Electron apps to the macOS App Store
• Integrating LLM APIs into desktop applications
You are switching to another project in VS Code. How?
You are launching some VS Code project folder when VS Code is not launched.
Solution: Just press ⌘ + Ctrl + R to quickly launch the SwitchV launcher.
Instanly
Recent Opened list, order by time, item could be outside working directory
Preload list
Menu bar icon, clicking it would app SwitchV too
VS Code extension
Electron UI renderer process - Chromium
Electron main process
VS Code
listening opened window event
Send opened window record to to NestJS server in main process of SwitchV app
NestJS using Prisma to save info. to sqlite (preference, recent open window path)
IPC (shared memory based)
React UI
OS Native UI
/API: Menu bar
When invoking the shortcut, react app request the list from server in electron,
When clicking one project item,
/** webpack.main.config.ts : entry: './src/main.ts'
/** main.ts: using electron": 29.4.6 */
import { app, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
// window (Chromium)
const createSwitcherWindow = (): BrowserWindow => {
const window = new BrowserWindow({
height: 600,
width: 800,
webPreferences: {
// webpack entry, inject by forge
preload: SWITCHER_WINDOW_PRELOAD_WEBPACK_ENTRY,
devTools: true,
},
});
};
// app
app.dock.hide();
app.once('before-quit', () => {
// clean up resources
});
// macOS menu bar
class TrayGenerator {
this.tray = new Tray(icon); // in some method
const menu = { /* */ }
this.tray.popUpContextMenu(Menu.buildFromTemplate(menu));
}
(async () => {
await app.whenReady(); // equivalent to old app.on('ready)
// some setup, e.g. createSwitcherWindow(), new TrayGenerator
})();
/** webpack.renderer.config.ts:
* some normal webpack compile setting */
/** switcher-renderer.ts content: */
import './switcher-ui';
/** switcher-ui.tsx (react app) */
import { FC, useCallback, useEffect, useRef, useState } from 'react';
// ...
const config: ForgeConfig = {
plugins: [
new WebpackPlugin({
// from webpack.main.config.ts
mainConfig: mainConfig
// from webpack.renderer.config.ts
renderer: rendererConfig,
entryPoints: [
{
html: './src/switcher.html',
js: './src/switcher-renderer.ts',
name: 'switcher_window',
preload: {
js: './src/preload.ts',
},
main.ts
forge.config.ts
(Electron Forge all-in-one tool)
UI entry files
// preload.ts where we set up IPC events
contextBridge.exposeInMainWorld('electronAPI', {
// react -> main.ts
// swithcer-ui.tsx: in popup event handler, calls
// (window as any).electronAPI.openFolderSelector()
// main.ts: register
// ipcMain.on('open-folder-selector', async (event) => {
// const result = await dialog.showOpenDialog({
openFolderSelector: () => ipcRenderer.send('open-folder-selector')
// main -> react
// main.ts
// switcherWindow.webContents.send('folder-selected', folderPath);
// switcher-ui.tsx register
// (window as any).electronAPI.onFolderSelected(
// async (_event: any, folderPath: string) => {
//if (!folderPath) {
// return;
onFolderSelected: (callback: any) => // we can add TS type !
ipcRenderer.on('folder-selected', callback),
//...
}
1. Background app: do not call `window.show()`
2. DB: Using Prisma to get flexibility for easily migrating to cloud DB
3. macOS App version: Use `version` field of `package.json` to represent
4. Auto DB migration:
1. Read app version while starting up,
2. then compare it with the version stored in `${app.getPath('userData')}/dbUsedVersion.txt`,
1. if not the same and not exist, execute Prisma migrate
1. (important!) use `import { fork } from 'child_process')` to let Prisma migration using Electron's node
2. if the same, not migrate
3. Sync app version with the dbUsedVersion.txt
4. You need to specifcy the SQLite path and Prisma binary path in the packaged app. e.g.,
const resourcePath = path.resolve(`${app.getAppPath()}/../`);
process.env.PRISMA_MIGRATION_ENGINE_BINARY = `${resourcePath}/migration-engine-darwin`
process.env.DATABASE_URL = `file:${app.getPath('userData'}/${dbFileName}`;
Tip1: Use Prisma Studio to debug DB: npm run db:view, which opens http://localhost:5555/
Tip 2: Electron lifecycles need to be handled well. Window close, widow hide, or creating window without showing to speed up
1. Set up app name, requested permission in the `parent.plist` and `child.plist` (below ref1)
2. Execute `npm run make_mas` to build switchv.app: `electron-forge make --platform=darwin -- --mas`
1. invokes prebuild.sh: cp files you additinally use. Prisma binary files and index.js file
3. Get macOS app embedded.provisionprofile on Apple developer site and put it the project root
4. Get the cerficate. ref: https://www.electronjs.org/docs/latest/tutorial/code-signing#signing--notarizing-macos-builds
5. Execute `sign.sh` (customize it for your need, e.g. app name, cerficate name)
1. copy the provisionprofile
2. use `plutil` to update `ElectronTeamID` Contents/Info.plist`
3. `codesign` for the Electron built-in binary files and your Prisma binary files
4. `productbuild` (app -> pkg)
6. Submit the pkg to app store (or TestFlight first).
7. if you are not submitting to app store but want to share the app with others, one more step: **notarization**,
https://www.electronjs.org/docs/latest/tutorial/code-signing
ref1: key>com.apple.security.files.user-selected.read-write</key>
Debugging tips of a packaged app (and also MAS built ver. for app store):
Tip 1: Inject the log in the menu bar app icon title, like. SwitchV(project_log:...).
Tip 2 (not for MAS): You can execute the built-in final executable file (e.g. "switchv/out/SwitchV-darwin arm64/SwitchV.app/Contents/MacOS/SwitchV" to check log, rather than clicking the built-in macOS app.
Tip 3: devtool.
I had tried to bundle an individual NestJS node.js program in electron, but the takeaway is the increased overhead and complexity resulted in it being harder to package the whole app
Pros:
A lot of out-of-box extensions, and extensible
core source is not open-source (not 100% customizable)
When you want to feed some code or text content to LLM AI to get some feedback or analysis,
auto-scroll
const stream = await this.anthropic.messages.create({
model: 'claude-3-7-sonnet-20250219',
max_tokens: 4000,
messages: [
{
role: 'user',
// // selected code/text
content: prompt,
},
],
stream: true,
});
for await (const chunk of stream) { // JavaScript AsyncIterator
//..
window.webContents.send('detected-language', detectedLanguage);
window.webContents.send(
'ai-assistant-insight-chunk',
chunk.delta.text,
);
//..
}
AnthropicService.ts in main process
main.ts in main process
renderer process (ai-assistant-ui.tsx)
- get streaming resp and show it
Chat/Insight history
You can use [⌘][n] to
open a new chat quickly
✅ What we learned
🔭 What’s next? (Future improvements)