sing-box-cli 0.1.7__tar.gz → 0.1.9__tar.gz

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 (40) hide show
  1. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/PKG-INFO +64 -3
  2. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/README.md +59 -0
  3. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/pyproject.toml +8 -3
  4. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/__init__.py +1 -1
  5. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/__init__.py +8 -1
  6. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/client.py +11 -2
  7. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/connections.py +9 -1
  8. sing_box_cli-0.1.9/src/sing_box_cli/api/logs.py +89 -0
  9. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/monitor.py +56 -22
  10. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/__init__.py +12 -6
  11. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/config.py +184 -85
  12. sing_box_cli-0.1.9/src/sing_box_cli/config/store.py +336 -0
  13. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/utils.py +46 -2
  14. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/main.py +4 -1
  15. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/service/__init__.py +24 -13
  16. sing_box_cli-0.1.9/src/sing_box_cli/service/desktop.py +336 -0
  17. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/service/manager.py +76 -14
  18. sing_box_cli-0.1.7/src/sing_box_cli/api/logs.py +0 -93
  19. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/.gitignore +0 -0
  20. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/LICENSE +0 -0
  21. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/docs/config-serve.md +0 -0
  22. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/docs/traefik-config-serve.yml +0 -0
  23. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/hatch_build.py +0 -0
  24. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/policy.py +0 -0
  25. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/widget.py +0 -0
  26. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/common.py +0 -0
  27. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/_serve_worker.py +0 -0
  28. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve.py +0 -0
  29. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_backend.py +0 -0
  30. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_tokens.py +0 -0
  31. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/installation.py +0 -0
  32. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/py.typed +0 -0
  33. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/common.go +0 -0
  34. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/common_test.go +0 -0
  35. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/go.mod +0 -0
  36. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/go.sum +0 -0
  37. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/integration_windows_test.go +0 -0
  38. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/main_other.go +0 -0
  39. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/main_windows.go +0 -0
  40. {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/testdata/core.go +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sing-box-cli
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: cross-platform sing-box manager.
5
5
  Project-URL: Homepage, https://github.com/aittcuszz/sing-box-service
6
6
  Project-URL: Issues, https://github.com/atticuszz/sing-box-service/issues
@@ -39,12 +39,14 @@ Classifier: Programming Language :: Python :: 3.13
39
39
  Classifier: Programming Language :: Python :: 3.14
40
40
  Requires-Python: <3.15,>=3.11
41
41
  Requires-Dist: httpx>=0.28.1
42
- Requires-Dist: plotext>=5.3.2
42
+ Requires-Dist: plotext<6,>=5.3.2
43
43
  Requires-Dist: prompt-toolkit>=3.0.50
44
44
  Requires-Dist: psutil>=6.1.1
45
45
  Requires-Dist: pydantic>=2.10.6
46
- Requires-Dist: sing-box-bin>=1.12.12
46
+ Requires-Dist: sing-box-bin<1.14,>=1.12.12
47
47
  Requires-Dist: typer>=0.15.1
48
+ Provides-Extra: desktop
49
+ Requires-Dist: grpcio>=1.60; extra == 'desktop'
48
50
  Description-Content-Type: text/markdown
49
51
 
50
52
  # Sing-Box CLI
@@ -167,6 +169,7 @@ Preview an update before applying it:
167
169
  ```bash
168
170
  sbc config update https://sub.example.com/alice.json --dry-run
169
171
  sbc config update https://sub.example.com/alice.json --restart
172
+ sbc config update --restart # no URL: the saved subscription
170
173
  ```
171
174
 
172
175
  `--dry-run` downloads and validates the configuration, then shows its diff without
@@ -187,6 +190,64 @@ sbc config get --subscription # subscription URL
187
190
 
188
191
  `config show` and `config show-sub` remain available as hidden compatibility aliases.
189
192
 
193
+ ### Where it is kept
194
+
195
+ sbc keeps its configuration the way the official sing-box desktop app does, in one
196
+ place, whether the app is installed or not:
197
+
198
+ ```
199
+ <config dir>/settings.db profiles and the selected one
200
+ <config dir>/profiles/<id>.json the selected profile's sing-box configuration
201
+ ```
202
+
203
+ The config directory is `~/.config/sing-box` on Linux. The selected profile is the
204
+ configuration and its subscription URL is the subscription. A subscription token is
205
+ stored inside that URL (`?token=...`), as the app stores it. It is still sent as a
206
+ bearer token and never printed.
207
+
208
+ Without the app, sbc creates this store itself with the app's schema, so the app
209
+ takes it over unchanged if it is installed later. Files of older versions are
210
+ imported the first time sbc runs anything but `config get` or a `--dry-run`, which
211
+ create nothing:
212
+
213
+ - `config.json` becomes the profile. It stays as a link to the profile, so a
214
+ service created before keeps running the same file. The previous file is kept as
215
+ `config.json.migrated`.
216
+ - The URL and token of `app_config.json` become the subscription. The file is kept
217
+ as `app_config.json.migrated`.
218
+ - A service created before follows the profile: `service restart` and
219
+ `config update --restart` point it at the profile when it points elsewhere.
220
+
221
+ A configuration is written exactly as downloaded. The diff, and whether there is a
222
+ change at all, ignore formatting.
223
+
224
+ ## sing-box desktop app (Linux)
225
+
226
+ When the official sing-box desktop app is installed, sbc drives its daemon instead
227
+ of a systemd service of its own, and the app's `settings.db` is the store. The app
228
+ counts as installed when its `sing-box-daemon.service` exists.
229
+
230
+ ```bash
231
+ uv tool install 'sing-box-cli[desktop]' # grpcio is only needed for this
232
+ sbc service restart --update # sudo works too, it acts as the invoking user
233
+ ```
234
+
235
+ | Command | With the desktop app |
236
+ |---|---|
237
+ | `service status/stop/restart/enable` | start, stop and reload the daemon's core. `restart` reloads in place, without a stop in between |
238
+ | `service disable` | stops the core. Autostart stays with the app's `sing-box-daemon.service` |
239
+ | `config update [URL]`, `--update` | refresh the profile selected in the app and record the refresh there. A new URL is saved to that profile once it downloads fine |
240
+ | `--clear-cache`, `config clear_cache` | clear the daemon's working directory, like "clear data" in the app |
241
+ | `stats/conns/proxy/logs` | unchanged, the default API address comes from the profile |
242
+ | `run` | refused, the daemon runs the core |
243
+
244
+ The core is started with the start options set in the app (OOM killer, memory
245
+ limit, power report). The app's store is never created or repaired from here: if it
246
+ cannot be read, or the daemon is stopped, sbc says so instead of working around it.
247
+
248
+ - A local profile of the app has no subscription, `config update URL` is refused for it.
249
+ - The app may show an old subscription URL until its profile list is reloaded.
250
+
190
251
  ## Serve private GitHub configurations
191
252
 
192
253
  Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
@@ -118,6 +118,7 @@ Preview an update before applying it:
118
118
  ```bash
119
119
  sbc config update https://sub.example.com/alice.json --dry-run
120
120
  sbc config update https://sub.example.com/alice.json --restart
121
+ sbc config update --restart # no URL: the saved subscription
121
122
  ```
122
123
 
123
124
  `--dry-run` downloads and validates the configuration, then shows its diff without
@@ -138,6 +139,64 @@ sbc config get --subscription # subscription URL
138
139
 
139
140
  `config show` and `config show-sub` remain available as hidden compatibility aliases.
140
141
 
142
+ ### Where it is kept
143
+
144
+ sbc keeps its configuration the way the official sing-box desktop app does, in one
145
+ place, whether the app is installed or not:
146
+
147
+ ```
148
+ <config dir>/settings.db profiles and the selected one
149
+ <config dir>/profiles/<id>.json the selected profile's sing-box configuration
150
+ ```
151
+
152
+ The config directory is `~/.config/sing-box` on Linux. The selected profile is the
153
+ configuration and its subscription URL is the subscription. A subscription token is
154
+ stored inside that URL (`?token=...`), as the app stores it. It is still sent as a
155
+ bearer token and never printed.
156
+
157
+ Without the app, sbc creates this store itself with the app's schema, so the app
158
+ takes it over unchanged if it is installed later. Files of older versions are
159
+ imported the first time sbc runs anything but `config get` or a `--dry-run`, which
160
+ create nothing:
161
+
162
+ - `config.json` becomes the profile. It stays as a link to the profile, so a
163
+ service created before keeps running the same file. The previous file is kept as
164
+ `config.json.migrated`.
165
+ - The URL and token of `app_config.json` become the subscription. The file is kept
166
+ as `app_config.json.migrated`.
167
+ - A service created before follows the profile: `service restart` and
168
+ `config update --restart` point it at the profile when it points elsewhere.
169
+
170
+ A configuration is written exactly as downloaded. The diff, and whether there is a
171
+ change at all, ignore formatting.
172
+
173
+ ## sing-box desktop app (Linux)
174
+
175
+ When the official sing-box desktop app is installed, sbc drives its daemon instead
176
+ of a systemd service of its own, and the app's `settings.db` is the store. The app
177
+ counts as installed when its `sing-box-daemon.service` exists.
178
+
179
+ ```bash
180
+ uv tool install 'sing-box-cli[desktop]' # grpcio is only needed for this
181
+ sbc service restart --update # sudo works too, it acts as the invoking user
182
+ ```
183
+
184
+ | Command | With the desktop app |
185
+ |---|---|
186
+ | `service status/stop/restart/enable` | start, stop and reload the daemon's core. `restart` reloads in place, without a stop in between |
187
+ | `service disable` | stops the core. Autostart stays with the app's `sing-box-daemon.service` |
188
+ | `config update [URL]`, `--update` | refresh the profile selected in the app and record the refresh there. A new URL is saved to that profile once it downloads fine |
189
+ | `--clear-cache`, `config clear_cache` | clear the daemon's working directory, like "clear data" in the app |
190
+ | `stats/conns/proxy/logs` | unchanged, the default API address comes from the profile |
191
+ | `run` | refused, the daemon runs the core |
192
+
193
+ The core is started with the start options set in the app (OOM killer, memory
194
+ limit, power report). The app's store is never created or repaired from here: if it
195
+ cannot be read, or the daemon is stopped, sbc says so instead of working around it.
196
+
197
+ - A local profile of the app has no subscription, `config update URL` is refused for it.
198
+ - The app may show an old subscription URL until its profile list is reloaded.
199
+
141
200
  ## Serve private GitHub configurations
142
201
 
143
202
  Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
@@ -23,11 +23,11 @@ maintainers = [{ name = "A.J.Zeller", email = "hello@atticux.me" }]
23
23
  dynamic = ["version"] # https://hatch.pypa.io/latest/config/metadata/#version
24
24
  dependencies = [
25
25
  "httpx>=0.28.1",
26
- "plotext>=5.3.2",
26
+ "plotext>=5.3.2,<6",
27
27
  "prompt-toolkit>=3.0.50",
28
28
  "psutil>=6.1.1",
29
29
  "pydantic>=2.10.6",
30
- "sing-box-bin>=1.12.12",
30
+ "sing-box-bin>=1.12.12,<1.14",
31
31
  "typer>=0.15.1",
32
32
  ]
33
33
 
@@ -47,6 +47,7 @@ environments = ["sys_platform == 'win32'", "sys_platform == 'linux'"]
47
47
 
48
48
  [project.optional-dependencies]
49
49
  # optional deps for package installation
50
+ desktop = ["grpcio>=1.60"] # drive the sing-box desktop app's daemon
50
51
 
51
52
  [dependency-groups]
52
53
  dev = [
@@ -61,6 +62,7 @@ dev = [
61
62
  "scipy-stubs>=1.15.2.1",
62
63
  "coverage>=7.7.1",
63
64
  "pytest-sugar>=1.0.0",
65
+ "grpcio>=1.60", # fake desktop daemon in tests
64
66
  ]
65
67
 
66
68
  [tool.hatch.version]
@@ -121,6 +123,9 @@ ignore = [
121
123
 
122
124
  isort = { combine-as-imports = true, split-on-trailing-comma = false }
123
125
 
126
+ [tool.ruff.lint.per-file-ignores]
127
+ "tests/**" = ["ARG001"] # pytest fixtures are used by being requested
128
+
124
129
  # Avoid trying to fix flake8-bugbear (`B`) violations.
125
130
  unfixable = ["B"]
126
131
 
@@ -215,7 +220,7 @@ topo_order = false
215
220
  sort_commits = "oldest"
216
221
 
217
222
  [tool.bumpversion]
218
- current_version = "0.1.7"
223
+ current_version = "0.1.9"
219
224
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
220
225
  serialize = ["{major}.{minor}.{patch}"]
221
226
  search = "{current_version}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.1.7"
5
+ __version__ = "0.1.9"
@@ -2,12 +2,13 @@ import asyncio
2
2
  from collections.abc import Callable, Coroutine
3
3
  from typing import Annotated, Any
4
4
 
5
+ import httpx
5
6
  import typer
6
7
 
7
8
  from ..common import LogLevel, StrOrNone
8
9
  from ..config.config import ConfigHandler
9
10
  from ..service import get_context_obj
10
- from .client import SingBoxAPIClient
11
+ from .client import SingBoxAPIClient, SingBoxAPIError
11
12
  from .connections import ConnectionsManager
12
13
  from .logs import get_logs
13
14
  from .monitor import ResourceMonitor, ResourceVisualizer
@@ -67,6 +68,12 @@ def _run_tui(
67
68
  asyncio.run(command(api_client))
68
69
  except (KeyboardInterrupt, EOFError):
69
70
  pass
71
+ except SingBoxAPIError as e:
72
+ typer.echo(f"Error: {e}", err=True)
73
+ raise typer.Exit(1)
74
+ except httpx.HTTPError as e:
75
+ typer.echo(f"Error: sing-box API request failed: {e}", err=True)
76
+ raise typer.Exit(1)
70
77
  print("Exited.")
71
78
 
72
79
 
@@ -104,6 +104,10 @@ class LogEntry(FrozenBaseModel):
104
104
  T = TypeVar("T", bound=FrozenBaseModel)
105
105
 
106
106
 
107
+ class SingBoxAPIError(Exception):
108
+ """The sing-box API is unreachable or rejected the client."""
109
+
110
+
107
111
  class SingBoxAPIClient:
108
112
  """Async client for interacting with Sing-Box API.
109
113
 
@@ -123,7 +127,10 @@ class SingBoxAPIClient:
123
127
  self.headers = {"Authorization": f"Bearer {token}"} if token else {}
124
128
  self.timeout = timeout
125
129
  if not self._health_check():
126
- raise ValueError(f"Invalid to initialize client: {self.base_url}")
130
+ raise SingBoxAPIError(
131
+ f"sing-box API is not reachable at {self.base_url}, "
132
+ "check that sing-box is running and the API URL and token are correct"
133
+ )
127
134
 
128
135
  def _health_check(self) -> bool:
129
136
  """Check if the API is reachable.
@@ -204,9 +211,11 @@ class SingBoxAPIClient:
204
211
  """
205
212
  url = f"{self.base_url}{endpoint}"
206
213
 
214
+ # streams may stay idle for long, so only reads are unbounded
215
+ timeout = httpx.Timeout(self.timeout, read=None)
207
216
  async with httpx.AsyncClient() as client:
208
217
  async with client.stream(
209
- "GET", url, headers=self.headers, params=params, timeout=None
218
+ "GET", url, headers=self.headers, params=params, timeout=timeout
210
219
  ) as response:
211
220
  response.raise_for_status()
212
221
  async for line in response.aiter_lines():
@@ -440,10 +440,18 @@ class ConnectionsManager:
440
440
  pass
441
441
 
442
442
  async def periodic_refresh(self, interval: float = 0.5) -> None:
443
- """Periodically refresh connections."""
443
+ """Periodically refresh connections, retrying while the API is unreachable."""
444
+ failed = False
444
445
  while self.running:
445
446
  try:
446
447
  await asyncio.sleep(interval)
447
448
  await self.refresh_connections()
448
449
  except asyncio.CancelledError:
449
450
  break
451
+ except Exception:
452
+ # refresh_connections already shows the error in the status bar
453
+ failed = True
454
+ continue
455
+ if failed:
456
+ failed = False
457
+ self.status_message = self.get_filter_state()
@@ -0,0 +1,89 @@
1
+ import re
2
+ from contextlib import aclosing
3
+ from datetime import datetime
4
+
5
+ from rich import print
6
+ from rich.text import Text
7
+
8
+ from ..common import LogLevel
9
+ from .client import SingBoxAPIClient
10
+
11
+
12
+ async def get_logs(client: SingBoxAPIClient, log_level: LogLevel) -> None:
13
+ # Define color mapping for log levels with brighter colors
14
+ # Ref: https://rich.readthedocs.io/en/stable/appendix/colors.html
15
+ log_level_colors = {
16
+ "trace": "bright_black",
17
+ "debug": "grey66",
18
+ "info": "bright_cyan",
19
+ "warning": "bright_yellow",
20
+ "error": "bright_red",
21
+ "fatal": "bright_magenta",
22
+ "panic": "bright_magenta",
23
+ }
24
+
25
+ # Bright colors for IDs
26
+ id_colors = [
27
+ "bright_red",
28
+ "bright_green",
29
+ "bright_yellow",
30
+ "bright_blue",
31
+ "bright_magenta",
32
+ "bright_cyan",
33
+ ]
34
+
35
+ # Dictionary to store ID to color mapping
36
+ id_color_map = {}
37
+
38
+ async with aclosing(client.log_stream(log_level.value.lower())) as stream:
39
+ async for entry in stream:
40
+ # Get current timestamp with automatic timezone offset
41
+ now = datetime.now().astimezone()
42
+ timestamp = now.strftime("%z %Y-%m-%d %H:%M:%S")
43
+
44
+ # Get level color
45
+ # Start building the output message
46
+ message = Text()
47
+ message.append(timestamp, style="bright_black")
48
+ message.append(" ")
49
+ message.append(
50
+ entry.type.upper(),
51
+ style=log_level_colors.get(entry.type.lower(), "bright_white"),
52
+ )
53
+ message.append(" ")
54
+
55
+ # Process the payload
56
+ payload = entry.payload
57
+
58
+ # Find all ID patterns in the payload
59
+ pattern = r"\[(\d+) (\d+ms)\]"
60
+ last_end = 0
61
+
62
+ for match in re.finditer(pattern, payload):
63
+ # Add text before the match
64
+ message.append(payload[last_end : match.start()])
65
+
66
+ # Extract ID and time part
67
+ log_id = match.group(1)
68
+ time_part = match.group(2)
69
+
70
+ # Assign a consistent color to this ID
71
+ if log_id not in id_color_map:
72
+ color_index = hash(log_id) % len(id_colors)
73
+ id_color_map[log_id] = id_colors[color_index]
74
+
75
+ id_color = id_color_map[log_id]
76
+
77
+ # Add the ID with coloring
78
+ message.append("[", style="default")
79
+ message.append(log_id, style=id_color)
80
+ message.append(f" {time_part}]", style="default")
81
+
82
+ last_end = match.end()
83
+
84
+ # Add any remaining text
85
+ if last_end < len(payload):
86
+ message.append(payload[last_end:])
87
+
88
+ # Print the complete message
89
+ print(message)
@@ -2,6 +2,7 @@ import asyncio
2
2
  import math
3
3
  import statistics
4
4
  from collections import deque
5
+ from collections.abc import AsyncGenerator, Callable
5
6
  from contextlib import aclosing
6
7
  from datetime import datetime
7
8
  from enum import Enum
@@ -14,7 +15,13 @@ from rich.panel import Panel
14
15
  from rich.table import Table
15
16
  from rich.text import Text
16
17
 
17
- from .client import ConnectionInfo, MemoryData, SingBoxAPIClient, TrafficData
18
+ from .client import (
19
+ ConnectionInfo,
20
+ FrozenBaseModel,
21
+ MemoryData,
22
+ SingBoxAPIClient,
23
+ TrafficData,
24
+ )
18
25
  from .widget import RichPlotMixin, create_header_panel
19
26
 
20
27
 
@@ -575,7 +582,10 @@ class ResourceVisualizer:
575
582
  return table
576
583
 
577
584
  def create_resources_layout(
578
- self, traffic_data: TrafficData, memory_data: MemoryData
585
+ self,
586
+ traffic_data: TrafficData,
587
+ memory_data: MemoryData,
588
+ api_error: str | None = None,
579
589
  ) -> Layout:
580
590
  """Create the main layout with all resource components."""
581
591
  layout = Layout()
@@ -617,7 +627,17 @@ class ResourceVisualizer:
617
627
  )
618
628
 
619
629
  # Add footer
620
- layout["footer"].update(Text("Press Ctrl+C to exit", justify="center"))
630
+ if api_error:
631
+ footer = Text(
632
+ f"{api_error} | Press Ctrl+C to exit",
633
+ style="bold red",
634
+ justify="center",
635
+ no_wrap=True,
636
+ overflow="ellipsis",
637
+ )
638
+ else:
639
+ footer = Text("Press Ctrl+C to exit", justify="center")
640
+ layout["footer"].update(footer)
621
641
 
622
642
  return layout
623
643
 
@@ -642,30 +662,44 @@ class ResourceMonitor:
642
662
  self.current_memory = MemoryData(inuse=0, total=0) # in bytes
643
663
  # 1.0 fix interval for avoiding repeated data
644
664
  self.task_interval = RefreshRate.SLOW.value
665
+ self.reconnect_interval = RefreshRate.SLOW.value
666
+ self.api_error: str | None = None
645
667
 
646
668
  async def monitor_traffic(self) -> None:
647
669
  """Monitor traffic stream from the API."""
648
- try:
649
- async with aclosing(self.api_client.traffic_stream()) as stream:
650
- async for traffic_data in stream:
651
- self.current_traffic = traffic_data
652
- if not self.running:
653
- break
654
- await asyncio.sleep(self.task_interval)
655
- except Exception as e:
656
- self.visualizer.console.print(f"[red]Error monitoring traffic: {str(e)}")
670
+ await self._follow_stream(
671
+ self.api_client.traffic_stream, "current_traffic", TrafficData()
672
+ )
657
673
 
658
674
  async def monitor_memory(self) -> None:
659
675
  """Monitor memory stream from the API."""
660
- try:
661
- async with aclosing(self.api_client.memory_stream()) as stream:
662
- async for memory_data in stream:
663
- self.current_memory = memory_data
664
- if not self.running:
665
- break
666
- await asyncio.sleep(self.task_interval)
667
- except Exception as e:
668
- self.visualizer.console.print(f"[red]Error monitoring memory: {str(e)}")
676
+ await self._follow_stream(
677
+ self.api_client.memory_stream, "current_memory", MemoryData()
678
+ )
679
+
680
+ async def _follow_stream(
681
+ self,
682
+ open_stream: Callable[[], AsyncGenerator[FrozenBaseModel, None]],
683
+ attr: str,
684
+ empty: FrozenBaseModel,
685
+ ) -> None:
686
+ """Keep ``attr`` updated from a stream, reopening it whenever it ends."""
687
+ while self.running:
688
+ try:
689
+ async with aclosing(open_stream()) as stream:
690
+ async for data in stream:
691
+ setattr(self, attr, data)
692
+ self.api_error = None
693
+ if not self.running:
694
+ return
695
+ await asyncio.sleep(self.task_interval)
696
+ reason = "closed by sing-box"
697
+ except Exception as e:
698
+ reason = str(e) or type(e).__name__
699
+ # don't keep showing stale numbers while sing-box is down
700
+ setattr(self, attr, empty)
701
+ self.api_error = f"sing-box API stream lost ({reason}), reconnecting..."
702
+ await asyncio.sleep(self.reconnect_interval)
669
703
 
670
704
  async def refresh_display(self) -> None:
671
705
  """Main display refresh loop."""
@@ -685,7 +719,7 @@ class ResourceMonitor:
685
719
 
686
720
  # Update display
687
721
  layout = self.visualizer.create_resources_layout(
688
- traffic_data, memory_data
722
+ traffic_data, memory_data, self.api_error
689
723
  )
690
724
  live.update(layout)
691
725
 
@@ -8,10 +8,13 @@ from ..service import SharedContext, get_context_obj
8
8
  from ..service.manager import create_service
9
9
  from .config import get_config
10
10
  from .serve import serve
11
+ from .utils import redact_url
11
12
 
12
13
  __all__ = ["config"]
13
14
 
14
- SubUrlArg = Annotated[str, typer.Argument(help="Subscription URL")]
15
+ SubUrlArg = Annotated[
16
+ StrOrNone, typer.Argument(help="Subscription URL, the saved one if omitted")
17
+ ]
15
18
  TokenOption = Annotated[
16
19
  StrOrNone,
17
20
  typer.Option(
@@ -40,7 +43,7 @@ def config_callback(ctx: typer.Context) -> None:
40
43
 
41
44
  @config.command("update")
42
45
  def config_update(
43
- url: SubUrlArg,
46
+ url: SubUrlArg = None,
44
47
  token: TokenOption = None,
45
48
  restart: RestartServiceOption = False,
46
49
  dry_run: DryRunOption = False,
@@ -51,10 +54,13 @@ def config_update(
51
54
  print("❌ Failed to update configuration.")
52
55
  raise typer.Exit(1)
53
56
  if restart and not dry_run:
54
- ensure_root()
55
57
  service = create_service(cfg)
58
+ if service.requires_root:
59
+ ensure_root()
56
60
  # init service
57
- if not service.check_service():
61
+ if service.check_service():
62
+ service.sync_service()
63
+ else:
58
64
  service.create_service()
59
65
  print("⌛ Service created successfully.")
60
66
  service.restart()
@@ -71,7 +77,7 @@ def config_get(
71
77
  cfg = get_context_obj(ctx).config
72
78
  if subscription:
73
79
  if cfg.sub_url:
74
- print(f"🔗 Current subscription URL: {cfg.sub_url}")
80
+ print(f"🔗 Current subscription URL: {redact_url(cfg.sub_url)}")
75
81
  else:
76
82
  print("❌ No subscription URL found.")
77
83
  else:
@@ -94,4 +100,4 @@ def config_show(ctx: typer.Context) -> None:
94
100
  def config_clear_cache(ctx: typer.Context) -> None:
95
101
  """Clean cache database"""
96
102
  ctx_obj = get_context_obj(ctx)
97
- ctx_obj.config.clear_cache()
103
+ ctx_obj.service.clear_cache()