#!/usr/bin/env python3 """Reconcile the local profile after FFSB or its managed source changes.""" import hashlib import json import os import re import time from pathlib import Path import subprocess import sys from overlay import build CLI = '/Applications/Surge.app/Contents/Applications/surge-cli' SERVICE = '/Applications/FFSB.app/Contents/MacOS/ffsb-service' PROFILE = 'Proxy Station Claude' ROOT = Path.home() / 'Library/Application Support/ProxyStationFFSB' PROFILES = Path.home() / 'Library/Application Support/Surge/Profiles' def run(args, timeout=8): return subprocess.run(args, capture_output=True, text=True, timeout=timeout) def main(): os.umask(0o077) manual_refresh = '--refresh' in sys.argv refresh_failed = False domains = json.loads((ROOT / 'domains.json').read_text()) status = run([SERVICE, 'status']) connected = status.returncode == 0 and 'Management: Connected' in status.stdout and 'Signal: Connected' in status.stdout if connected: answer = run(['/usr/bin/dig', '@100.70.255.254', 'api.ipify.org', 'A', '+time=1', '+tries=1', '+short']) connected = answer.returncode == 0 and any(line.count('.') == 3 and all(x.isdigit() for x in line.split('.')) for line in answer.stdout.splitlines()) managed = (PROFILES / 'Proxy Station.conf').read_text() cache = ROOT / 'managed-source.conf' if not cache.exists() or manual_refresh or time.time() - cache.stat().st_mtime > 3600: url = re.search(r'^#!MANAGED-CONFIG\s+(\S+)', managed, re.M) if not url or not url[1].startswith('https://'): raise RuntimeError('Expected existing HTTPS managed source') fetched = ROOT / 'download.conf' env = {k: v for k, v in os.environ.items() if k.lower() not in ['http_proxy', 'https_proxy', 'all_proxy']} result = subprocess.run(['/usr/bin/curl', '-fsSL', '--max-time', '20', url[1], '-o', str(fetched)], env=env, capture_output=True, text=True) if result.returncode == 0 and fetched.exists() and fetched.stat().st_size > 0: check = run([CLI, '--check', str(fetched)], timeout=30) if check.returncode == 0: fetched.replace(cache) elif not cache.exists(): raise RuntimeError('Downloaded source failed native validation') else: refresh_failed = True elif not cache.exists(): raise RuntimeError('Cannot fetch initial managed source') else: refresh_failed = True if refresh_failed: print('Warning: source refresh failed; using last validated cache', file=sys.stderr, flush=True) source = cache.read_text() candidate = build(source, domains, connected) target = PROFILES / (PROFILE + '.conf') profile_hash = hashlib.sha256(candidate.encode()).hexdigest() state_file = ROOT / 'state.json' try: previous_state = json.loads(state_file.read_text()) except (FileNotFoundError, ValueError): previous_state = {} if target.exists() and target.read_text() == candidate and previous_state.get('profile_sha256') == profile_hash: if manual_refresh: print(('Refresh failed; cached configuration unchanged' if refresh_failed else 'Refresh succeeded; configuration unchanged') + '; mode=' + ('FFSB' if connected else 'Surge') + '; no reload', flush=True) return temp = ROOT / 'candidate.conf' temp.write_text(candidate) check = run([CLI, '--check', str(temp)], timeout=30) if check.returncode: raise RuntimeError('Candidate rejected: ' + check.stdout + check.stderr) # Same-volume atomic replace, and preserve last working candidate for manual recovery. if target.exists(): (ROOT / 'previous.conf').write_text(target.read_text()) staged = target.with_suffix('.pending') staged.write_text(candidate) staged.replace(target) current = run([CLI, 'profile', 'current']) if current.returncode == 0 and current.stdout.strip().endswith(PROFILE): result = run([CLI, 'reload'], timeout=30) if result.returncode: raise RuntimeError('Reload failed: ' + result.stdout + result.stderr) run([CLI, 'flush', 'dns']) (ROOT / 'state.json').write_text(json.dumps({'mode': 'FFSB' if connected else 'Surge', 'source_sha256': hashlib.sha256(source.encode()).hexdigest(), 'profile_sha256': profile_hash}, indent=2)) print('Applied mode=' + ('FFSB' if connected else 'Surge'), flush=True) if __name__ == '__main__': try: main() except Exception as error: print(type(error).__name__ + ': ' + str(error), file=sys.stderr, flush=True) sys.exit(1)