@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
@@ -3,13 +3,12 @@ import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { loadSettings } from "./app-state.js";
6
7
  import { SashDaemonClient } from "./daemon-client.js";
7
8
  import { isCanonicalIsoTimestamp, isPlainObject } from "./json-shape.js";
8
- import { log } from "./log.js";
9
9
  import { boundedLogTailSince, logTailCursor } from "./log-follow.js";
10
10
  import { sashLayout } from "./paths.js";
11
- import { buildSanitizedEnv, commandLineContains, isProcessAlive, killProcessGracefully, withPrivateAppendLogFds, } from "./process.js";
12
- import { loadSettings } from "./settings.js";
11
+ import { buildSanitizedEnv, isProcessAlive, killProcessGracefully, withPrivateAppendLogFds, } from "./process.js";
13
12
  import { readStateLockRecord, withStateLock } from "./state-lock.js";
14
13
  export function readDaemonPidRecord(layout = sashLayout()) {
15
14
  try {
@@ -50,41 +49,11 @@ export async function evaluateDaemon(layout = sashLayout(), settings) {
50
49
  lease = readStateLockRecord(layout.daemonLeaseFile);
51
50
  }
52
51
  catch {
53
- return {
54
- kind: "unhealthy",
55
- running: true,
56
- healthy: false,
57
- ...(record ? { stalePidFile: true } : {}),
58
- };
52
+ return { kind: "unhealthy", running: true, healthy: false };
59
53
  }
60
- if (!lease || !isProcessAlive(lease.pid)) {
61
- if (record && isProcessAlive(record.pid)) {
62
- const s = settings ?? loadSettings(layout);
63
- const legacyClient = new SashDaemonClient(record.port || s.daemonPort, s.daemonSecret);
64
- try {
65
- const health = await legacyClient.health();
66
- if (health.token === record.token && health.pid === record.pid) {
67
- return {
68
- kind: "healthy",
69
- running: true,
70
- healthy: true,
71
- legacyOwnership: true,
72
- pid: record.pid,
73
- port: record.port,
74
- };
75
- }
76
- }
77
- catch {
78
- }
79
- return {
80
- kind: "unhealthy",
81
- running: true,
82
- healthy: false,
83
- legacyOwnership: true,
84
- pid: record.pid,
85
- ...(lease ? { staleLeaseFile: true } : {}),
86
- };
87
- }
54
+ const liveLease = lease && isProcessAlive(lease.pid);
55
+ const liveRecord = record && isProcessAlive(record.pid);
56
+ if (!liveLease && !liveRecord)
88
57
  return {
89
58
  kind: "stopped",
90
59
  running: false,
@@ -93,46 +62,19 @@ export async function evaluateDaemon(layout = sashLayout(), settings) {
93
62
  ...(record ? { stalePidFile: true } : {}),
94
63
  ...(lease ? { staleLeaseFile: true } : {}),
95
64
  };
65
+ if (!lease || !record || !liveLease || !liveRecord || lease.pid !== record.pid) {
66
+ return { kind: "unhealthy", running: true, healthy: false, pid: lease?.pid ?? record?.pid };
96
67
  }
97
- if (!record || record.pid !== lease.pid || !isProcessAlive(record.pid)) {
98
- return {
99
- kind: "unhealthy",
100
- running: true,
101
- healthy: false,
102
- pid: lease.pid,
103
- ...(record ? { stalePidFile: true } : {}),
104
- };
105
- }
106
- const s = settings ?? loadSettings(layout);
107
- const client = new SashDaemonClient(record.port || s.daemonPort, s.daemonSecret);
108
68
  try {
69
+ const client = new SashDaemonClient(record.port, (settings ?? loadSettings(layout)).daemonSecret);
109
70
  const health = await client.health();
110
71
  if (health.token === record.token && health.pid === record.pid) {
111
- return {
112
- kind: "healthy",
113
- running: true,
114
- pid: record.pid,
115
- healthy: true,
116
- port: record.port,
117
- };
72
+ return { kind: "healthy", running: true, healthy: true, pid: record.pid, port: record.port };
118
73
  }
119
- return {
120
- kind: "unhealthy",
121
- running: true,
122
- healthy: false,
123
- stalePidFile: true,
124
- pid: record.pid,
125
- };
126
74
  }
127
75
  catch {
128
- return {
129
- kind: "unhealthy",
130
- running: true,
131
- pid: record.pid,
132
- healthy: false,
133
- port: record.port,
134
- };
135
76
  }
77
+ return { kind: "unhealthy", running: true, healthy: false, pid: record.pid, port: record.port };
136
78
  }
137
79
  function resolveDaemonEntryPath() {
138
80
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -242,137 +184,18 @@ export async function ensureDaemon(opts = {}) {
242
184
  return;
243
185
  await spawnDaemon({ layout, settings });
244
186
  }
245
- async function waitForDaemonExit(pid, timeoutMs) {
246
- const deadline = Date.now() + timeoutMs;
247
- while (Date.now() < deadline && isProcessAlive(pid))
248
- await sleep(100);
249
- if (isProcessAlive(pid)) {
250
- throw new Error(`sashd PID ${pid} did not exit after maintenance shutdown`);
251
- }
252
- }
253
- export async function prepareDaemonMaintenance(layout, settings, purpose, deps = {}) {
254
- const daemonState = await (deps.evaluateDaemon ?? evaluateDaemon)(layout, settings);
255
- if (daemonState.kind === "unhealthy") {
256
- throw new Error(`sashd is running but unresponsive; refusing a competing ${purpose}`);
257
- }
258
- if (daemonState.kind === "stopped") {
259
- return { daemonWasRunning: false, legacyDaemon: false, coreWasRunning: false };
260
- }
261
- const client = (deps.clientFactory ?? ((port, secret) => new SashDaemonClient(port, secret)))(daemonState.port, settings.daemonSecret);
262
- if (daemonState.legacyOwnership) {
263
- if (!client.status || !client.shutdown) {
264
- throw new Error("Legacy sashd maintenance requires status and shutdown support");
265
- }
266
- log.warn("legacy sashd detected; using the pre-maintenance compatibility path");
267
- const coreWasRunning = (await client.status()).core.running;
268
- await client.shutdown();
269
- await (deps.waitForDaemonExit ?? waitForDaemonExit)(daemonState.pid, 20_000);
270
- return { daemonWasRunning: true, legacyDaemon: true, coreWasRunning };
271
- }
272
- const snapshot = await client.maintenanceShutdown();
273
- await (deps.waitForDaemonExit ?? waitForDaemonExit)(daemonState.pid, 20_000);
274
- return { daemonWasRunning: true, legacyDaemon: false, coreWasRunning: snapshot.coreWasRunning };
275
- }
276
187
  export async function stopDaemonFromCli(opts = {}) {
277
188
  const layout = opts.layout ?? sashLayout();
278
189
  const settings = opts.settings ?? loadSettings(layout);
279
- const record = readDaemonPidRecord(layout);
280
- if (!record) {
281
- try {
282
- const lease = readStateLockRecord(layout.daemonLeaseFile);
283
- if (!lease || !isProcessAlive(lease.pid))
284
- return true;
285
- log.warn(`sashd is still starting or unresponsive (PID=${lease.pid}); try stopping again.`);
286
- return false;
287
- }
288
- catch {
289
- log.warn("Refusing to stop sashd: singleton ownership record is corrupt.");
290
- return false;
291
- }
292
- }
293
- const pid = record.pid;
294
- if (!isProcessAlive(pid))
190
+ const state = await evaluateDaemon(layout, settings);
191
+ if (state.kind === "stopped")
295
192
  return true;
296
- let lease;
297
- try {
298
- lease = readStateLockRecord(layout.daemonLeaseFile);
299
- }
300
- catch {
301
- log.warn("Refusing to stop sashd: singleton ownership record is corrupt.");
302
- return false;
303
- }
304
- if (lease && (!isProcessAlive(lease.pid) || lease.pid !== pid)) {
305
- log.warn(`Refusing to stop PID ${pid}: daemon PID metadata does not match singleton ownership.`);
193
+ if (state.kind !== "healthy")
306
194
  return false;
307
- }
308
- const client = new SashDaemonClient(record.port || settings.daemonPort, settings.daemonSecret);
309
- const legacyOwnership = !lease;
310
- const healthMatchesRecord = async () => {
311
- try {
312
- const health = await client.health();
313
- return health.token === record.token && health.pid === pid;
314
- }
315
- catch {
316
- return false;
317
- }
318
- };
319
- if (!(await healthMatchesRecord())) {
320
- log.warn(`Refusing to stop unverified sashd PID ${pid}.`);
321
- return false;
322
- }
323
- try {
324
- await client.shutdown();
325
- }
326
- catch {
327
- }
195
+ const client = new SashDaemonClient(state.port, settings.daemonSecret);
196
+ await client.shutdown();
328
197
  const deadline = Date.now() + (opts.timeoutMs ?? 20_000);
329
- while (Date.now() < deadline && isProcessAlive(pid)) {
330
- await sleep(200);
331
- }
332
- if (!isProcessAlive(pid))
333
- return true;
334
- if (legacyOwnership) {
335
- log.warn(`Legacy sashd PID ${pid} did not stop gracefully; refusing force termination without a singleton lease.`);
336
- return false;
337
- }
338
- return withStateLock(layout.mutationLockFile, { purpose: "verify forced sashd stop", timeoutMs: 10_000 }, async () => {
339
- if (!isProcessAlive(pid))
340
- return true;
341
- try {
342
- const status = await client.status(true);
343
- const actual = status.systemProxy.actual;
344
- if (status.daemon.pid !== pid ||
345
- status.core.running ||
346
- status.systemProxy.applied ||
347
- !actual ||
348
- actual.enabled ||
349
- (actual.supported && Boolean(actual.details)) ||
350
- fs.existsSync(layout.systemProxyStateFile) ||
351
- fs.existsSync(`${layout.systemProxyStateFile}.lock`)) {
352
- log.warn(`Refusing to force-terminate sashd PID ${pid}: managed runtime cleanup is incomplete or unknown.`);
353
- return false;
354
- }
355
- }
356
- catch {
357
- log.warn(`Refusing to force-terminate unresponsive sashd PID ${pid}: runtime cleanup cannot be verified.`);
358
- return false;
359
- }
360
- const verifyDaemonIdentity = async () => {
361
- if (!(await healthMatchesRecord()))
362
- return "mismatch";
363
- return commandLineContains(pid, "daemon-entry") ? "match" : "unknown";
364
- };
365
- if ((await verifyDaemonIdentity()) !== "match") {
366
- log.warn(`Refusing to terminate PID ${pid}: process identity no longer matches sashd. Verify manually.`);
367
- return false;
368
- }
369
- const killed = await killProcessGracefully(pid, {
370
- timeoutMs: 4000,
371
- verify: verifyDaemonIdentity,
372
- });
373
- if (killed)
374
- return true;
375
- log.error(`sashd process is still running after termination attempt (PID=${pid}).`);
376
- return false;
377
- });
198
+ while (isProcessAlive(state.pid) && Date.now() < deadline)
199
+ await sleep(100);
200
+ return !isProcessAlive(state.pid);
378
201
  }
package/dist/github.js CHANGED
@@ -21,10 +21,11 @@ function ghTokenHeaders() {
21
21
  const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
22
22
  return token ? { authorization: `Bearer ${token}` } : {};
23
23
  }
24
- export async function resolveLatestTag(repo) {
24
+ export async function resolveLatestTag(repo, signal) {
25
25
  const latestUrl = `https://github.com/${repo}/releases/latest`;
26
26
  try {
27
27
  const res = await fetchWithRetry(latestUrl, {
28
+ signal,
28
29
  manualRedirect: true,
29
30
  attempts: 2,
30
31
  deadlineMs: 15_000,
@@ -50,6 +51,7 @@ export async function resolveLatestTag(repo) {
50
51
  }
51
52
  const apiUrl = `https://api.github.com/repos/${repo}/releases/latest`;
52
53
  const res = await fetchWithRetry(apiUrl, {
54
+ signal,
53
55
  headers: {
54
56
  accept: "application/vnd.github+json",
55
57
  ...ghTokenHeaders(),
@@ -74,9 +76,10 @@ export async function resolveLatestTag(repo) {
74
76
  }
75
77
  return data.tag_name;
76
78
  }
77
- export async function listReleaseAssets(repo, tag) {
79
+ export async function listReleaseAssets(repo, tag, signal) {
78
80
  const apiUrl = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`;
79
81
  const res = await fetchWithRetry(apiUrl, {
82
+ signal,
80
83
  headers: {
81
84
  accept: "application/vnd.github+json",
82
85
  ...ghTokenHeaders(),
@@ -157,6 +160,7 @@ export async function downloadReleaseAsset(opts) {
157
160
  const deadlineAt = Date.now() + deadlineMs;
158
161
  let lastError;
159
162
  for (const url of urls) {
163
+ opts.signal?.throwIfAborted();
160
164
  const remainingMs = deadlineAt - Date.now();
161
165
  if (remainingMs <= 0) {
162
166
  lastError = new Error(`Core asset download deadline exceeded after ${deadlineMs}ms`);
@@ -164,6 +168,7 @@ export async function downloadReleaseAsset(opts) {
164
168
  }
165
169
  try {
166
170
  await downloadToFile(url, opts.dest, {
171
+ signal: opts.signal,
167
172
  allowedHosts: GITHUB_DOWNLOAD_HOSTS,
168
173
  maxBytes: RELEASE_ASSET_SIZE_LIMIT,
169
174
  requireHttps: true,
package/dist/http.js CHANGED
@@ -87,6 +87,7 @@ export async function fetchWithRetry(url, opts = {}) {
87
87
  const bodyInactivityTimeoutMs = positiveTimeout(opts.bodyInactivityTimeoutMs, 30_000, "bodyInactivityTimeoutMs");
88
88
  const deadlineMs = positiveTimeout(opts.deadlineMs, 60_000, "deadlineMs");
89
89
  const deadline = new AbortController();
90
+ const signal = opts.signal ? AbortSignal.any([deadline.signal, opts.signal]) : deadline.signal;
90
91
  const deadlineTimer = setTimeout(() => {
91
92
  deadline.abort(new Error(`HTTP request deadline exceeded after ${deadlineMs}ms`));
92
93
  }, deadlineMs);
@@ -108,7 +109,7 @@ export async function fetchWithRetry(url, opts = {}) {
108
109
  body: opts.body,
109
110
  headersTimeout: headersTimeoutMs,
110
111
  bodyTimeout: bodyInactivityTimeoutMs,
111
- signal: deadline.signal,
112
+ signal,
112
113
  dispatcher: pickDispatcher(opts),
113
114
  });
114
115
  if (RETRYABLE_STATUS.has(res.statusCode) && attempt < attempts) {
@@ -166,10 +167,10 @@ export async function fetchWithRetry(url, opts = {}) {
166
167
  }
167
168
  catch (err) {
168
169
  lastErr = err;
169
- if (deadline.signal.aborted || attempt === attempts)
170
+ if (signal.aborted || attempt === attempts)
170
171
  break;
171
- await sleep(Math.min(4_000, 300 * 2 ** (attempt - 1)) + Math.random() * 200, deadline.signal);
172
- if (deadline.signal.aborted)
172
+ await sleep(Math.min(4_000, 300 * 2 ** (attempt - 1)) + Math.random() * 200, signal);
173
+ if (signal.aborted)
173
174
  break;
174
175
  }
175
176
  }
@@ -202,6 +203,7 @@ export async function downloadToFile(url, dest, opts) {
202
203
  const deadlineMs = positiveTimeout(opts.deadlineMs, 15 * 60_000, "deadlineMs");
203
204
  const maxRedirects = 5;
204
205
  const deadline = new AbortController();
206
+ const signal = opts.signal ? AbortSignal.any([deadline.signal, opts.signal]) : deadline.signal;
205
207
  const deadlineTimer = setTimeout(() => {
206
208
  deadline.abort(new Error(`Download deadline exceeded after ${deadlineMs}ms`));
207
209
  }, deadlineMs);
@@ -217,7 +219,7 @@ export async function downloadToFile(url, dest, opts) {
217
219
  headers: { "user-agent": USER_AGENT, ...opts.headers },
218
220
  headersTimeout: 30_000,
219
221
  bodyTimeout: stallMs,
220
- signal: deadline.signal,
222
+ signal,
221
223
  dispatcher,
222
224
  });
223
225
  const isRedirect = res.statusCode >= 300 && res.statusCode < 400 && Boolean(res.headers.location);
@@ -193,7 +193,8 @@ export function followLogFile(file, options) {
193
193
  if (watcher || stopped)
194
194
  return;
195
195
  try {
196
- const watched = fs.watch(path.dirname(file), (_event, filename) => {
196
+ const directory = fs.realpathSync.native(path.dirname(file));
197
+ const watched = fs.watch(directory, (_event, filename) => {
197
198
  if (filename === null || filename.toString() === path.basename(file))
198
199
  scheduleDrain();
199
200
  });
@@ -1,6 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import YAML from "yaml";
3
3
  import { ERROR_BODY_LIMIT, fetchWithRetry } from "./http.js";
4
+ import { isPlainObject } from "./json-shape.js";
4
5
  export function buildDefaultConfig() {
5
6
  return {
6
7
  mode: "rule",
@@ -226,7 +227,7 @@ export function resolveSubscriptionRedirect(initial, current, location) {
226
227
  }
227
228
  return target;
228
229
  }
229
- export async function fetchSubscriptionProfile(url) {
230
+ export async function fetchSubscriptionProfile(url, signal) {
230
231
  let initial;
231
232
  try {
232
233
  initial = new URL(url);
@@ -246,6 +247,7 @@ export async function fetchSubscriptionProfile(url) {
246
247
  throw new Error(`Subscription fetch deadline exceeded after ${PROFILE_FETCH_DEADLINE_MS}ms`);
247
248
  }
248
249
  res = await fetchWithRetry(current.href, {
250
+ signal,
249
251
  attempts: 3,
250
252
  deadlineMs: remainingDeadlineMs,
251
253
  manualRedirect: true,
@@ -312,14 +314,10 @@ export function overlayManagedKeys(base, settings) {
312
314
  out["allow-lan"] = settings.allowLan;
313
315
  out["external-controller"] = settings.controller;
314
316
  out.secret = settings.secret;
315
- if (settings.tun) {
316
- out.tun = {
317
- enable: true,
318
- stack: "mixed",
319
- "auto-route": true,
320
- "auto-detect-interface": true,
321
- "dns-hijack": ["any:53"],
322
- };
317
+ out.tun = { enable: false };
318
+ if (Array.isArray(out.listeners) &&
319
+ out.listeners.some((listener) => isPlainObject(listener) && listener.type === "tun")) {
320
+ throw new Error("TUN listeners are unavailable in this release");
323
321
  }
324
322
  return out;
325
323
  }
package/dist/paths.js CHANGED
@@ -24,27 +24,25 @@ export function sashRoot() {
24
24
  }
25
25
  export function sashLayout(root = sashRoot()) {
26
26
  const exeName = process.platform === "win32" ? "mihomo.exe" : "mihomo";
27
+ const userControlDir = process.platform === "win32"
28
+ ? path.join(envPathOr(path.join(os.homedir(), "AppData", "Local"), process.env.LOCALAPPDATA), "Sash")
29
+ : path.join(root, "state");
27
30
  return {
28
31
  root,
29
32
  binDir: path.join(root, "bin"),
30
33
  coreExe: path.join(root, "bin", exeName),
31
- configFile: path.join(root, "config.yaml"),
34
+ configFile: path.join(root, "runtime", "config.yaml"),
32
35
  settingsFile: path.join(root, "sash.json"),
33
36
  profilesDir: path.join(root, "profiles"),
34
- profilesIndexFile: path.join(root, "profiles", "index.json"),
35
37
  uiDir: path.join(root, "ui"),
36
38
  stateDir: path.join(root, "state"),
37
39
  pidFile: path.join(root, "state", "sash.pid"),
38
40
  daemonPidFile: path.join(root, "state", "sashd.pid"),
39
41
  daemonLeaseFile: path.join(root, "state", "sashd.lock"),
40
42
  daemonStartLockFile: path.join(root, "state", "sashd-start.lock"),
41
- runtimeOperationLockFile: path.join(root, "state", "runtime.lock"),
42
- mutationLockFile: path.join(root, "state", "mutation.lock"),
43
- settingsLockFile: path.join(root, "state", "settings.lock"),
44
43
  systemProxyStateFile: path.join(root, "state", "system-proxy.json"),
45
- managedStateTransactionFile: path.join(root, "state", "managed-state-transaction.json"),
44
+ systemProxyLockFile: path.join(userControlDir, "system-proxy.lock"),
46
45
  installFile: path.join(root, "state", "install.json"),
47
- coreInstallTransactionFile: path.join(root, "state", "core-install-transaction.json"),
48
46
  coreUpdateTransactionFile: path.join(root, "state", "core-update-transaction.json"),
49
47
  logsDir: path.join(root, "logs"),
50
48
  coreLogFile: path.join(root, "logs", "mihomo.log"),
@@ -0,0 +1,87 @@
1
+ import { isCanonicalIsoTimestamp, isPlainObject } from "./json-shape.js";
2
+ export const DEFAULT_PROFILE_INTERVAL_HOURS = 24;
3
+ export const MAX_PROFILE_INTERVAL_HOURS = 24 * 365;
4
+ export function parseProfilesIndex(value) {
5
+ if (!isPlainObject(value) ||
6
+ Object.keys(value).some((key) => key !== "activeId" && key !== "profiles") ||
7
+ !Array.isArray(value.profiles) ||
8
+ (value.activeId !== null && typeof value.activeId !== "string"))
9
+ throw new Error("Profiles index has an invalid shape");
10
+ const profiles = value.profiles.map(parseProfileMeta);
11
+ const ids = new Set(profiles.map((profile) => profile.id));
12
+ if (ids.size !== profiles.length)
13
+ throw new Error("Profiles index has duplicate profile ids");
14
+ if (value.activeId !== null && !ids.has(value.activeId))
15
+ throw new Error("Selected profile is missing");
16
+ return { activeId: value.activeId, profiles };
17
+ }
18
+ export function parseProfileMeta(item) {
19
+ if (!isPlainObject(item) ||
20
+ typeof item.id !== "string" ||
21
+ !/^[0-9]+$/.test(item.id) ||
22
+ typeof item.revision !== "number" ||
23
+ !Number.isSafeInteger(item.revision) ||
24
+ item.revision < 1 ||
25
+ typeof item.name !== "string" ||
26
+ !item.name.trim() ||
27
+ item.name.length > 120 ||
28
+ typeof item.url !== "string" ||
29
+ typeof item.intervalHours !== "number" ||
30
+ !Number.isSafeInteger(item.intervalHours) ||
31
+ item.intervalHours < 0 ||
32
+ item.intervalHours > MAX_PROFILE_INTERVAL_HOURS ||
33
+ !isCanonicalIsoTimestamp(item.createdAt) ||
34
+ !isCanonicalIsoTimestamp(item.updatedAt))
35
+ throw new Error("Profiles index has invalid profile metadata");
36
+ if (item.url) {
37
+ const url = new URL(item.url);
38
+ if (url.protocol !== "https:" && url.protocol !== "http:")
39
+ throw new Error("Invalid profile URL");
40
+ }
41
+ let subInfo;
42
+ if (item.subInfo !== undefined) {
43
+ const info = item.subInfo;
44
+ if (!isPlainObject(info) ||
45
+ typeof info.upload !== "number" ||
46
+ !Number.isFinite(info.upload) ||
47
+ info.upload < 0 ||
48
+ typeof info.download !== "number" ||
49
+ !Number.isFinite(info.download) ||
50
+ info.download < 0 ||
51
+ typeof info.total !== "number" ||
52
+ !Number.isFinite(info.total) ||
53
+ info.total < 0 ||
54
+ (info.expire !== undefined &&
55
+ (typeof info.expire !== "number" || !Number.isFinite(info.expire) || info.expire < 0)))
56
+ throw new Error("Invalid subscription quota");
57
+ subInfo = {
58
+ upload: info.upload,
59
+ download: info.download,
60
+ total: info.total,
61
+ ...(typeof info.expire === "number" ? { expire: info.expire } : {}),
62
+ };
63
+ }
64
+ if (item.homePage !== undefined) {
65
+ if (typeof item.homePage !== "string")
66
+ throw new Error("Invalid profile home page");
67
+ const url = new URL(item.homePage);
68
+ if (url.protocol !== "https:" && url.protocol !== "http:")
69
+ throw new Error("Invalid profile home page");
70
+ }
71
+ if (item.lastError !== undefined &&
72
+ (typeof item.lastError !== "string" || item.lastError.length > 300)) {
73
+ throw new Error("Invalid profile update error");
74
+ }
75
+ return {
76
+ id: item.id,
77
+ revision: item.revision,
78
+ name: item.name,
79
+ url: item.url,
80
+ intervalHours: item.intervalHours,
81
+ createdAt: item.createdAt,
82
+ updatedAt: item.updatedAt,
83
+ ...(subInfo ? { subInfo } : {}),
84
+ ...(typeof item.homePage === "string" ? { homePage: item.homePage } : {}),
85
+ ...(typeof item.lastError === "string" ? { lastError: item.lastError } : {}),
86
+ };
87
+ }