Skip to content

Commit 1db47d8

Browse files
author
cuiko
committed
ref: Split TermIMS.swift into SPM-managed sources
The single 2,185-line TermIMS.swift was getting unwieldy — navigating between, say, the matching logic and the table view code meant scrolling past several unrelated subsystems. Reorganise on Swift Package Manager along the same shape as the neighbouring notchnotes project: Package.swift # SPM manifest, executable target Sources/TermIMS/ # one file per logical concern main.swift # NSApplication boot AppDelegate.swift # status menu + lifecycle InputSource.swift # TIS wrappers Models.swift # Rule / TerminalRule / enums + notifs Log.swift # append-only debug log RuleStore.swift # UserDefaults-backed store TitleHeuristics.swift # String pattern + cwd predicates TerminalAdapters.swift # protocol + 5 adapters + subprocess utils IndicatorPanel.swift # NSPanel overlay FocusMonitor.swift # AX observers + match orchestration SettingsWindowController.swift # Settings UI + drag handle views PermissionWindowController.swift # onboarding modal Resources/AppIcon.png # source for the generated icns Scripts/package-app.sh # swift build + bundle assembly + sign Makefile # thin wrapper, same target names as before Info.plist # unchanged, copied into the .app The package script generates AppIcon.icns from AppIcon.png on every build (matching notchnotes), so the icns no longer lives in the repo. The Makefile keeps its public surface (\`make build/install/run/dist\`) so existing release docs and CLAUDE.md instructions still apply. \`fileprivate\` helpers that crossed the old file split — String extensions, \`FocusMonitor.ttyDev(forName:)\`, the subprocess utilities shared by kitty/wezterm adapters — were widened to internal so the new file layout compiles. Nothing changes in behaviour. Update README and CLAUDE.md to reflect the new layout and drop the "single Swift file compiled with swiftc" language.
1 parent 6da1494 commit 1db47d8

20 files changed

Lines changed: 2268 additions & 2217 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
build/
22
dist/
3+
.build/
4+
.swiftpm/
5+
Package.resolved
36
.DS_Store
47
*.o
58
*.swp

AppIcon.icns

-1 MB
Binary file not shown.

Makefile

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,13 @@
1-
APP_NAME := TermIMS
2-
BUILD_DIR := build
3-
BUNDLE := $(BUILD_DIR)/$(APP_NAME).app
4-
BIN := $(BUNDLE)/Contents/MacOS/$(APP_NAME)
1+
APP_NAME := TermIMS
2+
BUILD_DIR := build
3+
BUNDLE := $(BUILD_DIR)/$(APP_NAME).app
54
INSTALL_DIR := /Applications/$(APP_NAME).app
6-
SRC := TermIMS.swift
7-
PLIST := Info.plist
8-
DIST_DIR := dist
9-
FRAMEWORKS := -framework Cocoa -framework Carbon
5+
DIST_DIR := dist
106

11-
.PHONY: build install clean run dist
7+
.PHONY: build install run dist clean
128

13-
build: $(BIN)
14-
15-
$(BIN): $(SRC) $(PLIST) AppIcon.icns
16-
@mkdir -p $(BUNDLE)/Contents/MacOS $(BUNDLE)/Contents/Resources
17-
@cp $(PLIST) $(BUNDLE)/Contents/
18-
@cp AppIcon.icns $(BUNDLE)/Contents/Resources/
19-
swiftc -O -o $@ $(SRC) $(FRAMEWORKS)
20-
@codesign --force --sign - $(BUNDLE)
21-
@echo "Built → $(BUNDLE)"
9+
build:
10+
@bash Scripts/package-app.sh
2211

2312
install: build
2413
@pkill -x $(APP_NAME) 2>/dev/null && sleep 0.3 || true
@@ -40,4 +29,4 @@ dist: build
4029
@echo "Packaged → $(DIST_DIR)/$(APP_NAME).dmg"
4130

4231
clean:
43-
rm -rf $(BUILD_DIR) $(DIST_DIR)
32+
rm -rf $(BUILD_DIR) $(DIST_DIR) .build .swiftpm

Package.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// swift-tools-version: 5.9
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "TermIMS",
7+
platforms: [
8+
.macOS(.v13)
9+
],
10+
products: [
11+
.executable(name: "TermIMS", targets: ["TermIMS"])
12+
],
13+
targets: [
14+
.executableTarget(
15+
name: "TermIMS",
16+
path: "Sources/TermIMS"
17+
)
18+
]
19+
)

README.md

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<h1 align="center">TermIMS</h1>
22

3-
<p align="center"><img src="icon.png" width="128"></p>
3+
<p align="center"><img src="Resources/AppIcon.png" width="128"></p>
44

55
<p align="center">A lightweight macOS menu bar app that automatically switches input methods based on the active application — and for terminal apps, based on the running process or tab title.</p>
66

@@ -55,25 +55,20 @@ Download `TermIMS.dmg` from the [Releases](https://github.com/cuiko/TermIMS/rele
5555

5656
### Build from source
5757

58-
The project is a single Swift file compiled directly with `swiftc`. No Xcode project needed.
58+
Swift Package Manager layout, no Xcode project. `make` wraps `swift build -c release` and bundles the binary into `TermIMS.app` via `Scripts/package-app.sh`.
5959

6060
```sh
6161
git clone https://github.com/cuiko/TermIMS.git
6262
cd TermIMS
6363

64-
# Build locally
65-
make build
66-
67-
# Build and install to /Applications
68-
make install
69-
70-
# Build, install, and launch
71-
make run
72-
73-
# Package as DMG for distribution
74-
make dist
64+
make build # build into build/TermIMS.app
65+
make install # build + copy to /Applications
66+
make run # build + install + launch
67+
make dist # package dist/TermIMS.dmg
7568
```
7669

70+
Requires Swift 5.9+ (Xcode 15) and macOS 13.0+.
71+
7772
## Usage
7873

7974
1. Launch TermIMS (or run `make run`).
File renamed without changes.

Scripts/package-app.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env bash
2+
# Build the SPM target, then assemble a TermIMS.app bundle in build/.
3+
# Mirrors notchnotes' Scripts/package-app.sh layout: SPM builds the binary,
4+
# this script wraps it with Info.plist + an .icns generated from
5+
# Resources/AppIcon.png, and ad-hoc signs the result.
6+
set -euo pipefail
7+
8+
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
9+
BUILD_DIR="$ROOT_DIR/build"
10+
APP_DIR="$BUILD_DIR/TermIMS.app"
11+
CONTENTS_DIR="$APP_DIR/Contents"
12+
MACOS_DIR="$CONTENTS_DIR/MacOS"
13+
RESOURCES_DIR="$CONTENTS_DIR/Resources"
14+
SOURCE_ICON="$ROOT_DIR/Resources/AppIcon.png"
15+
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
16+
17+
cd "$ROOT_DIR"
18+
swift build -c release
19+
20+
rm -rf "$APP_DIR"
21+
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
22+
cp "$ROOT_DIR/.build/release/TermIMS" "$MACOS_DIR/TermIMS"
23+
cp "$ROOT_DIR/Info.plist" "$CONTENTS_DIR/Info.plist"
24+
25+
if [[ -f "$SOURCE_ICON" ]]; then
26+
TMP_DIR="$(mktemp -d)"
27+
ICONSET_DIR="$TMP_DIR/AppIcon.iconset"
28+
mkdir -p "$ICONSET_DIR"
29+
sips -z 16 16 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null
30+
sips -z 32 32 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null
31+
sips -z 32 32 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null
32+
sips -z 64 64 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null
33+
sips -z 128 128 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null
34+
sips -z 256 256 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null
35+
sips -z 256 256 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null
36+
sips -z 512 512 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null
37+
sips -z 512 512 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null
38+
sips -z 1024 1024 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null
39+
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/AppIcon.icns"
40+
rm -rf "$TMP_DIR"
41+
fi
42+
43+
codesign --force --sign "$SIGN_IDENTITY" "$APP_DIR" >/dev/null
44+
echo "Built → $APP_DIR"

Sources/TermIMS/AppDelegate.swift

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import Cocoa
2+
import ApplicationServices
3+
4+
class AppDelegate: NSObject, NSApplicationDelegate {
5+
private var statusItem: NSStatusItem?
6+
private let monitor = FocusMonitor()
7+
private let indicator = IndicatorPanel()
8+
private var settingsWC: SettingsWindowController?
9+
private var permissionWC: PermissionWindowController?
10+
private var enabledItem: NSMenuItem!
11+
private var loginItem: NSMenuItem!
12+
private var permissionPollTimer: Timer?
13+
private var wasTrusted = false
14+
15+
private var launchAgentPath: String {
16+
NSHomeDirectory() + "/Library/LaunchAgents/top.cuiko.termims.plist"
17+
}
18+
19+
func applicationDidFinishLaunching(_ n: Notification) {
20+
Log.debug("=== TermIMS started ===")
21+
installEditMenu()
22+
if AXIsProcessTrusted() {
23+
wasTrusted = true
24+
startApp()
25+
} else {
26+
showPermissionWindow()
27+
}
28+
startPermissionPolling()
29+
}
30+
31+
/// LSUIElement apps don't get a main menu by default, which means
32+
/// Cmd+C/V/X/A in any text field are dispatched into the void. Wire up
33+
/// a minimal Edit menu so the standard shortcuts reach the field editor.
34+
private func installEditMenu() {
35+
let main = NSMenu()
36+
let editItem = NSMenuItem()
37+
main.addItem(editItem)
38+
39+
let edit = NSMenu(title: "Edit")
40+
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
41+
let redo = NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "z")
42+
redo.keyEquivalentModifierMask = [.command, .shift]
43+
edit.addItem(redo)
44+
edit.addItem(.separator())
45+
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
46+
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
47+
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
48+
edit.addItem(withTitle: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: "")
49+
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
50+
51+
editItem.submenu = edit
52+
NSApp.mainMenu = main
53+
}
54+
55+
private func startPermissionPolling() {
56+
permissionPollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
57+
guard let self else { return }
58+
let trusted = AXIsProcessTrusted()
59+
if self.wasTrusted && !trusted {
60+
self.wasTrusted = false
61+
self.monitor.stop()
62+
self.rebuildMenu()
63+
} else if !self.wasTrusted && trusted {
64+
self.wasTrusted = true
65+
self.startApp()
66+
}
67+
}
68+
}
69+
70+
private func showPermissionWindow() {
71+
permissionWC = PermissionWindowController()
72+
permissionWC?.onGranted = { [weak self] in
73+
self?.permissionWC = nil
74+
self?.startApp()
75+
}
76+
permissionWC?.showWindow(nil)
77+
permissionWC?.startPolling()
78+
NSApp.activate(ignoringOtherApps: true)
79+
}
80+
81+
private var appStarted = false
82+
private func startApp() {
83+
guard !appStarted else { rebuildMenu(); monitor.reload(); return }
84+
appStarted = true
85+
applyMenuBarVisibility()
86+
NotificationCenter.default.addObserver(self, selector: #selector(rebuildMenu),
87+
name: .rulesDidChange, object: nil)
88+
NotificationCenter.default.addObserver(self, selector: #selector(imDidSwitch),
89+
name: .imDidSwitch, object: nil)
90+
monitor.start()
91+
}
92+
93+
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
94+
if !AXIsProcessTrusted() {
95+
showPermissionWindow()
96+
} else {
97+
showSettings()
98+
}
99+
return true
100+
}
101+
102+
@objc private func imDidSwitch() {
103+
guard RuleStore.shared.indicatorEnabled,
104+
let name = currentInputSourceName() else { return }
105+
indicator.show(text: name, position: RuleStore.shared.indicatorPosition)
106+
}
107+
108+
private func applyMenuBarVisibility() {
109+
if RuleStore.shared.hideMenuBarIcon {
110+
if let item = statusItem {
111+
NSStatusBar.system.removeStatusItem(item)
112+
statusItem = nil
113+
}
114+
} else {
115+
if statusItem == nil {
116+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
117+
if let btn = statusItem?.button {
118+
btn.image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: "TermIMS")
119+
}
120+
}
121+
rebuildMenu()
122+
}
123+
}
124+
125+
@objc private func rebuildMenu() {
126+
if RuleStore.shared.hideMenuBarIcon { applyMenuBarVisibility(); return }
127+
guard let statusItem else { applyMenuBarVisibility(); return }
128+
129+
let menu = NSMenu()
130+
let store = RuleStore.shared
131+
let trusted = AXIsProcessTrusted()
132+
133+
if !trusted {
134+
let warn = NSMenuItem(title: "Accessibility Permission Required", action: #selector(showPermissionPrompt), keyEquivalent: "")
135+
warn.target = self
136+
warn.image = NSImage(systemSymbolName: "exclamationmark.triangle", accessibilityDescription: nil)
137+
menu.addItem(warn)
138+
menu.addItem(.separator())
139+
}
140+
141+
enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled), keyEquivalent: "")
142+
enabledItem.target = self; enabledItem.state = monitor.enabled ? .on : .off
143+
menu.addItem(enabledItem)
144+
menu.addItem(.separator())
145+
146+
let defName = store.defaultSourceName ?? "None"
147+
let defItem = NSMenuItem(title: "Default: \(defName)", action: nil, keyEquivalent: "")
148+
defItem.isEnabled = false; menu.addItem(defItem)
149+
150+
let activeAppRules = store.rules.filter(\.enabled)
151+
if !activeAppRules.isEmpty {
152+
menu.addItem(.separator())
153+
let hdr = NSMenuItem(title: "App Rules", action: nil, keyEquivalent: "")
154+
hdr.isEnabled = false; menu.addItem(hdr)
155+
for rule in activeAppRules {
156+
let item = NSMenuItem(title: " \(rule.appName) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "")
157+
item.isEnabled = false; menu.addItem(item)
158+
}
159+
}
160+
161+
let activeTermRules = store.terminalRules.filter(\.enabled)
162+
if !activeTermRules.isEmpty {
163+
menu.addItem(.separator())
164+
let hdr = NSMenuItem(title: "Terminal Rules", action: nil, keyEquivalent: "")
165+
hdr.isEnabled = false; menu.addItem(hdr)
166+
for rule in activeTermRules {
167+
let typeStr = rule.matchType == .title ? "title" : "proc"
168+
let item = NSMenuItem(title: " \(typeStr):\(rule.pattern) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "")
169+
item.isEnabled = false; menu.addItem(item)
170+
}
171+
}
172+
173+
menu.addItem(.separator())
174+
let settings = NSMenuItem(title: "Settings\u{2026}", action: #selector(showSettings), keyEquivalent: ",")
175+
settings.target = self; menu.addItem(settings)
176+
177+
menu.addItem(.separator())
178+
loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLogin), keyEquivalent: "")
179+
loginItem.target = self
180+
loginItem.state = FileManager.default.fileExists(atPath: launchAgentPath) ? .on : .off
181+
menu.addItem(loginItem)
182+
183+
menu.addItem(.separator())
184+
let quit = NSMenuItem(title: "Quit TermIMS", action: #selector(quitApp), keyEquivalent: "q")
185+
quit.target = self; menu.addItem(quit)
186+
187+
statusItem.menu = menu
188+
}
189+
190+
@objc private func toggleEnabled() {
191+
monitor.enabled.toggle(); enabledItem.state = monitor.enabled ? .on : .off
192+
}
193+
@objc private func showPermissionPrompt() {
194+
showPermissionWindow()
195+
}
196+
@objc private func showSettings() {
197+
if !AXIsProcessTrusted() { showPermissionWindow(); return }
198+
if settingsWC == nil { settingsWC = SettingsWindowController() }
199+
settingsWC?.showWindow(nil); NSApp.activate(ignoringOtherApps: true)
200+
}
201+
@objc private func toggleLogin() {
202+
let fm = FileManager.default
203+
if fm.fileExists(atPath: launchAgentPath) {
204+
try? fm.removeItem(atPath: launchAgentPath)
205+
} else {
206+
try? fm.createDirectory(atPath: NSHomeDirectory() + "/Library/LaunchAgents", withIntermediateDirectories: true)
207+
let plist: NSDictionary = [
208+
"Label": "top.cuiko.termims",
209+
"ProgramArguments": [Bundle.main.executablePath ?? ""],
210+
"RunAtLoad": true,
211+
]
212+
plist.write(toFile: launchAgentPath, atomically: true)
213+
}
214+
loginItem.state = fm.fileExists(atPath: launchAgentPath) ? .on : .off
215+
}
216+
@objc private func quitApp() { monitor.stop(); NSApp.terminate(nil) }
217+
}

0 commit comments

Comments
 (0)