Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
build/
dist/
.build/
.swiftpm/
Package.resolved
.DS_Store
*.o
*.swp
Expand Down
Binary file removed AppIcon.icns
Binary file not shown.
27 changes: 8 additions & 19 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
APP_NAME := TermIMS
BUILD_DIR := build
BUNDLE := $(BUILD_DIR)/$(APP_NAME).app
BIN := $(BUNDLE)/Contents/MacOS/$(APP_NAME)
APP_NAME := TermIMS
BUILD_DIR := build
BUNDLE := $(BUILD_DIR)/$(APP_NAME).app
INSTALL_DIR := /Applications/$(APP_NAME).app
SRC := TermIMS.swift
PLIST := Info.plist
DIST_DIR := dist
FRAMEWORKS := -framework Cocoa -framework Carbon
DIST_DIR := dist

.PHONY: build install clean run dist
.PHONY: build install run dist clean

build: $(BIN)

$(BIN): $(SRC) $(PLIST) AppIcon.icns
@mkdir -p $(BUNDLE)/Contents/MacOS $(BUNDLE)/Contents/Resources
@cp $(PLIST) $(BUNDLE)/Contents/
@cp AppIcon.icns $(BUNDLE)/Contents/Resources/
swiftc -O -o $@ $(SRC) $(FRAMEWORKS)
@codesign --force --sign - $(BUNDLE)
@echo "Built → $(BUNDLE)"
build:
@bash Scripts/package-app.sh

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

clean:
rm -rf $(BUILD_DIR) $(DIST_DIR)
rm -rf $(BUILD_DIR) $(DIST_DIR) .build .swiftpm
19 changes: 19 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// swift-tools-version: 5.9

import PackageDescription

let package = Package(
name: "TermIMS",
platforms: [
.macOS(.v13)
],
products: [
.executable(name: "TermIMS", targets: ["TermIMS"])
],
targets: [
.executableTarget(
name: "TermIMS",
path: "Sources/TermIMS"
)
]
)
21 changes: 8 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<h1 align="center">TermIMS</h1>

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

<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>

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

### Build from source

The project is a single Swift file compiled directly with `swiftc`. No Xcode project needed.
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`.

```sh
git clone https://github.com/cuiko/TermIMS.git
cd TermIMS

# Build locally
make build

# Build and install to /Applications
make install

# Build, install, and launch
make run

# Package as DMG for distribution
make dist
make build # build into build/TermIMS.app
make install # build + copy to /Applications
make run # build + install + launch
make dist # package dist/TermIMS.dmg
```

Requires Swift 5.9+ (Xcode 15) and macOS 13.0+.

## Usage

1. Launch TermIMS (or run `make run`).
Expand Down
File renamed without changes
44 changes: 44 additions & 0 deletions Scripts/package-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Build the SPM target, then assemble a TermIMS.app bundle in build/.
# Mirrors notchnotes' Scripts/package-app.sh layout: SPM builds the binary,
# this script wraps it with Info.plist + an .icns generated from
# Resources/AppIcon.png, and ad-hoc signs the result.
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/build"
APP_DIR="$BUILD_DIR/TermIMS.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
SOURCE_ICON="$ROOT_DIR/Resources/AppIcon.png"
SIGN_IDENTITY="${SIGN_IDENTITY:--}"

cd "$ROOT_DIR"
swift build -c release

rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$ROOT_DIR/.build/release/TermIMS" "$MACOS_DIR/TermIMS"
cp "$ROOT_DIR/Info.plist" "$CONTENTS_DIR/Info.plist"

if [[ -f "$SOURCE_ICON" ]]; then
TMP_DIR="$(mktemp -d)"
ICONSET_DIR="$TMP_DIR/AppIcon.iconset"
mkdir -p "$ICONSET_DIR"
sips -z 16 16 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null
sips -z 32 32 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null
sips -z 32 32 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null
sips -z 64 64 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null
sips -z 128 128 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null
sips -z 256 256 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null
sips -z 256 256 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null
sips -z 512 512 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null
sips -z 512 512 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null
sips -z 1024 1024 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/AppIcon.icns"
rm -rf "$TMP_DIR"
fi

codesign --force --sign "$SIGN_IDENTITY" "$APP_DIR" >/dev/null
echo "Built → $APP_DIR"
217 changes: 217 additions & 0 deletions Sources/TermIMS/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import Cocoa
import ApplicationServices

class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private let monitor = FocusMonitor()
private let indicator = IndicatorPanel()
private var settingsWC: SettingsWindowController?
private var permissionWC: PermissionWindowController?
private var enabledItem: NSMenuItem!
private var loginItem: NSMenuItem!
private var permissionPollTimer: Timer?
private var wasTrusted = false

private var launchAgentPath: String {
NSHomeDirectory() + "/Library/LaunchAgents/top.cuiko.termims.plist"
}

func applicationDidFinishLaunching(_ n: Notification) {
Log.debug("=== TermIMS started ===")
installEditMenu()
if AXIsProcessTrusted() {
wasTrusted = true
startApp()
} else {
showPermissionWindow()
}
startPermissionPolling()
}

/// LSUIElement apps don't get a main menu by default, which means
/// Cmd+C/V/X/A in any text field are dispatched into the void. Wire up
/// a minimal Edit menu so the standard shortcuts reach the field editor.
private func installEditMenu() {
let main = NSMenu()
let editItem = NSMenuItem()
main.addItem(editItem)

let edit = NSMenu(title: "Edit")
edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
let redo = NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "z")
redo.keyEquivalentModifierMask = [.command, .shift]
edit.addItem(redo)
edit.addItem(.separator())
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
edit.addItem(withTitle: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: "")
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")

editItem.submenu = edit
NSApp.mainMenu = main
}

private func startPermissionPolling() {
permissionPollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
guard let self else { return }
let trusted = AXIsProcessTrusted()
if self.wasTrusted && !trusted {
self.wasTrusted = false
self.monitor.stop()
self.rebuildMenu()
} else if !self.wasTrusted && trusted {
self.wasTrusted = true
self.startApp()
}
}
}

private func showPermissionWindow() {
permissionWC = PermissionWindowController()
permissionWC?.onGranted = { [weak self] in
self?.permissionWC = nil
self?.startApp()
}
permissionWC?.showWindow(nil)
permissionWC?.startPolling()
NSApp.activate(ignoringOtherApps: true)
}

private var appStarted = false
private func startApp() {
guard !appStarted else { rebuildMenu(); monitor.reload(); return }
appStarted = true
applyMenuBarVisibility()
NotificationCenter.default.addObserver(self, selector: #selector(rebuildMenu),
name: .rulesDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(imDidSwitch),
name: .imDidSwitch, object: nil)
monitor.start()
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
if !AXIsProcessTrusted() {
showPermissionWindow()
} else {
showSettings()
}
return true
}

@objc private func imDidSwitch() {
guard RuleStore.shared.indicatorEnabled,
let name = currentInputSourceName() else { return }
indicator.show(text: name, position: RuleStore.shared.indicatorPosition)
}

private func applyMenuBarVisibility() {
if RuleStore.shared.hideMenuBarIcon {
if let item = statusItem {
NSStatusBar.system.removeStatusItem(item)
statusItem = nil
}
} else {
if statusItem == nil {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let btn = statusItem?.button {
btn.image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: "TermIMS")
}
}
rebuildMenu()
}
}

@objc private func rebuildMenu() {
if RuleStore.shared.hideMenuBarIcon { applyMenuBarVisibility(); return }
guard let statusItem else { applyMenuBarVisibility(); return }

let menu = NSMenu()
let store = RuleStore.shared
let trusted = AXIsProcessTrusted()

if !trusted {
let warn = NSMenuItem(title: "Accessibility Permission Required", action: #selector(showPermissionPrompt), keyEquivalent: "")
warn.target = self
warn.image = NSImage(systemSymbolName: "exclamationmark.triangle", accessibilityDescription: nil)
menu.addItem(warn)
menu.addItem(.separator())
}

enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled), keyEquivalent: "")
enabledItem.target = self; enabledItem.state = monitor.enabled ? .on : .off
menu.addItem(enabledItem)
menu.addItem(.separator())

let defName = store.defaultSourceName ?? "None"
let defItem = NSMenuItem(title: "Default: \(defName)", action: nil, keyEquivalent: "")
defItem.isEnabled = false; menu.addItem(defItem)

let activeAppRules = store.rules.filter(\.enabled)
if !activeAppRules.isEmpty {
menu.addItem(.separator())
let hdr = NSMenuItem(title: "App Rules", action: nil, keyEquivalent: "")
hdr.isEnabled = false; menu.addItem(hdr)
for rule in activeAppRules {
let item = NSMenuItem(title: " \(rule.appName) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "")
item.isEnabled = false; menu.addItem(item)
}
}

let activeTermRules = store.terminalRules.filter(\.enabled)
if !activeTermRules.isEmpty {
menu.addItem(.separator())
let hdr = NSMenuItem(title: "Terminal Rules", action: nil, keyEquivalent: "")
hdr.isEnabled = false; menu.addItem(hdr)
for rule in activeTermRules {
let typeStr = rule.matchType == .title ? "title" : "proc"
let item = NSMenuItem(title: " \(typeStr):\(rule.pattern) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "")
item.isEnabled = false; menu.addItem(item)
}
}

menu.addItem(.separator())
let settings = NSMenuItem(title: "Settings\u{2026}", action: #selector(showSettings), keyEquivalent: ",")
settings.target = self; menu.addItem(settings)

menu.addItem(.separator())
loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLogin), keyEquivalent: "")
loginItem.target = self
loginItem.state = FileManager.default.fileExists(atPath: launchAgentPath) ? .on : .off
menu.addItem(loginItem)

menu.addItem(.separator())
let quit = NSMenuItem(title: "Quit TermIMS", action: #selector(quitApp), keyEquivalent: "q")
quit.target = self; menu.addItem(quit)

statusItem.menu = menu
}

@objc private func toggleEnabled() {
monitor.enabled.toggle(); enabledItem.state = monitor.enabled ? .on : .off
}
@objc private func showPermissionPrompt() {
showPermissionWindow()
}
@objc private func showSettings() {
if !AXIsProcessTrusted() { showPermissionWindow(); return }
if settingsWC == nil { settingsWC = SettingsWindowController() }
settingsWC?.showWindow(nil); NSApp.activate(ignoringOtherApps: true)
}
@objc private func toggleLogin() {
let fm = FileManager.default
if fm.fileExists(atPath: launchAgentPath) {
try? fm.removeItem(atPath: launchAgentPath)
} else {
try? fm.createDirectory(atPath: NSHomeDirectory() + "/Library/LaunchAgents", withIntermediateDirectories: true)
let plist: NSDictionary = [
"Label": "top.cuiko.termims",
"ProgramArguments": [Bundle.main.executablePath ?? ""],
"RunAtLoad": true,
]
plist.write(toFile: launchAgentPath, atomically: true)
}
loginItem.state = fm.fileExists(atPath: launchAgentPath) ? .on : .off
}
@objc private func quitApp() { monitor.stop(); NSApp.terminate(nil) }
}
Loading
Loading