Shellporter is a menu bar-only macOS application that detects the project directory of the active IDE window and opens a terminal at that location with a single hotkey press.
- Language: Swift 6.2 (strict concurrency)
- UI framework: AppKit + SwiftUI (preferences/onboarding only)
- Build system: SwiftPM (no Xcode project)
- External dependencies: None
- Minimum target: macOS 14
- Activation policy:
.accessory(menu bar only, no dock icon)
Sources/Shellporter/
main.swift App entry point
App/
AppDelegate.swift Status bar, lifecycle, lazy deps
AppDelegate+Accessibility.swift Permission polling + onboarding
AppDelegate+Menu.swift Dynamic menu rebuild
AppDelegate+Terminal.swift Hotkey callbacks, resolve-then-launch flow
AppDelegate+Windows.swift Window controller helpers
Resolver/
FocusedProjectResolver.swift Orchestrator: strategy chain per IDE family
AXWindowInspector.swift macOS Accessibility API (AXUIElement)
PathHeuristics.swift Title parsing, path normalization, project root detection
JetBrainsRecentProjectsResolver.swift recentProjects.xml parser + tiered scoring
EditorRecentsResolver.swift VS Code / Cursor / Antigravity storage.json parser
ResolutionCacheStore.swift LRU cache (200 entries, JSON-persisted)
ResolverModels.swift IDEFamily enum, ResolvedProjectContext, ResolverAttempt
Terminal/
TerminalLauncher.swift Per-terminal launch logic (5 terminal types)
Config/
AppConfig.swift Settings model + TerminalChoice enum
ConfigStore.swift JSON persistence, auto-detect, fallback
SystemTerminalDetector.swift Reads default terminal from macOS URL scheme handler
Hotkey/
HotKeyManager.swift Carbon Events global hotkey registration
HotKeyShortcut.swift Key code display helpers
Diagnostics/
Logger.swift File logger with 2 MB rotation
Localization/
AppStrings.swift Localized string accessors
UI/
SettingsView.swift SwiftUI preferences
SettingsViewModel.swift Debounced config updates via Combine
AboutView.swift SwiftUI about window
AccessibilityOnboardingView.swift First-run permission prompt
Resources/
Localization/en.lproj/Localizable.strings
(icon assets)
Tests/ShellporterTests/ Unit tests for resolvers, config, heuristics
Scripts/
compile_and_run.sh Kill + package + relaunch dev loop
package_app.sh .app bundle assembly + signing
make_dmg.sh Manual-install DMG assembly + notarization
setup_dev_signing.sh Create stable local codesign identity
sign-and-notarize.sh Notarization (scaffolded)
make_appcast.sh Sparkle appcast generation (scaffolded)
build_icon.sh Icon.icon -> Icon.icns conversion
main.swift manually creates NSApplication, sets an AppDelegate, sets .accessory activation policy (no dock icon), and calls app.run(). No storyboards, no @main.
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()When the user presses the hotkey (default Ctrl+Opt+Cmd+T):
1. HotKeyManager (Carbon Events) fires onPress callback
2. AppDelegate.resolveAndOpenTerminal(using:)
3. Check accessibility permission; show onboarding if missing
4. FocusedProjectResolver.resolve(targetApp:)
5. Determine target app (frontmost, or cached "last external app")
6. Identify IDE family from bundle ID
7. Take AX window snapshot (title + document)
8. Run strategy chain (IDE-specific order)
9. On success: cache result, return path
10. On failure: try cache lookup, then return nil
11. If path found: TerminalLauncher.launch(at:choice:config:)
12. If path nil: show NSOpenPanel for manual folder pick
A second hotkey (Ctrl+Opt+Cmd+C) copies a cd command to the pasteboard instead and focuses the terminal.
IDEFamily.from(bundleIdentifier:) classifies the frontmost app:
| Family | Bundle ID patterns |
|---|---|
jetBrains |
com.jetbrains.*, org.jetbrains.*, com.intellij.*, com.google.android.studio* |
vscode |
com.microsoft.vscode, com.microsoft.vscodeinsiders, com.vscodium |
cursor |
*cursor*, com.todesktop.230313mzl4w4u92 |
antigravity |
com.google.antigravity |
xcode |
com.apple.dt.xcode |
unknown |
Everything else |
Each IDE family runs strategies in a specific order. The first successful strategy wins.
| Family | Strategy order |
|---|---|
| JetBrains | TitlePaths -> JetBrainsRecentProjects -> AXDocument -> Cache |
| VS Code / Cursor / Antigravity | AXDocument -> TitlePaths -> EditorRecents -> Cache |
| Xcode | AXDocument -> TitlePaths -> Cache |
| Unknown | AXDocument -> TitlePaths -> Cache |
Why different orders? JetBrains IDEs expose the project name reliably in the window title, but their AXDocument often points to a single file deep in the tree. VS Code exposes the workspace URI directly via AXDocument, making it the fastest source. Xcode's AXDocument is unreliable for workspace-level paths.
Reads kAXDocumentAttribute from the focused AX window element. If it's a file:// URI or absolute path, normalize it (strip file name, find project root). Fastest when available.
Parses the window title for path-like tokens:
- Split on common separators (
--,-,-) - Scan for tokens starting with
/or~ - Regex scan for embedded paths:
(~|/)[A-Za-z0-9._/\-]+ - For each candidate, check existence on disk and normalize
Parses recentProjects.xml files from ~/Library/Application Support/JetBrains/*/options/ and ~/Library/Application Support/Google/*/options/ (Android Studio).
Files are sorted by modification date (newest first). The XML parsing extracts <entry key="..."> elements with RecentProjectMetaInfo metadata including frame title, opened status, and timestamps.
Tiered scoring prevents false positives when multiple similar projects exist:
| Tier | Condition | Rationale |
|---|---|---|
| 0 | Frame title matches current window title AND mentions candidate path | Strongest: live window match |
| 1 | Folder name exactly matches a title hint (literal or canonical) | Current title is source of truth |
| 2 | Frame title contains the candidate's full path | Stale but useful when no exact name match |
| 3 | Partial/substring folder name overlap | Weakest: catches feed-flow matching feed-flow-2 |
Within a tier, candidates are further ordered by: isOpened > isLastOpened > activationTimestamp > projectOpenTimestamp > sourceRank > depth > path string.
Path normalization handles JetBrains tokens like $USER_HOME$ and $USER_HOME$.
Parses VS Code's storage.json from the editor's globalStorage directory. Uses two extraction methods:
- JSON traversal: Recursively searches for
history.recentlyOpenedPathsList.entries, then extracts paths from keys likefolderUri,workspaceUri,fileUri,folder,workspace,path,fsPath. - Regex fallback: Scans raw text for
file:///...URIs and absolute paths.
Both methods handle URI encoding (\u002F, \/, file:// prefix stripping).
Matching uses project name hints from the window title: exact match first, partial match second, most recent entry as last resort.
Last-resort strategy: looks up a previously successful resolution from the on-disk LRU cache. Handled on the main actor (not the resolver queue) because ResolutionCacheStore is @MainActor-isolated.
Two cache keys per resolution:
- Exact:
<bundleID>|title|<normalized window title>-- precise recall for the same window, handles multi-project setups (e.g. two IntelliJ windows with different projects). - Last:
<bundleID>|last-- fallback when the title has changed or is empty, covers the common single-project-per-editor case.
Lookup tries exact key first (higher confidence), then last key. Both require the cached path to still exist on disk.
PathHeuristics.normalizeProjectPath(from:):
- Standardize the URL
- If it's a file (not directory), go to parent
- If it ends in
.xcodeprojor.xcworkspace, go to parent - Walk up the directory tree looking for project root markers
Project root markers: .git, .hg, .svn, .xcworkspace, .xcodeproj, .code-workspace.
AXWindowInspector.snapshot(pid:) collects candidate windows in priority order:
- Focused window (
kAXFocusedWindowAttribute) -- preferred - Main window (
kAXMainWindowAttribute) - All windows (
kAXWindowsAttribute)
Focused window is always preferred to avoid reading a stale title from a background project window.
When the hotkey fires, Shellporter may already be frontmost (the menu was clicked). preferredTargetApp() handles this:
- If the frontmost app is not Shellporter, use it
- Otherwise, use
lastKnownExternalApp(tracked viaNSWorkspace.didActivateApplicationNotification) - If that's terminated, fall back to frontmost
Each terminal type has distinct launch mechanics:
AppleScript via osascript. If Terminal is already running, opens a new tab (do script "cd ..."). If not, waits up to 2 seconds for the window to appear, then runs the command.
AppleScript with session reuse. Sessions are named with a marker (shellporter:<path>). On subsequent invocations for the same path, Shellporter scans all iTerm2 windows/tabs/sessions for a matching marker and selects it instead of creating a duplicate. If no matching session exists, iTerm2OpenNewWindow controls whether Shellporter opens a dedicated window or a new tab.
CLI-based single-instance launch: kitty --single-instance --directory=<path>. Searches for the binary in /Applications/kitty.app/Contents/MacOS/kitty, /opt/homebrew/bin/kitty, /usr/local/bin/kitty, /usr/bin/kitty. Falls back to open -a kitty if the binary isn't found.
Two modes controlled by ghosttyOpenNewWindow config:
- Single instance (default): Tries
ghostty +new-window --working-directory=<path>(Kitty-style CLI, one dock icon). Falls back toopen -a Ghostty <path>(new tab in existing instance). - New window: Uses Ghostty 1.3+'s AppleScript API to create a new window with the project as its initial working directory inside the existing app instance. If AppleScript is unavailable or disabled, falls back to
open -na Ghostty --args --working-directory=<path>(separate process, may show an extra dock icon).
Note: Ghostty's macOS CLI support for +new-window / --working-directory is not fully stable yet (ghostty-org/ghostty#2353).
User provides a template string. {path} is replaced with the shell-escaped project path. Executed via /bin/zsh -lc "<command>".
Two escaping functions on String:
shellEscapedForBash(): Single-quote wrapping with'->'"'"'replacementappleScriptEscaped(): Backslash-escapes\,",\n,\r,\t
Uses the Carbon Events API (RegisterEventHotKey / InstallEventHandler), not the modern CGEvent tap, because Carbon hotkeys work reliably as a background/accessory app without requiring the "Input Monitoring" permission.
Each HotKeyManager instance:
- Installs an event handler for
kEventClassKeyboard/kEventHotKeyPressed - Registers the hotkey with a unique 4-char signature + numeric ID
- Uses
Unmanaged.passUnretained(self)as the event handler'suserData - Has an
isInvalidatedflag checked in the callback as a safety net against stale pointers
Two independent managers: "SHPO" (open terminal) and "SHPC" (copy cd command).
~/Library/Application Support/Shellporter/config.json:
{
"customCommandTemplate": "open -a Terminal {path}",
"defaultTerminal": "terminal",
"ghosttyOpenNewWindow": false,
"hotkeyKeyCode": 17,
"hotkeyModifiers": 2816,
"copyCommandHotkeyKeyCode": 8,
"copyCommandHotkeyModifiers": 2816
}hotkeyKeyCode17 =T,copyCommandHotkeyKeyCode8 =ChotkeyModifiers2816 =controlKey | optionKey | cmdKey
On first launch (no config file), ConfigStore:
- Uses
SystemTerminalDetectorto query the macOS default handler forx-man-page://URL scheme - Maps the handler's bundle ID to a
TerminalChoice - Creates config with the detected terminal as default
When loading an existing config, if the configured terminal is no longer installed, ConfigStore auto-detects a replacement and updates the config.
SettingsViewModel wraps ConfigStore with Combine publishers. Updates are debounced (300ms) before persisting. Hotkey changes unregister/re-register the Carbon event handlers immediately.
The app requires macOS Accessibility permission to read window titles and document attributes via AXUIElement.
applicationDidFinishLaunching: checkAXIsProcessTrusted()- If not trusted, show onboarding window + start 1.5s polling timer
- Timer calls
AXIsProcessTrustedWithOptions(without prompt) each tick - On grant: stop timer, dismiss onboarding, rebuild menu
- Manual grant: "Grant Permission" menu item calls
AXIsProcessTrustedWithOptions(prompt: true)which opens System Settings
Ad-hoc signed builds lose Accessibility permission on every rebuild (macOS TCC keyed on code signature). The setup_dev_signing.sh script creates a self-signed "Shellporter Development" certificate in the login keychain for a stable identity that persists across rebuilds.
File-based logging to ~/Library/Logs/Shellporter/app.log:
- ISO 8601 timestamps
- 2 MB rotation (current ->
app.1.logbackup, then overwrite) - Non-blocking writes via
DispatchQueue(qos: .utility) - Resolver logs every attempt with strategy name, success/fail, path, and details
ResolvedProjectContext.diagnosticsSummary produces a structured text block (app name, bundle ID, IDE family, resolved path, all attempts). The "Copy Last Resolution Info" menu item puts this on the pasteboard for bug reports.
~/Library/Application Support/Shellporter/resolution-cache.json
Live resolution strategies (AX APIs, title parsing, XML/JSON metadata files) are the preferred data source because they reflect current state, but they can all fail transiently:
- AX attributes return nil during app transitions. When an IDE is launching, switching windows, or entering/exiting full screen, the Accessibility API often reports no title or document for a brief period. The window exists, but its attributes aren't populated yet.
- Window titles change when switching tabs. VS Code and Cursor replace the workspace name in the title with the active file name. Once the title no longer contains a path or project name, title-based strategies lose their signal.
- IDE metadata files aren't continuously updated. JetBrains
recentProjects.xmland VS Codestorage.jsonare written on project open/close, not on every focus change. If the user opened a project but the file hasn't been flushed yet, parsing finds nothing. - Unknown IDEs have limited strategies. Apps the resolver doesn't recognize (
IDEFamily.unknown) only have AXDocument and title parsing. If neither works, there is no deeper metadata to fall back on -- without a cache, the resolution simply fails.
The cache turns every successful resolution into a durable fallback. Once Shellporter resolves a path for an app/window, the answer is remembered and can be returned instantly even if every live strategy fails on the next hotkey press. This also avoids re-parsing XML/JSON on every invocation when the user is working in the same project for hours.
Every successful live resolution writes two cache entries (see CachedResolution strategy above for key format). The cache is the last strategy in every chain: live data is always tried first.
Cached paths are validated against the filesystem before use:
lookup()checksfileExistsbefore returning a hit -- a deleted/moved project won't produce a stale result.load()prunes all entries whose paths no longer exist on disk, keeping the cache file clean across app restarts.
- LRU eviction at 200 entries (oldest
lastUseddate evicted first) - Supports transparent migration from a legacy
[String: String]format to the current[String: CacheEntry]format with timestamps - ISO 8601 date encoding, pretty-printed JSON for debuggability
# Quick build + run
./Scripts/compile_and_run.sh
# Run Shellporter Dev.app alongside Shellporter.app
./Scripts/compile_and_run.sh --dev
# With tests
./Scripts/compile_and_run.sh --test
# Universal binary
./Scripts/compile_and_run.sh --release-universalcompile_and_run.sh:
- Kills the targeted Shellporter app variant
- Optionally runs
swift test - Detects a stable signing identity ("Shellporter Development") or falls back to ad-hoc
- Calls
package_app.sh release - Launches the app and verifies it stays running
swift build -c release --arch <arch>for each target architecture- Create
.appbundle directory structure - Build
Icon.icnsfromIcon.icon(Icon Composer format) if present - Generate
Info.plistwith version, build timestamp, git commit - Install binary (or
lipofor universal) - Copy SwiftPM resource bundles to
Contents/Resources/ - Embed frameworks to
Contents/Frameworks/if any - Strip extended attributes (
xattr -cr) to prevent code signing failures - Generate empty entitlements file
- Sign embedded frameworks first, then the app bundle
- Verify signature with
codesign --verify
Read from version.env at project root (if present), otherwise defaults to 0.1.0 build 1.
| Mode | When | Identity |
|---|---|---|
| Named identity | APP_IDENTITY env var set or local dev cert found |
The named certificate |
| Ad-hoc | No identity available | - (ad-hoc) |
sign-and-notarize.sh, make_dmg.sh, and make_appcast.sh support the manual release flow. The zip remains the Sparkle update artifact; the DMG is the GitHub Release artifact for manual installation.
-
No
@main: The app uses manualNSApplicationsetup inmain.swiftbecause SwiftPM executable targets need an explicit entry point.@mainwithAppprotocol would require an Xcode project or workarounds. -
Carbon vs CGEvent: Carbon
RegisterEventHotKeyis used instead ofCGEventTapbecause event taps require "Input Monitoring" permission, while Carbon hotkeys work with just the process being alive. This is a deliberate trade-off: Carbon is legacy but requires fewer permissions for a menu bar utility. -
Unmanaged.passUnretainedin HotKeyManager: The event handler callback needs a pointer to the manager.passUnretainedavoids a retain cycle but requires careful lifetime management. TheisInvalidatedflag is the safety net. -
Resolver queue vs main actor: Non-cached strategies (file I/O heavy) run on a dedicated
DispatchQueue(qos: .userInitiated)viawithCheckedContinuation. Cache lookup stays on@MainActorbecauseResolutionCacheStoreis@MainActor-isolated. -
JetBrains title-first strategy: JetBrains IDEs put the project name in the window title more reliably than in AXDocument. AXDocument typically points to a single file, not the project root. So
TitlePathsruns beforeAXDocumentfor JetBrains, opposite to VS Code. -
Xcode bundle normalization: If the resolved path ends in
.xcodeprojor.xcworkspace, the normalizer strips it to the parent directory. Without this, the terminal would open inside the Xcode bundle (which is a directory). -
iTerm2 session reuse: Sessions are tagged with
shellporter:<standardized path>. On re-invocation, Shellporter iterates all iTerm2 windows/tabs/sessions via AppleScript to find and select the matching session rather than creating a duplicate. New unmatched launches use the configured iTerm2 window/tab preference. -
Ghostty dual mode: The
ghosttyOpenNewWindowconfig controls whether Ghostty may reuse an existing window as a tab, or explicitly creates a separate window. New-window mode prefers Ghostty's AppleScript API so the window stays in the existing app instance;open -nais only the compatibility fallback. -
Cache dual-key strategy: Every successful resolution writes two cache entries: one keyed by window title (for precise recall) and one keyed by
|last(for when the title changes or is empty). This means the cache can serve both "same project, same window" and "same app, any window" lookups. -
xattr -crbefore signing: macOS extended attributes (especially._*AppleDouble files created by file copies) break code signing. The packaging script strips all xattrs as a preventive measure. -
Logger
@unchecked Sendable: The logger uses a dispatch queue for serialization, which makes it concurrency-safe but not provably so to the compiler.@unchecked Sendablesuppresses the warning. -
Config migration:
ResolutionCacheStoresupports transparent migration from an older[String: String]cache format to the current[String: CacheEntry]format (which includeslastUseddates). This prevents cache loss on upgrade.
| What | Path |
|---|---|
| User config | ~/Library/Application Support/Shellporter/config.json |
| Resolution cache | ~/Library/Application Support/Shellporter/resolution-cache.json |
| App log | ~/Library/Logs/Shellporter/app.log |
| Log backup | ~/Library/Logs/Shellporter/app.1.log |
| JetBrains recents | ~/Library/Application Support/JetBrains/*/options/recentProjects.xml |
| Android Studio recents | ~/Library/Application Support/Google/*/options/recentProjects.xml |
| VS Code recents | ~/Library/Application Support/Code/User/globalStorage/storage.json |
| Cursor recents | ~/Library/Application Support/Cursor/User/globalStorage/storage.json |