Created
February 6, 2025 07:48
-
-
Save abisuq/0f29a816b7ce0f3c6ef3a6264885c2c1 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Tailscale 相关常量 | |
local TAILSCALE_BIN = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" | |
local COMMANDS = { | |
STATUS = TAILSCALE_BIN .. " status", | |
DOWN = TAILSCALE_BIN .. " down", | |
UP = TAILSCALE_BIN .. " up" | |
} | |
-- 选项常量 | |
local CHOICES = { | |
NONE = "None", | |
TURN_OFF = "Turn Off Tailscale", | |
TURN_ON = "Turn On Tailscale" | |
} | |
-- 缓存文件路径 | |
local CACHE_FILE = os.getenv("HOME") .. "/.hammerspoon/xxx/tailscale_location_cache.json" | |
local IP_MAPPING_CACHE_FILE = os.getenv("HOME") .. "/.hammerspoon/xxx/tailscale_ip_mapping_cache.json" | |
local IP_API_URL = "http://ip-api.com/json/%s?fields=country,regionName,city" | |
-- 添加 IP 映射缓存相关函数 | |
local function loadIpMappingCache() | |
local file = io.open(IP_MAPPING_CACHE_FILE, "r") | |
if not file then return {} end | |
local content = file:read("*all") | |
file:close() | |
return hs.json.decode(content) or {} | |
end | |
local function saveIpMappingCache(cache) | |
local file = io.open(IP_MAPPING_CACHE_FILE, "w") | |
if not file then return end | |
file:write(hs.json.encode(cache)) | |
file:close() | |
end | |
local function loadCache() | |
local file = io.open(CACHE_FILE, "r") | |
if not file then return {} end | |
local content = file:read("*all") | |
file:close() | |
return hs.json.decode(content) or {} | |
end | |
local function saveCache(cache) | |
local file = io.open(CACHE_FILE, "w") | |
if not file then return end | |
file:write(hs.json.encode(cache)) | |
file:close() | |
end | |
local function getLocationInfo(ip) | |
if not ip then return nil end | |
local cache = loadCache() | |
if cache[ip] then return cache[ip] end | |
local status, body = hs.http.get(string.format(IP_API_URL, ip)) | |
if status == 200 then | |
local location = hs.json.decode(body) | |
if location and location.country then | |
local info = string.format("%s, %s", location.country, location.city or location.regionName) | |
cache[ip] = info | |
saveCache(cache) | |
return info | |
end | |
end | |
return nil | |
end | |
-- 修改 parseExitNode 函数 | |
local function parseExitNode(line) | |
-- 检查是否为 exit node | |
if not (line:match("offers exit node") or line:match("exit node")) then | |
return nil | |
end | |
-- 解析节点基本信息 | |
local ip, name, status = line:match("([%d%.]+)%s+([^%s]+)%s+[^%s]+%s+([^%s]+)") | |
if not (ip and name) then | |
return nil | |
end | |
-- 提取并缓存公网 IP | |
local public_ip = line:match("direct%s+([%d%.]+):") | |
if public_ip then | |
local mapping_cache = loadIpMappingCache() | |
mapping_cache[ip] = public_ip | |
saveIpMappingCache(mapping_cache) | |
end | |
-- 获取位置信息 | |
local location = getLocationInfo(public_ip) | |
local isCurrentExitNode = line:match("active; exit node") ~= nil | |
return { | |
ip = ip, | |
name = name, | |
public_ip = public_ip, | |
location = location, | |
display = location and string.format("%s (%s)", name, location) or name, | |
status = status, | |
isCurrentExitNode = isCurrentExitNode | |
} | |
end | |
-- 添加获取公网 IP 的辅助函数 | |
local function getPublicIp(internal_ip) | |
local mapping_cache = loadIpMappingCache() | |
return mapping_cache[internal_ip] | |
end | |
local function executeCommand(cmd, message) | |
print("Executing:", cmd) | |
if hs.execute(cmd) then | |
hs.alert.show(message) | |
return true | |
end | |
hs.alert.show("Failed: " .. message) | |
return false | |
end | |
local function getExitNodes() | |
local nodes = {} | |
local handle = io.popen(COMMANDS.STATUS) | |
if not handle then return nodes end | |
for line in handle:lines() do | |
local node = parseExitNode(line) | |
if node then table.insert(nodes, node) end | |
end | |
handle:close() | |
return nodes | |
end | |
local function isTailscaleRunning() | |
local handle = io.popen(COMMANDS.STATUS) | |
local result = handle:read("*a") | |
handle:close() | |
return result:match("^%d+%.%d+%.%d+%.%d+") ~= nil | |
end | |
local function handleExitNodeChoice(choice) | |
if not choice then return end | |
if choice.text == CHOICES.TURN_OFF then | |
executeCommand(COMMANDS.DOWN, "Tailscale disabled") | |
elseif choice.text == CHOICES.TURN_ON then | |
executeCommand(COMMANDS.UP, "Tailscale enabled") | |
elseif choice.text == CHOICES.NONE then | |
executeCommand(COMMANDS.UP .. " --exit-node=", "Exit node disabled") | |
else | |
local node = choice.node | |
local cmd = string.format("%s --exit-node=%q --exit-node-allow-lan-access", COMMANDS.UP, node.ip) | |
local message = string.format("Switched to exit node: %s%s", | |
node.name, | |
node.location and " (" .. node.location .. ")" or "" | |
) | |
executeCommand(cmd, message) | |
end | |
end | |
-- 添加获取当前 exit node 的函数 | |
local function getCurrentExitNode() | |
local handle = io.popen(COMMANDS.STATUS) | |
if not handle then return nil end | |
for line in handle:lines() do | |
if line:match("active; exit node") then | |
local ip = line:match("([%d%.]+)%s+") | |
handle:close() | |
return ip | |
end | |
end | |
handle:close() | |
return nil | |
end | |
-- 修改 switchExitNode 函数中的节点显示部分 | |
local function switchExitNode() | |
local choices = {} | |
if not isTailscaleRunning() then | |
table.insert(choices, { | |
text = CHOICES.TURN_ON, | |
subText = "Start Tailscale service" | |
}) | |
else | |
local nodes = getExitNodes() | |
local currentExitNode = getCurrentExitNode() | |
for _, node in ipairs(nodes) do | |
local isActive = node.ip == currentExitNode | |
table.insert(choices, { | |
text = string.format("%s%s", | |
node.display, | |
isActive and " 🚩" or "" | |
), | |
subText = "IP: " .. node.ip, | |
node = node | |
}) | |
end | |
-- 添加特殊选项 | |
table.insert(choices, { | |
text = CHOICES.NONE, | |
subText = "Disable exit node" | |
}) | |
table.insert(choices, { | |
text = CHOICES.TURN_OFF, | |
subText = "Turn off Tailscale service" | |
}) | |
end | |
hs.chooser.new(handleExitNodeChoice) | |
:choices(choices) | |
:show() | |
end | |
hs.hotkey.bind({ "cmd", "shift" }, "8", switchExitNode) | |
return switchExitNode |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment