Skip to content

Commit e9fafb5

Browse files
author
cuiko tang
authored
Merge pull request #2 from cuiko/refactor/spm-layout
ref: Reorganise into Swift Package Manager layout
2 parents 6da1494 + 1db47d8 commit e9fafb5

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)