@astralyn/sash 0.1.0 → 0.1.2

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 (108) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +22 -7
  3. package/THIRD_PARTY_NOTICES.md +9 -0
  4. package/dist/api.js +14 -45
  5. package/dist/app-state.js +99 -0
  6. package/dist/autostart/command.js +24 -0
  7. package/dist/autostart/context.js +43 -0
  8. package/dist/autostart/files.js +60 -0
  9. package/dist/autostart/installation.js +48 -0
  10. package/dist/autostart/start.js +47 -0
  11. package/dist/autostart/windows-registry.js +88 -0
  12. package/dist/autostart/windows.js +59 -0
  13. package/dist/autostart-contract.js +31 -0
  14. package/dist/autostart-entry.js +10 -0
  15. package/dist/autostart.js +69 -0
  16. package/dist/cli.js +11 -11
  17. package/dist/commands/auto.js +15 -0
  18. package/dist/commands/lifecycle.js +5 -31
  19. package/dist/commands/logs.js +11 -4
  20. package/dist/commands/shared.js +2 -4
  21. package/dist/commands/status.js +6 -7
  22. package/dist/commands/update.js +7 -3
  23. package/dist/commands/web.js +19 -27
  24. package/dist/contracts.js +171 -335
  25. package/dist/core-config-validation.js +22 -38
  26. package/dist/core-update.js +126 -762
  27. package/dist/core.js +20 -46
  28. package/dist/daemon/app.js +132 -113
  29. package/dist/daemon/context.js +13 -7
  30. package/dist/daemon/entry.js +21 -38
  31. package/dist/daemon/errors.js +6 -1
  32. package/dist/daemon/handlers/autostart.js +18 -0
  33. package/dist/daemon/handlers/core.js +32 -9
  34. package/dist/daemon/handlers/daemon.js +32 -6
  35. package/dist/daemon/handlers/profiles.js +12 -2
  36. package/dist/daemon/handlers/settings.js +9 -36
  37. package/dist/daemon/router.js +40 -27
  38. package/dist/daemon/scheduler.js +3 -1
  39. package/dist/daemon/server.js +6 -3
  40. package/dist/daemon/web-auth.js +52 -0
  41. package/dist/daemon-auth.js +2 -2
  42. package/dist/daemon-client.js +18 -2
  43. package/dist/daemon-http.js +7 -0
  44. package/dist/daemon-lifecycle.js +19 -196
  45. package/dist/github.js +7 -2
  46. package/dist/http.js +7 -5
  47. package/dist/log-follow.js +2 -1
  48. package/dist/mihomo-config.js +7 -9
  49. package/dist/paths.js +5 -7
  50. package/dist/profile-model.js +87 -0
  51. package/dist/profile-service.js +250 -587
  52. package/dist/profiles.js +43 -171
  53. package/dist/runtime-lifecycle.js +112 -194
  54. package/dist/runtime-owner.js +37 -76
  55. package/dist/sash-client.js +63 -21
  56. package/dist/settings-service.js +29 -228
  57. package/dist/settings.js +61 -285
  58. package/dist/status.js +24 -40
  59. package/dist/supervisor.js +1 -14
  60. package/dist/sysproxy/common.js +5 -45
  61. package/dist/sysproxy/factory.js +24 -65
  62. package/dist/sysproxy/snapshot.js +24 -257
  63. package/dist/sysproxy.js +1 -4
  64. package/dist/system-proxy-manager.js +37 -35
  65. package/dist/ui/assets/{ConnectionsView-DNGmZBSU.js → ConnectionsView-BgXQM6Z4.js} +1 -1
  66. package/dist/ui/assets/{LogsView-fSiaxQ13.js → LogsView-2f542P8z.js} +1 -1
  67. package/dist/ui/assets/{PaginationFooter-B3kHzRfB.js → PaginationFooter-cj1tcZej.js} +1 -1
  68. package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
  69. package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
  70. package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
  71. package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
  72. package/dist/ui/assets/{RulesView-D9vZBiJ1.js → RulesView-CtKvlckZ.js} +1 -1
  73. package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
  74. package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
  75. package/dist/ui/assets/{0be242294f7d791af850c6df38ac78a0-2cL6Ntwf.woff2 → e2a57555d97d0b02b45d9418eb6ee295-D9nhF3rM.woff2} +0 -0
  76. package/dist/ui/assets/index-CBInDvdJ.js +19 -0
  77. package/dist/ui/assets/index-mOLy7fkB.css +1 -0
  78. package/dist/ui/assets/{theme-BNq4FkXS.js → theme-D40MF8cQ.js} +1 -1
  79. package/dist/ui/index.html +2 -2
  80. package/dist/web-bootstrap.js +113 -0
  81. package/docs/architecture-proposal.md +109 -0
  82. package/docs/autostart.md +101 -0
  83. package/docs/backend.md +92 -216
  84. package/docs/frontend.md +61 -97
  85. package/docs/usage.md +88 -186
  86. package/package.json +10 -6
  87. package/dist/commands/upgrade.js +0 -43
  88. package/dist/core-install-transaction.js +0 -114
  89. package/dist/core-update-coordination.js +0 -105
  90. package/dist/core-update-service.js +0 -245
  91. package/dist/managed-state-transaction.js +0 -377
  92. package/dist/offline-mutation.js +0 -58
  93. package/dist/profile-migration.js +0 -101
  94. package/dist/runtime-recovery.js +0 -31
  95. package/dist/sysproxy/darwin.js +0 -287
  96. package/dist/sysproxy/gnome.js +0 -181
  97. package/dist/sysproxy/legacy.js +0 -58
  98. package/dist/tun-guidance.js +0 -11
  99. package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
  100. package/dist/ui/assets/CodeEditorModal-CFEnWsyh.js +0 -14
  101. package/dist/ui/assets/ProfileEditorDialog-BydoZthX.js +0 -1
  102. package/dist/ui/assets/ProfilesView-Btc1DOxE.js +0 -2
  103. package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
  104. package/dist/ui/assets/SettingsFileDialog-CNFEAVs4.js +0 -1
  105. package/dist/ui/assets/SettingsView-BlDhZkXQ.js +0 -2
  106. package/dist/ui/assets/SettingsView-CngS3vBM.css +0 -1
  107. package/dist/ui/assets/index-B61V60w_.js +0 -19
  108. package/dist/ui/assets/index-bkyxJG8J.css +0 -1
@@ -1,667 +1,330 @@
1
1
  import fs from "node:fs";
2
- import { isDeepStrictEqual } from "node:util";
3
- import YAML from "yaml";
4
- import { commitManagedStateTransaction, defaultManagedStateFileOperations, } from "./managed-state-transaction.js";
5
- import { buildDefaultConfig, fetchSubscriptionProfile, isValidMihomoConfig, renderConfig, } from "./mihomo-config.js";
6
- import { allocateProfileId, findProfileByUrl, getActiveProfile, loadProfiles, MAX_PROFILE_INTERVAL_HOURS, profileDueForUpdate, profileFilePath, profileNameFromUrl, readProfileDigest, readProfileSource, } from "./profiles.js";
7
- import { sameSettings } from "./settings.js";
2
+ import { StateConflictError } from "./app-state.js";
3
+ import { atomicWriteFileSync, durableRemoveFileSync } from "./fs-atomic.js";
4
+ import { fetchSubscriptionProfile } from "./mihomo-config.js";
5
+ import { allocateProfileId, getActiveProfile, MAX_PROFILE_INTERVAL_HOURS, parseProfileText, profileDueForUpdate, profileFilePath, profileNameFromUrl, readProfileSource, } from "./profiles.js";
8
6
  export class ProfileInputError extends Error {
9
7
  }
10
- export class GeneratedConfigError extends Error {
11
- }
12
8
  export class ProfileNotFoundError extends Error {
13
9
  }
14
- export class ProfileConflictError extends Error {
15
- }
16
- async function mapConcurrent(items, limit, worker) {
17
- const results = new Array(items.length);
18
- let cursor = 0;
19
- const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
20
- for (;;) {
21
- const index = cursor++;
22
- const item = items[index];
23
- if (item === undefined)
24
- return;
25
- results[index] = await worker(item);
26
- }
27
- });
28
- await Promise.all(runners);
29
- return results;
30
- }
31
- function sameProfileState(before, current) {
32
- return isDeepStrictEqual(before, current);
33
- }
34
- function opaqueToken() {
35
- return Object.freeze({});
36
- }
37
- function replaceProfileMeta(index, replacement) {
38
- return {
39
- ...index,
40
- profiles: index.profiles.map((profile) => profile.id === replacement.id ? replacement : profile),
41
- };
42
- }
43
- function currentProfile(index, snapshot, activeId) {
44
- const profile = index.profiles.find((item) => item.id === snapshot.id);
45
- if (!profile || !isDeepStrictEqual(profile, snapshot) || index.activeId !== activeId) {
46
- throw new ProfileConflictError(`profile changed or was removed during update: ${snapshot.id}`);
10
+ export { StateConflictError as ProfileConflictError } from "./app-state.js";
11
+ function profileInput(content) {
12
+ try {
13
+ return parseProfileText(content);
14
+ }
15
+ catch (error) {
16
+ throw new ProfileInputError(error instanceof Error ? error.message : String(error));
47
17
  }
48
- return profile;
49
- }
50
- function validFetchedInterval(value, fallback) {
51
- return value !== undefined &&
52
- Number.isSafeInteger(value) &&
53
- value > 0 &&
54
- value <= MAX_PROFILE_INTERVAL_HOURS
55
- ? value
56
- : fallback;
57
18
  }
58
- function withFetchedContent(profile, fetched) {
59
- return {
60
- ...profile,
61
- updatedAt: new Date().toISOString(),
62
- ...(fetched.subInfo ? { subInfo: fetched.subInfo } : {}),
63
- ...(fetched.homePage ? { homePage: fetched.homePage } : {}),
64
- intervalHours: validFetchedInterval(fetched.intervalHours, profile.intervalHours),
65
- lastError: undefined,
66
- };
19
+ function validName(name) {
20
+ const result = name.trim();
21
+ if (!result || result.length > 120)
22
+ throw new ProfileInputError("Profile name must contain 1 to 120 characters");
23
+ return result;
67
24
  }
68
25
  export class ProfileService {
69
- layout;
70
- getSettings;
71
- fetchProfileFn;
72
- validateConfig;
73
- reloadConfig;
74
- commitBoundary;
75
- onChange;
76
- files;
77
- fetches = new Map();
78
- preparedActiveConfigs = new WeakMap();
79
- preparedActiveReloads = new WeakMap();
80
- commitTail = Promise.resolve();
81
- constructor(opts) {
82
- this.layout = opts.layout;
83
- this.getSettings = opts.settings;
84
- this.fetchProfileFn = opts.fetchProfile ?? fetchSubscriptionProfile;
85
- this.validateConfig = opts.validateConfig;
86
- this.reloadConfig = opts.reloadConfig;
87
- this.commitBoundary = opts.commit;
88
- this.onChange = opts.onChange;
89
- this.files = opts.fileOperations ?? defaultManagedStateFileOperations;
26
+ options;
27
+ downloads = new Set();
28
+ updatingDue = false;
29
+ downloadGeneration = 0;
30
+ constructor(options) {
31
+ this.options = options;
90
32
  }
91
33
  list() {
92
- return loadProfiles(this.layout);
34
+ return this.options.state.snapshot().profiles;
93
35
  }
94
36
  active() {
95
37
  return getActiveProfile(this.list());
96
38
  }
97
- commit(purpose, action) {
98
- if (this.commitBoundary)
99
- return this.commitBoundary(purpose, action);
100
- const next = this.commitTail.then(action, action);
101
- this.commitTail = next.then(() => undefined, () => undefined);
102
- return next;
103
- }
104
- fetch(url) {
105
- const existing = this.fetches.get(url);
106
- if (existing)
107
- return existing;
108
- const pending = this.fetchProfileFn(url).finally(() => {
109
- if (this.fetches.get(url) === pending)
110
- this.fetches.delete(url);
111
- });
112
- this.fetches.set(url, pending);
113
- return pending;
114
- }
115
- settingsSnapshot() {
116
- return { ...this.getSettings() };
39
+ cancelDownloads() {
40
+ this.downloadGeneration += 1;
41
+ for (const download of this.downloads)
42
+ download.abort(new StateConflictError("Profile download cancelled"));
117
43
  }
118
- assertSettingsUnchanged(snapshot) {
119
- if (!sameSettings(this.getSettings(), snapshot)) {
120
- throw new ProfileConflictError("Settings changed while preparing profile configuration");
121
- }
122
- }
123
- assertProfileContentCurrent(id, digest, message = "Profile content changed while preparing configuration") {
124
- if (readProfileDigest(this.layout, id) !== digest) {
125
- throw new ProfileConflictError(`${message}: ${id}`);
44
+ async fetch(url) {
45
+ const controller = new AbortController();
46
+ this.downloads.add(controller);
47
+ try {
48
+ const fetched = await (this.options.fetchProfile ?? fetchSubscriptionProfile)(url, controller.signal);
49
+ controller.signal.throwIfAborted();
50
+ profileInput(fetched.yamlText);
51
+ return fetched;
126
52
  }
127
- }
128
- updateSnapshot(profile, activeId, contentDigest = readProfileDigest(this.layout, profile.id)) {
129
- return { profile, activeId, contentDigest };
130
- }
131
- recheckProfileSnapshot(index, snapshot, contentMessage = "Profile content changed while preparing configuration") {
132
- const profile = currentProfile(index, snapshot.profile, snapshot.activeId);
133
- this.assertProfileContentCurrent(profile.id, snapshot.contentDigest, contentMessage);
134
- return profile;
135
- }
136
- async resolveActiveSource(index) {
137
- const active = getActiveProfile(index);
138
- if (!active) {
139
- return {
140
- activeId: index.activeId,
141
- active: null,
142
- sourceDigest: null,
143
- doc: null,
144
- };
53
+ catch (error) {
54
+ controller.signal.throwIfAborted();
55
+ throw error;
145
56
  }
146
- const stored = readProfileSource(this.layout, active.id);
147
- const fetched = stored === undefined && active.url ? await this.fetch(active.url) : undefined;
148
- if (stored === undefined && !fetched) {
149
- throw new ProfileInputError(`Local profile file is missing: ${active.id}`);
57
+ finally {
58
+ this.downloads.delete(controller);
150
59
  }
151
- return {
152
- activeId: index.activeId,
153
- active,
154
- sourceDigest: stored?.digest ?? null,
155
- doc: fetched?.doc ?? stored?.doc ?? null,
156
- ...(fetched ? { fetched } : {}),
157
- };
158
60
  }
159
- takePreparedActiveConfig(prepared) {
160
- const state = this.preparedActiveConfigs.get(prepared);
161
- if (!state) {
162
- throw new TypeError("Prepared active configuration is invalid or already consumed");
163
- }
164
- this.preparedActiveConfigs.delete(prepared);
165
- return state;
61
+ requireProfile(index, id) {
62
+ const profile = index.profiles.find((item) => item.id === id);
63
+ if (!profile)
64
+ throw new ProfileNotFoundError(`Profile not found: ${id}`);
65
+ return profile;
166
66
  }
167
- takePreparedActiveReload(prepared) {
168
- const state = this.preparedActiveReloads.get(prepared);
169
- if (!state) {
170
- throw new TypeError("Prepared active reload is invalid or already consumed");
67
+ recheck(index, before) {
68
+ const current = this.requireProfile(index, before.id);
69
+ if (current.revision !== before.revision || current.url !== before.url) {
70
+ throw new StateConflictError("Profile changed while downloading or editing; refresh and retry");
171
71
  }
172
- this.preparedActiveReloads.delete(prepared);
173
- return state;
174
- }
175
- preparedActiveReload(state) {
176
- const prepared = opaqueToken();
177
- this.preparedActiveReloads.set(prepared, state);
178
- return prepared;
72
+ return current;
179
73
  }
180
- async prepareActiveConfig(settings, rollbackSettings = this.settingsSnapshot()) {
181
- const rollbackSnapshot = { ...rollbackSettings };
182
- const source = await this.resolveActiveSource(this.list());
183
- const generated = await this.prepare(source.doc, settings);
184
- let rollbackConfig;
185
- if (sameSettings(settings, rollbackSnapshot)) {
186
- rollbackConfig = { generated };
187
- }
188
- else {
74
+ publishSource(index, profile, text, select) {
75
+ const state = this.options.state.snapshot();
76
+ const previous = index.profiles.find((item) => item.id === profile.id);
77
+ let unchanged = false;
78
+ if (previous) {
189
79
  try {
190
- rollbackConfig = { generated: await this.prepare(source.doc, rollbackSnapshot) };
80
+ unchanged = readProfileSource(this.options.layout, previous).yamlText === text;
191
81
  }
192
- catch (error) {
193
- rollbackConfig = { error };
82
+ catch {
194
83
  }
195
84
  }
196
- const prepared = opaqueToken();
197
- this.preparedActiveConfigs.set(prepared, {
198
- generated,
199
- rollbackConfig,
200
- rollbackSettings: rollbackSnapshot,
201
- activeId: source.activeId,
202
- sourceDigest: source.sourceDigest,
203
- ...(source.active ? { active: { id: source.active.id, url: source.active.url } } : {}),
204
- ...(source.fetched ? { fetched: source.fetched } : {}),
85
+ let revision = profile.revision;
86
+ if (unchanged && previous)
87
+ revision = previous.revision;
88
+ else
89
+ while (fs.existsSync(profileFilePath(this.options.layout, profile.id, revision)))
90
+ revision += 1;
91
+ const next = { ...profile, revision };
92
+ const file = profileFilePath(this.options.layout, next.id, next.revision);
93
+ if (!unchanged)
94
+ atomicWriteFileSync(file, text);
95
+ const profiles = previous
96
+ ? index.profiles.map((item) => (item.id === next.id ? next : item))
97
+ : [...index.profiles, next];
98
+ this.options.state.commit({
99
+ ...state,
100
+ profiles: { activeId: select ? next.id : index.activeId, profiles },
205
101
  });
206
- return prepared;
207
- }
208
- async withPreparedActivePublication(prepared, callback) {
209
- const state = this.takePreparedActiveConfig(prepared);
210
- this.assertSettingsUnchanged(state.rollbackSettings);
211
- const index = this.list();
212
- if (index.activeId !== state.activeId) {
213
- throw new ProfileConflictError("Active profile changed while preparing configuration");
214
- }
215
- let active = null;
216
- if (state.active) {
217
- active = getActiveProfile(index);
218
- if (!active || active.id !== state.active.id || active.url !== state.active.url) {
219
- throw new ProfileConflictError("Active profile changed while preparing configuration");
102
+ if (previous && previous.revision !== next.revision) {
103
+ try {
104
+ durableRemoveFileSync(profileFilePath(this.options.layout, previous.id, previous.revision));
220
105
  }
221
- this.assertProfileContentCurrent(active.id, state.sourceDigest);
222
- }
223
- const rollback = this.preparedActiveReload({
224
- ...state.rollbackConfig,
225
- settings: state.rollbackSettings,
226
- activeId: index.activeId,
227
- sourceDigest: state.sourceDigest,
228
- ...(active ? { active } : {}),
229
- });
230
- const publication = active && state.fetched
231
- ? {
232
- config: state.generated,
233
- rollback,
234
- index: replaceProfileMeta(index, withFetchedContent(active, state.fetched)),
235
- profile: { id: active.id, yamlText: state.fetched.yamlText },
106
+ catch {
236
107
  }
237
- : { config: state.generated, rollback };
238
- const result = await callback(publication);
239
- if (publication.profile)
240
- this.notifyChange();
241
- return result;
242
- }
243
- async prepareActiveReload() {
244
- const source = await this.resolveActiveSource(this.list());
245
- const settings = this.settingsSnapshot();
246
- return this.preparedActiveReload({
247
- generated: await this.prepare(source.doc, settings),
248
- settings,
249
- activeId: source.activeId,
250
- sourceDigest: source.sourceDigest,
251
- ...(source.active ? { active: source.active } : {}),
252
- ...(source.fetched ? { fetched: source.fetched } : {}),
253
- });
254
- }
255
- async withPreparedActiveReloadPublication(prepared, callback) {
256
- const state = this.takePreparedActiveReload(prepared);
257
- if ("error" in state)
258
- throw state.error;
259
- this.assertSettingsUnchanged(state.settings);
260
- const index = this.list();
261
- if (index.activeId !== state.activeId) {
262
- throw new ProfileConflictError("Active profile changed while preparing configuration");
263
- }
264
- let publication;
265
- if (!state.active) {
266
- publication = { config: state.generated, index };
267
- }
268
- else {
269
- const profile = this.recheckProfileSnapshot(index, this.updateSnapshot(state.active, state.activeId, state.sourceDigest));
270
- const updated = state.fetched ? withFetchedContent(profile, state.fetched) : profile;
271
- publication = {
272
- config: state.generated,
273
- index: replaceProfileMeta(index, updated),
274
- ...(state.fetched ? { profile: { id: profile.id, yamlText: state.fetched.yamlText } } : {}),
275
- };
276
108
  }
277
- const result = await callback(publication);
278
- this.notifyChange();
279
- return result;
280
- }
281
- async commitPreparedActiveReload(prepared, options = {}) {
282
- const reloadRuntime = options.reloadRuntime ?? true;
283
- const boundary = options.boundary ?? "acquire";
284
- if (boundary !== "acquire" && boundary !== "already-held") {
285
- throw new TypeError(`Unknown profile commit boundary: ${String(boundary)}`);
286
- }
287
- const action = () => this.withPreparedActiveReloadPublication(prepared, async (publication) => {
288
- await this.publishTransaction(publication.index, {
289
- ...(publication.profile ? { profile: publication.profile } : {}),
290
- config: publication.config,
291
- reloadRuntime,
292
- });
293
- return publication.config;
294
- });
295
- return boundary === "acquire" ? this.commit("reload active profile", action) : action();
296
- }
297
- async reloadActive(options = {}) {
298
- return this.commitPreparedActiveReload(await this.prepareActiveReload(), options);
299
- }
300
- notifyChange() {
301
- try {
302
- this.onChange?.();
303
- }
304
- catch {
305
- }
306
- }
307
- async prepare(doc, settings) {
308
- const generated = renderConfig(doc ?? buildDefaultConfig(), settings, doc ? "subscription" : "default");
309
- try {
310
- await this.validateConfig?.(generated);
311
- }
312
- catch (err) {
313
- throw new GeneratedConfigError(err.message);
314
- }
315
- return generated;
316
- }
317
- async publishTransaction(index, opts = {}) {
318
- await commitManagedStateTransaction(this.layout, { index, ...opts }, this.reloadConfig, this.files);
109
+ return next;
319
110
  }
320
- async publish(index, opts = {}) {
321
- await this.publishTransaction(index, opts);
322
- this.notifyChange();
111
+ fetchedMeta(profile, fetched) {
112
+ const interval = fetched.intervalHours;
113
+ return {
114
+ ...profile,
115
+ revision: profile.revision + 1,
116
+ updatedAt: new Date().toISOString(),
117
+ ...(fetched.subInfo ? { subInfo: fetched.subInfo } : {}),
118
+ ...(fetched.homePage ? { homePage: fetched.homePage } : {}),
119
+ intervalHours: interval !== undefined &&
120
+ Number.isSafeInteger(interval) &&
121
+ interval > 0 &&
122
+ interval <= MAX_PROFILE_INTERVAL_HOURS
123
+ ? interval
124
+ : profile.intervalHours,
125
+ lastError: undefined,
126
+ };
323
127
  }
324
128
  async addRemote(url, opts = {}) {
325
- const normalizedUrl = url.trim();
326
- if (!normalizedUrl)
129
+ const normalized = url.trim();
130
+ if (!normalized)
327
131
  throw new ProfileInputError("Missing required profile URL");
328
- const before = this.list();
329
- const settings = this.settingsSnapshot();
330
- const known = findProfileByUrl(before, normalizedUrl);
331
- const knownDigest = known ? readProfileDigest(this.layout, known.id) : null;
332
- const fetched = await this.fetch(normalizedUrl);
333
- const prepared = await this.prepare(fetched.doc, settings);
334
- return this.commit("add profile", async () => {
335
- this.assertSettingsUnchanged(settings);
336
- const current = this.list();
337
- let profile;
338
- let profileChange;
339
- if (known) {
340
- const existing = currentProfile(current, known, before.activeId);
341
- this.assertProfileContentCurrent(existing.id, knownDigest, "Profile changed while updating");
342
- profile = withFetchedContent(existing, fetched);
343
- profileChange = { id: profile.id, yamlText: fetched.yamlText };
344
- }
345
- else {
346
- if (!sameProfileState(before, current) || findProfileByUrl(current, normalizedUrl)) {
347
- throw new ProfileConflictError("Profiles changed while adding a remote profile");
348
- }
349
- const id = allocateProfileId(current, this.layout);
350
- const now = new Date().toISOString();
351
- profile = {
352
- id,
353
- name: opts.name?.trim() || fetched.name || profileNameFromUrl(normalizedUrl),
354
- url: normalizedUrl,
355
- intervalHours: validFetchedInterval(fetched.intervalHours, 24),
356
- createdAt: now,
357
- updatedAt: now,
358
- ...(fetched.subInfo ? { subInfo: fetched.subInfo } : {}),
359
- ...(fetched.homePage ? { homePage: fetched.homePage } : {}),
360
- };
361
- profileChange = { id, yamlText: fetched.yamlText };
362
- }
363
- const shouldActivate = opts.activate === true || current.activeId === null;
364
- const index = {
365
- activeId: shouldActivate ? profile.id : current.activeId,
366
- profiles: known
367
- ? current.profiles.map((item) => (item.id === profile.id ? profile : item))
368
- : [...current.profiles, profile],
369
- };
370
- await this.publish(index, {
371
- profile: profileChange,
372
- ...(shouldActivate ? { config: prepared } : {}),
373
- });
374
- return {
375
- profile,
376
- activated: shouldActivate,
377
- ...(shouldActivate ? { proxyCount: prepared.proxyCount } : {}),
132
+ const known = this.list().profiles.find((profile) => profile.url === normalized);
133
+ const fetched = await this.fetch(normalized);
134
+ return this.options.commit("save remote profile", () => {
135
+ const index = this.list();
136
+ const now = new Date().toISOString();
137
+ const existing = known ? this.recheck(index, known) : undefined;
138
+ if (!known && index.profiles.some((profile) => profile.url === normalized))
139
+ throw new StateConflictError("Profile was added during download");
140
+ const base = existing ?? {
141
+ id: allocateProfileId(index, this.options.layout),
142
+ revision: 0,
143
+ name: validName(opts.name ?? fetched.name?.slice(0, 120) ?? profileNameFromUrl(normalized)),
144
+ url: normalized,
145
+ intervalHours: 24,
146
+ createdAt: now,
147
+ updatedAt: now,
378
148
  };
149
+ const activated = opts.activate === true || index.activeId === null;
150
+ const profile = this.publishSource(index, this.fetchedMeta(base, fetched), fetched.yamlText, activated);
151
+ return { profile, activated };
379
152
  });
380
153
  }
381
154
  async importLocal(name, content) {
382
- if (!content.trim())
383
- throw new ProfileInputError("Missing required profile content");
384
- let doc;
385
- try {
386
- doc = YAML.parse(content);
387
- }
388
- catch (err) {
389
- throw new ProfileInputError(`Content is not valid YAML: ${err.message}`);
390
- }
391
- if (!isValidMihomoConfig(doc)) {
392
- throw new ProfileInputError("Content is not a valid core configuration (missing proxies/rules)");
393
- }
394
- const settings = this.settingsSnapshot();
395
- const prepared = await this.prepare(doc, settings);
396
- const before = this.list();
397
- return this.commit("import profile", async () => {
398
- this.assertSettingsUnchanged(settings);
399
- const current = this.list();
400
- if (!sameProfileState(before, current)) {
401
- throw new ProfileConflictError("Profiles changed while importing a local profile");
402
- }
403
- const id = allocateProfileId(current, this.layout);
155
+ profileInput(content);
156
+ const displayName = validName(name);
157
+ return this.options.commit("import profile", () => {
158
+ const index = this.list();
404
159
  const now = new Date().toISOString();
405
- const profile = {
406
- id,
407
- name: name.trim() || "imported",
160
+ const activated = index.activeId === null;
161
+ const profile = this.publishSource(index, {
162
+ id: allocateProfileId(index, this.options.layout),
163
+ revision: 1,
164
+ name: displayName,
408
165
  url: "",
409
166
  intervalHours: 0,
410
167
  createdAt: now,
411
168
  updatedAt: now,
412
- };
413
- const activated = current.activeId === null;
414
- await this.publish({ activeId: activated ? id : current.activeId, profiles: [...current.profiles, profile] }, {
415
- profile: { id, yamlText: content },
416
- ...(activated ? { config: prepared } : {}),
417
- });
418
- return { profile, activated, ...(activated ? { proxyCount: prepared.proxyCount } : {}) };
169
+ }, content, activated);
170
+ return { profile, activated };
419
171
  });
420
172
  }
421
173
  readContent(id) {
422
- const profile = this.list().profiles.find((item) => item.id === id);
423
- if (!profile)
424
- throw new ProfileNotFoundError(`profile not found: ${id}`);
425
- let content;
426
- try {
427
- content = fs.readFileSync(profileFilePath(this.layout, profile.id), "utf8");
428
- }
429
- catch {
430
- throw new ProfileNotFoundError(`profile file is missing: ${profile.id}`);
431
- }
432
- return { name: profile.name, content };
174
+ const profile = this.requireProfile(this.list(), id);
175
+ return {
176
+ name: profile.name,
177
+ content: readProfileSource(this.options.layout, profile).yamlText,
178
+ revision: profile.revision,
179
+ };
433
180
  }
434
- async writeContent(id, content) {
435
- if (!content.trim())
436
- throw new ProfileInputError("Missing required profile content");
437
- let doc;
438
- try {
439
- doc = YAML.parse(content);
440
- }
441
- catch (err) {
442
- throw new ProfileInputError(`Content is not valid YAML: ${err.message}`);
443
- }
444
- if (!isValidMihomoConfig(doc)) {
445
- throw new ProfileInputError("Content is not a valid core configuration (missing proxies/rules)");
446
- }
447
- const before = this.list();
448
- const target = before.profiles.find((item) => item.id === id);
449
- if (!target)
450
- throw new ProfileNotFoundError(`profile not found: ${id}`);
451
- const snapshot = this.updateSnapshot(target, before.activeId);
452
- const settings = this.settingsSnapshot();
453
- const prepared = await this.prepare(doc, settings);
454
- return this.commit("edit profile", async () => {
455
- this.assertSettingsUnchanged(settings);
181
+ async writeContent(id, content, revision) {
182
+ profileInput(content);
183
+ return this.options.commit("save profile edit", () => {
456
184
  const index = this.list();
457
- const profile = this.recheckProfileSnapshot(index, snapshot, "Profile changed while editing");
458
- const updated = {
459
- ...profile,
185
+ const before = this.requireProfile(index, id);
186
+ if (before.revision !== revision)
187
+ throw new StateConflictError("Profile changed since the editor opened; reload before saving");
188
+ const profile = this.publishSource(index, {
189
+ ...before,
190
+ revision: before.revision + 1,
460
191
  updatedAt: new Date().toISOString(),
461
192
  lastError: undefined,
462
- };
463
- const active = index.activeId === profile.id;
464
- await this.publish({
465
- ...index,
466
- profiles: index.profiles.map((item) => (item.id === profile.id ? updated : item)),
467
- }, {
468
- profile: { id: profile.id, yamlText: content },
469
- ...(active ? { config: prepared } : {}),
470
- });
471
- return { profile: updated, ...(active ? { proxyCount: prepared.proxyCount } : {}) };
193
+ }, content, false);
194
+ return { profile };
472
195
  });
473
196
  }
474
197
  async activate(id) {
475
- const before = this.list();
476
- const settings = this.settingsSnapshot();
477
- if (id === null) {
478
- const prepared = await this.prepare(null, settings);
479
- return this.commit("deselect profile", async () => {
480
- this.assertSettingsUnchanged(settings);
481
- const current = this.list();
482
- if (current.activeId !== before.activeId) {
483
- throw new ProfileConflictError("Active profile changed while preparing configuration");
484
- }
485
- await this.publish({ ...current, activeId: null }, { config: prepared });
486
- return { activeId: null, proxyCount: prepared.proxyCount };
487
- });
488
- }
489
- const initial = before.profiles.find((profile) => profile.id === id);
490
- if (!initial)
491
- throw new ProfileNotFoundError(`profile not found: ${id}`);
492
- const stored = readProfileSource(this.layout, id);
493
- const fetched = stored === undefined && initial.url ? await this.fetch(initial.url) : undefined;
494
- if (stored === undefined && !fetched) {
495
- throw new ProfileInputError(`Local profile file is missing: ${id}`);
496
- }
497
- const sourceDigest = stored?.digest ?? null;
498
- const prepared = await this.prepare(fetched?.doc ?? stored?.doc ?? null, settings);
499
- return this.commit("activate profile", async () => {
500
- this.assertSettingsUnchanged(settings);
501
- const current = this.list();
502
- const profile = currentProfile(current, initial, before.activeId);
503
- this.assertProfileContentCurrent(profile.id, sourceDigest);
504
- const updated = fetched ? withFetchedContent(profile, fetched) : profile;
505
- await this.publish({
506
- activeId: id,
507
- profiles: current.profiles.map((item) => (item.id === id ? updated : item)),
508
- }, {
509
- ...(fetched ? { profile: { id, yamlText: fetched.yamlText } } : {}),
510
- config: prepared,
511
- });
512
- return { activeId: id, proxyCount: prepared.proxyCount };
513
- });
514
- }
515
- async commitFetched(snapshot, fetched) {
516
- const settings = this.settingsSnapshot();
517
- const prepared = await this.prepare(fetched.doc, settings);
518
- return this.commit("update profile", async () => {
519
- this.assertSettingsUnchanged(settings);
520
- const index = this.list();
521
- const current = currentProfile(index, snapshot.profile, snapshot.activeId);
522
- this.assertProfileContentCurrent(current.id, snapshot.contentDigest, "Profile changed while updating");
523
- const profile = withFetchedContent(current, fetched);
524
- const active = index.activeId === current.id;
525
- await this.publish({
526
- ...index,
527
- profiles: index.profiles.map((item) => (item.id === current.id ? profile : item)),
528
- }, {
529
- profile: { id: current.id, yamlText: fetched.yamlText },
530
- ...(active ? { config: prepared } : {}),
531
- });
532
- return { profile, ...(active ? { proxyCount: prepared.proxyCount } : {}) };
533
- });
534
- }
535
- async recordError(snapshot, error) {
536
- await this.commit("record profile update error", async () => {
537
- const index = this.list();
538
- let current;
539
- try {
540
- current = currentProfile(index, snapshot.profile, snapshot.activeId);
541
- this.assertProfileContentCurrent(current.id, snapshot.contentDigest, "Profile changed while recording an update error");
542
- }
543
- catch (err) {
544
- if (err instanceof ProfileConflictError)
545
- return;
546
- throw err;
547
- }
548
- const profile = { ...current, lastError: error.slice(0, 300) };
549
- await this.publish({
550
- ...index,
551
- profiles: index.profiles.map((item) => (item.id === current.id ? profile : item)),
552
- });
198
+ return this.options.commit("select profile", () => {
199
+ const state = this.options.state.snapshot();
200
+ const profile = id === null ? null : this.requireProfile(state.profiles, id);
201
+ const doc = profile ? readProfileSource(this.options.layout, profile).doc : null;
202
+ if (state.profiles.activeId !== id)
203
+ this.options.state.commit({ ...state, profiles: { ...state.profiles, activeId: id } });
204
+ return { activeId: id, proxyCount: Array.isArray(doc?.proxies) ? doc.proxies.length : 0 };
553
205
  });
554
206
  }
555
207
  async update(id) {
556
- const index = this.list();
557
- const profile = index.profiles.find((item) => item.id === id);
558
- if (!profile)
559
- throw new ProfileNotFoundError(`profile not found: ${id}`);
560
- if (!profile.url)
208
+ const before = this.requireProfile(this.list(), id);
209
+ if (!before.url)
561
210
  throw new ProfileInputError("Local profile has no URL to update from");
562
- const snapshot = this.updateSnapshot(profile, index.activeId);
563
211
  try {
564
- return await this.commitFetched(snapshot, await this.fetch(profile.url));
212
+ const fetched = await this.fetch(before.url);
213
+ return await this.options.commit("update profile", () => {
214
+ const index = this.list();
215
+ const current = this.recheck(index, before);
216
+ return {
217
+ profile: this.publishSource(index, this.fetchedMeta(current, fetched), fetched.yamlText, false),
218
+ };
219
+ });
565
220
  }
566
- catch (err) {
567
- await this.recordError(snapshot, err.message);
568
- throw err;
221
+ catch (error) {
222
+ await this.options
223
+ .commit("record profile error", () => {
224
+ const state = this.options.state.snapshot();
225
+ const current = state.profiles.profiles.find((profile) => profile.id === id);
226
+ if (!current || current.revision !== before.revision || current.url !== before.url)
227
+ return;
228
+ const message = (error instanceof Error ? error.message : String(error)).slice(0, 300);
229
+ this.options.state.commit({
230
+ ...state,
231
+ profiles: {
232
+ ...state.profiles,
233
+ profiles: state.profiles.profiles.map((profile) => profile.id === id ? { ...profile, lastError: message } : profile),
234
+ },
235
+ });
236
+ })
237
+ .catch(() => undefined);
238
+ throw error;
569
239
  }
570
240
  }
571
241
  async updateProfiles(profiles) {
572
- const fetched = await mapConcurrent(profiles, 4, async (snapshot) => {
573
- try {
574
- return { snapshot, fetched: await this.fetch(snapshot.profile.url) };
575
- }
576
- catch (err) {
577
- return { snapshot, error: err.message };
578
- }
579
- });
580
- let updated = 0;
581
- let proxyCount;
582
- const failed = [];
583
- for (const result of fetched) {
584
- const { profile } = result.snapshot;
585
- if (!("fetched" in result)) {
586
- await this.recordError(result.snapshot, result.error);
587
- failed.push({ id: profile.id, name: profile.name, error: result.error });
588
- continue;
589
- }
590
- try {
591
- const committed = await this.commitFetched(result.snapshot, result.fetched);
592
- updated += 1;
593
- if (committed.proxyCount !== undefined)
594
- proxyCount = committed.proxyCount;
595
- }
596
- catch (err) {
597
- const message = err.message;
598
- await this.recordError(result.snapshot, message);
599
- failed.push({ id: profile.id, name: profile.name, error: message });
242
+ const result = { updated: 0, failed: [] };
243
+ const generation = this.downloadGeneration;
244
+ for (let start = 0; start < profiles.length; start += 4) {
245
+ if (generation !== this.downloadGeneration) {
246
+ result.failed.push(...profiles.slice(start).map((profile) => ({
247
+ id: profile.id,
248
+ name: profile.name,
249
+ error: "Profile update cancelled",
250
+ })));
251
+ break;
600
252
  }
253
+ await Promise.all(profiles.slice(start, start + 4).map(async (profile) => {
254
+ try {
255
+ await this.update(profile.id);
256
+ result.updated += 1;
257
+ }
258
+ catch (error) {
259
+ result.failed.push({
260
+ id: profile.id,
261
+ name: profile.name,
262
+ error: error instanceof Error ? error.message : String(error),
263
+ });
264
+ }
265
+ }));
601
266
  }
602
- return { updated, failed, ...(proxyCount !== undefined ? { proxyCount } : {}) };
267
+ return result;
603
268
  }
604
- async updateAll() {
605
- const index = this.list();
606
- return this.updateProfiles(index.profiles
607
- .filter((profile) => profile.url !== "")
608
- .map((profile) => this.updateSnapshot(profile, index.activeId)));
269
+ updateAll() {
270
+ return this.updateProfiles(this.list().profiles.filter((profile) => profile.url));
609
271
  }
610
272
  async updateDue(nowMs = Date.now()) {
611
- const index = this.list();
612
- return this.updateProfiles(index.profiles
613
- .filter((profile) => {
614
- let exists = false;
273
+ if (this.updatingDue)
274
+ return { updated: 0, failed: [] };
275
+ this.updatingDue = true;
276
+ try {
277
+ return await this.updateProfiles(this.list().profiles.filter((profile) => profileDueForUpdate(profile, nowMs)));
278
+ }
279
+ finally {
280
+ this.updatingDue = false;
281
+ }
282
+ }
283
+ async remove(id) {
284
+ return this.options.commit("remove profile", () => {
285
+ const state = this.options.state.snapshot();
286
+ const profile = this.requireProfile(state.profiles, id);
287
+ const wasActive = state.profiles.activeId === id;
288
+ this.options.state.commit({
289
+ ...state,
290
+ profiles: {
291
+ activeId: wasActive ? null : state.profiles.activeId,
292
+ profiles: state.profiles.profiles.filter((item) => item.id !== id),
293
+ },
294
+ });
615
295
  try {
616
- exists = fs.existsSync(profileFilePath(this.layout, profile.id));
296
+ durableRemoveFileSync(profileFilePath(this.options.layout, profile.id, profile.revision));
617
297
  }
618
298
  catch {
619
299
  }
620
- return profileDueForUpdate(profile, exists, nowMs);
621
- })
622
- .map((profile) => this.updateSnapshot(profile, index.activeId)));
300
+ return { wasActive };
301
+ });
623
302
  }
624
- async remove(id) {
625
- const before = this.list();
626
- const initial = before.profiles.find((profile) => profile.id === id);
627
- if (!initial)
628
- throw new ProfileNotFoundError(`profile not found: ${id}`);
629
- const sourceDigest = readProfileDigest(this.layout, id);
630
- const settings = this.settingsSnapshot();
631
- const prepared = before.activeId === id ? await this.prepare(null, settings) : undefined;
632
- return this.commit("remove profile", async () => {
633
- this.assertSettingsUnchanged(settings);
634
- const index = this.list();
635
- const profile = currentProfile(index, initial, before.activeId);
636
- this.assertProfileContentCurrent(profile.id, sourceDigest, "Profile changed while deleting");
637
- const wasActive = index.activeId === profile.id;
638
- await this.publish({
639
- activeId: wasActive ? null : index.activeId,
640
- profiles: index.profiles.filter((item) => item.id !== id),
641
- }, {
642
- profile: { id, yamlText: null },
643
- ...(prepared ? { config: prepared } : {}),
644
- });
645
- return { wasActive, ...(prepared ? { proxyCount: prepared.proxyCount } : {}) };
303
+ async reorder(ids) {
304
+ return this.options.commit("reorder profiles", () => {
305
+ const state = this.options.state.snapshot();
306
+ if (ids.length !== state.profiles.profiles.length || new Set(ids).size !== ids.length)
307
+ throw new ProfileInputError("Profile order must contain every profile exactly once");
308
+ const profiles = ids.map((id) => this.requireProfile(state.profiles, id));
309
+ if (profiles.every((profile, i) => profile.id === state.profiles.profiles[i]?.id))
310
+ return state.profiles;
311
+ return this.options.state.commit({ ...state, profiles: { ...state.profiles, profiles } })
312
+ .profiles;
646
313
  });
647
314
  }
648
315
  async rename(id, name) {
649
- const trimmed = name.trim();
650
- if (!trimmed)
651
- throw new ProfileInputError("Missing required profile name");
652
- if (trimmed.length > 120)
653
- throw new ProfileInputError("Profile name is too long");
654
- return this.commit("rename profile", async () => {
655
- const index = this.list();
656
- const profile = index.profiles.find((item) => item.id === id);
657
- if (!profile)
658
- throw new ProfileNotFoundError(`profile not found: ${id}`);
659
- const updated = { ...profile, name: trimmed };
660
- await this.publish({
661
- ...index,
662
- profiles: index.profiles.map((item) => (item.id === id ? updated : item)),
316
+ const displayName = validName(name);
317
+ return this.options.commit("rename profile", () => {
318
+ const state = this.options.state.snapshot();
319
+ const profile = { ...this.requireProfile(state.profiles, id), name: displayName };
320
+ this.options.state.commit({
321
+ ...state,
322
+ profiles: {
323
+ ...state.profiles,
324
+ profiles: state.profiles.profiles.map((item) => (item.id === id ? profile : item)),
325
+ },
663
326
  });
664
- return { profile: updated };
327
+ return { profile };
665
328
  });
666
329
  }
667
330
  }