Skip to content

Instantly share code, notes, and snippets.

@spilist
Last active April 7, 2026 22:28
Show Gist options
  • Select an option

  • Save spilist/b5933464f82cc66e856fdb2383fb2ad8 to your computer and use it in GitHub Desktop.

Select an option

Save spilist/b5933464f82cc66e856fdb2383fb2ad8 to your computer and use it in GitHub Desktop.
블루투스를 이용해 아이폰과 맥북 사이 거리가 일정 이상 멀어지면 화면을 잠가주는 HammerSpoon 기반 스크립트 + 설치 프롬프트
<?xml version="1.0" encoding="UTF-8"?>
<!--
LaunchAgent template for the Hammerspoon proximity-lock external watchdog.
Install location:
~/Library/LaunchAgents/com.hammerspoon-watchdog.plist
Before loading, replace __WATCHDOG_SH_PATH__ with the absolute path to the
hammerspoon_watchdog.sh script on this Mac (for example,
/Users/yourname/hammerspoon-scripts/hammerspoon_watchdog.sh).
Load with:
launchctl load ~/Library/LaunchAgents/com.hammerspoon-watchdog.plist
Unload with:
launchctl unload ~/Library/LaunchAgents/com.hammerspoon-watchdog.plist
This agent runs the watchdog shell script every 30 seconds and at load time.
The shell script itself decides whether to actually do anything — if the
proximity-lock log is fresh, it exits immediately.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.hammerspoon-watchdog</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>__WATCHDOG_SH_PATH__</string>
</array>
<key>StartInterval</key>
<integer>30</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/hammerspoon-watchdog.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/hammerspoon-watchdog.err.log</string>
</dict>
</plist>
#!/bin/bash
# External watchdog for the Hammerspoon iPhone proximity-lock script.
#
# Background:
# The Lua script polls Bluetooth RSSI on an hs.timer. In practice, a small
# number of Macs observe the Hammerspoon process staying alive while all of
# its internal timers silently stop firing. When that happens, nothing inside
# Hammerspoon can recover itself — any in-process watchdog is stuck with
# everything else. The only reliable recovery is an external process that
# notices the freeze and restarts Hammerspoon.
#
# This script is that external process. It is driven by a LaunchAgent (see
# com.hammerspoon-watchdog.plist) on a short interval. Each run:
# 1. Looks at the mtime of the proximity-lock log file.
# 2. If the log has not been written to in STALE_THRESHOLD seconds, assumes
# the polling loop is frozen and restarts Hammerspoon.
# 3. Otherwise, exits quietly.
#
# The proximity-lock Lua script writes to the log file on every poll, so a
# healthy system updates the log at least every few seconds. STALE_THRESHOLD
# is deliberately well above the normal poll interval to avoid false
# positives.
#
# Config:
# LOG_FILE must match CONFIG.logFile in the Lua script.
set -u
LOG_FILE="$HOME/Library/Logs/iphone-proximity-lock.log"
WATCHDOG_LOG="$HOME/Library/Logs/hammerspoon-watchdog.log"
STALE_THRESHOLD=90
now_ts=$(date +%s)
stamp=$(date "+%Y-%m-%d %H:%M:%S")
log() {
echo "$stamp $1" >> "$WATCHDOG_LOG"
}
if [ ! -f "$LOG_FILE" ]; then
log "no log file at $LOG_FILE; skipping"
exit 0
fi
mtime=$(stat -f %m "$LOG_FILE")
age=$((now_ts - mtime))
if [ "$age" -lt "$STALE_THRESHOLD" ]; then
exit 0
fi
log "log stale for ${age}s (threshold ${STALE_THRESHOLD}s); restarting Hammerspoon"
if pgrep -x Hammerspoon > /dev/null; then
killall Hammerspoon
# Give it a moment to actually die before relaunching.
for i in 1 2 3 4 5; do
if ! pgrep -x Hammerspoon > /dev/null; then
break
fi
sleep 1
done
fi
open -a Hammerspoon
log "Hammerspoon restart issued"

이 프롬프트를 그대로 복사해서 Codex/Claude Code 같은 로컬 코딩 에이전트에 붙여 넣어 주세요.

이 gist는 macOS + iPhone + Hammerspoon 조합 기준입니다. 다른 플랫폼에서도 비슷한 자동 잠금 흐름을 만들 수 있고, 아래 키워드는 탐색 시작점으로 쓸 만합니다.

  • 맥북 + 안드로이드: Hammerspoon + Tasker + Bluetooth presence
  • 윈도 + 아이폰: PowerShell/AutoHotkey + iPhone presence + Shortcuts/Home Assistant
  • 윈도 + 안드로이드: PowerShell/AutoHotkey + Tasker/KDE Connect + Bluetooth presence

기본 동작은 단순합니다. 5초마다 Bluetooth RSSI를 읽고, 최근 4개 샘플이 모두 임계값 밖이면 잠금을 시도합니다. 체감상 대략 20초 정도 멀어진 상태를 보는 보수적인 기본값입니다. 다만 RSSI는 실제 거리와 1:1로 대응하지 않고 꽤 흔들립니다. 같은 자리에서도 몸 방향, 벽, 가방, 책상, 주변 전파 환경 때문에 값이 달라질 수 있습니다. 필요하면 trustedWifiSSIDs로 집 Wi-Fi나 개인 핫스팟에서는 자동 잠금을 끌 수 있습니다.

이 gist에는 Lua 스크립트와 함께 외부 watchdog(hammerspoon_watchdog_gist.sh + com.hammerspoon-watchdog.plist)도 포함되어 있습니다. 일부 Mac에서 Hammerspoon 프로세스는 살아있는데 내부 hs.timer가 조용히 멈추는 상태가 관찰됩니다(특히 sleep/wake 사이클 직후). 이에 대비해 두 단계의 복구 장치가 있습니다:

  1. In-process recovery: Lua 스크립트 자체가 hs.timer와 별도의 dispatch 경로(hs.task /bin/sleep 체인)로 heartbeat를 돌리고, performCheck가 일정 시간 이상 silent하면 자동으로 polling 타이머 체인을 재시작합니다. wake 직후 1~2초 안에 복구되는 게 일반적입니다.
  2. External launchd watchdog: in-process 복구가 어떤 이유로든 실패해서 로그가 90초 이상 정체되면, launchd 에이전트가 Hammerspoon 프로세스를 통째로 재시작합니다. 마지막 안전망입니다.

두 단계 모두 설치해 주세요. Lua-only 단계는 빠르고 깔끔하지만 Hammerspoon runloop 자체가 멎는 드문 케이스는 잡지 못하고, 외부 단계만 두면 wake 후 복구까지 분 단위 공백이 생길 수 있습니다.

이 맥에 Hammerspoon 기반 iPhone 거리 감지 자동 잠금 스크립트를 설치해 주세요.

목적:
- 사용자가 맥북에서 멀어졌을 때 열린 세션이 오래 방치되지 않게 해 주세요.
- 이 설정은 물리적 접근, 어깨너머 보기, 분실/도난 상황 노출, 사회공학과 결합된 무단 접근 위험을 줄이기 위한 보조 통제입니다.
- 강한 인증을 대체하는 장치는 아니며, FileVault, 로그인 비밀번호, 회사 보안 정책, 기본 화면 잠금 정책을 약화하거나 대체하지 마세요.

전제와 제약:
- 이 작업은 `macOS`에서만 진행하세요.
- `Hammerspoon` 설치와 `손쉬운 사용(Accessibility)` 권한 부여가 가능한 계정이 아니면 중단하고 이유를 설명하세요.
- 관리형 회사 맥이나 `MDM` 정책 때문에 앱 설치, 로그인 항목 등록, 접근성 권한 허용이 막혀 있으면 우회하지 말고 그 지점에서 멈추세요.
- macOS의 `App Nap` 비활성화 설정(`defaults write ... NSAppSleepDisabled`)을 아래 단계에서 적용해 주세요. App Nap이 켜져 있으면 Hammerspoon이 idle 상태에서 throttling 되어 RSSI 폴링이 느려지거나 멎을 수 있고, 이걸 끄는 게 첫 번째 방어선입니다. 두 번째 방어선(in-process heartbeat)과 세 번째 방어선(외부 watchdog)은 Lua 스크립트와 watchdog plist 안에 이미 들어 있습니다.
- 기존 `~/.hammerspoon/init.lua`와 다른 사용자 설정을 임의로 덮어쓰지 말고, 변경이 필요하면 먼저 상태를 설명하고 백업 파일을 만든 뒤 최소 수정만 하세요.
- 아래 작업은 자동으로 강행하지 말고 직전에 짧게 확인을 받으세요: `Hammerspoon 설치`, `~/.hammerspoon/init.lua 수정`, `로그인 시 자동 실행 설정`, `defaults write org.hammerspoon.Hammerspoon NSAppSleepDisabled -bool YES` 실행, `~/Library/LaunchAgents/` 아래에 watchdog plist 배치와 `launchctl load`.
- 개인 장치명, 블루투스 주소, 사용자 경로 같은 식별자는 예시 값으로 남기지 말고 이 Mac과 이 iPhone에 맞게만 넣으세요. 답변에 전체 블루투스 주소를 그대로 재출력하지 말고 필요하면 일부만 마스킹하세요.
- 가능하면 `system_profiler -json SPBluetoothDataType`를 우선 사용하고, 실패할 때만 텍스트 출력을 보조로 사용하세요.
- 파괴적 명령, 보안 정책 우회, 기존 보안 설정 완화는 하지 마세요.

설치와 설정:
1. Hammerspoon이 없으면 설치해 주세요.
   - Homebrew가 있으면 `brew install --cask hammerspoon` 사용.
2. 이 Mac이 Bluetooth RSSI를 실제로 노출하는지 먼저 확인해 주세요.
   - 명령: `system_profiler SPBluetoothDataType | grep -iE "^\\s*rssi:"`
   - 최소 한 줄 이상의 `RSSI: -NN` 출력이 나와야 합니다. 나오면 진행해 주세요.
   - 아무것도 안 나오면 이 macOS 빌드에서 `system_profiler SPBluetoothDataType`이 RSSI 필드를 채워 주지 않는 상태라는 뜻입니다. 이 스크립트의 핵심 입력이 없으니 설치를 **중단**하고 사용자에게 이유를 설명해 주세요. 상황에 따라 macOS 업데이트 직후 일시적으로 안 나올 수도 있으니 잠시 후 재확인을 권해도 됩니다.
   - 참고: iPhone이 가까이 있고 Mac과 Bluetooth가 켜져 있는 상태에서 확인해 주세요. 장치가 한 대도 안 잡히면 RSSI도 안 나옵니다.
3. `~/.hammerspoon/` 디렉토리가 없으면 만들어 주세요.
4. gist의 `iphone_proximity_lock_gist.lua`를 `~/.hammerspoon/iphone_proximity_lock.lua`로 저장해 주세요.
5. `~/.hammerspoon/init.lua`를 만들거나 업데이트해서 `iphone_proximity_lock.lua`를 로드해 주세요.
   - `init.lua` 상단에 `require("hs.ipc")`를 함께 넣어 주세요. 이게 있으면 설치 후 이 단계에서 `hs -c "hs.reload()"` 같은 커맨드라인 도구로 검증과 재로드를 할 수 있습니다. 처음 로드될 때 `hs` CLI 설치 확인 팝업이 뜰 수 있는데, 그러면 사용자에게 어떤 권한인지 설명하고 허용 여부를 확인받으세요.
6. `Hammerspoon`에 대해 macOS `App Nap`을 비활성화해 주세요.
   - 명령: `defaults write org.hammerspoon.Hammerspoon NSAppSleepDisabled -bool YES`
   - App Nap이 켜져 있으면 macOS가 idle 상태의 `Hammerspoon` 프로세스를 suspend/throttle 해서 RSSI 폴링이 느려지거나 일시적으로 멎을 수 있습니다. 이게 첫 번째 방어선입니다. 스크립트 자체에도 in-process heartbeat가 들어 있고 외부 launchd watchdog도 함께 설치되지만, 이 설정 없이 그쪽들에만 의존하면 복구 latency가 길어집니다.
   - 이 설정은 다음 `Hammerspoon` 재시작부터 적용됩니다. 아래 단계에서 `Hammerspoon`을 실행할 때 자연스럽게 반영됩니다.
   - 되돌리려면: `defaults delete org.hammerspoon.Hammerspoon NSAppSleepDisabled` 후 `Hammerspoon`을 재시작하세요.
7. Hammerspoon을 실행해 주세요.
8. 로그인 시 자동 실행되게 설정해 주세요.
9. 제 iPhone의 Bluetooth 이름과 Bluetooth 주소를 찾아서 스크립트의 `targetName`, `targetAddress`에 넣어 주세요.
10. 기본값은 유지하되 너무 공격적으로 잠기지 않도록 보수적인 값부터 시작해 주세요.
11. 집처럼 내가 명시적으로 신뢰하는 네트워크에서는 자동 잠금을 끄고 싶다면 `trustedWifiSSIDs`에 해당 SSID를 넣어 주세요. 이 값은 기본적으로 비워 두고, 사용자가 요청한 경우에만 추가해 주세요.
12. 외부 watchdog을 설치해 주세요. 이건 선택이 아니라 권장사항입니다. Lua 스크립트 안에 in-process heartbeat가 이미 들어 있어 대부분의 sleep/wake 복구는 그쪽에서 처리하지만, Hammerspoon runloop 자체가 통째로 멎는 드문 경우는 외부 프로세스만이 회복시킬 수 있습니다.
    - gist의 `hammerspoon_watchdog_gist.sh`를 사용자가 선택한 디렉토리에 `hammerspoon_watchdog.sh`로 저장해 주세요. 예: `~/bin/hammerspoon_watchdog.sh` 또는 이 Lua 스크립트와 같은 폴더. 실행 권한을 부여해 주세요 (`chmod +x`).
    - 이 스크립트는 기본적으로 `$HOME/Library/Logs/iphone-proximity-lock.log`의 mtime을 보고 `90초` 이상 멈춰 있으면 `killall Hammerspoon` 후 `open -a Hammerspoon`으로 재시작합니다. Lua 스크립트의 `logFile` 경로를 바꿨다면 watchdog의 `LOG_FILE`도 같은 값으로 맞춰 주세요.
    - gist의 `com.hammerspoon-watchdog.plist` 템플릿을 `~/Library/LaunchAgents/com.hammerspoon-watchdog.plist`에 저장하고, 안의 `__WATCHDOG_SH_PATH__`를 위에서 저장한 `hammerspoon_watchdog.sh`의 **절대 경로**로 치환해 주세요.
    - `launchctl load ~/Library/LaunchAgents/com.hammerspoon-watchdog.plist`로 로드해 주세요. 이후 30초마다 실행되고, 로그가 신선하면 아무 일도 하지 않고 즉시 종료합니다.
    - 나중에 제거하고 싶다면 `launchctl unload ~/Library/LaunchAgents/com.hammerspoon-watchdog.plist` 후 plist 파일을 지우면 됩니다.

기본 추천값:
- `pollIntervalSeconds = 5`
- `awaySampleCount = 4`
- `awayRssiThreshold = -70`
- `rearmRssiThreshold = -62`
- `missingCountsAsAway = false`
- `trustedWifiSSIDs = {}`
- `logFile = ~/Library/Logs/iphone-proximity-lock.log`
- 과도한 디버그 로깅은 기본값으로 켜 두지 마세요.

설명에 포함할 내용:
- 기본값 기준으로는 `5초`마다 RSSI를 읽고 최근 `4개` 샘플이 모두 임계값 밖이면 잠금을 시도하므로, 체감상 약 `20초` 동안 iPhone이 충분히 멀리 있거나 신호가 약한 상태일 때 반응한다고 설명해 주세요.
- 이것은 정확한 미터 측정이 아니라 RSSI 기반 추정이며, 실내에서는 대략 몇 미터 이상에서 반응할 수 있다고만 설명하고 환경에 따라 크게 달라질 수 있다고 밝혀 주세요.
- iPhone과 Mac이 거의 움직이지 않아도 RSSI 수치가 꽤 유동적이고 같은 자리에서도 흔들릴 수 있다고 설명해 주세요.
- `trustedWifiSSIDs`를 쓰면 집 Wi-Fi나 개인 핫스팟 같은 특정 SSID에 연결된 동안 자동 잠금을 비활성화할 수 있지만, 이 예외는 사용자가 명시적으로 원할 때만 켜야 한다고 설명해 주세요.
- iPhone Bluetooth 테스트는 제어센터 토글보다 `설정 > Bluetooth`에서 완전히 끄거나 `비행기 모드`로 테스트하는 편이 더 정확하다고 설명해 주세요.
- `hs.caffeinate.lockScreen()`은 macOS 업데이트에 따라 동작이 달라질 수 있고, 필요하면 스크린세이버 시작 방식을 보조로 쓸 수 있다고 설명해 주세요.
- 로그에 `Heartbeat:` / `TaskHeartbeat:` 라인이 주기적으로 찍히는 게 정상입니다. `silence=...s`가 임계값 안이면 건강한 상태이고, `task_heartbeat_restart` / `heartbeat_restart` 라인은 in-process 복구가 발동했다는 뜻이므로 sleep/wake 직후에는 종종 보일 수 있다고 설명해 주세요. `~/Library/Logs/hammerspoon-watchdog.log`에 `restarting Hammerspoon` 라인이 보이면 외부 watchdog까지 동원돼 프로세스가 통째로 재시작된 것이라고 설명해 주세요.
- 이 스크립트는 보안 보조 수단이지, iPhone이 가까우면 자동으로 안전하다고 보장하는 인증 장치는 아니라고 설명해 주세요.

권한과 검증:
- macOS 권한 팝업이 뜨면 무엇을 허용해야 하는지 설명해 주세요.
- `손쉬운 사용(Accessibility)` 권한이 필요한 이유를 짧게 설명해 주세요.
- Hammerspoon에서 화면 잠금이 실제로 되는지 확인해 주세요.
- Hammerspoon이 실행 중인지 확인해 주세요.
- `defaults read org.hammerspoon.Hammerspoon NSAppSleepDisabled`가 `1`을 돌려주는지 확인해 주세요.
- `launchctl list | grep hammerspoon-watchdog`으로 watchdog 에이전트가 로드되어 있는지 확인해 주세요. 직전 exit status가 `0`이어야 정상입니다.
- `~/Library/Logs/hammerspoon-watchdog.log`가 존재한다면 (최초 stale 감지 전이면 없을 수 있음) 비정상 재시작 흔적이 있는지 확인해 주세요. 설치 직후 이 파일이 없는 건 정상입니다.
- 최근 로그 몇 줄을 보여 주고, 현재 감시 규칙을 짧게 요약해 주세요.
- 최종적으로 어떤 값들이 설정되었는지 알려 주세요.
- `targetAddress`는 일부만 마스킹해서 알려 주세요.
-- This script polls Bluetooth RSSI on a short interval and locks the Mac when
-- the configured iPhone has been "far" for the last N samples. Two notes about
-- reliability before reading the rest of the file:
--
-- 1. Recommended one-time macOS setting:
--
-- defaults write org.hammerspoon.Hammerspoon NSAppSleepDisabled -bool YES
--
-- Without this, macOS App Nap can suspend Hammerspoon while it is idle in
-- the background, which freezes every hs.timer and hs.task inside it. The
-- setting takes effect on the next Hammerspoon restart.
--
-- 2. This file builds in two recovery layers, because in practice the polling
-- loop occasionally goes silent on some Macs (process is alive but timers
-- have stopped firing — sometimes after a sleep/wake cycle, sometimes for
-- no obvious reason):
--
-- a. An in-process "task heartbeat" that drives itself with chained
-- hs.task /bin/sleep calls (a separate dispatch path from hs.timer).
-- After every wake from sleep, and any other time performCheck has been
-- silent for too long, it tears down and restarts the polling timer
-- chain. This is the primary recovery mechanism — it kicks in within
-- a couple of seconds of a wake.
--
-- b. An external launchd watchdog (see hammerspoon_watchdog_gist.sh and
-- com.hammerspoon-watchdog.plist) that watches the log file's mtime
-- and restarts the entire Hammerspoon process if the in-process
-- recovery somehow fails. This is the last line of defense.
--
-- Both layers are needed. The Lua-only layer is faster and cleaner; the
-- external layer is the only thing that can recover when the entire
-- Hammerspoon runloop has stopped firing all timers and tasks.
local home = os.getenv("HOME") or "/tmp"
local CONFIG = {
-- Fill in either the Bluetooth address, the device name, or both.
targetName = "YOUR_IPHONE_NAME",
targetAddress = "AA:BB:CC:DD:EE:FF",
-- Optional trusted Wi-Fi networks where proximity auto-lock should stay off.
-- Example: { "MyHomeWiFi" }
trustedWifiSSIDs = {},
-- Slightly conservative defaults to reduce false positives in real rooms.
pollIntervalSeconds = 5,
awayRssiThreshold = -70,
rearmRssiThreshold = -62,
awaySampleCount = 4,
lockAttemptCooldownSeconds = 20,
unlockCooldownSeconds = 90,
checkTimeoutSeconds = 15,
-- Safer default for public distribution: missing RSSI alone does not lock.
-- Note: this only applies to the "device is completely missing from the scan"
-- case. The separate "device visible but no RSSI field" case (a known macOS
-- system_profiler quirk) is always skipped, regardless of this flag — see
-- evaluateDistance below.
missingCountsAsAway = false,
-- If this many consecutive startup samples come back "device visible, no RSSI
-- field", emit a one-time warning. Some macOS builds stop exposing RSSI via
-- system_profiler SPBluetoothDataType entirely; in that state proximity
-- detection cannot work at all, and it is better to tell the user loudly than
-- to silently do nothing.
rssiHealthWarningThreshold = 15,
notify = true,
debug = false,
logFile = home .. "/Library/Logs/iphone-proximity-lock.log",
logSessionProperties = false,
-- `hs.caffeinate.lockScreen()` uses a private API. If macOS stops honoring it,
-- optionally fall back to starting the screensaver after a short delay.
allowLockFallbackToScreensaver = true,
lockFallbackDelaySeconds = 3,
}
local log = hs.logger.new("iphone-lock", CONFIG.debug and "debug" or "info")
local caffeinateWatcher = nil
local pollTimer = nil
local heartbeatTimer = nil
local taskHeartbeatTask = nil
local taskHeartbeatGeneration = 0
local activeTasks = {}
local state = nil
local scriptStartEpoch = nil
-- Forward declarations so the recovery helpers and the wake handler can call
-- each other regardless of definition order below.
local startPollTimer
local startHeartbeat
local startTaskHeartbeat
local performCheck
-- Recovery tuning. The script declares performCheck "silent" if it has not run
-- for HEARTBEAT_SILENCE_RECOVERY_SECONDS, and the heartbeat itself ticks every
-- HEARTBEAT_INTERVAL_SECONDS so the worst-case detection latency is roughly
-- HEARTBEAT_INTERVAL_SECONDS + HEARTBEAT_SILENCE_RECOVERY_SECONDS.
local HEARTBEAT_INTERVAL_SECONDS = 10
local HEARTBEAT_SILENCE_RECOVERY_SECONDS = 15
if CONFIG.rearmRssiThreshold <= CONFIG.awayRssiThreshold then
CONFIG.rearmRssiThreshold = CONFIG.awayRssiThreshold + 5
end
local function trim(value)
if type(value) ~= "string" then
return value
end
return value:match("^%s*(.-)%s*$")
end
local function normalizeAddress(value)
if type(value) ~= "string" then
return nil
end
local normalized = value:gsub("[^%x]", ""):upper()
if normalized == "" then
return nil
end
return normalized
end
local function hasConfiguredAddress()
return normalizeAddress(CONFIG.targetAddress) ~= nil
and normalizeAddress(CONFIG.targetAddress) ~= "AABBCCDDEEFF"
end
local function hasConfiguredName()
return trim(CONFIG.targetName) ~= nil
and trim(CONFIG.targetName) ~= ""
and trim(CONFIG.targetName) ~= "YOUR_IPHONE_NAME"
end
local function targetMatches(device)
if not device then
return false
end
if hasConfiguredAddress() and device.address == normalizeAddress(CONFIG.targetAddress) then
return true
end
if hasConfiguredName() and device.name == trim(CONFIG.targetName) then
return true
end
return false
end
local function notify(title, body)
if not CONFIG.notify then
return
end
hs.notify.new({ title = title, informativeText = body }):send()
end
local function appendLog(message)
if not CONFIG.logFile then
return
end
local file = io.open(CONFIG.logFile, "a")
if not file then
return
end
file:write(os.date("%Y-%m-%d %H:%M:%S "), message, "\n")
file:close()
end
local function firstString(node, keys)
for _, key in ipairs(keys) do
local value = node[key]
if type(value) == "string" and trim(value) ~= "" then
return trim(value)
end
end
return nil
end
local function firstNumber(node, keys)
for _, key in ipairs(keys) do
local value = node[key]
if type(value) == "number" then
return value
end
if type(value) == "string" and value:match("^-?%d+$") then
return tonumber(value)
end
end
return nil
end
local function candidateFromNode(node)
if type(node) ~= "table" then
return nil
end
local candidate = {
name = firstString(node, {
"_name",
"name",
"device_name",
"device_title",
"local_name",
"device_local_name",
}),
address = normalizeAddress(firstString(node, {
"device_address",
"address",
"mac_address",
"bd_addr",
"Address",
})),
rssi = firstNumber(node, {
"device_rssi",
"rssi",
"RSSI",
}),
}
if candidate.name or candidate.address or candidate.rssi then
return candidate
end
return nil
end
local function parseBluetoothJsonNode(node)
if type(node) ~= "table" then
return nil
end
local candidate = candidateFromNode(node)
if targetMatches(candidate) then
return candidate
end
for _, value in pairs(node) do
local match = parseBluetoothJsonNode(value)
if match then
return match
end
end
return nil
end
local function parseBluetoothJson(output)
local ok, decoded = pcall(hs.json.decode, output)
if not ok or type(decoded) ~= "table" then
return nil
end
return parseBluetoothJsonNode(decoded)
end
local function parseBluetoothText(output)
local current = nil
for rawLine in output:gmatch("[^\r\n]+") do
local indent, content = rawLine:match("^(%s*)(.-)%s*$")
local depth = #indent
if current and depth <= current.depth then
if targetMatches(current) then
return current
end
current = nil
end
local heading = content:match("^(.-):$")
if heading and depth >= 6 and heading ~= "Bluetooth Controller" and heading ~= "Connected" and heading ~= "Not Connected" then
current = { name = trim(heading), depth = depth }
elseif current then
local lower = content:lower()
local address = lower:match("^address:%s*(.+)$")
if address then
current.address = normalizeAddress(address)
end
local rssi = lower:match("^rssi:%s*(-?%d+)$")
if rssi then
current.rssi = tonumber(rssi)
end
end
end
if current and targetMatches(current) then
return current
end
return nil
end
local function parseBluetoothSnapshot(output, format)
if format == "json" then
local parsed = parseBluetoothJson(output)
if parsed then
return parsed
end
end
return parseBluetoothText(output)
end
state = {
isAway = false,
checkRunning = false,
checkToken = 0,
checkStartedAt = 0,
lastCheckAt = 0,
screenLocked = false,
lockPending = false,
lockPendingSince = 0,
autoLockCycleActive = false,
cooldownUntil = 0,
cooldownReason = nil,
recentSamples = {},
trustedWifiSSID = nil,
sawAnyRssi = false,
startupMissingRssiStreak = 0,
rssiHealthWarned = false,
}
local function describeSignal(device)
if not device then
return "not visible"
end
local label = device.name or "target device"
if device.rssi then
return string.format("%s RSSI %d", label, device.rssi)
end
return string.format("%s visible without RSSI", label)
end
local function startCooldown(seconds, reason)
if not seconds or seconds <= 0 then
state.cooldownUntil = 0
state.cooldownReason = nil
return
end
state.cooldownUntil = hs.timer.secondsSinceEpoch() + seconds
state.cooldownReason = reason
appendLog(string.format("Cooldown started: %s for %ss", reason, seconds))
end
local function clearAwayState(device, shouldNotify)
if state.isAway then
local message = device and ("Back in range: " .. describeSignal(device)) or "Away state cleared"
log.i(message)
appendLog(message)
if shouldNotify then
notify("iPhone proximity", "iPhone looks close again. Continuing to monitor.")
end
end
state.isAway = false
state.recentSamples = {}
end
local function resetPollingState(reason)
state.checkToken = state.checkToken + 1
state.checkRunning = false
state.checkStartedAt = 0
for _, task in pairs(activeTasks) do
pcall(function()
task:terminate()
end)
end
state.lockPending = false
state.lockPendingSince = 0
state.autoLockCycleActive = false
state.trustedWifiSSID = nil
activeTasks = {}
clearAwayState(nil, false)
appendLog("Polling state reset: " .. reason)
end
-- Lua's `table.insert(t, nil)` is a no-op, so a missing RSSI would silently
-- drop out of the window and break the "N recent samples" invariant. Store a
-- `false` sentinel for missing values and translate it back when reading.
local function pushSample(value)
local stored = value
if stored == nil then
stored = false
end
state.recentSamples[#state.recentSamples + 1] = stored
while #state.recentSamples > CONFIG.awaySampleCount do
table.remove(state.recentSamples, 1)
end
end
local function currentWifiSSID()
local ok, ssid = pcall(function()
return hs.wifi and hs.wifi.currentNetwork and hs.wifi.currentNetwork() or nil
end)
if ok and type(ssid) == "string" and trim(ssid) ~= "" then
return trim(ssid)
end
local output = hs.execute("/usr/sbin/ipconfig getsummary en0 2>/dev/null")
if not output or output == "" then
return nil
end
return trim(output:match("\n%s*SSID%s*:%s*([^\n]+)"))
end
local function trustedWifiSSID()
local currentSSID = currentWifiSSID()
if not currentSSID then
return nil
end
for _, ssid in ipairs(CONFIG.trustedWifiSSIDs or {}) do
if trim(ssid) == currentSSID then
return currentSSID
end
end
return nil
end
local function recentSamplesLabel()
local parts = {}
for _, value in ipairs(state.recentSamples) do
table.insert(parts, value == false and "n/a" or tostring(value))
end
return string.format("recent %d [%s]", CONFIG.awaySampleCount, table.concat(parts, ", "))
end
local function recentWindowIsAway()
if #state.recentSamples < CONFIG.awaySampleCount then
return false
end
for _, value in ipairs(state.recentSamples) do
if value == false then
if not CONFIG.missingCountsAsAway then
return false
end
elseif value > CONFIG.awayRssiThreshold then
return false
end
end
return true
end
local function recentWindowIsBackInRange()
if #state.recentSamples < CONFIG.awaySampleCount then
return false
end
for _, value in ipairs(state.recentSamples) do
if value == false or value < CONFIG.rearmRssiThreshold then
return false
end
end
return true
end
local function lockScreen()
log.i("Requesting macOS lock")
appendLog("Requesting macOS lock via hs.caffeinate.lockScreen()")
local ok, err = pcall(hs.caffeinate.lockScreen)
if not ok then
appendLog("lockScreen threw an error: " .. tostring(err))
end
if CONFIG.allowLockFallbackToScreensaver then
hs.timer.doAfter(CONFIG.lockFallbackDelaySeconds, function()
if state.screenLocked then
return
end
appendLog("Lock event not observed; starting screensaver fallback")
hs.caffeinate.startScreensaver()
end)
end
if CONFIG.logSessionProperties then
hs.timer.doAfter(2, function()
local props = hs.caffeinate.sessionProperties()
appendLog("Session properties after lock request: " .. hs.inspect(props))
end)
end
end
local function finishTask(taskId)
activeTasks[taskId] = nil
end
local function runBluetoothSnapshot(callback)
local taskId = tostring(hs.timer.secondsSinceEpoch()) .. "-" .. tostring(math.random(100000, 999999))
local format = "json"
local function makeTask(args, taskFormat)
return hs.task.new("/usr/sbin/system_profiler", function(exitCode, stdOut, stdErr)
finishTask(taskId)
if exitCode ~= 0 then
callback(nil, stdErr ~= "" and stdErr or ("system_profiler exited with " .. tostring(exitCode)))
return
end
callback(parseBluetoothSnapshot(stdOut, taskFormat), nil)
end, args)
end
local task = makeTask({ "-json", "SPBluetoothDataType" }, format)
if not task then
format = "text"
task = makeTask({ "SPBluetoothDataType" }, format)
end
if not task then
callback(nil, "failed to create system_profiler task")
return
end
activeTasks[taskId] = task
task:start()
end
local function evaluateDistance(device)
local now = hs.timer.secondsSinceEpoch()
local bypassSSID = trustedWifiSSID()
if bypassSSID then
if state.trustedWifiSSID ~= bypassSSID then
appendLog("Trusted Wi-Fi bypass active: " .. bypassSSID)
end
state.trustedWifiSSID = bypassSSID
state.lockPending = false
state.lockPendingSince = 0
clearAwayState(nil, false)
return "trusted wifi [" .. bypassSSID .. "]"
end
if state.trustedWifiSSID then
appendLog("Trusted Wi-Fi bypass ended: " .. state.trustedWifiSSID)
state.trustedWifiSSID = nil
end
-- Two "missing" cases to keep distinct:
-- (1) device == nil: the device is not in the scan at all. This is a real
-- absence signal; push a sentinel so missingCountsAsAway can act on it.
-- (2) device is visible but device.rssi == nil: a known macOS quirk where
-- system_profiler SPBluetoothDataType omits the RSSI field for a
-- still-present device. This is NOT a distance signal, so we skip the
-- sample entirely — the window keeps its last N *valid* RSSI values.
if device and device.rssi == nil then
if not state.sawAnyRssi then
state.startupMissingRssiStreak = state.startupMissingRssiStreak + 1
if state.startupMissingRssiStreak == CONFIG.rssiHealthWarningThreshold and not state.rssiHealthWarned then
state.rssiHealthWarned = true
local msg = string.format(
"Warning: %d consecutive samples have no RSSI field. This macOS build may not expose RSSI via system_profiler SPBluetoothDataType. Proximity detection will not work on this Mac.",
CONFIG.rssiHealthWarningThreshold)
appendLog(msg)
notify("iPhone proximity", "Warning: this Mac is not exposing Bluetooth RSSI. Proximity detection cannot work.")
end
end
return recentSamplesLabel() .. " (rssi-missing skipped)"
end
local rssi = device and device.rssi or nil
if rssi ~= nil then
state.sawAnyRssi = true
state.startupMissingRssiStreak = 0
end
pushSample(rssi)
local awayNow = recentWindowIsAway()
local backInRangeNow = recentWindowIsBackInRange()
local sampleLabel = recentSamplesLabel()
if state.isAway and backInRangeNow then
appendLog("Back in range samples: " .. sampleLabel)
clearAwayState(device, true)
return sampleLabel
end
if not state.isAway then
if not awayNow then
return sampleLabel
end
state.isAway = true
log.i("Away threshold crossed after recent samples: " .. describeSignal(device))
appendLog("Away threshold crossed after " .. sampleLabel .. ": " .. describeSignal(device))
end
local inCooldown = now < state.cooldownUntil
if state.lockPending and now - state.lockPendingSince >= 10 then
state.lockPending = false
state.lockPendingSince = 0
appendLog("Clearing stale lockPending state")
end
if inCooldown or state.screenLocked or state.lockPending then
return sampleLabel
end
if awayNow then
state.lockPending = true
state.lockPendingSince = now
state.autoLockCycleActive = true
startCooldown(CONFIG.lockAttemptCooldownSeconds, "after_auto_lock_request")
appendLog("Away across " .. sampleLabel .. ", locking")
notify("iPhone proximity", "iPhone looks far away. Locking this Mac.")
lockScreen()
end
return sampleLabel
end
performCheck = function()
state.lastCheckAt = hs.timer.secondsSinceEpoch()
if state.checkRunning then
local now = hs.timer.secondsSinceEpoch()
if state.checkStartedAt > 0 and now - state.checkStartedAt >= CONFIG.checkTimeoutSeconds then
appendLog(string.format("Check timed out after %ss; resetting", CONFIG.checkTimeoutSeconds))
resetPollingState("check_timeout")
else
log.df("Skipping check because previous task is still running")
return
end
end
state.checkToken = state.checkToken + 1
local checkToken = state.checkToken
state.checkRunning = true
state.checkStartedAt = hs.timer.secondsSinceEpoch()
runBluetoothSnapshot(function(device, err)
if checkToken ~= state.checkToken then
appendLog("Ignoring stale Bluetooth snapshot callback")
return
end
state.checkRunning = false
state.checkStartedAt = 0
if err then
log.e(err)
appendLog("Error: " .. tostring(err))
return
end
local sampleLabel = evaluateDistance(device)
log.df("Snapshot: %s | %s", describeSignal(device), sampleLabel)
appendLog("Snapshot: " .. describeSignal(device) .. " | " .. sampleLabel)
end)
end
-- ---------------------------------------------------------------------------
-- Recovery layer: in-process timer / task heartbeats
-- ---------------------------------------------------------------------------
--
-- The polling loop is driven by an hs.timer. On some Macs that timer can stop
-- firing after a sleep/wake cycle (or, more rarely, mid-session) while the
-- Hammerspoon process itself stays alive. None of the watcher events are
-- guaranteed to fire in that scenario, so the script needs its own way to
-- notice the silence and recover.
--
-- Two heartbeats run in parallel:
--
-- * heartbeatTimer is an hs.timer.doEvery loop. If it survives the same
-- event that killed pollTimer, it will notice the silence first.
--
-- * taskHeartbeatTask is a chained hs.task running /bin/sleep. This is a
-- completely separate dispatch path from hs.timer, so it can recover
-- cases where every hs.timer in the process has gone silent.
--
-- Both check `state.lastCheckAt` against HEARTBEAT_SILENCE_RECOVERY_SECONDS
-- and, if performCheck has been silent for too long, tear down and restart
-- the polling timer chain.
startPollTimer = function()
if pollTimer then
pcall(function() pollTimer:stop() end)
pollTimer = nil
end
pollTimer = hs.timer.doEvery(CONFIG.pollIntervalSeconds, performCheck)
appendLog("pollTimer (re)started")
end
startHeartbeat = function()
if heartbeatTimer then
pcall(function() heartbeatTimer:stop() end)
heartbeatTimer = nil
end
heartbeatTimer = hs.timer.doEvery(HEARTBEAT_INTERVAL_SECONDS, function()
local now = hs.timer.secondsSinceEpoch()
local silence = now - (state.lastCheckAt or 0)
local uptime = now - (scriptStartEpoch or now)
appendLog(string.format(
"Heartbeat: uptime=%.1fs silence=%.1fs checkRunning=%s",
uptime, silence, tostring(state.checkRunning)))
if silence > HEARTBEAT_SILENCE_RECOVERY_SECONDS then
appendLog(string.format(
"Heartbeat: performCheck silent for %.1fs, restarting pollTimer", silence))
resetPollingState("heartbeat_restart")
startPollTimer()
performCheck()
end
end)
appendLog("heartbeatTimer started")
end
startTaskHeartbeat = function()
taskHeartbeatGeneration = taskHeartbeatGeneration + 1
local myGen = taskHeartbeatGeneration
if taskHeartbeatTask then
pcall(function() taskHeartbeatTask:terminate() end)
taskHeartbeatTask = nil
end
local function tick()
if myGen ~= taskHeartbeatGeneration then
return
end
local now = hs.timer.secondsSinceEpoch()
local silence = now - (state.lastCheckAt or 0)
local uptime = now - (scriptStartEpoch or now)
appendLog(string.format(
"TaskHeartbeat: uptime=%.1fs silence=%.1fs checkRunning=%s",
uptime, silence, tostring(state.checkRunning)))
if silence > HEARTBEAT_SILENCE_RECOVERY_SECONDS then
appendLog(string.format(
"TaskHeartbeat: performCheck silent for %.1fs, restarting timers", silence))
resetPollingState("task_heartbeat_restart")
if startPollTimer then startPollTimer() end
if startHeartbeat then startHeartbeat() end
performCheck()
end
if myGen ~= taskHeartbeatGeneration then
return
end
local nextTask = hs.task.new("/bin/sleep", function()
if myGen == taskHeartbeatGeneration then
tick()
end
end, { tostring(HEARTBEAT_INTERVAL_SECONDS) })
if nextTask then
taskHeartbeatTask = nextTask
nextTask:start()
else
appendLog("TaskHeartbeat: failed to create next /bin/sleep task")
end
end
local first = hs.task.new("/bin/sleep", function()
if myGen == taskHeartbeatGeneration then
tick()
end
end, { tostring(HEARTBEAT_INTERVAL_SECONDS) })
if first then
taskHeartbeatTask = first
first:start()
appendLog("taskHeartbeat started (gen " .. tostring(myGen) .. ")")
else
appendLog("TaskHeartbeat: failed to create initial /bin/sleep task")
end
end
if not hasConfiguredAddress() and not hasConfiguredName() then
appendLog("Watcher not started: set targetName or targetAddress first")
notify("iPhone proximity", "Set targetName or targetAddress in the config before starting.")
return
end
appendLog("Starting iPhone proximity watcher")
notify("iPhone proximity", "Started iPhone proximity watcher.")
caffeinateWatcher = hs.caffeinate.watcher.new(function(event)
local names = {
[hs.caffeinate.watcher.systemWillSleep] = "systemWillSleep",
[hs.caffeinate.watcher.systemDidWake] = "systemDidWake",
[hs.caffeinate.watcher.screensDidLock] = "screensDidLock",
[hs.caffeinate.watcher.screensDidUnlock] = "screensDidUnlock",
[hs.caffeinate.watcher.sessionDidResignActive] = "sessionDidResignActive",
[hs.caffeinate.watcher.sessionDidBecomeActive] = "sessionDidBecomeActive",
[hs.caffeinate.watcher.screensaverDidStart] = "screensaverDidStart",
[hs.caffeinate.watcher.screensaverDidStop] = "screensaverDidStop",
}
appendLog("Caffeinate event: " .. (names[event] or tostring(event)))
if event == hs.caffeinate.watcher.systemWillSleep then
resetPollingState("system_will_sleep")
return
end
if event == hs.caffeinate.watcher.systemDidWake then
-- On Macs where systemDidWake actually fires, force a clean restart of
-- the entire timer chain. The taskHeartbeat path will also catch this
-- on Macs where systemDidWake never fires after a clamshell/dark sleep.
resetPollingState("system_did_wake")
hs.timer.doAfter(2, function()
startPollTimer()
startHeartbeat()
startTaskHeartbeat()
performCheck()
end)
return
end
if event == hs.caffeinate.watcher.screensDidLock then
state.screenLocked = true
state.lockPending = false
state.lockPendingSince = 0
return
end
if event == hs.caffeinate.watcher.screensDidUnlock then
local wasAutoLocked = state.screenLocked or state.autoLockCycleActive or state.lockPending
state.screenLocked = false
state.lockPending = false
state.lockPendingSince = 0
state.autoLockCycleActive = false
if wasAutoLocked then
clearAwayState(nil, false)
startCooldown(CONFIG.unlockCooldownSeconds, "after_manual_unlock")
end
end
end)
caffeinateWatcher:start()
scriptStartEpoch = hs.timer.secondsSinceEpoch()
state.lastCheckAt = scriptStartEpoch
performCheck()
startPollTimer()
startHeartbeat()
startTaskHeartbeat()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment