81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
|
|
/**
|
|
* Resolves a macOS input source from a list of candidate bundle IDs.
|
|
*
|
|
* Why candidates rather than one constant: Apple ships the same input method under different
|
|
* bundle IDs across macOS versions and localisations — Cangjie exists as BOTH
|
|
* `com.apple.inputmethod.TCIM.Cangjie` and `com.apple.inputmethod.TYIM.Cangjie` on a single
|
|
* host, and Simple Telex moved under a `VietnameseIM.` prefix. Hardcoding one ID makes a spec
|
|
* fail on hosts where the other is installed, and the failure reads like an operator error
|
|
* ("you didn't select the right source") rather than a stale constant.
|
|
*/
|
|
|
|
const LIST_INSTALLED_SWIFT = `
|
|
import Foundation
|
|
import Carbon
|
|
if let list = TISCreateInputSourceList(nil, true)?.takeRetainedValue() as? [TISInputSource] {
|
|
for s in list {
|
|
guard let idPtr = TISGetInputSourceProperty(s, kTISPropertyInputSourceID) else { continue }
|
|
let id = Unmanaged<CFString>.fromOpaque(idPtr).takeUnretainedValue() as String
|
|
var selectable = false
|
|
if let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceIsSelectCapable) {
|
|
selectable = CFBooleanGetValue(Unmanaged<CFBoolean>.fromOpaque(p).takeUnretainedValue())
|
|
}
|
|
var enabled = false
|
|
if let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceIsEnabled) {
|
|
enabled = CFBooleanGetValue(Unmanaged<CFBoolean>.fromOpaque(p).takeUnretainedValue())
|
|
}
|
|
print("\\(id)|\\(enabled)|\\(selectable)")
|
|
}
|
|
}
|
|
`
|
|
|
|
export type InstalledInputSource = { id: string; enabled: boolean; selectable: boolean }
|
|
|
|
export function listInstalledInputSources(): InstalledInputSource[] {
|
|
const output = execFileSync('swift', ['-'], { input: LIST_INSTALLED_SWIFT, encoding: 'utf8' })
|
|
return output
|
|
.split('\n')
|
|
.filter((line) => line.includes('|'))
|
|
.map((line) => {
|
|
const [id, enabled, selectable] = line.split('|')
|
|
return { id, enabled: enabled === 'true', selectable: selectable === 'true' }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Returns the first candidate that is installed AND selectable, or throws naming every candidate
|
|
* and what TIS actually reported. A spec that cannot establish its own input-source precondition
|
|
* must abort — proceeding captures the wrong source and the run looks valid.
|
|
*/
|
|
export function resolveInputSourceId(label: string, candidates: readonly string[]): string {
|
|
const installed = listInstalledInputSources()
|
|
const byId = new Map(installed.map((source) => [source.id, source]))
|
|
|
|
for (const candidate of candidates) {
|
|
if (byId.get(candidate)?.selectable) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
const present = candidates
|
|
.map((candidate) => {
|
|
const found = byId.get(candidate)
|
|
return found
|
|
? ` ${candidate} — installed but selectable=${found.selectable}, enabled=${found.enabled}`
|
|
: ` ${candidate} — NOT INSTALLED`
|
|
})
|
|
.join('\n')
|
|
// Surface near-matches so a renamed bundle ID is obvious rather than looking like a missing IME.
|
|
const stem = label.toLowerCase()
|
|
const near = installed
|
|
.filter((source) => source.id.toLowerCase().includes(stem))
|
|
.map((source) => ` ${source.id} (enabled=${source.enabled}, selectable=${source.selectable})`)
|
|
const hint =
|
|
near.length > 0
|
|
? `TIS reports these ${label}-like sources instead:\n${near.join('\n')}`
|
|
: `TIS reports no ${label}-like source at all — enable it in System Settings > Keyboard.`
|
|
throw new Error(`No selectable input source for ${label}. Candidates:\n${present}\n${hint}`)
|
|
}
|