How Lightspot works
Lightspot is a small Swift package with no dependencies. This page walks through the source: how the panel is built, what happens on each keystroke, the one rule that keeps typing fast, how only one copy runs and how it updates itself, and how the app is built, released and tested.
Lightspot guide
The source
Lightspot is written in Swift with AppKit and SwiftUI and built with the Swift Package Manager: no Xcode project, no third-party packages. The code is split into three layers:
mac-lightspot/
├── Package.swift Swift 6 package, macOS 13+, no dependencies
├── build.sh release build → build/Lightspot.app
├── install.sh / uninstall.sh
├── Resources/Info.plist menu bar app (LSUIElement), no Dock icon
├── scripts/
│ ├── test_engine.swift engine tests
│ └── deep_verify.swift checks against the live system
└── Sources/Lightspot/
├── AppMain.swift entry point; wires everything with closures
├── Core/ search engine and every result provider
├── System/ hotkey, menu bar, login item, terminals, Spotlight,
│ single-instance guard, updater
└── UI/ SwiftUI views and the AppKit panel- Core holds the search engine and one provider per kind of result. It uses only Foundation and AppKit, never SwiftUI, and every provider is a shared instance or a static engine.
- System talks to macOS: the global hot key (Carbon
RegisterEventHotKey), the menu bar item, the login item (SMAppService), the seven terminal launchers and the Spotlight switches. - UI is SwiftUI inside an AppKit panel.
AppMain.swiftcreates the view model, the panel, the hot key and the menu bar controller and connects them with closures; there is no dependency-injection container or notification bus.
The panel
The window is a borderless, non-activating NSPanel at the floating level, 740 by 560 points, so it can take keystrokes without pulling the app you were in to the front. Inside it are a 54-point capsule for the search field and a 450-point results area with 26-point corners, both backed by NSVisualEffectView with the HUD material blurring what is behind the window: that is where the Spotlight look comes from. It opens on the screen with the mouse pointer, 16% below the top of the visible area, fading in while scaling from 97% to 100% in 0.18 seconds.
The search field is an NSTextField with its own field editor, so arrow keys, Tab, ↵ with modifiers and the overlay shortcuts reach Lightspot before the text field handles them. The same editor reports text that an input method is still composing, which AppKit does not announce by itself; that was the change in version 1.1.2.
What happens on a keystroke
Search is synchronous and runs on the main thread, with no debounce. Every change of the text calls the search engine, which asks each provider in turn and merges the answers:
- Applications
- System Settings
- Quick actions
- Process killer
- Custom commands
- Recent projects
- zsh history
- Developer tools
- Hardware readout
- Browser
- Clipboard
- Snippets
- Calculator and conversions
- Web search
The engine then adds the learned bonus to each score, picks the Top Hit, sorts every section, cuts it to four results (six for history and browser) and hands the sections to the view in a fixed display order. The display order is an explicit list, deliberately not the order in which the result types are declared, so reordering code cannot silently reshuffle the panel; a test keeps the list complete.
The repository measured a full pass at about 2.8 ms with 7 days of browser history (1,499 pages), 337 bookmarks and 1,200 shell commands in memory, and well under 1 ms without the browser data, which dominates the cost.
The rule that keeps typing fast
Because every provider runs on every keystroke, and SwiftUI re-evaluates menus on every redraw, nothing on the keystroke path may block. The project notes that breaking this rule has happened three times, and each time it was felt the same way: typing became slow. These are the kinds of calls that are not allowed there, with what they cost:
| Call | Cost |
|---|---|
| Asking Chrome for its open tabs over AppleScript | 300–465 ms |
| Asking Finder for the front window’s folder | about 260 ms |
| Running launchctl or mdutil to read Spotlight’s state | about 66 ms |
| Running lsof to map listening ports to processes | about 55–110 ms |
| Parsing 337 Chrome bookmarks | about 12 ms |
| Asking macOS whether the login item is enabled | about 3 ms |
The pattern used everywhere instead: keep a snapshot in memory, return it at once, and refresh it on a background queue when it is stale. A flag marks a refresh in progress, so a burst of keystrokes queues one refresh instead of one per letter. NSAppleScript is not thread-safe, so the browser and Finder queries each get their own serial queue. Where a blocking call is still needed, for example asking Finder for the folder at the moment you press ↵, it is a separate method from the non-blocking one that search uses.
Work that is in memory but grows with the data counts too. Apps, shell commands, browser items and clipboard entries compute their lowercase and tokenised forms once, when they are loaded, not on every search. Substring matching compares UTF-8 bytes directly instead of using String.contains, which goes through Foundation’s collation: about 0.11 µs instead of 3.3 µs against a 90-character title, multiplied by every candidate on every keystroke.
The last exception, the port lookup behind kill :port, which ran lsof inline, became a background snapshot in version 1.2.0, like the process list next to it.
One copy at a time
Before it does anything else, Lightspot takes an exclusive lock on ~/Library/Application Support/Lightspot/com.lightspot.app.lock and writes its process ID and a build identity (the executable’s path, inode and modification time) into it. The kernel drops the lock when the process ends, so a crash never leaves a stale lock behind. When a second copy starts:
- If the running copy is the same build, the new one sends it a “show the panel” message and exits, so opening Lightspot from Finder simply shows the search bar. A copy that started less than ten seconds ago ignores that message.
- If it is a different build (reinstalled, rebuilt or updated), the new copy asks the old one to quit, waits up to five seconds, and takes over; if the old copy is still running then, the new one exits instead.
Both messages are macOS distributed notifications named after the bundle identifier. Copies older than 1.2.0 do not listen for them, so they have to be quit by hand once.
How an update is installed
The update check reads the latest release from the GitHub API and compares versions. Installing downloads Lightspot.zip (only from GitHub hosts, also after redirects), unpacks it with ditto and checks that the bundle is com.lightspot.app, has its executable, and carries exactly the version the release names. There is no signature to check: releases are signed ad hoc, because the project has no Apple Developer ID, so the updater trusts the GitHub release the same way the installer does.
An app cannot safely replace itself while it runs, so Lightspot writes a small shell script to a temporary folder, starts it detached, and quits. The script waits for the old process to exit, moves the old app aside, copies the new one in with ditto, removes the quarantine flag, signs it ad hoc and opens it; if the copy fails, it moves the old app back. When the app’s folder is not writable by your account, the script runs with administrator rights, through Touch ID or the password dialog.
At rest
When the panel is closed, Lightspot waits for its hot key. The only recurring work is the clipboard check every 0.6 seconds, which compares the pasteboard’s change counter and reads the text only when it changed, and the daily update check. Hiding the panel, whether with Esc, the hot key or a click outside, clears the result lists and asks the memory allocator to give unused memory back to the system. On my Mac, idle Lightspot processes show 0.0% CPU and between 12 and 30 MB of memory in ps.
Build and release
build.sh runs swift build -c release with size optimisation (-Osize), whole-module and cross-module optimisation and exclusivity checks turned off, strips the binary, wraps it into Lightspot.app with its Info.plist and icon, and signs it locally. The resulting Apple silicon binary is about 3 MB. The app icon is drawn by a script with Core Graphics and packed with iconutil.
Releases are built by GitHub Actions on a macOS runner: a version tag (or a manual run that bumps the version) sets the version in Info.plist, builds for arm64 and x86_64, zips the app as Lightspot.zip and publishes it as a GitHub release, which is what the installer downloads.
Tests
There is no XCTest target. Instead, two plain Swift programs are compiled together with the sources and run from a clone of the repository:
scripts/test_engine.swiftchecks the engines with about 650 assertions in 32 groups: the calculator and conversions, the fuzzy matcher, every provider, ranking and what search history may keep, custom and pinned commands, settings backup, snippets, the hardware readout, the first-run flow, the Spotlight shortcut state, the updater and the single-instance guard. The public IP lookup is replaced by a stub. The first failure stops the run.scripts/deep_verify.swiftchecks the live system: that the System Settings links and AppleScripts are valid, that the terminals and apps are found, that calculator answers are right, and that the “no file indexing” promise holds. It runs up to 88 checks, depending on whether the Mac has a zsh history and VS Code projects.
swiftc -o /tmp/test_engine scripts/test_engine.swift Sources/Lightspot/Core/*.swift Sources/Lightspot/System/TerminalLauncher.swift Sources/Lightspot/System/AutoStartManager.swift Sources/Lightspot/System/SpotlightManager.swift Sources/Lightspot/System/FirstRunManager.swift Sources/Lightspot/System/UpdateManager.swift Sources/Lightspot/System/SingleInstanceGuard.swift && /tmp/test_engineThe project’s history is public: commits on GitHub, from the first commit on 2 September 2026 to the 1.2.0 release on 16 October 2026.