sing-box-cli 0.1.8__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.
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/PKG-INFO +64 -3
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/README.md +59 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/pyproject.toml +8 -3
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/__init__.py +1 -1
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/__init__.py +12 -6
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/config.py +184 -85
- sing_box_cli-0.1.9/src/sing_box_cli/config/store.py +336 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/utils.py +46 -2
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/main.py +4 -1
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/service/__init__.py +24 -13
- sing_box_cli-0.1.9/src/sing_box_cli/service/desktop.py +336 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/service/manager.py +76 -14
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/.gitignore +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/LICENSE +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/docs/config-serve.md +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/docs/traefik-config-serve.yml +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/hatch_build.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/__init__.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/client.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/connections.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/logs.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/monitor.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/policy.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/widget.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/common.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/_serve_worker.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_backend.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_tokens.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/installation.py +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/py.typed +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/common.go +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/common_test.go +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/go.mod +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/go.sum +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/integration_windows_test.go +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/main_other.go +0 -0
- {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/main_windows.go +0 -0
- {sing_box_cli-0.1.8 → 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.
|
|
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
|
|
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
|
|
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.
|
|
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}"
|
|
@@ -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[
|
|
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
|
|
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.
|
|
103
|
+
ctx_obj.service.clear_cache()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import dataclasses
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import platform
|
|
@@ -11,7 +12,23 @@ from rich import print
|
|
|
11
12
|
from sing_box_bin import get_bin_path
|
|
12
13
|
|
|
13
14
|
from ..common import StrOrNone
|
|
14
|
-
from .
|
|
15
|
+
from .store import (
|
|
16
|
+
Profile,
|
|
17
|
+
SettingsError,
|
|
18
|
+
active_profile,
|
|
19
|
+
init_store,
|
|
20
|
+
legacy_profile,
|
|
21
|
+
settings_path,
|
|
22
|
+
update_profile_record,
|
|
23
|
+
)
|
|
24
|
+
from .utils import (
|
|
25
|
+
atomic_write_text,
|
|
26
|
+
redact_url,
|
|
27
|
+
request_get,
|
|
28
|
+
show_diff_config,
|
|
29
|
+
subscription_credentials,
|
|
30
|
+
with_token,
|
|
31
|
+
)
|
|
15
32
|
|
|
16
33
|
|
|
17
34
|
class ClashApiConfig(BaseModel):
|
|
@@ -37,7 +54,7 @@ class ExperimentalConfig(BaseModel):
|
|
|
37
54
|
|
|
38
55
|
|
|
39
56
|
class ConfigModel(BaseModel):
|
|
40
|
-
"""Load
|
|
57
|
+
"""Load configuration files."""
|
|
41
58
|
|
|
42
59
|
@classmethod
|
|
43
60
|
def load(cls, config_path: Path) -> Self:
|
|
@@ -52,10 +69,6 @@ class ConfigModel(BaseModel):
|
|
|
52
69
|
return cls()
|
|
53
70
|
return cls()
|
|
54
71
|
|
|
55
|
-
def save(self, config_path: Path) -> None:
|
|
56
|
-
"""Save configuration to file."""
|
|
57
|
-
config_path.write_text(self.model_dump_json(indent=2), encoding="utf-8")
|
|
58
|
-
|
|
59
72
|
|
|
60
73
|
class SingBoxConfig(ConfigModel):
|
|
61
74
|
"""Model representing the sing-box configuration file structure."""
|
|
@@ -67,13 +80,6 @@ class SingBoxConfig(ConfigModel):
|
|
|
67
80
|
model_config = ConfigDict(extra="allow")
|
|
68
81
|
|
|
69
82
|
|
|
70
|
-
class SingBoxAppConfig(ConfigModel):
|
|
71
|
-
"""Application-specific configuration for sing-box-cli."""
|
|
72
|
-
|
|
73
|
-
subscription_url: str = Field(default="", description="Subscription URL")
|
|
74
|
-
token: str = Field(default="", description="Authentication token")
|
|
75
|
-
|
|
76
|
-
|
|
77
83
|
class ConfigHandler:
|
|
78
84
|
def __init__(self) -> None:
|
|
79
85
|
self._config_dir = (
|
|
@@ -81,39 +87,90 @@ class ConfigHandler:
|
|
|
81
87
|
if self.is_windows
|
|
82
88
|
else Path(f"~{self.user}/.config/sing-box").expanduser()
|
|
83
89
|
)
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
# Everything lives in the profile store (see config.store): the selected profile
|
|
91
|
+
# is the configuration and its subscription URL is the subscription. With the
|
|
92
|
+
# sing-box desktop app installed the store is the app's: it is only read, and an
|
|
93
|
+
# unusable one is reported, never worked around. Without the app the store is
|
|
94
|
+
# ours and init_directories() creates it.
|
|
95
|
+
self._desktop_app = self._detect_desktop_app()
|
|
96
|
+
self._profile: Profile | None = None
|
|
97
|
+
self._profile_error: str | None = None
|
|
98
|
+
# False while showing the files of an older version, before our store exists
|
|
99
|
+
self._store_ready = False
|
|
100
|
+
try:
|
|
101
|
+
self._profile = active_profile(self._config_dir)
|
|
102
|
+
self._store_ready = True
|
|
103
|
+
except SettingsError as e:
|
|
104
|
+
if self._desktop_app:
|
|
105
|
+
self._profile_error = str(e)
|
|
106
|
+
else:
|
|
107
|
+
self._profile = legacy_profile(self._config_dir)
|
|
108
|
+
self._load_config_file()
|
|
109
|
+
print(self)
|
|
110
|
+
|
|
111
|
+
def _detect_desktop_app(self) -> bool:
|
|
112
|
+
if self.is_windows:
|
|
113
|
+
return False
|
|
114
|
+
# Lazy import: the service package imports this module.
|
|
115
|
+
from ..service.desktop import desktop_app_installed
|
|
116
|
+
|
|
117
|
+
return desktop_app_installed()
|
|
87
118
|
|
|
88
|
-
|
|
119
|
+
def _load_config_file(self) -> None:
|
|
120
|
+
# never read or written without a profile, see _require_profile()
|
|
121
|
+
self._config_file = (
|
|
122
|
+
self._profile.path
|
|
123
|
+
if self._profile
|
|
124
|
+
else self._config_dir / "profiles" / "unavailable.json"
|
|
125
|
+
)
|
|
89
126
|
self._sing_box_config = SingBoxConfig.load(self._config_file)
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
|
|
128
|
+
def _require_profile(self) -> Profile:
|
|
129
|
+
"""The selected profile, or report why there is none and exit."""
|
|
130
|
+
if self._profile is None:
|
|
131
|
+
print(f"❌ {self._profile_error}")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
return self._profile
|
|
134
|
+
|
|
135
|
+
def _require_store(self) -> Profile:
|
|
136
|
+
"""The selected profile of a store that may be written."""
|
|
137
|
+
profile = self._require_profile()
|
|
138
|
+
if not self._store_ready:
|
|
139
|
+
raise RuntimeError("The profile store is not initialized.")
|
|
140
|
+
return profile
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def external_config(self) -> bool:
|
|
144
|
+
"""Whether the sing-box desktop app owns the configuration."""
|
|
145
|
+
return self._desktop_app
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def legacy_config(self) -> Path | None:
|
|
149
|
+
"""config.json of an older version that is still a file of its own.
|
|
150
|
+
|
|
151
|
+
It normally becomes a link to the profile. Where linking is not allowed a
|
|
152
|
+
service created before still runs it, until it is pointed at the profile.
|
|
153
|
+
"""
|
|
154
|
+
path = self._config_dir / "config.json"
|
|
155
|
+
if self._desktop_app or not self._store_ready:
|
|
156
|
+
return None
|
|
157
|
+
return path if path.is_file() and not path.is_symlink() else None
|
|
92
158
|
|
|
93
159
|
def init_directories(self) -> bool:
|
|
94
|
-
"""
|
|
95
|
-
|
|
96
|
-
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
-
|
|
98
|
-
# Create/save default configs if needed
|
|
99
|
-
if not self._config_file.exists():
|
|
100
|
-
self._sing_box_config.save(self._config_file)
|
|
101
|
-
print(f"📁 Created config file: {self._config_file}")
|
|
102
|
-
|
|
103
|
-
if not self._app_config_file.exists():
|
|
104
|
-
self._app_config.save(self._app_config_file)
|
|
105
|
-
print(f"📁 Created application config file: {self._app_config_file}")
|
|
106
|
-
|
|
107
|
-
# For Linux/Unix systems only - set proper ownership
|
|
108
|
-
if not self.is_windows:
|
|
109
|
-
for file in [self._config_file, self._app_config_file]:
|
|
110
|
-
if file.exists():
|
|
111
|
-
shutil.chown(file, user=self.user, group=self.user)
|
|
112
|
-
shutil.chown(self.config_dir, user=self.user, group=self.user)
|
|
160
|
+
"""Create the profile store if it is ours to create (no desktop app)."""
|
|
161
|
+
if self._desktop_app or self._store_ready:
|
|
113
162
|
return True
|
|
114
|
-
|
|
163
|
+
created = not settings_path(self._config_dir).exists()
|
|
164
|
+
try:
|
|
165
|
+
self._profile = init_store(self._config_dir)
|
|
166
|
+
except SettingsError as e:
|
|
115
167
|
print(f"❌ Failed to initialize directories: {e}")
|
|
116
168
|
return False
|
|
169
|
+
self._store_ready = True
|
|
170
|
+
self._load_config_file()
|
|
171
|
+
if created:
|
|
172
|
+
print(f"📁 Created profile store: {settings_path(self._config_dir)}")
|
|
173
|
+
return True
|
|
117
174
|
|
|
118
175
|
@property
|
|
119
176
|
def user(self) -> str:
|
|
@@ -169,6 +226,7 @@ class ConfigHandler:
|
|
|
169
226
|
@property
|
|
170
227
|
def api_base_url(self) -> str:
|
|
171
228
|
"""Get the API base URL from the configuration file."""
|
|
229
|
+
self._require_profile()
|
|
172
230
|
if (
|
|
173
231
|
self._sing_box_config.experimental
|
|
174
232
|
and self._sing_box_config.experimental.clash_api
|
|
@@ -199,6 +257,7 @@ class ConfigHandler:
|
|
|
199
257
|
@property
|
|
200
258
|
def config_file_content(self) -> str:
|
|
201
259
|
"""Get the content of the configuration file."""
|
|
260
|
+
self._require_profile()
|
|
202
261
|
return (
|
|
203
262
|
self._config_file.read_text(encoding="utf-8")
|
|
204
263
|
if self._config_file.exists()
|
|
@@ -207,37 +266,66 @@ class ConfigHandler:
|
|
|
207
266
|
|
|
208
267
|
@config_file_content.setter
|
|
209
268
|
def config_file_content(self, value: str) -> None:
|
|
210
|
-
"""
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
print(f"⚠️ Error parsing new configuration: {e}")
|
|
269
|
+
"""Write the configuration as given and reload the model."""
|
|
270
|
+
self._require_store()
|
|
271
|
+
self._sing_box_config = SingBoxConfig.model_validate_json(value)
|
|
272
|
+
# the bytes as downloaded: the desktop app compares them on its own refresh
|
|
273
|
+
atomic_write_text(self._config_file, value)
|
|
216
274
|
print("📁 Configuration updated successfully.")
|
|
217
275
|
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _normalized(text: str, *, strict: bool = False) -> str:
|
|
278
|
+
"""One layout to compare configurations in, so formatting is no difference."""
|
|
279
|
+
try:
|
|
280
|
+
return SingBoxConfig.model_validate_json(text).model_dump_json(indent=2)
|
|
281
|
+
except ValueError:
|
|
282
|
+
if strict:
|
|
283
|
+
raise
|
|
284
|
+
return text
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def _stored_sub_url(self) -> str:
|
|
288
|
+
"""The subscription as stored: with its token, like the desktop app keeps it."""
|
|
289
|
+
return self._require_profile().remote_url or ""
|
|
290
|
+
|
|
291
|
+
def _subscription(self) -> tuple[str, str]:
|
|
292
|
+
stored = self._stored_sub_url
|
|
293
|
+
try:
|
|
294
|
+
url, token = subscription_credentials(stored)
|
|
295
|
+
except ValueError:
|
|
296
|
+
return redact_url(stored), ""
|
|
297
|
+
return url, token or ""
|
|
298
|
+
|
|
218
299
|
@property
|
|
219
300
|
def sub_url(self) -> str:
|
|
220
|
-
"""Get the subscription URL
|
|
221
|
-
return self.
|
|
222
|
-
|
|
223
|
-
@sub_url.setter
|
|
224
|
-
def sub_url(self, value: str) -> None:
|
|
225
|
-
"""Set the subscription URL in app config."""
|
|
226
|
-
self._app_config.subscription_url = value.strip()
|
|
227
|
-
self._app_config.save(self._app_config_file)
|
|
228
|
-
print("📁 Subscription updated successfully.")
|
|
301
|
+
"""Get the subscription URL, without its token."""
|
|
302
|
+
return self._subscription()[0]
|
|
229
303
|
|
|
230
304
|
@property
|
|
231
305
|
def token_content(self) -> str:
|
|
232
|
-
"""Get the token
|
|
233
|
-
return self.
|
|
306
|
+
"""Get the subscription token, which is kept inside the stored URL."""
|
|
307
|
+
return self._subscription()[1]
|
|
308
|
+
|
|
309
|
+
def _save_subscription(self, stored: str) -> None:
|
|
310
|
+
profile = self._require_store()
|
|
311
|
+
update_profile_record(
|
|
312
|
+
self._config_dir,
|
|
313
|
+
profile.id,
|
|
314
|
+
remote_url=stored,
|
|
315
|
+
# a local profile of the app is not ours to turn into a remote one
|
|
316
|
+
make_remote=not self._desktop_app,
|
|
317
|
+
)
|
|
318
|
+
self._profile = dataclasses.replace(profile, remote_url=stored)
|
|
319
|
+
print("📁 Subscription updated successfully.")
|
|
234
320
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
321
|
+
def _record_refresh(self) -> None:
|
|
322
|
+
"""Show the refresh in the store, the app lists when a profile was updated."""
|
|
323
|
+
if not (self._profile and self._profile.remote_url):
|
|
324
|
+
return
|
|
325
|
+
try:
|
|
326
|
+
update_profile_record(self._config_dir, self._profile.id)
|
|
327
|
+
except SettingsError as e:
|
|
328
|
+
print(f"⚠️ Could not record the refresh: {e}")
|
|
241
329
|
|
|
242
330
|
def update_config(
|
|
243
331
|
self,
|
|
@@ -247,19 +335,25 @@ class ConfigHandler:
|
|
|
247
335
|
dry_run: bool = False,
|
|
248
336
|
) -> bool:
|
|
249
337
|
"""Download and diff configuration, optionally without saving changes."""
|
|
338
|
+
profile = self._require_profile() # exits when the desktop app has none
|
|
339
|
+
if self._desktop_app and profile.remote_url is None and sub_url is not None:
|
|
340
|
+
print(
|
|
341
|
+
"❌ The selected profile in the sing-box app is a local profile, "
|
|
342
|
+
"it has no subscription to set."
|
|
343
|
+
)
|
|
344
|
+
return False
|
|
250
345
|
try:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
sub_url, token = subscription_credentials(sub_url, token)
|
|
346
|
+
# the saved subscription unless another one is given
|
|
347
|
+
given = (self._stored_sub_url if sub_url is None else sub_url).strip()
|
|
348
|
+
if not given:
|
|
349
|
+
print("❌ No subscription URL found.")
|
|
350
|
+
return False
|
|
351
|
+
sub_url, token = subscription_credentials(given, token)
|
|
258
352
|
if token is None:
|
|
259
|
-
#
|
|
353
|
+
# the token of the saved subscription
|
|
260
354
|
token = self.token_content
|
|
261
355
|
action = "Previewing" if dry_run else "Updating"
|
|
262
|
-
print(f"⌛ {action} configuration from {sub_url}")
|
|
356
|
+
print(f"⌛ {action} configuration from {redact_url(sub_url)}")
|
|
263
357
|
response = request_get(sub_url, token)
|
|
264
358
|
if response is None:
|
|
265
359
|
print("❌ Failed to get configuration.")
|
|
@@ -268,30 +362,29 @@ class ConfigHandler:
|
|
|
268
362
|
new_config = response.text
|
|
269
363
|
|
|
270
364
|
# make sure same order of keys to avoid showing wrong diff
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if self.config_file_content == new_config:
|
|
365
|
+
current = self._normalized(self.config_file_content)
|
|
366
|
+
proposed = self._normalized(new_config, strict=True)
|
|
367
|
+
if current == proposed:
|
|
275
368
|
print("📄 Configuration is up to date.")
|
|
276
369
|
else:
|
|
277
370
|
# show differences before saving
|
|
278
|
-
show_diff_config(
|
|
371
|
+
show_diff_config(current, proposed)
|
|
279
372
|
|
|
280
373
|
if dry_run:
|
|
281
374
|
print("📄 Dry run: no configuration or subscription changes saved.")
|
|
282
375
|
return True
|
|
283
376
|
|
|
284
|
-
if
|
|
285
|
-
shutil.chown(self._config_file, user=self.user, group=self.user)
|
|
286
|
-
|
|
287
|
-
if self.config_file_content != new_config:
|
|
377
|
+
if current != proposed:
|
|
288
378
|
self.config_file_content = new_config
|
|
289
379
|
|
|
290
|
-
#
|
|
291
|
-
if sub_url != self.sub_url:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
380
|
+
# Save the subscription once it is known to work
|
|
381
|
+
if (sub_url, token) != (self.sub_url, self.token_content):
|
|
382
|
+
if given != sub_url:
|
|
383
|
+
stored = given # the token came inside the URL: keep it as given
|
|
384
|
+
else:
|
|
385
|
+
stored = with_token(sub_url, token) if token else sub_url
|
|
386
|
+
self._save_subscription(stored)
|
|
387
|
+
self._record_refresh()
|
|
295
388
|
return True
|
|
296
389
|
except Exception as e:
|
|
297
390
|
print(f"❌ Failed to update configuration: {e}")
|
|
@@ -313,6 +406,12 @@ class ConfigHandler:
|
|
|
313
406
|
|
|
314
407
|
def __str__(self) -> str:
|
|
315
408
|
"""Return a string representation of the configuration."""
|
|
409
|
+
if self._desktop_app:
|
|
410
|
+
return "🔧 Using sing-box desktop daemon\n" + (
|
|
411
|
+
f"📄 Using configuration: {self._profile.path}"
|
|
412
|
+
if self._profile
|
|
413
|
+
else f"⚠️ {self._profile_error}"
|
|
414
|
+
)
|
|
316
415
|
info = (
|
|
317
416
|
f"🔧 Using binary: {self.bin_path}\n"
|
|
318
417
|
f"📄 Using configuration: {self._config_file}\n"
|