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.
Files changed (39) hide show
  1. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/PKG-INFO +64 -3
  2. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/README.md +59 -0
  3. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/pyproject.toml +8 -3
  4. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/__init__.py +1 -1
  5. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/__init__.py +12 -6
  6. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/config.py +184 -85
  7. sing_box_cli-0.1.9/src/sing_box_cli/config/store.py +336 -0
  8. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/utils.py +46 -2
  9. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/main.py +4 -1
  10. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/service/__init__.py +24 -13
  11. sing_box_cli-0.1.9/src/sing_box_cli/service/desktop.py +336 -0
  12. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/service/manager.py +76 -14
  13. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/.gitignore +0 -0
  14. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/LICENSE +0 -0
  15. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/docs/config-serve.md +0 -0
  16. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/docs/traefik-config-serve.yml +0 -0
  17. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/hatch_build.py +0 -0
  18. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/__init__.py +0 -0
  19. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/client.py +0 -0
  20. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/connections.py +0 -0
  21. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/logs.py +0 -0
  22. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/monitor.py +0 -0
  23. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/policy.py +0 -0
  24. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/api/widget.py +0 -0
  25. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/common.py +0 -0
  26. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/_serve_worker.py +0 -0
  27. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve.py +0 -0
  28. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_backend.py +0 -0
  29. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_tokens.py +0 -0
  30. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/installation.py +0 -0
  31. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/src/sing_box_cli/py.typed +0 -0
  32. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/common.go +0 -0
  33. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/common_test.go +0 -0
  34. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/go.mod +0 -0
  35. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/go.sum +0 -0
  36. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/integration_windows_test.go +0 -0
  37. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/main_other.go +0 -0
  38. {sing_box_cli-0.1.8 → sing_box_cli-0.1.9}/windows-service/main_windows.go +0 -0
  39. {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.8
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.8"
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.8"
5
+ __version__ = "0.1.9"
@@ -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()
@@ -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 .utils import request_get, show_diff_config, subscription_credentials
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 and save configuration files."""
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
- # Configs file path
85
- self._config_file = self._config_dir / "config.json"
86
- self._app_config_file = self._config_dir / "app_config.json"
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
- # Load both configurations
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
- self._app_config = SingBoxAppConfig.load(self._app_config_file)
91
- print(self)
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
- """Initialize necessary directories and files for sing-box."""
95
- try:
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
- except Exception as e:
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
- """Set the content of the configuration file and reload the model."""
211
- try:
212
- self._sing_box_config = SingBoxConfig.model_validate_json(value)
213
- self._sing_box_config.save(self._config_file)
214
- except Exception as e:
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 from app config."""
221
- return self._app_config.subscription_url
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 from app config."""
233
- return self._app_config.token
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
- @token_content.setter
236
- def token_content(self, value: str) -> None:
237
- """Set the token in app config."""
238
- self._app_config.token = value.strip()
239
- self._app_config.save(self._app_config_file)
240
- print("🔑 Token added successfully.")
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
- if sub_url is None:
252
- # load from file
253
- if not self.sub_url:
254
- print("❌ No subscription URL found.")
255
- return False
256
- sub_url = self.sub_url
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
- # load from file
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
- new_config = self._sing_box_config.model_validate_json(
272
- new_config
273
- ).model_dump_json(indent=2)
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(self.config_file_content, new_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 not self.is_windows:
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
- # Update subscription url file
291
- if sub_url != self.sub_url:
292
- self.sub_url = sub_url
293
- if token != self.token_content:
294
- self.token_content = token
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"