@astralyn/sash 0.1.1 → 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 (102) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +17 -5
  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 -15
  19. package/dist/commands/logs.js +11 -4
  20. package/dist/commands/shared.js +2 -4
  21. package/dist/commands/status.js +5 -1
  22. package/dist/commands/update.js +7 -3
  23. package/dist/commands/web.js +14 -50
  24. package/dist/contracts.js +168 -348
  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 +129 -117
  29. package/dist/daemon/context.js +13 -7
  30. package/dist/daemon/entry.js +21 -38
  31. package/dist/daemon/errors.js +3 -0
  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 +17 -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 +26 -21
  38. package/dist/daemon/scheduler.js +3 -1
  39. package/dist/daemon/server.js +5 -2
  40. package/dist/daemon-client.js +13 -3
  41. package/dist/daemon-http.js +6 -0
  42. package/dist/daemon-lifecycle.js +19 -196
  43. package/dist/github.js +7 -2
  44. package/dist/http.js +7 -5
  45. package/dist/mihomo-config.js +2 -1
  46. package/dist/paths.js +5 -7
  47. package/dist/profile-model.js +87 -0
  48. package/dist/profile-service.js +250 -587
  49. package/dist/profiles.js +43 -171
  50. package/dist/runtime-lifecycle.js +112 -194
  51. package/dist/runtime-owner.js +37 -76
  52. package/dist/sash-client.js +40 -19
  53. package/dist/settings-service.js +29 -224
  54. package/dist/settings.js +61 -289
  55. package/dist/status.js +24 -16
  56. package/dist/supervisor.js +1 -14
  57. package/dist/sysproxy/common.js +5 -45
  58. package/dist/sysproxy/factory.js +24 -65
  59. package/dist/sysproxy/snapshot.js +24 -257
  60. package/dist/sysproxy.js +1 -4
  61. package/dist/system-proxy-manager.js +37 -35
  62. package/dist/ui/assets/{ConnectionsView-CfaOG2JV.js → ConnectionsView-BgXQM6Z4.js} +1 -1
  63. package/dist/ui/assets/{LogsView-BDiRATXN.js → LogsView-2f542P8z.js} +1 -1
  64. package/dist/ui/assets/{PaginationFooter-DTytr1iu.js → PaginationFooter-cj1tcZej.js} +1 -1
  65. package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
  66. package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
  67. package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
  68. package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
  69. package/dist/ui/assets/{RulesView-CjEyCN1A.js → RulesView-CtKvlckZ.js} +1 -1
  70. package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
  71. package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
  72. package/dist/ui/assets/index-CBInDvdJ.js +19 -0
  73. package/dist/ui/assets/index-mOLy7fkB.css +1 -0
  74. package/dist/ui/assets/{theme-BwDBMsKO.js → theme-D40MF8cQ.js} +1 -1
  75. package/dist/ui/index.html +2 -2
  76. package/docs/architecture-proposal.md +109 -0
  77. package/docs/autostart.md +101 -0
  78. package/docs/backend.md +92 -224
  79. package/docs/frontend.md +60 -106
  80. package/docs/usage.md +92 -164
  81. package/package.json +3 -3
  82. package/dist/commands/upgrade.js +0 -43
  83. package/dist/core-install-transaction.js +0 -114
  84. package/dist/core-update-coordination.js +0 -105
  85. package/dist/core-update-service.js +0 -245
  86. package/dist/managed-state-transaction.js +0 -375
  87. package/dist/offline-mutation.js +0 -58
  88. package/dist/profile-migration.js +0 -101
  89. package/dist/runtime-recovery.js +0 -31
  90. package/dist/sysproxy/darwin.js +0 -287
  91. package/dist/sysproxy/gnome.js +0 -181
  92. package/dist/sysproxy/legacy.js +0 -58
  93. package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
  94. package/dist/ui/assets/CodeEditorModal-D1zn-jRS.js +0 -14
  95. package/dist/ui/assets/ProfileEditorDialog-CyyP6OF1.js +0 -1
  96. package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
  97. package/dist/ui/assets/ProfilesView-hFa7-O72.js +0 -2
  98. package/dist/ui/assets/SettingsFileDialog-CjBSyH4K.js +0 -1
  99. package/dist/ui/assets/SettingsView-D5QvJId6.css +0 -1
  100. package/dist/ui/assets/SettingsView-DsRntRfn.js +0 -2
  101. package/dist/ui/assets/index-CxQTf_P9.css +0 -1
  102. package/dist/ui/assets/index-_mb4OfYp.js +0 -19
package/dist/core.js CHANGED
@@ -6,12 +6,11 @@ import { pipeline } from "node:stream/promises";
6
6
  import zlib from "node:zlib";
7
7
  import AdmZip from "adm-zip";
8
8
  import { currentCoreVersion, readInstallRecord, validateCoreReleaseTag, writeInstallRecord, } from "./core-install-record.js";
9
- import { beginCoreInstallTransaction, clearCoreInstallTransaction, markCoreInstallTransactionCommitted, recoverCoreInstallTransaction, } from "./core-install-transaction.js";
10
9
  import { containsCoreVersionToken } from "./core-version.js";
11
- import { durableRenameSync, pathEntryExists } from "./fs-atomic.js";
10
+ import { pathEntryExists } from "./fs-atomic.js";
12
11
  import { downloadReleaseAsset, listReleaseAssets, MIHOMO_REPO, RELEASE_ASSET_SIZE_LIMIT, resolveLatestTag, } from "./github.js";
13
12
  import { sashLayout } from "./paths.js";
14
- import { buildSanitizedEnv, recoverBinaryUnlockProbe } from "./process.js";
13
+ import { buildSanitizedEnv } from "./process.js";
15
14
  export function goOsArch(platform = process.platform, arch = process.arch) {
16
15
  const osMap = { win32: "windows", darwin: "darwin", linux: "linux" };
17
16
  const archMap = { x64: "amd64", arm64: "arm64" };
@@ -56,9 +55,12 @@ export async function extractCoreArchive(archivePath, assetName, destExe) {
56
55
  throw new Error("Core archive exceeds the download safety limit");
57
56
  }
58
57
  const zip = new AdmZip(archivePath);
59
- const entry = zip
60
- .getEntries()
61
- .find((candidate) => !candidate.isDirectory && /^mihomo.*\.exe$/i.test(path.basename(candidate.entryName)));
58
+ const entries = zip.getEntries();
59
+ if (entries.some((entry) => entry.entryName.split(/[\\/]/).includes("..") ||
60
+ /^(?:[\\/]|[A-Za-z]:)/.test(entry.entryName))) {
61
+ throw new Error("Core archive contains an unsafe path");
62
+ }
63
+ const entry = entries.find((candidate) => !candidate.isDirectory && /^mihomo.*\.exe$/i.test(path.basename(candidate.entryName)));
62
64
  if (!entry)
63
65
  throw new Error(`No mihomo*.exe found inside ${assetName}`);
64
66
  if (entry.header.size > EXTRACT_SIZE_LIMIT) {
@@ -112,15 +114,17 @@ export function verifyCoreExecutable(exe, timeoutMs = 5000, expectedVersion) {
112
114
  }
113
115
  export async function stageCore(opts = {}) {
114
116
  const layout = opts.layout ?? sashLayout();
115
- const tag = validateCoreReleaseTag(opts.tag ?? (await resolveLatestTag(MIHOMO_REPO)));
116
- const assets = await listReleaseAssets(MIHOMO_REPO, tag);
117
+ const tag = validateCoreReleaseTag(opts.tag ?? (await resolveLatestTag(MIHOMO_REPO, opts.signal)));
118
+ const assets = await listReleaseAssets(MIHOMO_REPO, tag, opts.signal);
117
119
  const candidates = mihomoAssetCandidates(tag);
118
120
  fs.mkdirSync(layout.tempDir, { recursive: true });
119
121
  fs.mkdirSync(layout.binDir, { recursive: true });
120
- const archivePath = path.join(layout.tempDir, `mihomo-${tag}-${process.pid}.download`);
121
- const stagedExe = `${layout.coreExe}.${process.pid}.new`;
122
+ const directory = fs.mkdtempSync(path.join(layout.tempDir, "core-download-"));
123
+ const archivePath = path.join(directory, "archive.download");
124
+ const stagedExe = path.join(directory, path.basename(layout.coreExe));
122
125
  try {
123
126
  const assetName = await downloadReleaseAsset({
127
+ signal: opts.signal,
124
128
  repo: MIHOMO_REPO,
125
129
  tag,
126
130
  assets,
@@ -129,6 +133,7 @@ export async function stageCore(opts = {}) {
129
133
  onProgress: opts.onProgress,
130
134
  });
131
135
  await extractCoreArchive(archivePath, assetName, stagedExe);
136
+ opts.signal?.throwIfAborted();
132
137
  fs.chmodSync(stagedExe, 0o755);
133
138
  verifyCoreExecutable(stagedExe, 5000, tag);
134
139
  return { version: tag, exe: stagedExe };
@@ -139,41 +144,11 @@ export async function stageCore(opts = {}) {
139
144
  }
140
145
  finally {
141
146
  fs.rmSync(archivePath, { force: true });
142
- }
143
- }
144
- export async function installCore(opts = {}) {
145
- const layout = opts.layout ?? sashLayout();
146
- recoverCoreInstallTransaction(layout);
147
- assertCoreInstallationConsistent(layout);
148
- if (coreInstalled(layout)) {
149
- throw new Error(`Core executable already exists at ${layout.coreExe}; use the update flow`);
150
- }
151
- const staged = await stageCore({ ...opts, layout });
152
- let transactionStarted = false;
153
- let committed = false;
154
- try {
155
- const transaction = beginCoreInstallTransaction(staged.version, layout);
156
- transactionStarted = true;
157
- durableRenameSync(staged.exe, layout.coreExe);
158
- writeInstallRecord({ coreVersion: staged.version, installedAt: transaction.createdAt }, layout);
159
- markCoreInstallTransactionCommitted(transaction, layout);
160
- committed = true;
161
- clearCoreInstallTransaction(layout);
162
- return { version: staged.version, exe: layout.coreExe };
163
- }
164
- catch (err) {
165
- if (transactionStarted && !committed) {
166
- try {
167
- recoverCoreInstallTransaction(layout);
168
- }
169
- catch (recoveryErr) {
170
- throw new Error(`${err.message}; Core install recovery also failed: ${recoveryErr.message}`);
171
- }
147
+ try {
148
+ fs.rmdirSync(directory);
149
+ }
150
+ catch {
172
151
  }
173
- throw err;
174
- }
175
- finally {
176
- fs.rmSync(staged.exe, { force: true });
177
152
  }
178
153
  }
179
154
  function isRegularFile(file) {
@@ -188,7 +163,6 @@ export function coreInstalled(layout = sashLayout()) {
188
163
  return isRegularFile(layout.coreExe) && readInstallRecord(layout) !== undefined;
189
164
  }
190
165
  export function assertCoreInstallationConsistent(layout = sashLayout()) {
191
- recoverBinaryUnlockProbe(layout.coreExe);
192
166
  const binaryExists = pathEntryExists(layout.coreExe);
193
167
  const installRecordExists = pathEntryExists(layout.installFile);
194
168
  const binaryValid = isRegularFile(layout.coreExe);
@@ -202,5 +176,5 @@ export function assertCoreInstallationConsistent(layout = sashLayout()) {
202
176
  : record
203
177
  ? "Core install metadata exists but the executable is missing"
204
178
  : "Core install metadata is malformed without an executable";
205
- throw new Error(`Core installation is incomplete or invalid: ${reason}. Run \`sash update --force\` to repair it.`);
179
+ throw new Error(`Core installation is incomplete or invalid: ${reason}. Preserve these files and reinstall into a clean data directory.`);
206
180
  }
@@ -1,163 +1,175 @@
1
1
  import crypto from "node:crypto";
2
- import { MihomoApi } from "../api.js";
3
- import { currentCoreVersion } from "../core.js";
4
- import { validateCoreConfigText } from "../core-config-validation.js";
5
- import { pendingCoreUpdateVersion, readCoreUpdateTransaction } from "../core-update.js";
6
- import { completeCoordinatedCoreUpdateAfterStart, rollbackCoordinatedCoreUpdate, } from "../core-update-coordination.js";
7
- import { ProfileConflictError, ProfileService, } from "../profile-service.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { SashStateStore, StateConflictError } from "../app-state.js";
5
+ import { AutostartService } from "../autostart.js";
6
+ import { assertCoreInstallationConsistent, coreInstalled, currentCoreVersion, stageCore, } from "../core.js";
7
+ import { validateCoreConfig } from "../core-config-validation.js";
8
+ import { readCoreUpdateTransaction } from "../core-update.js";
9
+ import { ProfileService } from "../profile-service.js";
10
+ import { getActiveProfile, renderActiveConfig } from "../profiles.js";
8
11
  import { RuntimeLifecycle } from "../runtime-lifecycle.js";
9
- import { saveSettings } from "../settings.js";
10
12
  import { SettingsService } from "../settings-service.js";
11
- import { StateMutationQueue } from "../state-lock.js";
12
13
  import { CoreSupervisor } from "../supervisor.js";
13
14
  import { SystemProxyManager } from "../system-proxy-manager.js";
14
15
  import { DaemonGate } from "./context.js";
15
16
  import { WebAuthManager } from "./web-auth.js";
16
17
  export function buildDaemonContext(deps) {
17
- const layout = deps.layout;
18
- let committedSettings = saveSettings({ ...deps.settings }, layout);
19
- let runtimeSettings = committedSettings;
18
+ const { layout } = deps;
19
+ const state = deps.state ?? new SashStateStore(layout, deps.settings);
20
+ const settings = () => state.snapshot().settings;
20
21
  const token = deps.token ?? crypto.randomBytes(24).toString("hex");
21
- const startedAt = new Date().toISOString();
22
22
  const systemProxy = deps.systemProxy ?? new SystemProxyManager({ layout });
23
- const mutations = new StateMutationQueue(layout.mutationLockFile);
24
- let profileRevision = 0;
25
23
  let lifecycle;
24
+ let gate;
26
25
  const supervisor = deps.supervisor ??
27
26
  new CoreSupervisor({
28
27
  layout,
29
- settings: () => runtimeSettings,
30
- expectedVersion: () => pendingCoreUpdateVersion(layout) || currentCoreVersion(layout) || undefined,
31
- onExit: async () => {
32
- try {
33
- await lifecycle?.handleUnexpectedCoreExit();
34
- }
35
- catch (err) {
36
- console.error(`[sashd] failed to restore system proxy after Core exit: ${err.message}`);
37
- }
38
- },
28
+ settings: () => lifecycle?.settings() ?? settings(),
29
+ expectedVersion: () => currentCoreVersion(layout) || undefined,
30
+ onExit: () => gate
31
+ .mutate("recover Core exit", () => lifecycle.handleUnexpectedCoreExit())
32
+ .catch((error) => {
33
+ console.error(`[sashd] Core exit cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
34
+ }),
39
35
  });
40
36
  lifecycle = new RuntimeLifecycle({
37
+ layout,
41
38
  supervisor,
42
39
  systemProxy,
43
- settings: () => runtimeSettings,
44
- coreUpdate: {
45
- pending: () => readCoreUpdateTransaction(layout) !== undefined,
46
- completeAfterStart: () => {
47
- completeCoordinatedCoreUpdateAfterStart(layout);
48
- },
49
- rollbackAfterStartFailure: () => rollbackCoordinatedCoreUpdate(layout),
50
- },
51
- });
52
- const gate = new DaemonGate(mutations, async () => {
53
- const coreWasRunning = supervisor.isRunning();
54
- await lifecycle.close();
55
- return { coreWasRunning };
40
+ settings,
41
+ verifyExecutable: deps.verifyCoreFn,
42
+ controllerProbe: deps.controllerProbe,
56
43
  });
44
+ let downloading = false;
45
+ let preparation = new AbortController();
46
+ let profiles;
47
+ const cancelPreparations = () => {
48
+ preparation.abort(new StateConflictError("Core operation cancelled"));
49
+ preparation = new AbortController();
50
+ profiles.cancelDownloads();
51
+ };
52
+ gate = new DaemonGate(() => lifecycle.stop(), cancelPreparations);
57
53
  const mutate = (purpose, action) => gate.mutate(purpose, action);
58
- const profiles = new ProfileService({
54
+ profiles = new ProfileService({
59
55
  layout,
60
- settings: () => committedSettings,
61
- ...(deps.fetchProfileFn ? { fetchProfile: deps.fetchProfileFn } : {}),
62
- validateConfig: deps.validateConfigFn ?? ((generated) => validateCoreConfigText(generated.yaml, layout)),
63
- reloadConfig: async (configPath) => {
64
- if (!supervisor.isRunning())
65
- return;
66
- const api = new MihomoApi(runtimeSettings.controller, runtimeSettings.secret);
67
- await api.reloadConfig(configPath);
68
- },
69
- commit: mutate,
70
- onChange: () => {
71
- profileRevision += 1;
72
- },
73
- });
74
- const settingsService = new SettingsService({
75
- layout,
76
- getCommitted: () => committedSettings,
77
- setCommitted: (next) => {
78
- committedSettings = { ...next };
79
- },
80
- setRuntime: (next) => {
81
- runtimeSettings = { ...next };
82
- },
83
- profiles,
84
- supervisor,
85
- lifecycle,
56
+ state,
86
57
  commit: mutate,
58
+ fetchProfile: deps.fetchProfileFn,
87
59
  });
88
- const commitPreparedReload = (prepared, reloadRuntime) => profiles.commitPreparedActiveReload(prepared, {
89
- reloadRuntime,
90
- boundary: "already-held",
91
- });
92
- const startCore = async () => {
93
- if (readCoreUpdateTransaction(layout)) {
94
- return mutate("start pending Core update", () => lifecycle.start());
60
+ const settingsService = new SettingsService({ state, commit: mutate, lifecycle, supervisor });
61
+ const validate = (generated, executable, signal) => Promise.resolve(deps.validateConfigFn
62
+ ? deps.validateConfigFn(generated, executable, signal)
63
+ : validateCoreConfig(executable, generated.yaml, layout, { signal }));
64
+ const savedConfiguration = () => {
65
+ const snapshot = state.snapshot();
66
+ const profile = getActiveProfile(snapshot.profiles);
67
+ return {
68
+ generated: renderActiveConfig(snapshot, layout),
69
+ settings: snapshot.settings,
70
+ profile: profile
71
+ ? { id: profile.id, revision: profile.revision, name: profile.name, url: profile.url }
72
+ : null,
73
+ };
74
+ };
75
+ const requireRecoveredInstall = () => {
76
+ assertCoreInstallationConsistent(layout);
77
+ if (readCoreUpdateTransaction(layout))
78
+ throw new StateConflictError("Core update recovery is pending; run sash stop, then sash start");
79
+ };
80
+ const updateCore = async (version) => {
81
+ if (gate.isClosing)
82
+ throw new Error("sashd is shutting down");
83
+ if (downloading)
84
+ throw new StateConflictError("A Core download is already in progress");
85
+ requireRecoveredInstall();
86
+ const revision = state.snapshot().revision;
87
+ const epoch = lifecycle.revision;
88
+ const configuration = supervisor.isRunning() ? lifecycle.configuration() : savedConfiguration();
89
+ if (!configuration)
90
+ throw new Error("Running Core configuration is unknown");
91
+ const { signal } = preparation;
92
+ downloading = true;
93
+ let staged;
94
+ try {
95
+ staged = await (deps.stageCoreFn ?? stageCore)({ layout, tag: version, signal });
96
+ signal.throwIfAborted();
97
+ await validate(configuration.generated, staged.exe, signal);
98
+ const candidate = staged;
99
+ return await mutate("update Core", async () => {
100
+ signal.throwIfAborted();
101
+ state.assertCurrent(revision);
102
+ if (lifecycle.revision !== epoch)
103
+ throw new StateConflictError("Core changed during download; retry the update");
104
+ return lifecycle.update(candidate, configuration);
105
+ });
95
106
  }
96
- const retryAfterPreparation = Symbol("retry Core start after preparation");
97
- for (;;) {
98
- let prepared;
99
- if (!supervisor.isRunning()) {
107
+ catch (error) {
108
+ signal.throwIfAborted();
109
+ throw error;
110
+ }
111
+ finally {
112
+ downloading = false;
113
+ if (staged) {
114
+ fs.rmSync(staged.exe, { force: true });
100
115
  try {
101
- prepared = await profiles.prepareActiveReload();
116
+ fs.rmdirSync(path.dirname(staged.exe));
102
117
  }
103
- catch (err) {
104
- if (supervisor.isRunning())
105
- continue;
106
- throw err;
118
+ catch {
107
119
  }
108
120
  }
109
- const preparedForStart = prepared;
110
- const result = await mutate("start core", async () => {
111
- if (!preparedForStart && !supervisor.isRunning())
112
- return retryAfterPreparation;
113
- return lifecycle.start(preparedForStart
114
- ? async () => {
115
- await commitPreparedReload(preparedForStart, false);
116
- }
117
- : undefined);
118
- });
119
- if (result !== retryAfterPreparation)
120
- return result;
121
121
  }
122
122
  };
123
- const withPreparedReloadRetry = async (purpose, action) => {
124
- for (let attempt = 0;; attempt += 1) {
125
- const prepared = await profiles.prepareActiveReload();
126
- try {
127
- return await mutate(purpose, () => action(prepared));
128
- }
129
- catch (err) {
130
- if (!(err instanceof ProfileConflictError) || attempt >= 1)
131
- throw err;
132
- }
133
- }
123
+ const applyCore = async (onlyIfStopped = false) => {
124
+ const { signal } = preparation;
125
+ if (!coreInstalled(layout))
126
+ await updateCore();
127
+ return mutate("apply saved configuration", async () => {
128
+ signal.throwIfAborted();
129
+ requireRecoveredInstall();
130
+ if (onlyIfStopped && supervisor.isRunning())
131
+ return lifecycle.start();
132
+ const configuration = savedConfiguration();
133
+ await validate(configuration.generated, layout.coreExe, signal);
134
+ signal.throwIfAborted();
135
+ return lifecycle.apply(configuration);
136
+ });
134
137
  };
135
138
  const context = {
136
139
  layout,
140
+ state,
137
141
  token,
138
- startedAt,
142
+ startedAt: new Date().toISOString(),
139
143
  webAuth: new WebAuthManager(),
140
144
  profiles,
141
145
  settingsService,
142
146
  lifecycle,
143
147
  supervisor,
144
148
  systemProxy,
149
+ autostart: deps.autostart ?? new AutostartService({ layout }),
145
150
  gate,
146
- settings: {
147
- committed: () => committedSettings,
148
- runtime: () => runtimeSettings,
149
- },
150
151
  mutate,
151
- profileRevision: () => profileRevision,
152
- startCore,
153
- restartCore: () => readCoreUpdateTransaction(layout)
154
- ? startCore()
155
- : withPreparedReloadRetry("restart core", (prepared) => lifecycle.restart(async () => {
156
- await commitPreparedReload(prepared, false);
157
- })),
158
- reloadCoreConfig: () => withPreparedReloadRetry("reload core config", (prepared) => commitPreparedReload(prepared, true)),
152
+ settings: { committed: settings, runtime: () => lifecycle.settings() },
153
+ profileRevision: () => state.snapshot().revision,
154
+ pendingApply: () => {
155
+ const saved = state.snapshot();
156
+ const active = getActiveProfile(saved.profiles);
157
+ const applied = lifecycle.configuration();
158
+ return (!applied ||
159
+ applied.profile?.id !== active?.id ||
160
+ applied.profile?.revision !== active?.revision ||
161
+ applied.settings.mixedPort !== saved.settings.mixedPort ||
162
+ applied.settings.allowLan !== saved.settings.allowLan);
163
+ },
164
+ startCore: () => applyCore(true),
165
+ restartCore: () => applyCore(),
166
+ updateCore,
167
+ stopCore: () => {
168
+ cancelPreparations();
169
+ return mutate("stop Core", () => lifecycle.stop());
170
+ },
159
171
  shutdown: () => gate.shutdown(),
160
- closeListener: () => Promise.reject(new Error("listener close is not wired yet")),
172
+ closeListener: () => Promise.reject(new Error("Listener is not ready")),
161
173
  ...(deps.onShutdown ? { onShutdown: deps.onShutdown } : {}),
162
174
  };
163
175
  return { context, supervisor, lifecycle, token };
@@ -1,30 +1,35 @@
1
1
  import { ShuttingDownError } from "./errors.js";
2
2
  export class DaemonGate {
3
- queue;
4
3
  cleanup;
4
+ cancel;
5
+ tail = Promise.resolve();
5
6
  closing = false;
6
7
  cleanupPromise;
7
- constructor(queue, cleanup) {
8
- this.queue = queue;
8
+ constructor(cleanup, cancel) {
9
9
  this.cleanup = cleanup;
10
+ this.cancel = cancel;
10
11
  }
11
12
  get isClosing() {
12
13
  return this.closing;
13
14
  }
14
- async mutate(purpose, action) {
15
+ mutate(_purpose, action) {
15
16
  if (this.closing)
16
- throw new ShuttingDownError();
17
- return this.queue.run(purpose, () => {
17
+ return Promise.reject(new ShuttingDownError());
18
+ const next = this.tail.then(() => {
18
19
  if (this.closing)
19
20
  throw new ShuttingDownError();
20
21
  return action();
21
22
  });
23
+ this.tail = next.then(() => undefined, () => undefined);
24
+ return next;
22
25
  }
23
26
  shutdown() {
24
27
  if (this.cleanupPromise)
25
28
  return this.cleanupPromise;
26
29
  this.closing = true;
27
- const attempt = this.queue.run("close daemon", this.cleanup);
30
+ this.cancel();
31
+ const attempt = this.tail.then(this.cleanup);
32
+ this.tail = attempt.then(() => undefined, () => undefined);
28
33
  this.cleanupPromise = attempt;
29
34
  void attempt.catch(() => {
30
35
  if (this.cleanupPromise === attempt) {
@@ -36,5 +41,6 @@ export class DaemonGate {
36
41
  }
37
42
  reopen() {
38
43
  this.closing = false;
44
+ this.cleanupPromise = undefined;
39
45
  }
40
46
  }
@@ -1,67 +1,50 @@
1
- import { assertCoreInstallationConsistent } from "../core.js";
2
- import { recoverCoreInstallTransaction } from "../core-install-transaction.js";
3
- import { atomicWriteFileSync } from "../fs-atomic.js";
4
- import { readManagedStateTransactionStatus, recoverManagedStateTransaction, } from "../managed-state-transaction.js";
1
+ import { SashStateStore } from "../app-state.js";
2
+ import { atomicWriteFileSync, durableRemoveFileSync } from "../fs-atomic.js";
5
3
  import { sashLayout } from "../paths.js";
6
- import { clearPidRecord } from "../process.js";
7
- import { migrateProfileState } from "../profile-migration.js";
8
- import { reconcileOrphanedRuntime } from "../runtime-recovery.js";
9
- import { loadSettings } from "../settings.js";
10
- import { acquireStateLock, StateMutationQueue } from "../state-lock.js";
4
+ import { acquireStateLock } from "../state-lock.js";
11
5
  import { createDaemonServer } from "./server.js";
12
6
  export async function runDaemon(opts = {}) {
13
7
  const layout = opts.layout ?? sashLayout();
14
- const daemonLease = await acquireStateLock(layout.daemonLeaseFile, {
8
+ const lease = await acquireStateLock(layout.daemonLeaseFile, {
15
9
  purpose: "sashd singleton",
16
10
  timeoutMs: 0,
17
11
  });
18
12
  let onSignal;
13
+ let published = false;
19
14
  try {
20
- const initialization = new StateMutationQueue(layout.mutationLockFile);
21
- const settings = await initialization.run("initialize daemon state", async () => {
22
- recoverCoreInstallTransaction(layout);
23
- const managed = readManagedStateTransactionStatus(layout);
24
- if (managed?.coordination !== "core-update") {
25
- recoverManagedStateTransaction(layout);
26
- }
27
- let loaded = loadSettings(layout);
28
- const pendingUpdate = await reconcileOrphanedRuntime({ layout, settings: loaded });
29
- assertCoreInstallationConsistent(layout);
30
- loaded = loadSettings(layout);
31
- if (!pendingUpdate) {
32
- await migrateProfileState(loaded, layout);
33
- }
34
- return loaded;
35
- });
36
- const instance = createDaemonServer({ layout, settings });
37
- const serverClosed = new Promise((resolve) => instance.server.once("close", resolve));
38
- const port = settings.daemonPort;
15
+ const state = new SashStateStore(layout);
16
+ const instance = createDaemonServer({ layout, state });
17
+ await instance.lifecycle.recoverStartup();
18
+ const closed = new Promise((resolve) => instance.server.once("close", resolve));
19
+ const port = state.snapshot().settings.daemonPort;
39
20
  await new Promise((resolve, reject) => {
40
- instance.server.listen(port, "127.0.0.1", () => resolve());
41
21
  instance.server.once("error", reject);
22
+ instance.server.listen(port, "127.0.0.1", resolve);
42
23
  });
43
- const pidRecord = {
24
+ const record = {
44
25
  pid: process.pid,
45
26
  token: instance.token,
46
27
  port,
47
28
  startedAt: new Date().toISOString(),
48
29
  };
49
- atomicWriteFileSync(layout.daemonPidFile, `${JSON.stringify(pidRecord, null, 2)}\n`);
30
+ atomicWriteFileSync(layout.daemonPidFile, `${JSON.stringify(record, null, 2)}\n`);
31
+ published = true;
50
32
  onSignal = () => {
51
- void instance.close().catch((err) => {
52
- console.error(`[sashd] shutdown blocked: ${err.message}`);
53
- });
33
+ void instance
34
+ .close()
35
+ .catch((error) => console.error(`[sashd] shutdown blocked: ${error instanceof Error ? error.message : String(error)}`));
54
36
  };
55
37
  process.on("SIGTERM", onSignal);
56
38
  process.on("SIGINT", onSignal);
57
- await serverClosed;
39
+ await closed;
58
40
  }
59
41
  finally {
60
42
  if (onSignal) {
61
43
  process.removeListener("SIGTERM", onSignal);
62
44
  process.removeListener("SIGINT", onSignal);
63
45
  }
64
- clearPidRecord(layout.daemonPidFile);
65
- daemonLease.release();
46
+ if (published)
47
+ durableRemoveFileSync(layout.daemonPidFile);
48
+ lease.release();
66
49
  }
67
50
  }
@@ -1,3 +1,4 @@
1
+ import { AutostartUnavailableError } from "../autostart.js";
1
2
  import { HttpError } from "../daemon-http.js";
2
3
  import { ProfileConflictError, ProfileInputError, ProfileNotFoundError, } from "../profile-service.js";
3
4
  import { CoreUnhealthyError, SettingsConflictError, SettingsInputError, } from "../settings-service.js";
@@ -31,6 +32,8 @@ export function errorToHttp(err) {
31
32
  };
32
33
  }
33
34
  const message = err instanceof Error ? err.message : String(err);
35
+ if (err instanceof AutostartUnavailableError)
36
+ return { status: 409, code: "conflict", message };
34
37
  if (err instanceof ProfileNotFoundError)
35
38
  return { status: 404, code: "not_found", message };
36
39
  if (err instanceof ProfileInputError)
@@ -0,0 +1,18 @@
1
+ import { parseAutostartEnabled } from "../../autostart-contract.js";
2
+ import { HttpError } from "../../daemon-http.js";
3
+ import { errorMessage } from "../../error-utils.js";
4
+ export async function readAutostart(ctx) {
5
+ return { status: 200, json: await ctx.autostart.inspect() };
6
+ }
7
+ export async function writeAutostart(ctx, req) {
8
+ const body = await req.readJson();
9
+ let enabled;
10
+ try {
11
+ enabled = parseAutostartEnabled(body);
12
+ }
13
+ catch (error) {
14
+ throw new HttpError(400, errorMessage(error));
15
+ }
16
+ const status = await ctx.mutate("configure login startup", () => ctx.autostart.set(enabled));
17
+ return { status: 200, json: status };
18
+ }
@@ -1,17 +1,40 @@
1
+ import { MihomoApi } from "../../api.js";
2
+ import { validateCoreReleaseTag } from "../../core-install-record.js";
3
+ import { HttpError } from "../../daemon-http.js";
1
4
  export async function startCore(ctx) {
2
- const result = await ctx.startCore();
3
- return { status: 200, json: result };
5
+ return { status: 200, json: await ctx.startCore() };
4
6
  }
5
7
  export async function stopCore(ctx) {
6
- await ctx.mutate("stop core", () => ctx.lifecycle.stop());
8
+ await ctx.stopCore();
7
9
  return { status: 204 };
8
10
  }
9
11
  export async function restartCore(ctx) {
10
- const result = await ctx.restartCore();
11
- return { status: 200, json: result };
12
+ return { status: 200, json: await ctx.restartCore() };
12
13
  }
13
- export async function reloadCoreConfig(ctx) {
14
- const result = await ctx.reloadCoreConfig();
15
- const body = { proxyCount: result.proxyCount, source: result.source };
16
- return { status: 200, json: body };
14
+ export async function updateCore(ctx, req) {
15
+ const body = await req.readJson(1024);
16
+ if (Object.keys(body).some((key) => key !== "version") ||
17
+ (body.version !== undefined && typeof body.version !== "string")) {
18
+ throw new HttpError(400, "Expected an optional Core version string");
19
+ }
20
+ let version;
21
+ try {
22
+ version = typeof body.version === "string" ? validateCoreReleaseTag(body.version) : undefined;
23
+ }
24
+ catch (error) {
25
+ throw new HttpError(400, error instanceof Error ? error.message : String(error));
26
+ }
27
+ return { status: 200, json: await ctx.updateCore(version) };
28
+ }
29
+ export async function setCoreMode(ctx, req) {
30
+ const body = await req.readJson(1024);
31
+ const mode = body.mode;
32
+ if (Object.keys(body).some((key) => key !== "mode") ||
33
+ (mode !== "rule" && mode !== "global" && mode !== "direct"))
34
+ throw new HttpError(400, "Invalid routing mode");
35
+ await ctx.mutate("set routing mode", async () => {
36
+ const settings = ctx.settings.runtime();
37
+ await new MihomoApi(settings.controller, settings.secret).setMode(mode);
38
+ });
39
+ return { status: 204 };
17
40
  }