--================================================ -- MULTISHORTCUTS PRO for Mac · v2.0.0 beta · 2026 -- Taking shortcuts to a whole new level. --================================================ -- © GnuKey LLC · https://speedkee.com -- Part of the SpeedKee Ecosystem. Formerly "ShortKees". -- -- Text expansion, app / web / document launchers, date shortcuts, -- home-row ; commands and sequence macros, all driven from short, -- memorable triggers. Powered by Hammerspoon. The hold-for-modifier -- keys (hold ; = Command, hold ` or Caps Lock = Option) come from the -- companion Karabiner-Elements rule file, which this script can install. -- -- MIT License. Permission is hereby granted, free of charge, to any -- person obtaining a copy of this software to use, copy, modify, and -- distribute it, provided the above copyright notice is included. -- -- THE SOFTWARE IS PROVIDED "AS-IS", WITHOUT WARRANTY OF ANY KIND. -- GNUKEY LLC SHALL NOT BE LIABLE FOR ANY DAMAGES ARISING FROM USE OF -- THIS SOFTWARE OR FROM SHORTCUTS CREATED WITH IT. -- -- Implementation: Claude (Anthropic) · Review: Gemini & DeepSeek --================================================ -- REQUIREMENTS: -- • macOS 11+ (Big Sur or later) -- • Hammerspoon (free) from https://www.hammerspoon.org -- • Accessibility permission for Hammerspoon -- • Optional, recommended: Karabiner-Elements (free) for the -- home-row modifier keys. https://karabiner-elements.pqrs.org -- -- INSTALLATION: -- 1. Install Hammerspoon and grant it Accessibility permission. -- 2. Save this file as ~/.hammerspoon/MultiShortcutsPro.lua -- 3. Add this line to ~/.hammerspoon/init.lua: -- require("MultiShortcutsPro") -- (Upgrading from ShortKees? Remove the old require("ShortKees") -- and require("ShortKeesHome") lines. Your shortcuts are copied -- over automatically on first start.) -- 4. Hammerspoon menu icon → Reload Config. -- 5. Menu bar "MSP" icon → Set up hold keys… (installs the Karabiner rule) -- -- DEFAULT HOTKEYS (full mode): -- ⌥T Add Text Expansion ⌥A Add App Launcher -- ⌥C Capture Selected Text ⌥D Add Document Launcher -- ⌥W Add Web/Email Launcher ⌥E Edit Shortcuts -- ⌥L Manage Launchers ⌥M Manage Expansions -- ⌥Q Quick Expansion Picker ⌥Z Undo Last Expansion -- ⌥B Backup Now ⌥P Pause / Resume -- ⌥H Help ⌥R Reload -- ⌥⇧S macOS Screenshot Picker (⌘⇧4) -- Starter mode keeps ⌥Q ⌥Z ⌥P ⌥H ⌥R only. --================================================ local VERSION = "2.0.0 beta" local APP = "MultiShortcuts Pro" local eventtap = require("hs.eventtap") local keycodes = require("hs.keycodes") local hotkey = require("hs.hotkey") local pasteboard = require("hs.pasteboard") local timer = require("hs.timer") local application = require("hs.application") local urlevent = require("hs.urlevent") local fs = require("hs.fs") local menubar = require("hs.menubar") local dialog = require("hs.dialog") local chooser = require("hs.chooser") local notify = require("hs.notify") local json = require("hs.json") local sound = require("hs.sound") local pathwatcher = require("hs.pathwatcher") --================================================ -- CONFIGURATION --================================================ local config = { hotkeys = { AddExpansion = { {"alt"}, "t", full = true }, AddLauncher = { {"alt"}, "a", full = true }, CaptureExpansion = { {"alt"}, "c", full = true }, AddDocumentLauncher = { {"alt"}, "d", full = true }, ShowEditGUI = { {"alt"}, "e", full = true }, AddWebLauncher = { {"alt"}, "w", full = true }, ImmediateBackup = { {"alt"}, "b", full = true }, ManageLaunchers = { {"alt"}, "l", full = true }, ManageExpansions = { {"alt"}, "m", full = true }, Screenshot = { {"alt","shift"}, "s", full = true }, QuickTextExpansion = { {"alt"}, "q" }, ReloadScript = { {"alt"}, "r" }, ShowHelp = { {"alt"}, "h" }, PauseResume = { {"alt"}, "p" }, UndoExpansion = { {"alt"}, "z" }, }, security = { maxShortcutLength = 50, minShortcutLength = 2, -- Link types a launcher may open. Anything else is blocked. allowedSchemes = { http = true, https = true, mailto = true, tel = true, sms = true, facetime = true, ["facetime-audio"] = true, maps = true, }, -- File types a launcher may open (allowlist). allowedExts = { -- documents pdf=true, txt=true, md=true, rtf=true, rtfd=true, csv=true, tsv=true, doc=true, docx=true, xls=true, xlsx=true, ppt=true, pptx=true, pages=true, numbers=true, key=true, odt=true, ods=true, odp=true, html=true, htm=true, epub=true, json=true, xml=true, log=true, -- media png=true, jpg=true, jpeg=true, gif=true, heic=true, heif=true, tif=true, tiff=true, bmp=true, webp=true, svg=true, mp3=true, m4a=true, wav=true, aiff=true, aif=true, flac=true, mp4=true, mov=true, m4v=true, avi=true, mkv=true, -- links and aliases webloc=true, inetloc=true, fileloc=true, alias=true, -- archives and disk images open in Archive Utility / Finder zip=true, dmg=true, }, -- Never opened, even if someone edits the allowlist above. blockedExts = { command=true, sh=true, bash=true, zsh=true, csh=true, tcsh=true, ksh=true, fish=true, py=true, pl=true, rb=true, php=true, js=true, scpt=true, scptd=true, applescript=true, workflow=true, action=true, terminal=true, tool=true, pkg=true, mpkg=true, jar=true, shortcut=true, mobileconfig=true, kext=true, prefpane=true, }, -- Powerful apps: ask before a launcher opens them. confirmApps = { ["terminal"]=true, ["iterm"]=true, ["iterm2"]=true, ["warp"]=true, ["script editor"]=true, ["automator"]=true, ["shortcuts"]=true, ["activity monitor"]=true, ["disk utility"]=true, ["console"]=true, ["keychain access"]=true, ["system settings"]=true, ["system preferences"]=true, ["migration assistant"]=true, ["boot camp assistant"]=true, ["directory utility"]=true, }, -- Apps living here open without a question; elsewhere, we ask. trustedAppDirs = { "/Applications/", "/System/Applications/", os.getenv("HOME") .. "/Applications/", }, }, performance = { maxBackupFiles = 10, sequenceDelay = 0.15, largeTextThreshold = 2000, -- longer expansions are pasted, not typed }, homeRow = { pendingWindow = 0.40, -- how long ; waits for the command letter idleGate = 0.18, -- ; right after a letter is just punctuation }, textExpander = { backupOnSave = true, maxHistorySize = 20, }, feedback = { playSounds = false, expandSound = "Tink", launchSound = "Tink", errorSound = "Funk", backupSound = "Pop", }, } local PERSIST_INTERVAL_S = 300 -- write stats/history every 5 minutes local DEBUG = false local function dbg(...) if DEBUG then print("[MSP:dbg]", ...) end end local HOME = os.getenv("HOME") --================================================ -- PATHS + INSTALL STATE --================================================ local scriptDir = HOME .. "/.hammerspoon/MultiShortcutsPro" local legacyDir = HOME .. "/.hammerspoon/ShortKees" local backupDir = scriptDir .. "/backups" local launcherPath = scriptDir .. "/launchers.txt" local textExpansionPath = scriptDir .. "/text_expansion.txt" local statsPath = scriptDir .. "/usage_stats.json" local historyPath = scriptDir .. "/expansion_history.json" local prefsPath = scriptDir .. "/prefs.json" local function isDir(p) return fs.attributes(p, "mode") == "directory" end local dataExisted = isDir(scriptDir) local legacyExisted = isDir(legacyDir) fs.mkdir(scriptDir) fs.mkdir(backupDir) --================================================ -- STATE --================================================ local loadedLaunchers = {} local loadedExpansions = {} local forcedInstant = {} -- triggers saved with a trailing $$ local expansionHistory = {} local usageStats = {} local macrosPaused = false local keystrokeWarnShown = false local typedBuffer = "" local maxBuffer = 60 local registeredHotkeys = {} local lastExpansionInfo = { sc = nil, length = 0 } local keysSinceExpansion = 0 local saving = false local expansionTailIndex = {} local launcherTailIndex = {} local prefs = {} local installKind = "existing" -- "new" | "upgrade" | "existing" --================================================ -- UTILITIES --================================================ local function fileExists(path) local f = io.open(path, "r") if f then f:close(); return true end return false end local function readFile(path) local f = io.open(path, "r"); if not f then return nil end local content = f:read("*a"); f:close() return content end local function writeFile(path, content) local f = io.open(path, "w"); if not f then return false end f:write(content); f:close() return true end local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end local function countMap(t) local n = 0 for _ in pairs(t) do n = n + 1 end return n end -- Character count (not bytes), so é, — and emoji count as one. local function ulen(s) if not s then return 0 end return utf8.len(s) or #s end local function expandHome(p) if p == "~" then return HOME end if p:sub(1, 2) == "~/" then return HOME .. p:sub(2) end return p end local function notifyUser(title, msg) notify.new({ title = title, informativeText = msg or "" }):send() end local function alert(msg) hs.alert.show(msg, 2) end local function playSound(name) if not config.feedback.playSounds then return end local s = sound.getByName(name); if s then s:play() end end -- Open a path with /usr/bin/open, passed as its own argument (no shell). local function openPath(path) hs.task.new("/usr/bin/open", nil, { path }):start() end --================================================ -- PREFS --================================================ local function loadPrefs() prefs = {} if fileExists(prefsPath) then local ok, p = pcall(json.decode, readFile(prefsPath) or "{}") if ok and type(p) == "table" then prefs = p end end if prefs.homeRowCommands == nil then prefs.homeRowCommands = true end if prefs.playSounds ~= nil then config.feedback.playSounds = prefs.playSounds end end local function savePrefs() prefs.playSounds = config.feedback.playSounds prefs.fkeyExempt = nil -- F-key hub removed in 2.0.0 writeFile(prefsPath, json.encode(prefs) or "{}") end --================================================ -- MIGRATION FROM SHORTKEES 1.x --================================================ local function migrateLegacy() if dataExisted or not legacyExisted then return false end local copied = 0 for _, name in ipairs({ "launchers.txt", "text_expansion.txt", "usage_stats.json" }) do local src = legacyDir .. "/" .. name if fileExists(src) then writeFile(scriptDir .. "/" .. name, readFile(src) or "") copied = copied + 1 end end print("[MSP] Copied " .. copied .. " file(s) from " .. legacyDir) return true end -- Warn if init.lua still loads the old ShortKees scripts (they would -- run side by side with this one and double-fire). local function legacyStillLoaded() local init = readFile(HOME .. "/.hammerspoon/init.lua") or "" for line in init:gmatch("[^\r\n]+") do local code = line:gsub("%-%-.*$", "") if code:find("ShortKees", 1, true) then return true end end return false end --================================================ -- CLIPBOARD (privacy-aware) -- Writes are marked concealed + transient so clipboard managers and -- Universal Clipboard history skip them (nspasteboard.org convention). --================================================ local function clipboardSnapshot() local ok, data = pcall(pasteboard.readAllData) if ok and type(data) == "table" then return data end return nil end local function clipboardRestore(snap) if not snap then return end local ok = pcall(pasteboard.writeAllData, snap) if not ok then pasteboard.clearContents() end end local function setClipboardPrivate(text) local ok = pcall(pasteboard.writeAllData, { ["public.utf8-plain-text"] = text, ["org.nspasteboard.ConcealedType"] = "", ["org.nspasteboard.TransientType"] = "", ["org.nspasteboard.AutoGeneratedType"] = "", }) if not ok then pasteboard.setContents(text) end end --================================================ -- SYNTHETIC KEYSTROKES -- Every event we post carries MARKER so our own tap ignores it. --================================================ local MARKER = 0xACAB5350 local USERDATA_PROP = eventtap.event.properties.eventSourceUserData local DELETE_KC = keycodes.map["delete"] or 51 -- 51 = kVK_Delete local function markSynthetic(event) event:setProperty(USERDATA_PROP, MARKER) return event end local function isOurEvent(event) return event:getProperty(USERDATA_PROP) == MARKER end local function postKey(mods, key) local kc = type(key) == "number" and key or keycodes.map[key] if not kc then dbg("postKey: no keycode for", key); return end markSynthetic(eventtap.event.newKeyEvent(mods, kc, true)):post() markSynthetic(eventtap.event.newKeyEvent(mods, kc, false)):post() end local pressKey = postKey -- keyStrokes builds its own (unmarked) events, so we hold a short -- "in flight" window during which the typing tap ignores input. local typingInFlight = 0 local function typeText(text) if not text or text == "" then return end typingInFlight = typingInFlight + 1 local ok, err = pcall(eventtap.keyStrokes, text) local drain = ok and (0.030 + (ulen(text) * 0.005)) or 0 if not ok then dbg("typeText failed:", err) end timer.doAfter(drain, function() typingInFlight = math.max(0, typingInFlight - 1) end) end local function eraseChars(n) if not n or n <= 0 then return end n = math.min(n, config.security.maxShortcutLength * 200) for _ = 1, n do markSynthetic(eventtap.event.newKeyEvent({}, DELETE_KC, true)):post() markSynthetic(eventtap.event.newKeyEvent({}, DELETE_KC, false)):post() end end --================================================ -- LAUNCH TARGET SAFETY -- classifyTarget returns kind, value, reason. Kinds: -- url, app, appbundle, folder, file → may open -- missing → clear message at launch time -- blocked → never opens -- It runs when shortcuts load AND every time a launcher fires, so a -- file swapped behind a trigger is caught. --================================================ local function classifyTarget(target) if not target then return "blocked", nil, "empty target" end target = trim(target) if target == "" then return "blocked", nil, "empty target" end local sec = config.security local scheme = target:match("^([%a][%w%+%.%-]*):") if scheme and not target:find("^/") then if sec.allowedSchemes[scheme:lower()] then return "url", target end return "blocked", nil, "link type '" .. scheme .. ":' is not allowed" end if not target:find("/") and target:sub(1, 1) ~= "~" then if target:match("^[%w%s%.%-%+&'()]+$") then return "app", (target:gsub("%.app$", "")) end return "blocked", nil, "not a valid app name" end local p = expandHome(target) local attrs = fs.attributes(p) if not attrs then return "missing", p end local lower = p:lower():gsub("/+$", "") local ext = lower:match("%.([%w]+)$") if attrs.mode == "directory" then if ext == "app" then return "appbundle", p end if ext and sec.blockedExts[ext] then return "blocked", nil, "." .. ext .. " bundles run code" end return "folder", p end if ext and sec.blockedExts[ext] then return "blocked", nil, "." .. ext .. " files run code" end if ext then if sec.allowedExts[ext] then return "file", p end return "blocked", nil, "." .. ext .. " is not on the allowed file list" end -- No extension: Finder aliases are fine; executables are not. local perms = attrs.permissions or "" if perms:find("x") then return "blocked", nil, "file is a program" end return "file", p end local function isTrustedAppPath(p) for _, dir in ipairs(config.security.trustedAppDirs) do if p:sub(1, #dir) == dir then return true end end return false end local function confirmLaunch(what, why) local btn = dialog.blockAlert(APP .. ": open " .. what .. "?", why .. "\n\nOpen it?", "Open", "Cancel", "warning") return btn == "Open" end local function validateShortcut(sc) if not sc or sc == "" then return false, "Shortcut is empty" end if sc:find("%s") then return false, "Shortcuts can't contain spaces" end if ulen(sc) < config.security.minShortcutLength then return false, "Shortcut too short (min " .. config.security.minShortcutLength .. ")" end if ulen(sc) > config.security.maxShortcutLength then return false, "Shortcut too long (max " .. config.security.maxShortcutLength .. ")" end return true end --================================================ -- FILE FORMAT (shared with SpeedKee and Windows) -- one shortcut per line: trigger expansion -- line breaks stored as [NEWLINE] -- lines starting with "; " (or #, --) are comments -- a trailing $$ on a trigger makes it fire instantly --================================================ local function encodeTarget(t) return (t:gsub("\r\n", "\n"):gsub("\r", "\n"):gsub("\n", "[NEWLINE]")) end local function decodeTarget(t) return (t:gsub("%[NEWLINE%]", "\n")) end local function parseShortcutLine(line) line = trim(line) if line == "" or line == ";" then return nil end if line:sub(1, 2) == "; " or line:sub(1, 1) == "#" or line:sub(1, 2) == "--" then return nil end local sc, target = line:match("^(%S+)%s+(.+)$") if not (sc and target) then return nil end local instant = false if #sc > 2 and sc:sub(-2) == "$$" then sc = sc:sub(1, -3); instant = true end return sc, decodeTarget(trim(target)), instant end local function rebuildTailIndex(map) local idx = {} for sc in pairs(map) do if #sc > 0 then local tail = sc:sub(-1) idx[tail] = idx[tail] or {} idx[tail][#idx[tail] + 1] = sc end end return idx end local function loadLaunchers() loadedLaunchers = {} local blocked = {} if fileExists(launcherPath) then for line in (readFile(launcherPath) or ""):gmatch("[^\r\n]+") do local sc, target, instant = parseShortcutLine(line) if sc then local kind, _, reason = classifyTarget(target) if kind == "blocked" then blocked[#blocked + 1] = sc .. " (" .. (reason or "unsafe") .. ")" else loadedLaunchers[sc] = target if instant then forcedInstant[sc] = true end end end end end if #blocked > 0 then notifyUser(APP, #blocked .. " launcher(s) blocked as unsafe and not loaded: " .. table.concat(blocked, ", ")) end launcherTailIndex = rebuildTailIndex(loadedLaunchers) end local function loadExpansions() loadedExpansions = {} local keyTokens = 0 if fileExists(textExpansionPath) then for line in (readFile(textExpansionPath) or ""):gmatch("[^\r\n]+") do local sc, target, instant = parseShortcutLine(line) if sc then loadedExpansions[sc] = target if instant then forcedInstant[sc] = true end if target:find("{key:", 1, true) or target:find("{focus:", 1, true) then keyTokens = keyTokens + 1 end end end end if keyTokens > 0 and not keystrokeWarnShown then keystrokeWarnShown = true notifyUser(APP, keyTokens .. " expansion(s) contain keystroke tokens ({key:}/{focus:})." .. "\nThese press keys, not just type text. Review if unexpected.") end expansionTailIndex = rebuildTailIndex(loadedExpansions) end local saveCounter = 0 local function saveMap(path, t) saveCounter = saveCounter + 1 local mySaveId = saveCounter saving = true local keys = {} for k in pairs(t) do keys[#keys + 1] = k end table.sort(keys) local lines = { "; " .. APP .. " for Mac · one shortcut per line: trigger expansion" } for _, k in ipairs(keys) do local trig = forcedInstant[k] and (k .. "$$") or k lines[#lines + 1] = trig .. " " .. encodeTarget(t[k]) end writeFile(path, table.concat(lines, "\n") .. "\n") timer.doAfter(0.5, function() if saveCounter == mySaveId then saving = false end end) end local function saveExpansionsFile() saveMap(textExpansionPath, loadedExpansions) expansionTailIndex = rebuildTailIndex(loadedExpansions) end local function saveLaunchersFile() saveMap(launcherPath, loadedLaunchers) launcherTailIndex = rebuildTailIndex(loadedLaunchers) end --================================================ -- BUILT-IN SHORTCUTS -- The = date family is written into text_expansion.txt on start if a -- trigger is missing, and never on top of a user shortcut (expansion -- OR launcher) that already uses the same trigger. --================================================ local BUILTIN_EXPANSIONS = { { "-=", "{when:today}" }, { "=-", "{when:today,short}" }, { "1-=", "{when:+7d}" }, { "2-=", "{when:+14d}" }, { "3-=", "{when:+21d}" }, { "1m=", "{when:+1m}" }, { "2m=", "{when:+2m}" }, { "1y=", "{when:+1y}" }, } -- ` launchers are seeded once, on a brand-new install, as examples. local STARTER_LAUNCHERS = { { "`a", "https://claude.ai" }, { "`c", "https://calendar.google.com" }, { "`e", "https://mail.google.com" }, { "`g", "https://voice.google.com" }, { "`1", "https://voice.google.com" }, { "`2", "https://mail.google.com" }, { "`3", "https://calendar.google.com" }, } -- Sample text shortcuts, added when Full Features are unlocked. local SAMPLE_EXPANSIONS = { { "mP", "My pleasure!" }, { "omW", "On my way!" }, { "brB", "Be right back!" }, { "tyS", "Thank you so much!" }, { "hruD", "How are you doing?" }, -- q + letter asks, j + letter exclaims (stand-ins for SpeedKee's ? and ! keys) { "qp", "What's your phone number?" }, { "jg", "That's great!" }, { "jj", "That's great!!" }, { "jt", "Thank you!" }, { "jT", "Thank you very much!" }, } local function ensureBuiltins() local added = 0 for _, b in ipairs(BUILTIN_EXPANSIONS) do local sc, target = b[1], b[2] if loadedExpansions[sc] == nil and loadedLaunchers[sc] == nil then loadedExpansions[sc] = target added = added + 1 end end if added > 0 then saveExpansionsFile() end end local function seedNewInstall() if not fileExists(launcherPath) then for _, l in ipairs(STARTER_LAUNCHERS) do loadedLaunchers[l[1]] = l[2] end saveLaunchersFile() end if not fileExists(textExpansionPath) then saveExpansionsFile() -- creates the file; ensureBuiltins fills it end end local function addSamples() local added = 0 for _, s in ipairs(SAMPLE_EXPANSIONS) do if loadedExpansions[s[1]] == nil and loadedLaunchers[s[1]] == nil then loadedExpansions[s[1]] = s[2]; added = added + 1 end end if added > 0 then saveExpansionsFile() end return added end --================================================ -- USAGE STATS & HISTORY --================================================ local MAX_STATS_ENTRIES = 1000 local function loadStats() usageStats = {} if not fileExists(statsPath) then return end local ok, parsed = pcall(json.decode, readFile(statsPath) or "{}") if not ok or type(parsed) ~= "table" then return end local ranked = {} for sc, count in pairs(parsed) do if (loadedLaunchers[sc] or loadedExpansions[sc]) and type(count) == "number" then ranked[#ranked + 1] = { sc = sc, count = count } end end table.sort(ranked, function(a, b) return a.count > b.count end) for i = 1, math.min(MAX_STATS_ENTRIES, #ranked) do usageStats[ranked[i].sc] = ranked[i].count end end local function saveStats() writeFile(statsPath, json.encode(usageStats) or "{}") end local function recordUsage(sc) usageStats[sc] = (usageStats[sc] or 0) + 1 end local function loadHistory() expansionHistory = {} if not fileExists(historyPath) then return end local ok, parsed = pcall(json.decode, readFile(historyPath) or "[]") if ok and type(parsed) == "table" then expansionHistory = parsed end end local function saveHistory() writeFile(historyPath, json.encode(expansionHistory) or "[]") end local function recordHistory(sc, chars) -- privacy: trigger name + character count only, never the text table.insert(expansionHistory, 1, { shortcut = sc, chars = chars or 0, ts = os.time() }) while #expansionHistory > config.textExpander.maxHistorySize do table.remove(expansionHistory) end end --================================================ -- BACKUP --================================================ local function makeBackup(silent) local stamp = os.date("%Y%m%d_%H%M%S") if fileExists(launcherPath) then writeFile(backupDir .. "/launchers_" .. stamp .. ".txt", readFile(launcherPath) or "") end if fileExists(textExpansionPath) then writeFile(backupDir .. "/text_expansion_" .. stamp .. ".txt", readFile(textExpansionPath) or "") end local files = {} for file in fs.dir(backupDir) do if file:match("%.txt$") then files[#files + 1] = file end end table.sort(files) while #files > config.performance.maxBackupFiles * 2 do os.remove(backupDir .. "/" .. files[1]) table.remove(files, 1) end if not silent then playSound(config.feedback.backupSound) notifyUser(APP, "Backup saved: " .. stamp) end end --================================================ -- DATES ({when:...} placeholder, used by the = family) -- {when:today} {when:today,short} {when:+7d} {when:+2w} -- {when:+1m} {when:+1y} add ",short" for 10/28/26 --================================================ local monthsLong = { "January","February","March","April","May","June", "July","August","September","October","November","December" } local function formatFullDate(t) local d = os.date("*t", t) return monthsLong[d.month] .. " " .. d.day .. ", " .. d.year end local function formatShortDate(t) return os.date("%m/%d/%y", t) end local function shiftDate(n, unit) local d = os.date("*t") d.hour = 12; d.min = 0; d.sec = 0 -- noon: immune to DST edges if unit == "d" then d.day = d.day + n; return os.time(d) end if unit == "w" then d.day = d.day + 7 * n; return os.time(d) end local wantDay = d.day d.day = 1 if unit == "m" then d.month = d.month + n else d.year = d.year + n end local first = os.date("*t", os.time(d)) -- clamp: Jan 31 + 1 month = Feb 28/29, not Mar 3 local lastDay = os.date("*t", os.time({ year = first.year, month = first.month + 1, day = 0, hour = 12 })).day first.day = math.min(wantDay, lastDay) return os.time(first) end local function whenText(spec) local what, style = spec:match("^%s*([^,]-)%s*,%s*(%a+)%s*$") what = what or trim(spec) local t if what == "today" or what == "" then t = os.time() else local n, unit = what:match("^%+(%d+)([dwmy])$") if not n then return "{when:" .. spec .. "}" end t = shiftDate(tonumber(n), unit) end if style == "short" then return formatShortDate(t) end return formatFullDate(t) end --================================================ -- PLACEHOLDERS -- {clip} {clip:upper} {clip:lower} {user} {comp} -- {date:fmt} {time:fmt} {when:spec} {prompt:Question?} {cursor} --================================================ local function safeDate(fmt) local ok, out = pcall(os.date, fmt) return ok and out or ("{date:" .. fmt .. "}") end local function applyPlaceholders(text) if not text then return "" end text = text:gsub("{clip:upper}", function() return (pasteboard.getContents() or ""):upper() end) text = text:gsub("{clip:lower}", function() return (pasteboard.getContents() or ""):lower() end) text = text:gsub("{clip}", function() return pasteboard.getContents() or "" end) text = text:gsub("{user}", function() return os.getenv("USER") or "" end) text = text:gsub("{comp}", function() local h = (hs.host and hs.host.localizedName and hs.host.localizedName()) or "" return (trim(h):gsub("[^%w%.%-_ ]", "")) end) text = text:gsub("{when:([^}]*)}", whenText) text = text:gsub("{date:([^}]*)}", safeDate) text = text:gsub("{time:([^}]*)}", safeDate) text = text:gsub("{prompt:([^}]*)}", function(q) local btn, value = dialog.textPrompt(APP, q, "", "OK", "Cancel") return (btn == "OK") and value or "" end) return text end --================================================ -- SEQUENCE MACROS -- Tokens: {key:^s} {key:{tab}} {wait:500} {focus:Safari} -- ^ = ⌘ Command, ! = ⌥ Option, # = ⌃ Control, + = ⇧ Shift --================================================ local function isSequence(text) return text:find("{key:", 1, true) or text:find("{wait:", 1, true) or text:find("{focus:", 1, true) end local modMap = { ["^"] = "cmd", ["+"] = "shift", ["!"] = "alt", ["#"] = "ctrl" } local namedKeyMap = { tab = "tab", enter = "return", ["return"] = "return", space = "space", esc = "escape", escape = "escape", backspace = "delete", up = "up", down = "down", left = "left", right = "right", home = "home", ["end"] = "end", pageup = "pageup", pagedown = "pagedown", delete = "forwarddelete", f1="f1",f2="f2",f3="f3",f4="f4",f5="f5",f6="f6", f7="f7",f8="f8",f9="f9",f10="f10",f11="f11",f12="f12", } local function sendKeystroke(spec) local mods, i = {}, 1 while i <= #spec do local c = spec:sub(i, i) if modMap[c] and i < #spec then mods[#mods + 1] = modMap[c]; i = i + 1 else break end end local rest = spec:sub(i) local named = rest:match("^{(.-)}$") if named then pressKey(mods, namedKeyMap[named:lower()] or named:lower()) else pressKey(mods, rest:lower()) end end local function nextSequenceToken(text, pos) local best for kind, pattern in pairs({ key = "{key:([^}]*}?)}", wait = "{wait:([^}]*)}", focus = "{focus:([^}]*)}", }) do local s, e, arg = text:find(pattern, pos) if s and (not best or s < best.s) then best = { s = s, e = e, kind = kind, arg = arg } end end if not best then return nil end return best.s, best.e, best.kind, best.arg end local function runSequence(text) local steps, pos = {}, 1 while pos <= #text do local s, e, kind, arg = nextSequenceToken(text, pos) if not s then steps[#steps + 1] = { kind = "literal", arg = text:sub(pos) } break end if s > pos then steps[#steps + 1] = { kind = "literal", arg = text:sub(pos, s - 1) } end steps[#steps + 1] = { kind = kind, arg = arg } pos = e + 1 end -- {wait:N} replaces the delay before the next step. local collapsed, pending = {}, nil for _, step in ipairs(steps) do if step.kind == "wait" then pending = (tonumber(step.arg) or 0) / 1000 else step.leadIn = pending collapsed[#collapsed + 1] = step pending = nil end end local idx = 1 local function nextStep() local step = collapsed[idx] if not step then return end local ok, err = pcall(function() if step.kind == "literal" then typeText(step.arg) elseif step.kind == "key" then sendKeystroke(step.arg) elseif step.kind == "focus" then local app = application.find(step.arg) if app then app:activate() end end end) if not ok then dbg("sequence step failed:", step.kind, step.arg, err) end idx = idx + 1 local nxt = collapsed[idx] if nxt then timer.doAfter(nxt.leadIn or config.performance.sequenceDelay, nextStep) end end nextStep() end --================================================ -- EXPANSION & LAUNCH --================================================ local function setUndo(sc, length) lastExpansionInfo = { sc = sc, length = length } keysSinceExpansion = 0 end -- term: the terminator the user typed after a word trigger (space, -- punctuation, return, tab), or nil for an instant trigger. Plain text -- gets the terminator re-typed after it; sequences and launchers don't, -- so no stray space lands in a keystroke macro. local function expandText(sc, raw, term) if isSequence(raw) then -- Sequence intent comes from the RAW template; placeholder values -- (clipboard, prompts) are defanged so they can't inject keys. local out, pos = {}, 1 while pos <= #raw do local s, e, kind, arg = nextSequenceToken(raw, pos) local lit = raw:sub(pos, (s or (#raw + 1)) - 1) if #lit > 0 then local r = applyPlaceholders(lit) r = r:gsub("{(key):", "{ %1:"):gsub("{(wait):", "{ %1:"):gsub("{(focus):", "{ %1:") out[#out + 1] = r end if not s then break end out[#out + 1] = "{" .. kind .. ":" .. arg .. "}" pos = e + 1 end runSequence(table.concat(out)) recordUsage(sc); recordHistory(sc, 0) setUndo(nil, 0) -- sequences can't be undone playSound(config.feedback.expandSound) return end local text = applyPlaceholders(raw) local cursorAt = text:find("{cursor}", 1, true) local before, after = text, "" if cursorAt then before = text:sub(1, cursorAt - 1) after = text:sub(cursorAt + #"{cursor}"):gsub("{cursor}", "") term = nil -- keep the cursor where the author put it end local tail = "" if term == " " or (term and term:match("^[%.,:%?!]$")) then tail = term end local body = before .. after .. tail if ulen(body) > config.performance.largeTextThreshold then local snap = clipboardSnapshot() setClipboardPrivate(body) pressKey({ "cmd" }, "v") timer.doAfter(0.6, function() clipboardRestore(snap) end) else typeText(body) end local extra = 0 if term == "\r" or term == "\n" then pressKey({}, "return"); extra = 1 elseif term == "\t" then pressKey({}, "tab"); extra = 1 end for _ = 1, ulen(after) do pressKey({}, "left") end setUndo(sc, ulen(body) + extra) recordUsage(sc); recordHistory(sc, ulen(body)) playSound(config.feedback.expandSound) end local function launchTarget(target, sc) local kind, value, reason = classifyTarget(target) if kind == "blocked" then notifyUser(APP, "Blocked " .. (sc or "launcher") .. ": " .. (reason or "unsafe target")) playSound(config.feedback.errorSound) return end if kind == "missing" then notifyUser(APP, "Can't find what " .. (sc or "this launcher") .. " opens:\n" .. tostring(target) .. "\nIt may have moved or been deleted. Fix it with ⌥L.") playSound(config.feedback.errorSound) return end if kind == "url" then urlevent.openURL(value) elseif kind == "app" then if config.security.confirmApps[value:lower()] and not confirmLaunch(value, value .. " can change your Mac's settings or run commands.") then return end if not application.launchOrFocus(value) then notifyUser(APP, "No app named \"" .. value .. "\" was found for " .. (sc or "this launcher") .. ".") playSound(config.feedback.errorSound) return end elseif kind == "appbundle" then local name = value:match("([^/]+)%.app/?$") or value if config.security.confirmApps[name:lower()] then if not confirmLaunch(name, name .. " can change your Mac's settings or run commands.") then return end elseif not isTrustedAppPath(value) then if not confirmLaunch(name, "This app is outside your Applications folders:\n" .. value) then return end end application.launchOrFocus(value) else -- file or folder openPath(value) end recordUsage(sc or target) playSound(config.feedback.launchSound) end --================================================ -- HOME-ROW ; COMMANDS -- Tap ; then a letter. The ; is held back for a moment so a selection -- isn't overwritten; if no command letter follows, ; is typed as usual. -- lowercase = what you use most · Capital = the rarer or riskier twin. -- A user shortcut with the same trigger (e.g. ";x") always wins. --================================================ local HOME_COMMANDS = { s = { {{"cmd"}, "s"} }, -- Save p = { {{"cmd"}, "p"} }, -- Print c = { {{"cmd"}, "c"} }, -- Copy v = { {{"cmd"}, "v"} }, -- Paste x = { {{"cmd"}, "x"} }, -- Cut z = { {{"cmd"}, "z"} }, -- Undo y = { {{"cmd","shift"}, "z"} }, -- Redo a = { {{"cmd"}, "a"} }, -- Select All f = { {{"cmd"}, "f"} }, -- Find h = { {{"cmd","alt"}, "f"} }, -- Find & Replace (most apps) n = { {{"cmd"}, "n"} }, -- New o = { {{"cmd"}, "o"} }, -- Open w = { {{"cmd"}, "w"} }, -- Close tab/window t = { {{"cmd"}, "t"} }, -- New tab b = { {{"cmd"}, "b"} }, -- Bold i = { {{"cmd"}, "i"} }, -- Italic u = { {{"cmd"}, "u"} }, -- Underline r = { {{"cmd"}, "r"} }, -- Refresh d = { {{"cmd","alt"}, "i"} }, -- Developer tools e = { {{"cmd"}, "right"} }, -- Line end l = { {{"cmd"}, "left"} }, -- Line start ["8"] = { {{"ctrl","cmd"}, "space"} }, -- Emoji & symbols picker ["*"] = { {{"ctrl","cmd"}, "space"} }, -- (Shift+8 works too) E = { {{"cmd"}, "right"} }, -- Line end L = { {{"ctrl","cmd"}, "q"} }, -- Lock screen A = { {{"cmd"}, "a"}, {{"cmd"}, "c"} }, -- Select all + copy T = { {{"cmd"}, "up"} }, -- Top of document B = { {{"cmd"}, "down"} }, -- Bottom of document U = { {{}, "pageup"} }, -- Page up D = { {{}, "pagedown"} }, -- Page down S = { {{"cmd","shift"}, "s"} }, -- Save As N = { {{"cmd","shift"}, "n"} }, -- New window W = { {{"cmd","shift"}, "w"} }, -- Close window } local HOME_LABELS = { { "s", "Save" }, { "c", "Copy" }, { "v", "Paste" }, { "x", "Cut" }, { "z", "Undo" }, { "y", "Redo" }, { "a", "Select all" }, { "A", "Select all + copy" }, { "e", "Line end" }, { "l", "Line start" }, { "f", "Find" }, { "h", "Find & replace" }, { "p", "Print" }, { "n", "New" }, { "o", "Open" }, { "w", "Close tab" }, { "t", "New tab" }, { "b / i / u", "Bold / italic / underline" }, { "r", "Refresh" }, { "d", "Developer tools" }, { "8", "Emoji picker" }, { "T / B", "Top / bottom" }, { "U / D", "Page up / down" }, { "S", "Save As" }, { "N", "New window" }, { "W", "Close window" }, { "L", "Lock screen" }, } local HR_TRIGGER = ";" local HR_KC = keycodes.map[";"] or keycodes.map["semicolon"] or 41 -- 41 = kVK_ANSI_Semicolon local hrPending = false local hrTimer = nil local lastKeyTime = 0 local lastKeyChar = "" local function runHomeCommand(steps) for i, step in ipairs(steps) do if i == 1 then pressKey(step[1], step[2]) else timer.doAfter(0.08 * (i - 1), function() pressKey(step[1], step[2]) end) end end end local function isBoundaryChar(c) if c == "" or c == "\n" or c == "\r" or c == "\t" then return true end if #c == 1 and c:match("[%p%s]") then return true end return c == "—" or c == "–" or c == "…" or c == "“" or c == "”" or c == "‘" or c == "’" end local function hrCancel() hrPending = false if hrTimer then hrTimer:stop(); hrTimer = nil end end local function appendToBuffer(chars) typedBuffer = typedBuffer .. chars if #typedBuffer > maxBuffer then typedBuffer = typedBuffer:sub(-maxBuffer) end end -- Type the held-back ; for real and let the hotstring engine see it. local function hrReplay() postKey({}, HR_KC) appendToBuffer(HR_TRIGGER) lastKeyChar = HR_TRIGGER end --================================================ -- HOTSTRING ENGINE -- Instant: trigger ends in a capital, contains a digit or a symbol, -- or was saved with a trailing $$. -- Word: all-lowercase triggers wait for space / return / tab / -- . , : ? ! --================================================ local TERMINATORS = { [" "]=true, ["\r"]=true, ["\n"]=true, ["\t"]=true, ["."]=true, [","]=true, [":"]=true, ["?"]=true, ["!"]=true } local function isImmediateTrigger(sc) if forcedInstant[sc] then return true end local last = sc:sub(-1) if last >= "A" and last <= "Z" then return true end return sc:find("[^%a]") ~= nil end local function clearBuffer() typedBuffer = "" end local function findMatch(index, buffer, immediateOnly) if #buffer == 0 then return nil end local bucket = index[buffer:sub(-1)] if not bucket then return nil end local matched for _, sc in ipairs(bucket) do if ((not immediateOnly) or isImmediateTrigger(sc)) and #buffer >= #sc and buffer:sub(-#sc) == sc then if not matched or #sc > #matched then matched = sc end end end return matched end -- term ~= nil: a terminator was just typed (and is being held back). -- term == nil: the last character of the trigger is being held back. local function tryFire(term) if macrosPaused then return false end local immediateOnly = (term == nil) local exp = findMatch(expansionTailIndex, typedBuffer, immediateOnly) local lau = findMatch(launcherTailIndex, typedBuffer, immediateOnly) local kind, sc if exp and lau then if #exp >= #lau then kind, sc = "exp", exp else kind, sc = "lau", lau end elseif exp then kind, sc = "exp", exp elseif lau then kind, sc = "lau", lau end if not sc then return false end -- Only the characters already on screen are erased. local onScreen = ulen(sc) - (term == nil and 1 or 0) eraseChars(onScreen) clearBuffer() if kind == "exp" then expandText(sc, loadedExpansions[sc], term) else launchTarget(loadedLaunchers[sc], sc) setUndo(nil, 0) end return true end -- Returns true to swallow the key. local function processHotstring(chars) if TERMINATORS[chars] then local fired = tryFire(chars) clearBuffer() return fired end if #chars >= 1 then appendToBuffer(chars) if tryFire(nil) then return true end end return false end local function hrTimeout() hrCancel() hrReplay() lastKeyTime = timer.secondsSinceEpoch() end local typingTap = eventtap.new({ eventtap.event.types.keyDown }, function(event) if isOurEvent(event) then return false end if typingInFlight > 0 then return false end if macrosPaused then return false end local flags = event:getFlags() local chars = event:getCharacters(true) or "" local keyCode = event:getKeyCode() local now = timer.secondsSinceEpoch() if not flags.alt then keysSinceExpansion = keysSinceExpansion + 1 end -- 1. A ; is waiting for its command letter. if hrPending then hrCancel() if flags.cmd or flags.ctrl or flags.alt then postKey({}, HR_KC) clearBuffer() lastKeyTime, lastKeyChar = now, chars -- let the modified key through after the replayed ; local copy = markSynthetic(event:copy()); copy:post() return true end local cmd = HOME_COMMANDS[chars] local userOwns = loadedExpansions[HR_TRIGGER .. chars] or loadedLaunchers[HR_TRIGGER .. chars] if prefs.homeRowCommands and cmd and not userOwns then runHomeCommand(cmd) clearBuffer() setUndo(nil, 0) lastKeyTime, lastKeyChar = now, "" return true end -- Not a command: type the ; first, then handle this key normally. hrReplay() lastKeyTime, lastKeyChar = now, chars if keyCode == DELETE_KC then typedBuffer = typedBuffer:sub(1, -2) local copy = markSynthetic(event:copy()); copy:post() return true end if processHotstring(chars) then return true end local copy = markSynthetic(event:copy()); copy:post() return true end if flags.cmd or flags.ctrl then clearBuffer(); lastKeyTime, lastKeyChar = now, "" return false end -- 2. Could this ; start a home-row command? if prefs.homeRowCommands and chars == HR_TRIGGER and not flags.alt then if (now - lastKeyTime) >= config.homeRow.idleGate or isBoundaryChar(lastKeyChar) then hrPending = true hrTimer = timer.doAfter(config.homeRow.pendingWindow, hrTimeout) return true end end lastKeyTime, lastKeyChar = now, chars -- 3. Text shortcuts and launchers. if keyCode == DELETE_KC then typedBuffer = typedBuffer:sub(1, -2) return false end return processHotstring(chars) end) typingTap:start() -- A click moves the cursor: forget what was typed and drop undo. local clickTap = eventtap.new({ eventtap.event.types.leftMouseDown }, function() clearBuffer() keysSinceExpansion = keysSinceExpansion + 1 return false end) clickTap:start() --================================================ -- UNDO LAST EXPANSION -- Only right after the expansion, before any other typing or click, -- so it can never delete text you wrote yourself. --================================================ local function undoLastExpansion() if not lastExpansionInfo.sc or lastExpansionInfo.length == 0 then alert("Nothing to undo"); return end if keysSinceExpansion > 0 then alert("Undo works right after an expansion. Use ⌘Z instead.") lastExpansionInfo = { sc = nil, length = 0 } return end eraseChars(lastExpansionInfo.length) notifyUser(APP, "Undid: " .. lastExpansionInfo.sc) lastExpansionInfo = { sc = nil, length = 0 } end --================================================ -- DIALOGS: Add / Capture --================================================ local function triggerInUse(sc) if loadedExpansions[sc] then return "text expansion" end if loadedLaunchers[sc] then return "launcher" end return nil end local function confirmReplace(sc) local used = triggerInUse(sc) if not used then return true end local btn = dialog.blockAlert(APP, "'" .. sc .. "' is already a " .. used .. ". Replace it?\n(To change an existing shortcut, use ⌥E.)", "Replace", "Cancel") return btn == "Replace" end local function saved(sc) if config.textExpander.backupOnSave then makeBackup(true) end notifyUser(APP, "Saved: " .. sc) end local function captureSelectedText() local snap = clipboardSnapshot() pasteboard.clearContents() pressKey({ "cmd" }, "c") timer.doAfter(0.25, function() local selected = pasteboard.getContents() clipboardRestore(snap) if not selected or selected == "" then alert("No text selected"); return end local btn, sc = dialog.textPrompt("Capture Expansion", "Trigger for the selected text (e.g. 'sigE'):", "", "Save", "Cancel") if btn == "Save" and sc and sc ~= "" then local ok, err = validateShortcut(sc) if not ok then alert(err); return end if not confirmReplace(sc) then return end loadedLaunchers[sc] = nil loadedExpansions[sc] = selected saveExpansionsFile(); saveLaunchersFile(); saved(sc) end end) end local function addExpansion() local btn1, sc = dialog.textPrompt("Add Text Expansion", "Trigger (e.g. 'addrE' for address):", "", "Next", "Cancel") if btn1 ~= "Next" or not sc or sc == "" then return end local ok, err = validateShortcut(sc); if not ok then alert(err); return end if not confirmReplace(sc) then return end local btn2, target = dialog.textPrompt("Add Text Expansion", "Text to expand for '" .. sc .. "' (type [NEWLINE] for a line break):", "", "Save", "Cancel") if btn2 == "Save" and target and target ~= "" then loadedLaunchers[sc] = nil loadedExpansions[sc] = decodeTarget(target) saveExpansionsFile(); saveLaunchersFile(); saved(sc) end end local function saveLauncher(sc, target) local kind, _, reason = classifyTarget(target) if kind == "blocked" then alert("Not allowed: " .. (reason or "unsafe target")); return end loadedExpansions[sc] = nil loadedLaunchers[sc] = target saveLaunchersFile(); saveExpansionsFile(); saved(sc) end local function addLauncher() local btn1, sc = dialog.textPrompt("Add App Launcher", "Trigger (e.g. '`s' for Safari):", "", "Browse...", "Cancel") if btn1 ~= "Browse..." or not sc or sc == "" then return end local ok, err = validateShortcut(sc); if not ok then alert(err); return end if not confirmReplace(sc) then return end local picked = dialog.chooseFileOrFolder("Select an app", "/Applications", true, false, false, { "app" }) if not picked or not picked["1"] then return end saveLauncher(sc, picked["1"]) end local function addDocumentLauncher() local btn1, sc = dialog.textPrompt("Add Document Launcher", "Trigger (e.g. 'rptD' for report):", "", "Browse...", "Cancel") if btn1 ~= "Browse..." or not sc or sc == "" then return end local ok, err = validateShortcut(sc); if not ok then alert(err); return end if not confirmReplace(sc) then return end local picked = dialog.chooseFileOrFolder("Select a document or folder", HOME .. "/Documents", true, true, false) if not picked or not picked["1"] then return end saveLauncher(sc, picked["1"]) end local function addWebLauncher() local btn1, sc = dialog.textPrompt("Add Web Launcher", "Trigger (e.g. '`m' for Gmail):", "", "Next", "Cancel") if btn1 ~= "Next" or not sc or sc == "" then return end local ok, err = validateShortcut(sc); if not ok then alert(err); return end if not confirmReplace(sc) then return end local btn2, url = dialog.textPrompt("Add Web Launcher", "URL, or a mailto: / tel: / sms: / facetime: link for '" .. sc .. "':", "https://", "Save", "Cancel") if btn2 == "Save" and url and url ~= "" and url ~= "https://" then saveLauncher(sc, trim(url)) end end --================================================ -- QUICK EXPANSION PICKER --================================================ local function quickExpansionPicker() local choices = {} for sc, target in pairs(loadedExpansions) do choices[#choices + 1] = { text = sc, subText = target:gsub("\n", " ⏎ "):sub(1, 80), shortcut = sc, target = target, } end table.sort(choices, function(a, b) local ua, ub = usageStats[a.shortcut] or 0, usageStats[b.shortcut] or 0 if ua ~= ub then return ua > ub end return a.shortcut < b.shortcut end) local picker = chooser.new(function(choice) if choice then expandText(choice.shortcut, choice.target, nil) end end) picker:choices(choices) picker:placeholderText("Pick an expansion to type…") picker:searchSubText(true) picker:show() end --================================================ -- EDIT GUI --================================================ local function showEditGUI(which) which = which or "all" local label = ({ launchers = "Launchers", expansions = "Text Expansions" })[which] or "Shortcuts" local choices = {} local function addFrom(map, isLau) for sc, target in pairs(map) do choices[#choices + 1] = { text = sc, subText = (isLau and "Launcher · " or "Text · ") .. tostring(target):gsub("\n", " ⏎ "):sub(1, 120), shortcut = sc, target = target, isLau = isLau, } end end if which ~= "launchers" then addFrom(loadedExpansions, false) end if which ~= "expansions" then addFrom(loadedLaunchers, true) end table.sort(choices, function(a, b) return a.shortcut < b.shortcut end) local picker = chooser.new(function(choice) if not choice then return end local isLau = choice.isLau local source = isLau and loadedLaunchers or loadedExpansions local action = dialog.blockAlert("Edit " .. label, "Shortcut: " .. choice.shortcut .. "\nTarget: " .. choice.target, "Edit", "Delete") if action == "Edit" then local btn, newTarget = dialog.textPrompt("Edit " .. choice.shortcut, "New target:", encodeTarget(choice.target), "Save", "Cancel") if btn == "Save" and newTarget and newTarget ~= "" then if isLau then saveLauncher(choice.shortcut, trim(newTarget)) else loadedExpansions[choice.shortcut] = decodeTarget(newTarget) saveExpansionsFile(); saved(choice.shortcut) end end elseif action == "Delete" then local confirm = dialog.blockAlert("Confirm Delete", "Delete '" .. choice.shortcut .. "'?", "Delete", "Cancel") if confirm == "Delete" then source[choice.shortcut] = nil forcedInstant[choice.shortcut] = nil if isLau then saveLaunchersFile() else saveExpansionsFile() end if config.textExpander.backupOnSave then makeBackup(true) end notifyUser(APP, "Deleted: " .. choice.shortcut) end end end) picker:choices(choices) picker:placeholderText("Search " .. label:lower() .. "…") picker:searchSubText(true) picker:show() end --================================================ -- KARABINER RULE (hold ; = ⌘, hold ` / Caps Lock = ⌥, middle mouse) --================================================ local KARABINER_JSON = [==[{ "title": "MultiShortcuts Pro for Mac: modifier keys on the home row", "maintainers": ["GnuKey LLC"], "rules": [ { "description": "MultiShortcuts Pro: Hold ; = Command (tap types ;)", "manipulators": [ { "type": "basic", "from": { "key_code": "semicolon", "modifiers": { "optional": ["any"] } }, "to_if_alone": [ { "key_code": "semicolon", "halt": true } ], "to_if_held_down": [ { "key_code": "left_command" } ], "to_delayed_action": { "to_if_canceled": [ { "key_code": "semicolon" } ] }, "parameters": { "basic.to_if_alone_timeout_milliseconds": 200, "basic.to_if_held_down_threshold_milliseconds": 200, "basic.to_delayed_action_delay_milliseconds": 200 } } ] }, { "description": "MultiShortcuts Pro: Hold ` = Option (tap types `)", "manipulators": [ { "type": "basic", "from": { "key_code": "grave_accent_and_tilde", "modifiers": { "optional": ["any"] } }, "to_if_alone": [ { "key_code": "grave_accent_and_tilde", "halt": true } ], "to_if_held_down": [ { "key_code": "left_option" } ], "to_delayed_action": { "to_if_canceled": [ { "key_code": "grave_accent_and_tilde" } ] }, "parameters": { "basic.to_if_alone_timeout_milliseconds": 200, "basic.to_if_held_down_threshold_milliseconds": 200, "basic.to_delayed_action_delay_milliseconds": 200 } } ] }, { "description": "MultiShortcuts Pro: Caps Lock tap = ` (launcher key), hold = Option, Shift + Caps Lock = Caps Lock", "manipulators": [ { "type": "basic", "from": { "key_code": "caps_lock", "modifiers": { "mandatory": ["shift"], "optional": ["caps_lock"] } }, "to": [ { "key_code": "caps_lock" } ] }, { "type": "basic", "from": { "key_code": "caps_lock", "modifiers": { "optional": ["any"] } }, "to_if_alone": [ { "key_code": "grave_accent_and_tilde", "halt": true } ], "to_if_held_down": [ { "key_code": "left_option" } ], "to_delayed_action": { "to_if_canceled": [ { "key_code": "grave_accent_and_tilde" } ] }, "parameters": { "basic.to_if_alone_timeout_milliseconds": 200, "basic.to_if_held_down_threshold_milliseconds": 200, "basic.to_delayed_action_delay_milliseconds": 200 } } ] }, { "description": "MultiShortcuts Pro: Middle mouse button like ; (click = ;, hold = Command, Shift + click = middle click)", "manipulators": [ { "type": "basic", "from": { "pointing_button": "button3", "modifiers": { "mandatory": ["shift"] } }, "to": [ { "pointing_button": "button3" } ] }, { "type": "basic", "from": { "pointing_button": "button3", "modifiers": { "optional": ["any"] } }, "to_if_alone": [ { "key_code": "semicolon", "halt": true } ], "to_if_held_down": [ { "key_code": "left_command" } ], "to_delayed_action": { "to_if_canceled": [ { "key_code": "semicolon" } ] }, "parameters": { "basic.to_if_alone_timeout_milliseconds": 200, "basic.to_if_held_down_threshold_milliseconds": 200, "basic.to_delayed_action_delay_milliseconds": 200 } } ] }, { "description": "MultiShortcuts Pro (alternative): Middle mouse button like Caps Lock (click = `, hold = Option, Shift + click = middle click)", "manipulators": [ { "type": "basic", "from": { "pointing_button": "button3", "modifiers": { "mandatory": ["shift"] } }, "to": [ { "pointing_button": "button3" } ] }, { "type": "basic", "from": { "pointing_button": "button3", "modifiers": { "optional": ["any"] } }, "to_if_alone": [ { "key_code": "grave_accent_and_tilde", "halt": true } ], "to_if_held_down": [ { "key_code": "left_option" } ], "to_delayed_action": { "to_if_canceled": [ { "key_code": "grave_accent_and_tilde" } ] }, "parameters": { "basic.to_if_alone_timeout_milliseconds": 200, "basic.to_if_held_down_threshold_milliseconds": 200, "basic.to_delayed_action_delay_milliseconds": 200 } } ] } ] } ]==] local KARABINER_APP = "/Applications/Karabiner-Elements.app" local KARABINER_ASSETS = HOME .. "/.config/karabiner/assets/complex_modifications" local function karabinerInstalled() return fs.attributes(KARABINER_APP) ~= nil end local function setupHoldKeys() if not karabinerInstalled() then local btn = dialog.blockAlert("Hold keys need Karabiner-Elements", "Hold ; for ⌘ Command and hold Caps Lock or ` for ⌥ Option come from " .. "Karabiner-Elements, a free keyboard tool.\n\nInstall it, open it once, " .. "then choose this menu item again.", "Get Karabiner-Elements", "Not now") if btn == "Get Karabiner-Elements" then urlevent.openURL("https://karabiner-elements.pqrs.org") end return end local path = "" for part in KARABINER_ASSETS:gmatch("[^/]+") do path = path .. "/" .. part if not isDir(path) then fs.mkdir(path) end end local dest = KARABINER_ASSETS .. "/multishortcutspro.json" if not writeFile(dest, KARABINER_JSON) then alert("Couldn't write the Karabiner rule file"); return end dialog.blockAlert("Hold keys are ready to turn on", "In Karabiner-Elements, open Complex Modifications → Add predefined rule, " .. "find \"MultiShortcuts Pro\" and click Enable on each rule you want:\n\n" .. "• Hold ; = ⌘ Command\n• Hold ` = ⌥ Option\n• Caps Lock: tap = `, hold = ⌥ Option\n" .. "• Middle mouse like ; (or the alternative: like Caps Lock)\n\n" .. "To turn one off later, remove it there. For the mouse rule, also tick your " .. "mouse under Devices.", "Open Karabiner-Elements") application.launchOrFocus("Karabiner-Elements") end --================================================ -- PAUSE / HELP / RELOAD --================================================ local updateMenu -- forward declaration local function togglePause() macrosPaused = not macrosPaused hrCancel(); clearBuffer() notifyUser(APP, macrosPaused and "Paused: shortcuts off" or "Resumed: shortcuts on") if updateMenu then updateMenu() end end local function homeRowHelpText() local lines = {} for _, l in ipairs(HOME_LABELS) do lines[#lines + 1] = string.format(" ;%-7s %s", l[1], l[2]) end return table.concat(lines, "\n") end local function showHelp() local msg = APP .. " for Mac v" .. VERSION .. [[ HOLD KEYS (Karabiner-Elements rule, menu → Set up hold keys…) Hold ; ⌘ Command (hold ; + C copies) Hold ` / Caps ⌥ Option Tap Caps Lock types ` (Caps Lock then a = Claude) Shift+Caps Lock real Caps Lock ; COMMANDS (tap ;, then a letter) ]] .. homeRowHelpText() .. [[ = DATES -= today (October 28, 2026) =- today (10/28/26) 1-= / 2-= / 3-= in 1 / 2 / 3 weeks 1m= / 2m= in 1 / 2 months 1y= in a year HOTKEYS ⌥T text ⌥A app ⌥D document ⌥W web ⌥C capture ⌥E edit ⌥L launchers ⌥M expansions ⌥Q picker ⌥Z undo ⌥B backup ⌥P pause ⌥R reload ⌥H help PLACEHOLDERS & SEQUENCES {clip} {user} {comp} {cursor} {prompt:Question?} {date:%A} {when:+7d} {key:^s} {key:{tab}} {wait:500} {focus:Safari} ]] if prefs.starterMode then msg = msg .. "\nStarter mode: menu → Unlock Full Features to add your own shortcuts.\n" end dialog.blockAlert("Help: " .. APP, msg, "Close") end local function reloadScript() notifyUser(APP, "Reloading…") hs.reload() end --================================================ -- HOTKEYS --================================================ local actions = { AddExpansion = addExpansion, AddLauncher = addLauncher, CaptureExpansion = captureSelectedText, AddDocumentLauncher = addDocumentLauncher, ShowEditGUI = function() showEditGUI("all") end, AddWebLauncher = addWebLauncher, ImmediateBackup = function() makeBackup(false) end, QuickTextExpansion = quickExpansionPicker, ManageLaunchers = function() showEditGUI("launchers") end, ManageExpansions = function() showEditGUI("expansions") end, ReloadScript = reloadScript, ShowHelp = showHelp, PauseResume = togglePause, UndoExpansion = undoLastExpansion, Screenshot = function() pressKey({ "cmd", "shift" }, "4") end, } local function registerAllHotkeys() for _, hk in ipairs(registeredHotkeys) do hk:delete() end registeredHotkeys = {} for name, def in pairs(config.hotkeys) do if actions[name] and (not def.full or not prefs.starterMode) then registeredHotkeys[#registeredHotkeys + 1] = hotkey.bind(def[1], def[2], actions[name]) end end end --================================================ -- STARTER MODE --================================================ local function unlockFullFeatures() local btn = dialog.blockAlert("Unlock Full Features", "Adds your own text expansions, app / web / document launchers, " .. "the shortcut editor and the ⌥ hotkeys (⌥T, ⌥A, ⌥E …).\n\n" .. "A few sample text shortcuts are added to get you started.", "Unlock", "Not now") if btn ~= "Unlock" then return end prefs.starterMode = false savePrefs() local n = addSamples() registerAllHotkeys() updateMenu() notifyUser(APP, "Full Features unlocked" .. (n > 0 and (" · " .. n .. " samples added") or "")) end --================================================ -- MENU BAR --================================================ local menuIcon = menubar.new() updateMenu = function() local full = not prefs.starterMode local items = { { title = APP .. " v" .. VERSION, disabled = true }, } if not full then items[#items + 1] = { title = "Starter mode · Unlock Full Features…", fn = unlockFullFeatures } end items[#items + 1] = { title = "-" } if full then local add = { { title = "Add Text Expansion ⌥T", fn = addExpansion }, { title = "Add App Launcher ⌥A", fn = addLauncher }, { title = "Add Document Launcher ⌥D", fn = addDocumentLauncher }, { title = "Add Web Launcher ⌥W", fn = addWebLauncher }, { title = "Capture Selected Text ⌥C", fn = captureSelectedText }, { title = "-" }, { title = "Edit All Shortcuts ⌥E", fn = function() showEditGUI("all") end }, { title = "Edit Text Expansions ⌥M", fn = function() showEditGUI("expansions") end }, { title = "Edit Launchers ⌥L", fn = function() showEditGUI("launchers") end }, } for _, it in ipairs(add) do items[#items + 1] = it end end local rest = { { title = "Quick Expansion Picker ⌥Q", fn = quickExpansionPicker }, { title = "Undo Last Expansion ⌥Z", fn = undoLastExpansion }, { title = "-" }, { title = "Set up hold keys (⌘ on ;, ⌥ on Caps Lock)…", fn = setupHoldKeys }, { title = "; Commands (tap ; then a letter)", checked = prefs.homeRowCommands, fn = function() prefs.homeRowCommands = not prefs.homeRowCommands hrCancel(); savePrefs(); updateMenu() end }, { title = "Sound Feedback", checked = config.feedback.playSounds, fn = function() config.feedback.playSounds = not config.feedback.playSounds savePrefs(); updateMenu() end }, { title = macrosPaused and "Resume Shortcuts ⌥P" or "Pause Shortcuts ⌥P", fn = togglePause }, { title = "Backup Now" .. (full and " ⌥B" or ""), fn = function() makeBackup(false) end }, { title = "-" }, { title = "Open Shortcuts Folder", fn = function() openPath(scriptDir) end }, { title = "Help ⌥H", fn = showHelp }, { title = "Reload ⌥R", fn = reloadScript }, { title = "-" }, { title = "Phone ⇄ PC Shortcut Converter", fn = function() urlevent.openURL("https://speedkee.com/phone-pc-converter") end }, { title = "MultiShortcuts Pro for Windows", fn = function() urlevent.openURL("https://multishortcutspro.speedkee.com") end }, { title = "Get SpeedKee (Android)", fn = function() urlevent.openURL("https://speedkee.com") end }, { title = "-" }, { title = "Report a Problem (beta)…", fn = function() urlevent.openURL("mailto:support.team@gnukey.com?subject=MultiShortcuts%20Pro%20for%20Mac%20" .. VERSION:gsub(" ", "%%20")) end }, } for _, it in ipairs(rest) do items[#items + 1] = it end menuIcon:setMenu(items) menuIcon:setTitle(macrosPaused and "MSP ⏸" or "MSP") end menuIcon:setTooltip(APP .. " v" .. VERSION) --================================================ -- WELCOME / WHAT'S NEW (shown once) --================================================ local function announce() local holdLine = karabinerInstalled() and "Turn on the hold keys: menu bar MSP → Set up hold keys…" or "The hold keys use Karabiner-Elements (free). Menu bar MSP → Set up hold keys… walks you through it." local warn = legacyStillLoaded() and "\n\n⚠️ Your init.lua still loads ShortKees. Remove the require(\"ShortKees\") / require(\"ShortKeesHome\") lines and reload, or both will run at once." or "" local btn if installKind == "new" then btn = dialog.blockAlert("Welcome to " .. APP, "⌘ and ⌥, now on your home row. And ⌘ on your mouse, for easy editing.\n\n" .. "• Hold ; = ⌘ Command (hold ; + C copies)\n" .. "• Hold Caps Lock or ` = ⌥ Option; tap Caps Lock for the ` launcher key\n" .. "• Tap ; then a letter: ;c copy, ;v paste, ;s save, ;e line end\n" .. "• -= types today's date · ` then a opens Claude\n\n" .. "Go no further than this, and your Mac is already easier to use. It's just the beginning.\n\n" .. holdLine .. warn, "Set up hold keys…", "Later") elseif installKind == "upgrade" then btn = dialog.blockAlert("What's new in " .. APP .. " " .. VERSION, "ShortKees is now MultiShortcuts Pro, matching the Windows version.\n\n" .. "• ⌘ and ⌥ on your home row: hold ; = ⌘, hold Caps Lock or ` = ⌥\n" .. "• New ; commands: ;e line end, ;l line start, ;8 emoji, ;A select all + copy, ;L lock screen\n" .. "• Long expansions stay out of clipboard history\n" .. "• Safer launchers: scripts and installers are blocked; Terminal and apps outside Applications ask first\n" .. "• Multi-line shortcuts saved as [NEWLINE], so files stay SpeedKee-compatible\n" .. "• The F-Key Hub is gone: F-keys behave normally again\n\n" .. "Your shortcuts were kept." .. (legacyExisted and " They now live in ~/.hammerspoon/MultiShortcutsPro." or "") .. "\n\n" .. holdLine .. warn, "Set up hold keys…", "OK") else return end if btn == "Set up hold keys…" then setupHoldKeys() end end --================================================ -- WATCHERS --================================================ local fileWatcher = pathwatcher.new(scriptDir, function(paths) if saving then return end for _, p in ipairs(paths) do if p:match("launchers%.txt$") or p:match("text_expansion%.txt$") then forcedInstant = {} loadLaunchers(); loadExpansions() notifyUser(APP, "Reloaded shortcuts after an outside edit") break end end end) fileWatcher:start() local appWatcher = application.watcher.new(function(_, eventType) if eventType == application.watcher.activated then clearBuffer(); hrCancel() keysSinceExpansion = keysSinceExpansion + 1 end end) appWatcher:start() --================================================ -- ACCESSIBILITY PERMISSION CHECK --================================================ if eventtap.checkPermissions then local ok, granted = pcall(eventtap.checkPermissions) if ok and granted == false then if eventtap.requestPermissions then pcall(eventtap.requestPermissions) end print("[MSP] Accessibility permission not granted. Open System Settings → " .. "Privacy & Security → Accessibility and turn on Hammerspoon.") end end --================================================ -- INITIALIZE --================================================ local function init() loadPrefs() local migrated = migrateLegacy() if migrated then installKind = "upgrade" elseif not dataExisted then installKind = "new" elseif prefs.lastVersionSeen ~= VERSION then installKind = "upgrade" end if prefs.starterMode == nil then prefs.starterMode = (installKind == "new") end if installKind == "new" then seedNewInstall() end loadLaunchers(); loadExpansions() ensureBuiltins() loadStats(); loadHistory() registerAllHotkeys() updateMenu() makeBackup(true) timer.doEvery(PERSIST_INTERVAL_S, function() saveStats(); saveHistory() end) local announceKind = installKind prefs.lastVersionSeen = VERSION savePrefs() if announceKind ~= "existing" then timer.doAfter(1.5, announce) else notifyUser(APP, string.format("Loaded %d launchers, %d expansions", countMap(loadedLaunchers), countMap(loadedExpansions))) end end init() print("[MultiShortcutsPro " .. VERSION .. "] Initialized · " .. scriptDir)