block-proxy 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.agents/skills/commit/skill.md +40 -0
  2. package/.claude/settings.local.json +29 -1
  3. package/.claude/skills/build-client/skill.md +24 -0
  4. package/.claude/skills/commit/skill.md +34 -26
  5. package/.claude/skills/release-client/skill.md +68 -0
  6. package/CLAUDE.md +109 -47
  7. package/Dockerfile +1 -1
  8. package/README.md +69 -60
  9. package/build/asset-manifest.json +6 -6
  10. package/build/index.html +1 -1
  11. package/build/static/css/main.3f317ce6.css +2 -0
  12. package/build/static/css/main.3f317ce6.css.map +1 -0
  13. package/build/static/js/{main.2247fb80.js → main.68f66be0.js} +3 -3
  14. package/build/static/js/main.68f66be0.js.map +1 -0
  15. package/client/app.py +312 -0
  16. package/client/build.sh +84 -0
  17. package/client/config.py +49 -0
  18. package/client/config_window.py +155 -0
  19. package/client/icons/app.icns +0 -0
  20. package/client/icons/app_example.png +0 -0
  21. package/client/icons/app_icon.png +0 -0
  22. package/client/icons/backup/app_example.png +0 -0
  23. package/client/icons/backup/christmas-sock_dark.png +0 -0
  24. package/client/icons/backup/christmas-sock_light.png +0 -0
  25. package/client/icons/backup/socks_on_G.png +0 -0
  26. package/client/icons/backup/socks_on_M.png +0 -0
  27. package/client/icons/christmas-sock_dark.png +0 -0
  28. package/client/icons/christmas-sock_light.png +0 -0
  29. package/client/icons/christmas-sock_light_bar.png +0 -0
  30. package/client/icons/socks_on_G.png +0 -0
  31. package/client/icons/socks_on_G_bar.png +0 -0
  32. package/client/icons/socks_on_M.png +0 -0
  33. package/client/icons/socks_on_M_bar.png +0 -0
  34. package/client/main.py +28 -0
  35. package/client/proxy_core.py +475 -0
  36. package/client/requirements.txt +3 -0
  37. package/client/scripts/download_xray.sh +30 -0
  38. package/client/setup.py +30 -0
  39. package/client/system_proxy.py +94 -0
  40. package/client/tests/__init__.py +0 -0
  41. package/client/tests/test_config.py +72 -0
  42. package/client/tests/test_system_proxy.py +69 -0
  43. package/client/watch-icons.js +31 -0
  44. package/config.json +82 -5
  45. package/docs/superpowers/plans/2026-05-27-blockproxyclient.md +1274 -0
  46. package/docs/superpowers/specs/2026-05-27-blockproxyclient-design.md +264 -0
  47. package/package.json +11 -5
  48. package/proxy/proxy.js +70 -18
  49. package/server/express.js +17 -1
  50. package/skills-lock.json +11 -0
  51. package/socks5/server.js +2 -2
  52. package/src/App.css +596 -276
  53. package/src/App.js +25 -22
  54. package/src/index.css +3 -4
  55. package/test/lib/mock-server.js +133 -0
  56. package/test/proxy-tests.js +708 -0
  57. package/test/run.js +330 -0
  58. package/build/static/css/main.8bfa3d5f.css +0 -2
  59. package/build/static/css/main.8bfa3d5f.css.map +0 -1
  60. package/build/static/js/main.2247fb80.js.map +0 -1
  61. package/hack-of-anyproxy/lib/requestHandler.js +0 -1060
  62. /package/build/static/js/{main.2247fb80.js.LICENSE.txt → main.68f66be0.js.LICENSE.txt} +0 -0
@@ -0,0 +1,1274 @@
1
+ # BlockProxyClient Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Build a macOS status bar application that connects to a block-proxy SOCKS5 over TLS server via xray-core, providing local SOCKS5 and HTTP proxy ports with optional system-wide proxy configuration.
6
+
7
+ **Architecture:** Python rumps app manages UI (status bar menu + tkinter config window). It generates xray-core JSON config from user settings and manages the xray-core subprocess. system_proxy module wraps macOS `networksetup` commands to toggle global proxy on/off.
8
+
9
+ **Tech Stack:** Python 3.11+, rumps (macOS status bar), tkinter (config window), xray-core (binary, subprocess), py2app (packaging)
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ client/
17
+ ├── main.py # Entry point, launches rumps app
18
+ ├── app.py # BlockProxyClient rumps.App subclass, menu logic
19
+ ├── config.py # Config read/write to ~/Library/Application Support/BlockProxyClient/config.json
20
+ ├── xray_manager.py # Generate xray config JSON, start/stop subprocess, health monitoring
21
+ ├── system_proxy.py # Detect active interfaces, set/clear macOS system proxy via networksetup
22
+ ├── resources/
23
+ │ ├── icon.png # Status bar icon (connected) - 22x22 template image
24
+ │ ├── icon_off.png # Status bar icon (disconnected) - 22x22 template image
25
+ │ └── xray-core # xray-core binary (downloaded separately)
26
+ ├── tests/
27
+ │ ├── test_config.py
28
+ │ ├── test_xray_manager.py
29
+ │ └── test_system_proxy.py
30
+ ├── requirements.txt
31
+ └── setup.py # py2app packaging
32
+ ```
33
+
34
+ ---
35
+
36
+ ### Task 1: Project Scaffolding
37
+
38
+ **Files:**
39
+ - Create: `client/requirements.txt`
40
+ - Create: `client/resources/.gitkeep`
41
+ - Create: `client/tests/__init__.py`
42
+
43
+ - [ ] **Step 1: Create directory structure**
44
+
45
+ ```bash
46
+ cd /Users/bachi/jaylli/block-proxy
47
+ mkdir -p client/resources client/tests
48
+ ```
49
+
50
+ - [ ] **Step 2: Create requirements.txt**
51
+
52
+ Create `client/requirements.txt`:
53
+ ```
54
+ rumps>=0.4.0
55
+ py2app>=0.28
56
+ pytest>=7.0
57
+ ```
58
+
59
+ - [ ] **Step 3: Create virtual environment and install dependencies**
60
+
61
+ ```bash
62
+ cd /Users/bachi/jaylli/block-proxy/client
63
+ python3 -m venv .venv
64
+ source .venv/bin/activate
65
+ pip install -r requirements.txt
66
+ ```
67
+
68
+ - [ ] **Step 4: Create placeholder files**
69
+
70
+ Create `client/resources/.gitkeep` (empty file).
71
+ Create `client/tests/__init__.py` (empty file).
72
+
73
+ - [ ] **Step 5: Commit**
74
+
75
+ ```bash
76
+ git add client/
77
+ git commit -m "feat(client): scaffold BlockProxyClient project structure"
78
+ ```
79
+
80
+ ---
81
+
82
+ ### Task 2: Configuration Module
83
+
84
+ **Files:**
85
+ - Create: `client/tests/test_config.py`
86
+ - Create: `client/config.py`
87
+
88
+ - [ ] **Step 1: Write failing tests for config module**
89
+
90
+ Create `client/tests/test_config.py`:
91
+ ```python
92
+ import json
93
+ import os
94
+ import tempfile
95
+ import pytest
96
+ from config import Config, DEFAULT_CONFIG
97
+
98
+
99
+ class TestConfig:
100
+ def setup_method(self):
101
+ self.tmp_dir = tempfile.mkdtemp()
102
+ self.config_path = os.path.join(self.tmp_dir, "config.json")
103
+ self.config = Config(config_path=self.config_path)
104
+
105
+ def test_default_config_created_on_first_load(self):
106
+ data = self.config.load()
107
+ assert data["server"]["port"] == 8002
108
+ assert data["server"]["tls"] is True
109
+ assert data["server"]["allowInsecure"] is True
110
+ assert data["local"]["socks_port"] == 1080
111
+ assert data["local"]["http_port"] == 1087
112
+ assert data["local"]["udp"] is True
113
+ assert data["mode"] == "global"
114
+ assert os.path.exists(self.config_path)
115
+
116
+ def test_save_and_load_roundtrip(self):
117
+ self.config.load()
118
+ self.config.data["server"]["address"] = "10.0.0.1"
119
+ self.config.data["server"]["port"] = 9002
120
+ self.config.data["server"]["username"] = "user1"
121
+ self.config.data["server"]["password"] = "pass1"
122
+ self.config.save()
123
+
124
+ config2 = Config(config_path=self.config_path)
125
+ data = config2.load()
126
+ assert data["server"]["address"] == "10.0.0.1"
127
+ assert data["server"]["port"] == 9002
128
+ assert data["server"]["username"] == "user1"
129
+ assert data["server"]["password"] == "pass1"
130
+
131
+ def test_load_existing_config(self):
132
+ existing = {
133
+ "server": {
134
+ "address": "example.com",
135
+ "port": 443,
136
+ "username": "abc",
137
+ "password": "def",
138
+ "tls": False,
139
+ "allowInsecure": False,
140
+ },
141
+ "local": {"socks_port": 2080, "http_port": 2087, "udp": False},
142
+ "mode": "manual",
143
+ }
144
+ with open(self.config_path, "w") as f:
145
+ json.dump(existing, f)
146
+
147
+ data = self.config.load()
148
+ assert data["server"]["address"] == "example.com"
149
+ assert data["server"]["tls"] is False
150
+ assert data["local"]["socks_port"] == 2080
151
+ assert data["mode"] == "manual"
152
+
153
+ def test_is_configured_false_when_no_address(self):
154
+ self.config.load()
155
+ assert self.config.is_configured() is False
156
+
157
+ def test_is_configured_true_when_address_set(self):
158
+ self.config.load()
159
+ self.config.data["server"]["address"] = "10.0.0.1"
160
+ assert self.config.is_configured() is True
161
+ ```
162
+
163
+ - [ ] **Step 2: Run tests to verify they fail**
164
+
165
+ ```bash
166
+ cd /Users/bachi/jaylli/block-proxy/client
167
+ source .venv/bin/activate
168
+ python -m pytest tests/test_config.py -v
169
+ ```
170
+ Expected: FAIL with `ModuleNotFoundError: No module named 'config'`
171
+
172
+ - [ ] **Step 3: Implement config.py**
173
+
174
+ Create `client/config.py`:
175
+ ```python
176
+ import json
177
+ import os
178
+ import copy
179
+
180
+ DEFAULT_CONFIG = {
181
+ "server": {
182
+ "address": "",
183
+ "port": 8002,
184
+ "username": "",
185
+ "password": "",
186
+ "tls": True,
187
+ "allowInsecure": True,
188
+ },
189
+ "local": {
190
+ "socks_port": 1080,
191
+ "http_port": 1087,
192
+ "udp": True,
193
+ },
194
+ "mode": "global",
195
+ }
196
+
197
+ DEFAULT_CONFIG_DIR = os.path.expanduser(
198
+ "~/Library/Application Support/BlockProxyClient"
199
+ )
200
+
201
+
202
+ class Config:
203
+ def __init__(self, config_path=None):
204
+ if config_path is None:
205
+ config_path = os.path.join(DEFAULT_CONFIG_DIR, "config.json")
206
+ self.config_path = config_path
207
+ self.data = None
208
+
209
+ def load(self):
210
+ if os.path.exists(self.config_path):
211
+ with open(self.config_path, "r") as f:
212
+ self.data = json.load(f)
213
+ else:
214
+ self.data = copy.deepcopy(DEFAULT_CONFIG)
215
+ self.save()
216
+ return self.data
217
+
218
+ def save(self):
219
+ os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
220
+ with open(self.config_path, "w") as f:
221
+ json.dump(self.data, f, indent=2)
222
+
223
+ def is_configured(self):
224
+ return bool(self.data and self.data["server"]["address"])
225
+ ```
226
+
227
+ - [ ] **Step 4: Run tests to verify they pass**
228
+
229
+ ```bash
230
+ cd /Users/bachi/jaylli/block-proxy/client
231
+ source .venv/bin/activate
232
+ python -m pytest tests/test_config.py -v
233
+ ```
234
+ Expected: All 5 tests PASS
235
+
236
+ - [ ] **Step 5: Commit**
237
+
238
+ ```bash
239
+ git add client/config.py client/tests/test_config.py
240
+ git commit -m "feat(client): add configuration management module"
241
+ ```
242
+
243
+ ---
244
+
245
+ ### Task 3: xray-core Manager Module
246
+
247
+ **Files:**
248
+ - Create: `client/tests/test_xray_manager.py`
249
+ - Create: `client/xray_manager.py`
250
+
251
+ - [ ] **Step 1: Write failing tests for xray_manager**
252
+
253
+ Create `client/tests/test_xray_manager.py`:
254
+ ```python
255
+ import json
256
+ import os
257
+ import tempfile
258
+ import pytest
259
+ from unittest.mock import patch, MagicMock
260
+ from xray_manager import XrayManager
261
+
262
+
263
+ class TestXrayConfigGeneration:
264
+ def setup_method(self):
265
+ self.tmp_dir = tempfile.mkdtemp()
266
+ self.manager = XrayManager(
267
+ xray_path="/usr/local/bin/xray",
268
+ config_dir=self.tmp_dir,
269
+ )
270
+
271
+ def test_generate_config_basic(self):
272
+ user_config = {
273
+ "server": {
274
+ "address": "10.0.0.1",
275
+ "port": 8002,
276
+ "username": "user1",
277
+ "password": "pass1",
278
+ "tls": True,
279
+ "allowInsecure": True,
280
+ },
281
+ "local": {
282
+ "socks_port": 1080,
283
+ "http_port": 1087,
284
+ "udp": True,
285
+ },
286
+ }
287
+ config_path = self.manager.generate_config(user_config)
288
+
289
+ assert os.path.exists(config_path)
290
+ with open(config_path) as f:
291
+ xray_config = json.load(f)
292
+
293
+ assert len(xray_config["inbounds"]) == 2
294
+
295
+ socks_in = xray_config["inbounds"][0]
296
+ assert socks_in["port"] == 1080
297
+ assert socks_in["listen"] == "127.0.0.1"
298
+ assert socks_in["protocol"] == "socks"
299
+ assert socks_in["settings"]["udp"] is True
300
+
301
+ http_in = xray_config["inbounds"][1]
302
+ assert http_in["port"] == 1087
303
+ assert http_in["listen"] == "127.0.0.1"
304
+ assert http_in["protocol"] == "http"
305
+
306
+ outbound = xray_config["outbounds"][0]
307
+ assert outbound["protocol"] == "socks"
308
+ server = outbound["settings"]["servers"][0]
309
+ assert server["address"] == "10.0.0.1"
310
+ assert server["port"] == 8002
311
+ assert server["users"][0]["user"] == "user1"
312
+ assert server["users"][0]["pass"] == "pass1"
313
+
314
+ stream = outbound["streamSettings"]
315
+ assert stream["security"] == "tls"
316
+ assert stream["tlsSettings"]["allowInsecure"] is True
317
+ assert stream["tlsSettings"]["serverName"] == "10.0.0.1"
318
+
319
+ def test_generate_config_no_tls(self):
320
+ user_config = {
321
+ "server": {
322
+ "address": "10.0.0.1",
323
+ "port": 8002,
324
+ "username": "user1",
325
+ "password": "pass1",
326
+ "tls": False,
327
+ "allowInsecure": True,
328
+ },
329
+ "local": {
330
+ "socks_port": 1080,
331
+ "http_port": 1087,
332
+ "udp": False,
333
+ },
334
+ }
335
+ config_path = self.manager.generate_config(user_config)
336
+
337
+ with open(config_path) as f:
338
+ xray_config = json.load(f)
339
+
340
+ socks_in = xray_config["inbounds"][0]
341
+ assert socks_in["settings"]["udp"] is False
342
+
343
+ outbound = xray_config["outbounds"][0]
344
+ assert "streamSettings" not in outbound
345
+
346
+ def test_generate_config_no_auth(self):
347
+ user_config = {
348
+ "server": {
349
+ "address": "10.0.0.1",
350
+ "port": 8002,
351
+ "username": "",
352
+ "password": "",
353
+ "tls": True,
354
+ "allowInsecure": True,
355
+ },
356
+ "local": {
357
+ "socks_port": 1080,
358
+ "http_port": 1087,
359
+ "udp": True,
360
+ },
361
+ }
362
+ config_path = self.manager.generate_config(user_config)
363
+
364
+ with open(config_path) as f:
365
+ xray_config = json.load(f)
366
+
367
+ outbound = xray_config["outbounds"][0]
368
+ server = outbound["settings"]["servers"][0]
369
+ assert "users" not in server
370
+
371
+
372
+ class TestXrayProcessManagement:
373
+ def setup_method(self):
374
+ self.tmp_dir = tempfile.mkdtemp()
375
+ self.manager = XrayManager(
376
+ xray_path="/usr/local/bin/xray",
377
+ config_dir=self.tmp_dir,
378
+ )
379
+
380
+ @patch("subprocess.Popen")
381
+ def test_start_launches_process(self, mock_popen):
382
+ mock_process = MagicMock()
383
+ mock_process.poll.return_value = None
384
+ mock_popen.return_value = mock_process
385
+
386
+ user_config = {
387
+ "server": {
388
+ "address": "10.0.0.1",
389
+ "port": 8002,
390
+ "username": "u",
391
+ "password": "p",
392
+ "tls": True,
393
+ "allowInsecure": True,
394
+ },
395
+ "local": {"socks_port": 1080, "http_port": 1087, "udp": True},
396
+ }
397
+
398
+ self.manager.start(user_config)
399
+
400
+ mock_popen.assert_called_once()
401
+ args = mock_popen.call_args[0][0]
402
+ assert args[0] == "/usr/local/bin/xray"
403
+ assert args[1] == "run"
404
+ assert args[2] == "-c"
405
+ assert self.manager.is_running()
406
+
407
+ @patch("subprocess.Popen")
408
+ def test_stop_terminates_process(self, mock_popen):
409
+ mock_process = MagicMock()
410
+ mock_process.poll.return_value = None
411
+ mock_popen.return_value = mock_process
412
+
413
+ user_config = {
414
+ "server": {
415
+ "address": "10.0.0.1",
416
+ "port": 8002,
417
+ "username": "u",
418
+ "password": "p",
419
+ "tls": True,
420
+ "allowInsecure": True,
421
+ },
422
+ "local": {"socks_port": 1080, "http_port": 1087, "udp": True},
423
+ }
424
+
425
+ self.manager.start(user_config)
426
+ self.manager.stop()
427
+
428
+ mock_process.terminate.assert_called_once()
429
+
430
+ def test_is_running_false_initially(self):
431
+ assert self.manager.is_running() is False
432
+ ```
433
+
434
+ - [ ] **Step 2: Run tests to verify they fail**
435
+
436
+ ```bash
437
+ cd /Users/bachi/jaylli/block-proxy/client
438
+ source .venv/bin/activate
439
+ python -m pytest tests/test_xray_manager.py -v
440
+ ```
441
+ Expected: FAIL with `ModuleNotFoundError: No module named 'xray_manager'`
442
+
443
+ - [ ] **Step 3: Implement xray_manager.py**
444
+
445
+ Create `client/xray_manager.py`:
446
+ ```python
447
+ import json
448
+ import os
449
+ import subprocess
450
+ import shutil
451
+
452
+
453
+ class XrayManager:
454
+ def __init__(self, xray_path=None, config_dir=None):
455
+ if xray_path is None:
456
+ xray_path = shutil.which("xray") or "/usr/local/bin/xray"
457
+ if config_dir is None:
458
+ config_dir = os.path.expanduser(
459
+ "~/Library/Application Support/BlockProxyClient"
460
+ )
461
+ self.xray_path = xray_path
462
+ self.config_dir = config_dir
463
+ self.config_file = os.path.join(config_dir, "xray_config.json")
464
+ self.process = None
465
+
466
+ def generate_config(self, user_config):
467
+ server = user_config["server"]
468
+ local = user_config["local"]
469
+
470
+ xray_config = {
471
+ "inbounds": [
472
+ {
473
+ "tag": "socks-in",
474
+ "port": local["socks_port"],
475
+ "listen": "127.0.0.1",
476
+ "protocol": "socks",
477
+ "settings": {"udp": local["udp"]},
478
+ },
479
+ {
480
+ "tag": "http-in",
481
+ "port": local["http_port"],
482
+ "listen": "127.0.0.1",
483
+ "protocol": "http",
484
+ },
485
+ ],
486
+ "outbounds": [self._build_outbound(server)],
487
+ }
488
+
489
+ os.makedirs(self.config_dir, exist_ok=True)
490
+ with open(self.config_file, "w") as f:
491
+ json.dump(xray_config, f, indent=2)
492
+
493
+ return self.config_file
494
+
495
+ def _build_outbound(self, server):
496
+ outbound = {
497
+ "protocol": "socks",
498
+ "settings": {
499
+ "servers": [self._build_server(server)],
500
+ },
501
+ }
502
+
503
+ if server["tls"]:
504
+ outbound["streamSettings"] = {
505
+ "network": "tcp",
506
+ "security": "tls",
507
+ "tlsSettings": {
508
+ "allowInsecure": server["allowInsecure"],
509
+ "serverName": server["address"],
510
+ },
511
+ }
512
+
513
+ return outbound
514
+
515
+ def _build_server(self, server):
516
+ entry = {
517
+ "address": server["address"],
518
+ "port": server["port"],
519
+ }
520
+ if server["username"] and server["password"]:
521
+ entry["users"] = [
522
+ {"user": server["username"], "pass": server["password"]}
523
+ ]
524
+ return entry
525
+
526
+ def start(self, user_config):
527
+ if self.is_running():
528
+ self.stop()
529
+
530
+ config_path = self.generate_config(user_config)
531
+ self.process = subprocess.Popen(
532
+ [self.xray_path, "run", "-c", config_path],
533
+ stdout=subprocess.PIPE,
534
+ stderr=subprocess.PIPE,
535
+ )
536
+
537
+ def stop(self):
538
+ if self.process is None:
539
+ return
540
+ self.process.terminate()
541
+ try:
542
+ self.process.wait(timeout=3)
543
+ except subprocess.TimeoutExpired:
544
+ self.process.kill()
545
+ self.process.wait()
546
+ self.process = None
547
+
548
+ def is_running(self):
549
+ if self.process is None:
550
+ return False
551
+ return self.process.poll() is None
552
+ ```
553
+
554
+ - [ ] **Step 4: Run tests to verify they pass**
555
+
556
+ ```bash
557
+ cd /Users/bachi/jaylli/block-proxy/client
558
+ source .venv/bin/activate
559
+ python -m pytest tests/test_xray_manager.py -v
560
+ ```
561
+ Expected: All 6 tests PASS
562
+
563
+ - [ ] **Step 5: Commit**
564
+
565
+ ```bash
566
+ git add client/xray_manager.py client/tests/test_xray_manager.py
567
+ git commit -m "feat(client): add xray-core process manager module"
568
+ ```
569
+
570
+ ---
571
+
572
+ ### Task 4: System Proxy Module
573
+
574
+ **Files:**
575
+ - Create: `client/tests/test_system_proxy.py`
576
+ - Create: `client/system_proxy.py`
577
+
578
+ - [ ] **Step 1: Write failing tests for system_proxy**
579
+
580
+ Create `client/tests/test_system_proxy.py`:
581
+ ```python
582
+ import pytest
583
+ from unittest.mock import patch, call
584
+ from system_proxy import SystemProxy
585
+
586
+
587
+ class TestSystemProxy:
588
+ def setup_method(self):
589
+ self.proxy = SystemProxy()
590
+
591
+ @patch("system_proxy.SystemProxy._get_active_interfaces")
592
+ @patch("subprocess.run")
593
+ def test_enable_sets_all_proxy_types(self, mock_run, mock_interfaces):
594
+ mock_interfaces.return_value = ["Wi-Fi"]
595
+ mock_run.return_value = None
596
+
597
+ self.proxy.enable(socks_port=1080, http_port=1087)
598
+
599
+ expected_calls = [
600
+ call(["networksetup", "-setsocksfirewallproxy", "Wi-Fi", "127.0.0.1", "1080"], check=True),
601
+ call(["networksetup", "-setsocksfirewallproxystate", "Wi-Fi", "on"], check=True),
602
+ call(["networksetup", "-setwebproxy", "Wi-Fi", "127.0.0.1", "1087"], check=True),
603
+ call(["networksetup", "-setwebproxystate", "Wi-Fi", "on"], check=True),
604
+ call(["networksetup", "-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "1087"], check=True),
605
+ call(["networksetup", "-setsecurewebproxystate", "Wi-Fi", "on"], check=True),
606
+ ]
607
+ mock_run.assert_has_calls(expected_calls)
608
+
609
+ @patch("system_proxy.SystemProxy._get_active_interfaces")
610
+ @patch("subprocess.run")
611
+ def test_disable_clears_all_proxy_types(self, mock_run, mock_interfaces):
612
+ mock_interfaces.return_value = ["Wi-Fi"]
613
+ mock_run.return_value = None
614
+
615
+ self.proxy.disable()
616
+
617
+ expected_calls = [
618
+ call(["networksetup", "-setsocksfirewallproxystate", "Wi-Fi", "off"], check=True),
619
+ call(["networksetup", "-setwebproxystate", "Wi-Fi", "off"], check=True),
620
+ call(["networksetup", "-setsecurewebproxystate", "Wi-Fi", "off"], check=True),
621
+ ]
622
+ mock_run.assert_has_calls(expected_calls)
623
+
624
+ @patch("system_proxy.SystemProxy._get_active_interfaces")
625
+ @patch("subprocess.run")
626
+ def test_enable_multiple_interfaces(self, mock_run, mock_interfaces):
627
+ mock_interfaces.return_value = ["Wi-Fi", "Ethernet"]
628
+ mock_run.return_value = None
629
+
630
+ self.proxy.enable(socks_port=1080, http_port=1087)
631
+
632
+ # 6 calls per interface × 2 interfaces = 12 calls
633
+ assert mock_run.call_count == 12
634
+
635
+ @patch("subprocess.run")
636
+ def test_get_active_interfaces(self, mock_run):
637
+ mock_run.return_value = type("Result", (), {
638
+ "stdout": "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Bluetooth PAN\nEthernet\n",
639
+ "returncode": 0,
640
+ })()
641
+
642
+ interfaces = self.proxy._get_active_interfaces()
643
+
644
+ assert "Wi-Fi" in interfaces
645
+ assert "Ethernet" in interfaces
646
+ assert "Bluetooth PAN" not in interfaces
647
+ ```
648
+
649
+ - [ ] **Step 2: Run tests to verify they fail**
650
+
651
+ ```bash
652
+ cd /Users/bachi/jaylli/block-proxy/client
653
+ source .venv/bin/activate
654
+ python -m pytest tests/test_system_proxy.py -v
655
+ ```
656
+ Expected: FAIL with `ModuleNotFoundError: No module named 'system_proxy'`
657
+
658
+ - [ ] **Step 3: Implement system_proxy.py**
659
+
660
+ Create `client/system_proxy.py`:
661
+ ```python
662
+ import subprocess
663
+ import atexit
664
+ import signal
665
+
666
+
667
+ class SystemProxy:
668
+ def __init__(self):
669
+ self._enabled = False
670
+ self._interfaces = []
671
+ atexit.register(self._cleanup)
672
+ signal.signal(signal.SIGTERM, self._signal_handler)
673
+
674
+ def _signal_handler(self, signum, frame):
675
+ self._cleanup()
676
+ raise SystemExit(0)
677
+
678
+ def _cleanup(self):
679
+ if self._enabled:
680
+ self.disable()
681
+
682
+ def _get_active_interfaces(self):
683
+ result = subprocess.run(
684
+ ["networksetup", "-listallnetworkservices"],
685
+ capture_output=True,
686
+ text=True,
687
+ )
688
+ interfaces = []
689
+ for line in result.stdout.strip().split("\n"):
690
+ if line.startswith("*") or line.startswith("An asterisk"):
691
+ continue
692
+ if line.strip():
693
+ interfaces.append(line.strip())
694
+ return interfaces
695
+
696
+ def enable(self, socks_port, http_port):
697
+ self._interfaces = self._get_active_interfaces()
698
+ for iface in self._interfaces:
699
+ subprocess.run(
700
+ ["networksetup", "-setsocksfirewallproxy", iface, "127.0.0.1", str(socks_port)],
701
+ check=True,
702
+ )
703
+ subprocess.run(
704
+ ["networksetup", "-setsocksfirewallproxystate", iface, "on"],
705
+ check=True,
706
+ )
707
+ subprocess.run(
708
+ ["networksetup", "-setwebproxy", iface, "127.0.0.1", str(http_port)],
709
+ check=True,
710
+ )
711
+ subprocess.run(
712
+ ["networksetup", "-setwebproxystate", iface, "on"],
713
+ check=True,
714
+ )
715
+ subprocess.run(
716
+ ["networksetup", "-setsecurewebproxy", iface, "127.0.0.1", str(http_port)],
717
+ check=True,
718
+ )
719
+ subprocess.run(
720
+ ["networksetup", "-setsecurewebproxystate", iface, "on"],
721
+ check=True,
722
+ )
723
+ self._enabled = True
724
+
725
+ def disable(self):
726
+ interfaces = self._interfaces or self._get_active_interfaces()
727
+ for iface in interfaces:
728
+ subprocess.run(
729
+ ["networksetup", "-setsocksfirewallproxystate", iface, "off"],
730
+ check=True,
731
+ )
732
+ subprocess.run(
733
+ ["networksetup", "-setwebproxystate", iface, "off"],
734
+ check=True,
735
+ )
736
+ subprocess.run(
737
+ ["networksetup", "-setsecurewebproxystate", iface, "off"],
738
+ check=True,
739
+ )
740
+ self._enabled = False
741
+ self._interfaces = []
742
+ ```
743
+
744
+ - [ ] **Step 4: Run tests to verify they pass**
745
+
746
+ ```bash
747
+ cd /Users/bachi/jaylli/block-proxy/client
748
+ source .venv/bin/activate
749
+ python -m pytest tests/test_system_proxy.py -v
750
+ ```
751
+ Expected: All 4 tests PASS
752
+
753
+ - [ ] **Step 5: Commit**
754
+
755
+ ```bash
756
+ git add client/system_proxy.py client/tests/test_system_proxy.py
757
+ git commit -m "feat(client): add macOS system proxy management module"
758
+ ```
759
+
760
+ ---
761
+
762
+ ### Task 5: Main Application (rumps status bar)
763
+
764
+ **Files:**
765
+ - Create: `client/app.py`
766
+ - Create: `client/main.py`
767
+
768
+ - [ ] **Step 1: Implement app.py**
769
+
770
+ Create `client/app.py`:
771
+ ```python
772
+ import os
773
+ import threading
774
+ import rumps
775
+ from config import Config
776
+ from xray_manager import XrayManager
777
+ from system_proxy import SystemProxy
778
+
779
+
780
+ class BlockProxyClient(rumps.App):
781
+ def __init__(self):
782
+ super().__init__("BlockProxyClient", quit_button=None)
783
+
784
+ self.config = Config()
785
+ self.config.load()
786
+ self.xray = XrayManager()
787
+ self.sys_proxy = SystemProxy()
788
+ self.connected = False
789
+
790
+ self._build_menu()
791
+ self._update_icon()
792
+ self._start_health_check()
793
+
794
+ def _build_menu(self):
795
+ self.toggle_item = rumps.MenuItem("开启代理", callback=self.toggle_proxy)
796
+ self.config_item = rumps.MenuItem("节点配置...", callback=self.open_config)
797
+
798
+ self.global_item = rumps.MenuItem("全局代理", callback=self.set_global_mode)
799
+ self.manual_item = rumps.MenuItem("手动模式", callback=self.set_manual_mode)
800
+
801
+ self.quit_item = rumps.MenuItem("退出", callback=self.quit_app)
802
+
803
+ self.menu = [
804
+ self.toggle_item,
805
+ self.config_item,
806
+ None,
807
+ self.global_item,
808
+ self.manual_item,
809
+ None,
810
+ self.quit_item,
811
+ ]
812
+
813
+ self._update_mode_menu()
814
+
815
+ def _update_mode_menu(self):
816
+ is_global = self.config.data["mode"] == "global"
817
+ self.global_item.state = 1 if is_global else 0
818
+ self.manual_item.state = 1 if not is_global else 0
819
+
820
+ def _update_icon(self):
821
+ icon_name = "icon.png" if self.connected else "icon_off.png"
822
+ icon_path = os.path.join(self._resource_dir(), icon_name)
823
+ if os.path.exists(icon_path):
824
+ self.icon = icon_path
825
+
826
+ def _resource_dir(self):
827
+ app_dir = os.path.dirname(os.path.abspath(__file__))
828
+ return os.path.join(app_dir, "resources")
829
+
830
+ def toggle_proxy(self, sender):
831
+ if self.connected:
832
+ self._disconnect()
833
+ else:
834
+ self._connect()
835
+
836
+ def _connect(self):
837
+ if not self.config.is_configured():
838
+ rumps.alert("请先配置节点信息")
839
+ return
840
+
841
+ self.xray.start(self.config.data)
842
+ if self.config.data["mode"] == "global":
843
+ self.sys_proxy.enable(
844
+ socks_port=self.config.data["local"]["socks_port"],
845
+ http_port=self.config.data["local"]["http_port"],
846
+ )
847
+ self.connected = True
848
+ self.toggle_item.title = "关闭代理"
849
+ self._update_icon()
850
+
851
+ def _disconnect(self):
852
+ self.sys_proxy.disable()
853
+ self.xray.stop()
854
+ self.connected = False
855
+ self.toggle_item.title = "开启代理"
856
+ self._update_icon()
857
+
858
+ def open_config(self, sender):
859
+ self._show_config_window()
860
+
861
+ def _show_config_window(self):
862
+ import tkinter as tk
863
+ from tkinter import ttk
864
+
865
+ def save_and_close():
866
+ self.config.data["server"]["address"] = entries["address"].get()
867
+ self.config.data["server"]["port"] = int(entries["port"].get())
868
+ self.config.data["server"]["username"] = entries["username"].get()
869
+ self.config.data["server"]["password"] = entries["password"].get()
870
+ self.config.data["server"]["tls"] = tls_var.get()
871
+ self.config.data["server"]["allowInsecure"] = insecure_var.get() == "true"
872
+ self.config.data["local"]["socks_port"] = int(entries["socks_port"].get())
873
+ self.config.data["local"]["http_port"] = int(entries["http_port"].get())
874
+ self.config.data["local"]["udp"] = udp_var.get()
875
+ self.config.save()
876
+ root.destroy()
877
+
878
+ root = tk.Tk()
879
+ root.title("节点配置")
880
+ root.geometry("400x380")
881
+ root.resizable(False, False)
882
+
883
+ frame = ttk.Frame(root, padding=20)
884
+ frame.pack(fill="both", expand=True)
885
+
886
+ entries = {}
887
+ fields = [
888
+ ("address", "地址:", self.config.data["server"]["address"]),
889
+ ("port", "端口:", str(self.config.data["server"]["port"])),
890
+ ("username", "用户名:", self.config.data["server"]["username"]),
891
+ ("password", "密码:", self.config.data["server"]["password"]),
892
+ ("socks_port", "本地SOCKS端口:", str(self.config.data["local"]["socks_port"])),
893
+ ("http_port", "本地HTTP端口:", str(self.config.data["local"]["http_port"])),
894
+ ]
895
+
896
+ for i, (key, label, default) in enumerate(fields):
897
+ ttk.Label(frame, text=label).grid(row=i, column=0, sticky="w", pady=4)
898
+ entry = ttk.Entry(frame, width=30)
899
+ if key == "password":
900
+ entry.config(show="*")
901
+ entry.insert(0, default)
902
+ entry.grid(row=i, column=1, sticky="w", pady=4)
903
+ entries[key] = entry
904
+
905
+ row = len(fields)
906
+
907
+ tls_var = tk.BooleanVar(value=self.config.data["server"]["tls"])
908
+ ttk.Checkbutton(frame, text="启用 TLS", variable=tls_var).grid(
909
+ row=row, column=0, columnspan=2, sticky="w", pady=4
910
+ )
911
+ row += 1
912
+
913
+ ttk.Label(frame, text="allowInsecure:").grid(row=row, column=0, sticky="w", pady=4)
914
+ insecure_var = tk.StringVar(
915
+ value="true" if self.config.data["server"]["allowInsecure"] else "false"
916
+ )
917
+ insecure_combo = ttk.Combobox(
918
+ frame, textvariable=insecure_var, values=["true", "false"], state="readonly", width=10
919
+ )
920
+ insecure_combo.grid(row=row, column=1, sticky="w", pady=4)
921
+ row += 1
922
+
923
+ udp_var = tk.BooleanVar(value=self.config.data["local"]["udp"])
924
+ ttk.Checkbutton(frame, text="启用 UDP", variable=udp_var).grid(
925
+ row=row, column=0, columnspan=2, sticky="w", pady=4
926
+ )
927
+ row += 1
928
+
929
+ ttk.Button(frame, text="保存", command=save_and_close).grid(
930
+ row=row, column=0, columnspan=2, pady=15
931
+ )
932
+
933
+ root.lift()
934
+ root.attributes("-topmost", True)
935
+ root.mainloop()
936
+
937
+ def set_global_mode(self, sender):
938
+ self.config.data["mode"] = "global"
939
+ self.config.save()
940
+ self._update_mode_menu()
941
+ if self.connected:
942
+ self.sys_proxy.enable(
943
+ socks_port=self.config.data["local"]["socks_port"],
944
+ http_port=self.config.data["local"]["http_port"],
945
+ )
946
+
947
+ def set_manual_mode(self, sender):
948
+ self.config.data["mode"] = "manual"
949
+ self.config.save()
950
+ self._update_mode_menu()
951
+ if self.connected:
952
+ self.sys_proxy.disable()
953
+
954
+ def quit_app(self, sender):
955
+ if self.connected:
956
+ self._disconnect()
957
+ rumps.quit_application()
958
+
959
+ def _start_health_check(self):
960
+ def check():
961
+ while True:
962
+ import time
963
+ time.sleep(5)
964
+ if self.connected and not self.xray.is_running():
965
+ self._disconnect()
966
+ rumps.notification(
967
+ "BlockProxyClient",
968
+ "代理已断开",
969
+ "xray-core 进程意外退出",
970
+ )
971
+
972
+ t = threading.Thread(target=check, daemon=True)
973
+ t.start()
974
+ ```
975
+
976
+ - [ ] **Step 2: Implement main.py**
977
+
978
+ Create `client/main.py`:
979
+ ```python
980
+ from app import BlockProxyClient
981
+
982
+
983
+ def main():
984
+ client = BlockProxyClient()
985
+ client.run()
986
+
987
+
988
+ if __name__ == "__main__":
989
+ main()
990
+ ```
991
+
992
+ - [ ] **Step 3: Manual test - verify app launches**
993
+
994
+ ```bash
995
+ cd /Users/bachi/jaylli/block-proxy/client
996
+ source .venv/bin/activate
997
+ python main.py
998
+ ```
999
+ Expected: Status bar icon appears, menu opens with all items, clicking "节点配置..." opens tkinter window.
1000
+
1001
+ Press "退出" to close.
1002
+
1003
+ - [ ] **Step 4: Commit**
1004
+
1005
+ ```bash
1006
+ git add client/app.py client/main.py
1007
+ git commit -m "feat(client): add main application with status bar menu and config window"
1008
+ ```
1009
+
1010
+ ---
1011
+
1012
+ ### Task 6: Resource Files and Icons
1013
+
1014
+ **Files:**
1015
+ - Create: `client/resources/icon.png`
1016
+ - Create: `client/resources/icon_off.png`
1017
+
1018
+ - [ ] **Step 1: Generate placeholder icons**
1019
+
1020
+ ```bash
1021
+ cd /Users/bachi/jaylli/block-proxy/client
1022
+ source .venv/bin/activate
1023
+ pip install Pillow
1024
+ python -c "
1025
+ from PIL import Image, ImageDraw
1026
+ # Connected icon - solid dark circle
1027
+ img = Image.new('RGBA', (22, 22), (0, 0, 0, 0))
1028
+ draw = ImageDraw.Draw(img)
1029
+ draw.ellipse([4, 4, 18, 18], fill=(0, 0, 0, 255))
1030
+ img.save('resources/icon.png')
1031
+
1032
+ # Disconnected icon - hollow circle
1033
+ img2 = Image.new('RGBA', (22, 22), (0, 0, 0, 0))
1034
+ draw2 = ImageDraw.Draw(img2)
1035
+ draw2.ellipse([4, 4, 18, 18], outline=(128, 128, 128, 255), width=2)
1036
+ img2.save('resources/icon_off.png')
1037
+ print('Icons created')
1038
+ "
1039
+ ```
1040
+
1041
+ - [ ] **Step 2: Verify icons exist**
1042
+
1043
+ ```bash
1044
+ ls -la /Users/bachi/jaylli/block-proxy/client/resources/icon*.png
1045
+ ```
1046
+ Expected: Both `icon.png` and `icon_off.png` exist, ~22x22 PNG files.
1047
+
1048
+ - [ ] **Step 3: Commit**
1049
+
1050
+ ```bash
1051
+ git add client/resources/icon.png client/resources/icon_off.png
1052
+ git commit -m "feat(client): add status bar icons"
1053
+ ```
1054
+
1055
+ ---
1056
+
1057
+ ### Task 7: py2app Packaging Setup
1058
+
1059
+ **Files:**
1060
+ - Create: `client/setup.py`
1061
+
1062
+ - [ ] **Step 1: Create setup.py**
1063
+
1064
+ Create `client/setup.py`:
1065
+ ```python
1066
+ from setuptools import setup
1067
+
1068
+ APP = ["main.py"]
1069
+ DATA_FILES = [
1070
+ ("resources", ["resources/icon.png", "resources/icon_off.png"]),
1071
+ ]
1072
+ OPTIONS = {
1073
+ "argv_emulation": False,
1074
+ "iconfile": "resources/icon.png",
1075
+ "plist": {
1076
+ "LSUIElement": True,
1077
+ "CFBundleName": "BlockProxyClient",
1078
+ "CFBundleIdentifier": "com.jaylli.blockproxyclient",
1079
+ "CFBundleVersion": "1.0.0",
1080
+ "CFBundleShortVersionString": "1.0.0",
1081
+ },
1082
+ "packages": ["rumps"],
1083
+ }
1084
+
1085
+ setup(
1086
+ name="BlockProxyClient",
1087
+ app=APP,
1088
+ data_files=DATA_FILES,
1089
+ options={"py2app": OPTIONS},
1090
+ setup_requires=["py2app"],
1091
+ )
1092
+ ```
1093
+
1094
+ - [ ] **Step 2: Test build (development mode)**
1095
+
1096
+ ```bash
1097
+ cd /Users/bachi/jaylli/block-proxy/client
1098
+ source .venv/bin/activate
1099
+ python setup.py py2app -A
1100
+ ```
1101
+ Expected: Creates `dist/BlockProxyClient.app` (alias mode, for development testing).
1102
+
1103
+ - [ ] **Step 3: Verify .app launches**
1104
+
1105
+ ```bash
1106
+ open dist/BlockProxyClient.app
1107
+ ```
1108
+ Expected: Status bar icon appears. Close via menu "退出".
1109
+
1110
+ - [ ] **Step 4: Commit**
1111
+
1112
+ ```bash
1113
+ git add client/setup.py
1114
+ git commit -m "feat(client): add py2app packaging configuration"
1115
+ ```
1116
+
1117
+ ---
1118
+
1119
+ ### Task 8: Download xray-core and Integration Test
1120
+
1121
+ **Files:**
1122
+ - Create: `client/scripts/download_xray.sh`
1123
+
1124
+ - [ ] **Step 1: Create xray-core download script**
1125
+
1126
+ Create `client/scripts/download_xray.sh`:
1127
+ ```bash
1128
+ #!/bin/bash
1129
+ set -e
1130
+
1131
+ XRAY_VERSION="v25.5.16"
1132
+ PLATFORM="macos"
1133
+ ARCH=$(uname -m)
1134
+
1135
+ if [ "$ARCH" = "arm64" ]; then
1136
+ FILENAME="Xray-macos-arm64-v8a.zip"
1137
+ elif [ "$ARCH" = "x86_64" ]; then
1138
+ FILENAME="Xray-macos-64.zip"
1139
+ else
1140
+ echo "Unsupported architecture: $ARCH"
1141
+ exit 1
1142
+ fi
1143
+
1144
+ URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${FILENAME}"
1145
+ DEST_DIR="$(dirname "$0")/../resources"
1146
+
1147
+ echo "Downloading xray-core ${XRAY_VERSION} for ${ARCH}..."
1148
+ curl -L -o /tmp/xray.zip "$URL"
1149
+
1150
+ echo "Extracting..."
1151
+ unzip -o /tmp/xray.zip xray -d "$DEST_DIR"
1152
+ chmod +x "$DEST_DIR/xray"
1153
+ rm /tmp/xray.zip
1154
+
1155
+ echo "Done: $DEST_DIR/xray"
1156
+ "$DEST_DIR/xray" version
1157
+ ```
1158
+
1159
+ - [ ] **Step 2: Download xray-core**
1160
+
1161
+ ```bash
1162
+ cd /Users/bachi/jaylli/block-proxy/client
1163
+ mkdir -p scripts
1164
+ chmod +x scripts/download_xray.sh
1165
+ bash scripts/download_xray.sh
1166
+ ```
1167
+ Expected: xray binary downloaded to `resources/xray` (or `resources/xray-core`), version printed.
1168
+
1169
+ - [ ] **Step 3: Add xray binary to .gitignore**
1170
+
1171
+ Append to `client/.gitignore`:
1172
+ ```
1173
+ .venv/
1174
+ dist/
1175
+ build/
1176
+ *.egg-info/
1177
+ resources/xray
1178
+ resources/xray-core
1179
+ __pycache__/
1180
+ ```
1181
+
1182
+ - [ ] **Step 4: Integration test - connect to server**
1183
+
1184
+ Manually test:
1185
+ 1. Start the app: `python main.py`
1186
+ 2. Click "节点配置..." → fill in your server details → Save
1187
+ 3. Click "开启代理"
1188
+ 4. Verify: `curl --proxy socks5://127.0.0.1:1080 https://httpbin.org/ip`
1189
+ 5. Verify: `curl --proxy http://127.0.0.1:1087 https://httpbin.org/ip`
1190
+ 6. Click "关闭代理"
1191
+
1192
+ Expected: Both curl commands return your server's IP when proxy is on, and fail/return local IP when off.
1193
+
1194
+ - [ ] **Step 5: Commit**
1195
+
1196
+ ```bash
1197
+ git add client/scripts/download_xray.sh client/.gitignore
1198
+ git commit -m "feat(client): add xray-core download script and gitignore"
1199
+ ```
1200
+
1201
+ ---
1202
+
1203
+ ### Task 9: Final Production Build
1204
+
1205
+ **Files:**
1206
+ - Modify: `client/setup.py` (add xray-core to DATA_FILES)
1207
+
1208
+ - [ ] **Step 1: Update setup.py to include xray binary**
1209
+
1210
+ In `client/setup.py`, update DATA_FILES to:
1211
+ ```python
1212
+ DATA_FILES = [
1213
+ ("resources", ["resources/icon.png", "resources/icon_off.png", "resources/xray"]),
1214
+ ]
1215
+ ```
1216
+
1217
+ - [ ] **Step 2: Build production .app**
1218
+
1219
+ ```bash
1220
+ cd /Users/bachi/jaylli/block-proxy/client
1221
+ source .venv/bin/activate
1222
+ rm -rf dist build
1223
+ python setup.py py2app
1224
+ ```
1225
+ Expected: Creates standalone `dist/BlockProxyClient.app` with all dependencies bundled.
1226
+
1227
+ - [ ] **Step 3: Update xray_manager to find bundled binary**
1228
+
1229
+ In `client/xray_manager.py`, update the `__init__` method's xray_path default detection:
1230
+ ```python
1231
+ def __init__(self, xray_path=None, config_dir=None):
1232
+ if xray_path is None:
1233
+ # Check for bundled binary in .app Resources
1234
+ bundle_path = os.path.join(
1235
+ os.path.dirname(os.path.abspath(__file__)),
1236
+ "resources",
1237
+ "xray",
1238
+ )
1239
+ if os.path.exists(bundle_path):
1240
+ xray_path = bundle_path
1241
+ else:
1242
+ xray_path = shutil.which("xray") or "/usr/local/bin/xray"
1243
+ if config_dir is None:
1244
+ config_dir = os.path.expanduser(
1245
+ "~/Library/Application Support/BlockProxyClient"
1246
+ )
1247
+ self.xray_path = xray_path
1248
+ self.config_dir = config_dir
1249
+ self.config_file = os.path.join(config_dir, "xray_config.json")
1250
+ self.process = None
1251
+ ```
1252
+
1253
+ - [ ] **Step 4: Verify production build launches and connects**
1254
+
1255
+ ```bash
1256
+ open dist/BlockProxyClient.app
1257
+ ```
1258
+ Expected: App runs from .app bundle, proxy connect/disconnect works.
1259
+
1260
+ - [ ] **Step 5: Run all unit tests one final time**
1261
+
1262
+ ```bash
1263
+ cd /Users/bachi/jaylli/block-proxy/client
1264
+ source .venv/bin/activate
1265
+ python -m pytest tests/ -v
1266
+ ```
1267
+ Expected: All tests PASS.
1268
+
1269
+ - [ ] **Step 6: Commit**
1270
+
1271
+ ```bash
1272
+ git add client/setup.py client/xray_manager.py
1273
+ git commit -m "feat(client): finalize production build with bundled xray-core"
1274
+ ```