@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.
- package/CHANGELOG.md +48 -0
- package/README.md +22 -7
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/dist/api.js +14 -45
- package/dist/app-state.js +99 -0
- package/dist/autostart/command.js +24 -0
- package/dist/autostart/context.js +43 -0
- package/dist/autostart/files.js +60 -0
- package/dist/autostart/installation.js +48 -0
- package/dist/autostart/start.js +47 -0
- package/dist/autostart/windows-registry.js +88 -0
- package/dist/autostart/windows.js +59 -0
- package/dist/autostart-contract.js +31 -0
- package/dist/autostart-entry.js +10 -0
- package/dist/autostart.js +69 -0
- package/dist/cli.js +11 -11
- package/dist/commands/auto.js +15 -0
- package/dist/commands/lifecycle.js +5 -31
- package/dist/commands/logs.js +11 -4
- package/dist/commands/shared.js +2 -4
- package/dist/commands/status.js +6 -7
- package/dist/commands/update.js +7 -3
- package/dist/commands/web.js +19 -27
- package/dist/contracts.js +171 -335
- package/dist/core-config-validation.js +22 -38
- package/dist/core-update.js +126 -762
- package/dist/core.js +20 -46
- package/dist/daemon/app.js +132 -113
- package/dist/daemon/context.js +13 -7
- package/dist/daemon/entry.js +21 -38
- package/dist/daemon/errors.js +6 -1
- package/dist/daemon/handlers/autostart.js +18 -0
- package/dist/daemon/handlers/core.js +32 -9
- package/dist/daemon/handlers/daemon.js +32 -6
- package/dist/daemon/handlers/profiles.js +12 -2
- package/dist/daemon/handlers/settings.js +9 -36
- package/dist/daemon/router.js +40 -27
- package/dist/daemon/scheduler.js +3 -1
- package/dist/daemon/server.js +6 -3
- package/dist/daemon/web-auth.js +52 -0
- package/dist/daemon-auth.js +2 -2
- package/dist/daemon-client.js +18 -2
- package/dist/daemon-http.js +7 -0
- package/dist/daemon-lifecycle.js +19 -196
- package/dist/github.js +7 -2
- package/dist/http.js +7 -5
- package/dist/log-follow.js +2 -1
- package/dist/mihomo-config.js +7 -9
- package/dist/paths.js +5 -7
- package/dist/profile-model.js +87 -0
- package/dist/profile-service.js +250 -587
- package/dist/profiles.js +43 -171
- package/dist/runtime-lifecycle.js +112 -194
- package/dist/runtime-owner.js +37 -76
- package/dist/sash-client.js +63 -21
- package/dist/settings-service.js +29 -228
- package/dist/settings.js +61 -285
- package/dist/status.js +24 -40
- package/dist/supervisor.js +1 -14
- package/dist/sysproxy/common.js +5 -45
- package/dist/sysproxy/factory.js +24 -65
- package/dist/sysproxy/snapshot.js +24 -257
- package/dist/sysproxy.js +1 -4
- package/dist/system-proxy-manager.js +37 -35
- package/dist/ui/assets/{ConnectionsView-DNGmZBSU.js → ConnectionsView-BgXQM6Z4.js} +1 -1
- package/dist/ui/assets/{LogsView-fSiaxQ13.js → LogsView-2f542P8z.js} +1 -1
- package/dist/ui/assets/{PaginationFooter-B3kHzRfB.js → PaginationFooter-cj1tcZej.js} +1 -1
- package/dist/ui/assets/ProfileEditorDialog-C9M8uKZC.css +1 -0
- package/dist/ui/assets/ProfileEditorDialog-DD4y4GBC.js +14 -0
- package/dist/ui/assets/ProfilesView-BXU2DOA7.css +1 -0
- package/dist/ui/assets/ProfilesView-DNnzYIEY.js +7 -0
- package/dist/ui/assets/{RulesView-D9vZBiJ1.js → RulesView-CtKvlckZ.js} +1 -1
- package/dist/ui/assets/SettingsView-BaeKFF_N.js +1 -0
- package/dist/ui/assets/SettingsView-CWjIe05v.css +1 -0
- package/dist/ui/assets/{0be242294f7d791af850c6df38ac78a0-2cL6Ntwf.woff2 → e2a57555d97d0b02b45d9418eb6ee295-D9nhF3rM.woff2} +0 -0
- package/dist/ui/assets/index-CBInDvdJ.js +19 -0
- package/dist/ui/assets/index-mOLy7fkB.css +1 -0
- package/dist/ui/assets/{theme-BNq4FkXS.js → theme-D40MF8cQ.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/dist/web-bootstrap.js +113 -0
- package/docs/architecture-proposal.md +109 -0
- package/docs/autostart.md +101 -0
- package/docs/backend.md +92 -216
- package/docs/frontend.md +61 -97
- package/docs/usage.md +88 -186
- package/package.json +10 -6
- package/dist/commands/upgrade.js +0 -43
- package/dist/core-install-transaction.js +0 -114
- package/dist/core-update-coordination.js +0 -105
- package/dist/core-update-service.js +0 -245
- package/dist/managed-state-transaction.js +0 -377
- package/dist/offline-mutation.js +0 -58
- package/dist/profile-migration.js +0 -101
- package/dist/runtime-recovery.js +0 -31
- package/dist/sysproxy/darwin.js +0 -287
- package/dist/sysproxy/gnome.js +0 -181
- package/dist/sysproxy/legacy.js +0 -58
- package/dist/tun-guidance.js +0 -11
- package/dist/ui/assets/CodeEditorModal-6m0TJWyF.css +0 -1
- package/dist/ui/assets/CodeEditorModal-CFEnWsyh.js +0 -14
- package/dist/ui/assets/ProfileEditorDialog-BydoZthX.js +0 -1
- package/dist/ui/assets/ProfilesView-Btc1DOxE.js +0 -2
- package/dist/ui/assets/ProfilesView-ByqdFNHN.css +0 -1
- package/dist/ui/assets/SettingsFileDialog-CNFEAVs4.js +0 -1
- package/dist/ui/assets/SettingsView-BlDhZkXQ.js +0 -2
- package/dist/ui/assets/SettingsView-CngS3vBM.css +0 -1
- package/dist/ui/assets/index-B61V60w_.js +0 -19
- package/dist/ui/assets/index-bkyxJG8J.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.2] - 2026-09-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Add Windows login startup through `sash auto [on|off|status]` and the dashboard settings. Preserve the selected data directory and report stale or OS-disabled entries; bare `sash auto` reads status.
|
|
14
|
+
- Record login startup attempts and failures in a private rotating log, readable with `sash logs --startup` even when settings are invalid.
|
|
15
|
+
- Add a source development launcher with separate data and ports, plus a `build` command for the WebUI.
|
|
16
|
+
- Reorder profile cards with a long press and drag or Alt + Up/Down. Persist the order across dashboard refreshes without changing the active profile or reloading Core.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Make the daemon the only application state writer. Replace offline mutation, maintenance handoff and coordinated profile/Core transactions with one in-memory mutation queue.
|
|
21
|
+
- Store settings, profile metadata and selection in one atomic schema-2 `sash.json`; store profile sources as immutable `<id>/<revision>.yaml` files. No old-format or API migration is provided.
|
|
22
|
+
- Separate saving from applying. Profile edits, selection, scheduled updates and network preferences stay saved until Apply or `sash restart`; failed application preserves saved edits.
|
|
23
|
+
- Make `sash web` start management without Core, and make `sash restart` replace Core while retaining the daemon and browser sessions.
|
|
24
|
+
- Narrow Core updates to executable/install metadata. First installs and updates always complete health verification immediately, including a temporary start when initially stopped; retain rollback files until verification and runtime restoration succeed.
|
|
25
|
+
- Keep Vue, the existing WebUI and LXGW WenKai Lite font. Share Core controls, refresh Core resources independently by visible page, and retain caches and latency results across metadata edits.
|
|
26
|
+
- Remove TUN product fields, non-Windows desktop integration, raw settings editing in the dashboard, configuration reload aliases, `sash upgrade` and `sash update --force`.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Cancel pending downloads and configuration validation on stop/shutdown; prevent stale responses from changing a replacement runtime.
|
|
31
|
+
- Wrap long proxy node names across the full card width and keep latency controls on the metadata row.
|
|
32
|
+
- Make profile card padding and unused space clickable while keeping rename, edit, update and delete actions independent.
|
|
33
|
+
|
|
34
|
+
## [0.1.1] - 2026-09-08
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Start a stopped Core update from its already validated configuration before normal profile publication, preserving coordinated rollback until the health check succeeds. This also fixes first startup after installing Core with `sash update`.
|
|
39
|
+
- Authorize dashboard HTTP and WebSocket control through private, single-use browser handoffs; keep credentials out of public health responses and preserve sessions across page refreshes.
|
|
40
|
+
- Keep the recovery dashboard available when Core startup fails without authorizing competing starts from unknown runtime observations.
|
|
41
|
+
- Fix native Windows log watching, clean font builds, temporary-directory aliases, private browser-handoff file checks and cross-platform test timing.
|
|
42
|
+
- Retry ownership-safe system-proxy cleanup when an explicit disable request repeats the saved off state.
|
|
43
|
+
- Preserve original transaction errors after successful rollback, retain causes on incomplete rollback and report stale settings snapshots as conflicts.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- Publish verified npm artifacts through GitHub Actions trusted publishing, with provenance and registry-install checks.
|
|
48
|
+
- Add an npm release runbook, external package smoke checks and dependency update configuration.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- Defer TUN and Windows Service Mode to the `feat/tun-service-mode` development branch. This release provides local HTTP/SOCKS endpoints and system-proxy controls, with no TUN or service administration controls.
|
|
53
|
+
- Migrate enabled legacy TUN settings to off, reject new enable requests and explicitly disable TUN in generated configurations. Preserve original profile files and DNS/provider options; reject separate TUN listeners before publication.
|
|
54
|
+
|
|
7
55
|
## [0.1.0] - 2026-09-05
|
|
8
56
|
|
|
9
57
|
### Added
|
package/README.md
CHANGED
|
@@ -2,25 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
> A lightweight command-line companion and web dashboard for a rule-based network core.
|
|
4
4
|
|
|
5
|
-
Sash is a **network toolbox for developers and
|
|
5
|
+
Sash is a **network toolbox for developers, learning, and research**. It installs, runs, and maintains a rule-based network core on your machine: local HTTP/SOCKS endpoints, rule-driven traffic routing, remote profile management, and live traffic inspection through a built-in web dashboard.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- **Supervisor daemon (`sashd`)** — background supervisor on port `19090` managing the core lifecycle, recovery, and reverse proxying
|
|
10
10
|
- **Zero-download web dashboard** — built-in modern Vue 3 dashboard bundled with the package at `http://127.0.0.1:19090/ui/`
|
|
11
|
-
- **
|
|
11
|
+
- **Windows system proxy** — snapshots and conditionally restores the current user's prior proxy/PAC settings
|
|
12
12
|
- **One-command lifecycle** — `sash start`, `stop`, `restart`, `status`, `logs`
|
|
13
|
-
- **
|
|
13
|
+
- **Windows login startup** — configure it through `sash auto on/off` or the dashboard settings
|
|
14
|
+
- **Explicit save and apply** — import, edit and update profiles, then apply saved changes with one Core restart
|
|
14
15
|
- **Verified upgrades** — SHA-256-verified downloads, bounded extraction, exact-version checks and atomic rollback (`sash update`)
|
|
15
|
-
- **TUN mode** — device-level traffic takeover (requires starting the whole Sash runtime with elevated privileges)
|
|
16
16
|
- **Credential hygiene** — child processes run with scrubbed environments; loopback traffic never traverses proxy dispatchers
|
|
17
17
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
20
|
- Node.js **24 or newer**
|
|
21
|
-
- Windows 10
|
|
21
|
+
- Windows 10+ — x64 and arm64
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Basic Core lifecycle and local endpoints remain portable to macOS and Linux. Desktop system-proxy and login-startup integration are Windows-only.
|
|
24
24
|
|
|
25
25
|
## Install
|
|
26
26
|
|
|
@@ -49,11 +49,26 @@ sash status # runtime state, endpoints, and proxy status
|
|
|
49
49
|
sash stop # restores prior proxy state, stops core and sashd
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
Use `sash web` to authorize and open the dashboard. Opening its address directly shows connection instructions. Refreshing an authorized tab preserves access; after restarting Sash, run `sash web` again.
|
|
53
|
+
|
|
54
|
+
`sash web` also works while Core is stopped or missing. Profile selection, content edits and network settings are saved first; **Apply configuration** (or `sash restart`) restarts Core with those changes. Core updates and restarts keep the dashboard session alive.
|
|
55
|
+
|
|
56
|
+
Use `sash auto on` to start Sash at login, `sash auto status` to inspect the
|
|
57
|
+
registration and `sash auto off` to remove it. This requires a direct global npm
|
|
58
|
+
installation and preserves the current data directory. See [Automatic Startup](./docs/autostart.md)
|
|
59
|
+
for platform behavior, diagnostics and removal before uninstalling.
|
|
60
|
+
|
|
61
|
+
TUN and Windows Service Mode remain on the [development branch](https://github.com/ming-kang/Sash/tree/feat/tun-service-mode). This branch keeps TUN disabled and runs without a privileged service.
|
|
62
|
+
|
|
63
|
+
This refactor introduces a new state format and API without migration support. Use a fresh data directory and import any profile YAML you want to keep; existing state is never silently overwritten.
|
|
64
|
+
|
|
52
65
|
## Documentation
|
|
53
66
|
|
|
54
67
|
Comprehensive documentation is available in the [`docs/`](./docs) directory:
|
|
55
68
|
|
|
56
|
-
- [**User & Operations Guide**](./docs/usage.md) — complete CLI command reference, configuration parameters
|
|
69
|
+
- [**User & Operations Guide**](./docs/usage.md) — complete CLI command reference, configuration parameters and troubleshooting.
|
|
70
|
+
- [**High-level Architecture**](./docs/architecture-proposal.md) — the implemented design, ownership boundaries and save/apply flow.
|
|
71
|
+
- [**Automatic Startup**](./docs/autostart.md) — login startup, OS registration state and failure diagnostics.
|
|
57
72
|
- [**Backend Architecture**](./docs/backend.md) — supervisor daemon model (`sashd`), API endpoints, lifecycle management, system proxy adapters, and safety invariants.
|
|
58
73
|
- [**Frontend Architecture**](./docs/frontend.md) — built-in Vue 3 + Vite dashboard, shared API contracts, reactive runtime state, and WebSocket streaming.
|
|
59
74
|
- [**Third-Party Notices**](./THIRD_PARTY_NOTICES.md) — licenses and attribution for code/assets embedded in the dashboard and the runtime-downloaded Core.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -29,6 +29,15 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
29
29
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
30
30
|
THE SOFTWARE.
|
|
31
31
|
|
|
32
|
+
## SortableJS
|
|
33
|
+
|
|
34
|
+
- Package: `sortablejs` 1.15.7
|
|
35
|
+
- Project: <https://github.com/SortableJS/Sortable>
|
|
36
|
+
- License: MIT (permission and warranty terms reproduced above)
|
|
37
|
+
- Copyright: Copyright (c) 2019 All contributors to Sortable
|
|
38
|
+
|
|
39
|
+
The profile page embeds SortableJS for mouse and touch drag ordering.
|
|
40
|
+
|
|
32
41
|
## Remix Icon
|
|
33
42
|
|
|
34
43
|
- Package: `@remixicon/vue` 4.9.0
|
package/dist/api.js
CHANGED
|
@@ -36,8 +36,11 @@ export class MihomoApi {
|
|
|
36
36
|
return false;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
-
async version() {
|
|
40
|
-
const res = await this.request("/version", {
|
|
39
|
+
async version(options = {}) {
|
|
40
|
+
const res = await this.request("/version", {
|
|
41
|
+
deadlineMs: options.deadlineMs ?? 5_000,
|
|
42
|
+
attempts: options.attempts ?? 2,
|
|
43
|
+
});
|
|
41
44
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
42
45
|
const summary = (await res.text(ERROR_BODY_LIMIT)).slice(0, 200).trim();
|
|
43
46
|
throw new Error(`Mihomo API returned HTTP ${res.statusCode}: ${summary}`);
|
|
@@ -54,50 +57,16 @@ export class MihomoApi {
|
|
|
54
57
|
return data.version.trim();
|
|
55
58
|
throw new Error("Mihomo /version response is missing a non-empty version");
|
|
56
59
|
}
|
|
57
|
-
async
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
const text = await res.text(1024 * 1024);
|
|
64
|
-
let data;
|
|
65
|
-
try {
|
|
66
|
-
data = JSON.parse(text);
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
throw new Error(`Invalid JSON response from Mihomo /configs: ${text.slice(0, 200).trim()}`);
|
|
70
|
-
}
|
|
71
|
-
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
72
|
-
throw new Error("Mihomo /configs response is not an object");
|
|
73
|
-
}
|
|
74
|
-
const tun = data.tun;
|
|
75
|
-
if (typeof tun !== "object" || tun === null || Array.isArray(tun)) {
|
|
76
|
-
throw new Error("Mihomo /configs response is missing the TUN runtime state");
|
|
77
|
-
}
|
|
78
|
-
const enable = tun.enable;
|
|
79
|
-
if (typeof enable !== "boolean") {
|
|
80
|
-
throw new Error("Mihomo /configs response is missing boolean tun.enable");
|
|
81
|
-
}
|
|
82
|
-
return enable;
|
|
83
|
-
}
|
|
84
|
-
async reloadConfig(configPath) {
|
|
85
|
-
const body = JSON.stringify({ path: configPath });
|
|
86
|
-
const res = await this.request("/configs?force=true", {
|
|
87
|
-
method: "PUT",
|
|
88
|
-
body,
|
|
89
|
-
deadlineMs: 5_000,
|
|
60
|
+
async setMode(mode) {
|
|
61
|
+
const response = await this.request("/configs", {
|
|
62
|
+
method: "PATCH",
|
|
63
|
+
body: JSON.stringify({ mode }),
|
|
64
|
+
attempts: 1,
|
|
90
65
|
});
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
bodyText = (await res.text(ERROR_BODY_LIMIT)).trim();
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
}
|
|
98
|
-
const summary = bodyText ? `: ${bodyText.slice(0, 300)}` : "";
|
|
99
|
-
throw new Error(`Failed to reload Mihomo config (HTTP ${res.statusCode})${summary}`);
|
|
66
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
67
|
+
const message = await response.text(ERROR_BODY_LIMIT);
|
|
68
|
+
throw new Error(`Core rejected mode change: HTTP ${response.statusCode}: ${message.slice(0, 200)}`);
|
|
100
69
|
}
|
|
101
|
-
await
|
|
70
|
+
await response.discard();
|
|
102
71
|
}
|
|
103
72
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { atomicWriteFileSync } from "./fs-atomic.js";
|
|
3
|
+
import { hasExactOwnKeys, isPlainObject } from "./json-shape.js";
|
|
4
|
+
import { sashLayout } from "./paths.js";
|
|
5
|
+
import { parseProfilesIndex } from "./profile-model.js";
|
|
6
|
+
import { DEFAULT_SETTINGS, initialSettings, validateSettingsCandidate, } from "./settings.js";
|
|
7
|
+
const MAX_STATE_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
export class StateConflictError extends Error {
|
|
9
|
+
}
|
|
10
|
+
function readStateText(layout) {
|
|
11
|
+
try {
|
|
12
|
+
const stat = fs.lstatSync(layout.settingsFile);
|
|
13
|
+
if (!stat.isFile() || stat.size > MAX_STATE_BYTES)
|
|
14
|
+
throw new Error("Sash state must be a bounded regular file");
|
|
15
|
+
const bytes = fs.readFileSync(layout.settingsFile);
|
|
16
|
+
if (bytes.length > MAX_STATE_BYTES)
|
|
17
|
+
throw new Error("Sash state is too large");
|
|
18
|
+
return bytes.toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === "ENOENT")
|
|
22
|
+
return undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function parseState(value) {
|
|
27
|
+
if (!isPlainObject(value) || value.schemaVersion !== 2) {
|
|
28
|
+
throw new Error("Invalid Sash state: schemaVersion must be 2");
|
|
29
|
+
}
|
|
30
|
+
if (!hasExactOwnKeys(value, ["schemaVersion", "revision", "settings", "profiles"]) ||
|
|
31
|
+
typeof value.revision !== "number" ||
|
|
32
|
+
!Number.isSafeInteger(value.revision) ||
|
|
33
|
+
value.revision < 0) {
|
|
34
|
+
throw new Error("Sash state has an invalid shape or revision");
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
schemaVersion: 2,
|
|
38
|
+
revision: value.revision,
|
|
39
|
+
settings: validateSettingsCandidate(value.settings),
|
|
40
|
+
profiles: parseProfilesIndex(value.profiles),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function parseStateText(text, layout) {
|
|
44
|
+
try {
|
|
45
|
+
return parseState(JSON.parse(text));
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error(`Cannot read Sash state at ${layout.settingsFile}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function readState(layout = sashLayout()) {
|
|
52
|
+
const text = readStateText(layout);
|
|
53
|
+
return text === undefined ? undefined : parseStateText(text, layout);
|
|
54
|
+
}
|
|
55
|
+
export function loadSettings(layout = sashLayout()) {
|
|
56
|
+
return readState(layout)?.settings ?? { ...DEFAULT_SETTINGS };
|
|
57
|
+
}
|
|
58
|
+
export class SashStateStore {
|
|
59
|
+
layout;
|
|
60
|
+
state;
|
|
61
|
+
text;
|
|
62
|
+
constructor(layout, settings) {
|
|
63
|
+
this.layout = layout;
|
|
64
|
+
const stored = readStateText(layout);
|
|
65
|
+
if (stored !== undefined) {
|
|
66
|
+
this.state = parseStateText(stored, layout);
|
|
67
|
+
this.text = stored;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.state = {
|
|
71
|
+
schemaVersion: 2,
|
|
72
|
+
revision: 0,
|
|
73
|
+
settings: validateSettingsCandidate(settings ?? initialSettings()),
|
|
74
|
+
profiles: { activeId: null, profiles: [] },
|
|
75
|
+
};
|
|
76
|
+
this.text = `${JSON.stringify(this.state, null, 2)}\n`;
|
|
77
|
+
atomicWriteFileSync(layout.settingsFile, this.text);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
snapshot() {
|
|
81
|
+
return structuredClone(this.state);
|
|
82
|
+
}
|
|
83
|
+
assertCurrent(revision) {
|
|
84
|
+
if (revision !== this.state.revision || readStateText(this.layout) !== this.text) {
|
|
85
|
+
throw new StateConflictError("Sash state changed; refresh before retrying. Edit files only while Sash is stopped.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
commit(candidate) {
|
|
89
|
+
this.assertCurrent(candidate.revision);
|
|
90
|
+
const next = parseState({ ...candidate, revision: candidate.revision + 1 });
|
|
91
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
92
|
+
if (Buffer.byteLength(text) > MAX_STATE_BYTES)
|
|
93
|
+
throw new Error("Sash state is too large");
|
|
94
|
+
atomicWriteFileSync(this.layout.settingsFile, text);
|
|
95
|
+
this.state = next;
|
|
96
|
+
this.text = text;
|
|
97
|
+
return this.snapshot();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { buildSanitizedEnv } from "../process.js";
|
|
3
|
+
export const runAutostartCommand = (command, args, env) => new Promise((resolve, reject) => {
|
|
4
|
+
const child = execFile(command, args, {
|
|
5
|
+
encoding: "utf8",
|
|
6
|
+
env: buildSanitizedEnv(env),
|
|
7
|
+
timeout: 10_000,
|
|
8
|
+
maxBuffer: 256 * 1024,
|
|
9
|
+
windowsHide: true,
|
|
10
|
+
shell: false,
|
|
11
|
+
}, (error, stdout, stderr) => {
|
|
12
|
+
if (error && typeof error.code !== "number")
|
|
13
|
+
reject(error);
|
|
14
|
+
else
|
|
15
|
+
resolve({ code: typeof error?.code === "number" ? error.code : 0, stdout, stderr });
|
|
16
|
+
});
|
|
17
|
+
child.stdin?.end();
|
|
18
|
+
});
|
|
19
|
+
export function requireCommandSuccess(result) {
|
|
20
|
+
if (result.code !== 0) {
|
|
21
|
+
throw new Error("Autostart command failed: " +
|
|
22
|
+
(result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`).slice(0, 1000));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { sashLayout } from "../paths.js";
|
|
5
|
+
import { buildSanitizedEnv } from "../process.js";
|
|
6
|
+
import { runAutostartCommand } from "./command.js";
|
|
7
|
+
function absoluteEnvPath(value, fallback) {
|
|
8
|
+
const configured = value?.trim();
|
|
9
|
+
return configured && path.isAbsolute(configured) ? configured : fallback;
|
|
10
|
+
}
|
|
11
|
+
export function autostartContext(options = {}) {
|
|
12
|
+
const platform = options.platform ?? process.platform;
|
|
13
|
+
const homedir = options.homedir ?? os.homedir();
|
|
14
|
+
const env = buildSanitizedEnv(options.env ?? process.env);
|
|
15
|
+
const packageRoot = options.packageRoot ?? path.resolve(import.meta.dirname, "../..");
|
|
16
|
+
const controlDir = path.join(absoluteEnvPath(env.LOCALAPPDATA, path.join(homedir, "AppData", "Local")), "Sash", "autostart");
|
|
17
|
+
const runCommand = options.runCommand ?? runAutostartCommand;
|
|
18
|
+
return {
|
|
19
|
+
platform,
|
|
20
|
+
homedir,
|
|
21
|
+
controlDir,
|
|
22
|
+
dataDir: path.resolve((options.layout ?? sashLayout()).root),
|
|
23
|
+
packageRoot,
|
|
24
|
+
nodePath: options.nodePath ?? process.execPath,
|
|
25
|
+
entryPath: path.join(packageRoot, "dist", "autostart-entry.js"),
|
|
26
|
+
env,
|
|
27
|
+
run: (command, args, extraEnv) => runCommand(command, args, buildSanitizedEnv({ ...env, ...extraEnv })),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function assertLauncherValue(value) {
|
|
31
|
+
if (!value ||
|
|
32
|
+
Array.from(value).some((char) => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127)) {
|
|
33
|
+
throw new Error("Autostart paths must be nonempty and contain no control characters");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function launcherFilesExist(ctx) {
|
|
37
|
+
try {
|
|
38
|
+
return [ctx.nodePath, ctx.entryPath].every((file) => fs.statSync(file).isFile());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { errnoCode, errorMessage } from "../error-utils.js";
|
|
3
|
+
import { atomicWriteFileSync, durableRemoveFileSync } from "../fs-atomic.js";
|
|
4
|
+
export function readRegistration(file) {
|
|
5
|
+
try {
|
|
6
|
+
const stat = fs.lstatSync(file);
|
|
7
|
+
if (!stat.isFile() || stat.size > 64 * 1024) {
|
|
8
|
+
throw new Error(`Invalid autostart registration file: ${file}`);
|
|
9
|
+
}
|
|
10
|
+
const flags = fs.constants.O_RDONLY | (process.platform === "win32" ? 0 : fs.constants.O_NOFOLLOW);
|
|
11
|
+
const fd = fs.openSync(file, flags);
|
|
12
|
+
try {
|
|
13
|
+
const opened = fs.fstatSync(fd);
|
|
14
|
+
if (!opened.isFile() || opened.size > 64 * 1024 || opened.ino !== stat.ino) {
|
|
15
|
+
throw new Error(`Autostart registration changed while reading: ${file}`);
|
|
16
|
+
}
|
|
17
|
+
const data = Buffer.alloc(64 * 1024 + 1);
|
|
18
|
+
const length = fs.readSync(fd, data, 0, data.length, 0);
|
|
19
|
+
if (length > 64 * 1024)
|
|
20
|
+
throw new Error(`Autostart registration is too large: ${file}`);
|
|
21
|
+
return data.subarray(0, length);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
fs.closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (errnoCode(error) === "ENOENT")
|
|
29
|
+
return undefined;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function registerFile(file, contents, register, compensate) {
|
|
34
|
+
const before = readRegistration(file);
|
|
35
|
+
const next = Buffer.from(contents);
|
|
36
|
+
atomicWriteFileSync(file, next);
|
|
37
|
+
try {
|
|
38
|
+
await register();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
try {
|
|
42
|
+
if (!readRegistration(file)?.equals(next)) {
|
|
43
|
+
throw new Error("Autostart launcher changed during registration; preserved it");
|
|
44
|
+
}
|
|
45
|
+
if (before)
|
|
46
|
+
atomicWriteFileSync(file, before);
|
|
47
|
+
else
|
|
48
|
+
durableRemoveFileSync(file);
|
|
49
|
+
await compensate?.();
|
|
50
|
+
}
|
|
51
|
+
catch (rollbackError) {
|
|
52
|
+
throw new Error(`${errorMessage(error)}; autostart rollback failed: ${errorMessage(rollbackError)}`, { cause: error });
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function removeRegistration(file) {
|
|
58
|
+
if (readRegistration(file) !== undefined)
|
|
59
|
+
durableRemoveFileSync(file);
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertLauncherValue } from "./context.js";
|
|
4
|
+
const INSTALL_HINT = "Autostart requires a direct global installation. Install with npm install -g @astralyn/sash.";
|
|
5
|
+
export function installationIssue(ctx) {
|
|
6
|
+
try {
|
|
7
|
+
for (const value of [ctx.nodePath, ctx.entryPath, ctx.dataDir])
|
|
8
|
+
assertLauncherValue(value);
|
|
9
|
+
if (!path.isAbsolute(ctx.nodePath) || !path.isAbsolute(ctx.entryPath))
|
|
10
|
+
return INSTALL_HINT;
|
|
11
|
+
const parts = path.resolve(ctx.packageRoot).replaceAll("\\", "/").toLowerCase().split("/");
|
|
12
|
+
if (parts.some((part) => ["_npx", ".pnpm", ".yarn", ".bun"].includes(part)))
|
|
13
|
+
return INSTALL_HINT;
|
|
14
|
+
const scope = path.dirname(ctx.packageRoot);
|
|
15
|
+
const modules = path.dirname(scope);
|
|
16
|
+
if (path.basename(ctx.packageRoot).toLowerCase() !== "sash" ||
|
|
17
|
+
path.basename(scope) !== "@astralyn" ||
|
|
18
|
+
path.basename(modules).toLowerCase() !== "node_modules") {
|
|
19
|
+
return INSTALL_HINT;
|
|
20
|
+
}
|
|
21
|
+
for (const directory of [ctx.packageRoot, scope, modules]) {
|
|
22
|
+
if (!fs.lstatSync(directory).isDirectory())
|
|
23
|
+
return INSTALL_HINT;
|
|
24
|
+
}
|
|
25
|
+
const parent = path.dirname(modules);
|
|
26
|
+
const prefix = parent;
|
|
27
|
+
for (const marker of ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"]) {
|
|
28
|
+
if (fs.existsSync(path.join(prefix, marker)))
|
|
29
|
+
return INSTALL_HINT;
|
|
30
|
+
}
|
|
31
|
+
const cli = path.join(ctx.packageRoot, "dist", "cli.js");
|
|
32
|
+
for (const file of [ctx.nodePath, ctx.entryPath, cli]) {
|
|
33
|
+
if (!fs.statSync(file).isFile())
|
|
34
|
+
return INSTALL_HINT;
|
|
35
|
+
}
|
|
36
|
+
const shim = path.join(prefix, "sash.cmd");
|
|
37
|
+
const stat = fs.lstatSync(shim);
|
|
38
|
+
if (!stat.isFile() || stat.size > 64 * 1024)
|
|
39
|
+
return INSTALL_HINT;
|
|
40
|
+
const contents = fs.readFileSync(shim, "utf8").replaceAll("/", "\\").toLowerCase();
|
|
41
|
+
if (!contents.includes("node_modules\\@astralyn\\sash\\dist\\cli.js"))
|
|
42
|
+
return INSTALL_HINT;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return INSTALL_HINT;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { loadSettings } from "../app-state.js";
|
|
3
|
+
import { errnoCode, errorMessage } from "../error-utils.js";
|
|
4
|
+
import { durableRenameSync } from "../fs-atomic.js";
|
|
5
|
+
import { sashLayout } from "../paths.js";
|
|
6
|
+
import { withPrivateAppendLogFds } from "../process.js";
|
|
7
|
+
import { ensureRunning } from "../runtime-owner.js";
|
|
8
|
+
function recordStart(layout, message) {
|
|
9
|
+
try {
|
|
10
|
+
fs.mkdirSync(layout.logsDir, { recursive: true, mode: 0o700 });
|
|
11
|
+
let stat;
|
|
12
|
+
try {
|
|
13
|
+
stat = fs.lstatSync(layout.sashLogFile);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (errnoCode(error) !== "ENOENT")
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
if (stat && !stat.isFile())
|
|
20
|
+
throw new Error("Startup log is not a regular file");
|
|
21
|
+
if (stat && stat.size >= 1024 * 1024) {
|
|
22
|
+
durableRenameSync(layout.sashLogFile, `${layout.sashLogFile}.1`);
|
|
23
|
+
}
|
|
24
|
+
const line = `${new Date().toISOString()} ${message.replace(/[\r\n\t]+/g, " ").slice(0, 2000)}\n`;
|
|
25
|
+
withPrivateAppendLogFds(layout.sashLogFile, layout.sashLogFile, ({ stdoutFd }) => {
|
|
26
|
+
fs.writeSync(stdoutFd, line);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.error(`[sash] Could not write startup log: ${errorMessage(error)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function startAtLogin(layout = sashLayout(), start = async () => {
|
|
34
|
+
const { result } = await ensureRunning({ layout, settings: loadSettings(layout) });
|
|
35
|
+
return result;
|
|
36
|
+
}) {
|
|
37
|
+
recordStart(layout, "login start requested");
|
|
38
|
+
try {
|
|
39
|
+
const result = await start();
|
|
40
|
+
recordStart(layout, `login start ok pid=${result.pid}`);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
recordStart(layout, `login start failed: ${errorMessage(error)}`);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isPlainObject } from "../json-shape.js";
|
|
3
|
+
import { findExecutableOnPath } from "../process.js";
|
|
4
|
+
import { requireCommandSuccess } from "./command.js";
|
|
5
|
+
const REGISTRY_SETUP = [
|
|
6
|
+
"$ErrorActionPreference = 'Stop';",
|
|
7
|
+
"$runPath = 'Software\\Microsoft\\Windows\\CurrentVersion\\Run';",
|
|
8
|
+
"$approvalPath = 'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run';",
|
|
9
|
+
"$name = 'Sash';",
|
|
10
|
+
];
|
|
11
|
+
const INSPECT_SCRIPT = [
|
|
12
|
+
...REGISTRY_SETUP,
|
|
13
|
+
"try {",
|
|
14
|
+
"$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($runPath);",
|
|
15
|
+
"$value = if ($key) { $key.GetValue($name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null };",
|
|
16
|
+
"$run = if ($null -eq $value) { $null } elseif ($key.GetValueKind($name) -eq [Microsoft.Win32.RegistryValueKind]::String) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$value)) } else { '' };",
|
|
17
|
+
"if ($key) { $key.Dispose() };",
|
|
18
|
+
"$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($approvalPath);",
|
|
19
|
+
"$value = if ($key) { $key.GetValue($name) } else { $null };",
|
|
20
|
+
"$approval = if ($null -eq $value) { $null } elseif (($value -is [byte[]]) -and $value.Length -ge 12) { [Convert]::ToBase64String($value) } else { '' };",
|
|
21
|
+
"if ($key) { $key.Dispose() };",
|
|
22
|
+
"[Console]::Out.Write((ConvertTo-Json ([ordered]@{ run = $run; approval = $approval }) -Compress));",
|
|
23
|
+
"} catch { [Console]::Error.Write($_.Exception.Message); exit 1 }",
|
|
24
|
+
].join(" ");
|
|
25
|
+
const SET_SCRIPT = [
|
|
26
|
+
...REGISTRY_SETUP,
|
|
27
|
+
"$runKey = $null; $approvalKey = $null; $captured = $false;",
|
|
28
|
+
"try {",
|
|
29
|
+
"if ($env:SASH_AUTOSTART_MODE -notin @('on', 'off')) { throw 'Invalid autostart mode' };",
|
|
30
|
+
"$runKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($runPath);",
|
|
31
|
+
"$approvalKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($approvalPath, $true);",
|
|
32
|
+
"$oldRun = $runKey.GetValue($name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);",
|
|
33
|
+
"$oldRunKind = if ($null -ne $oldRun) { $runKey.GetValueKind($name) } else { $null };",
|
|
34
|
+
"$oldApproval = if ($approvalKey) { $approvalKey.GetValue($name) } else { $null };",
|
|
35
|
+
"$oldApprovalKind = if ($null -ne $oldApproval) { $approvalKey.GetValueKind($name) } else { $null };",
|
|
36
|
+
"$captured = $true;",
|
|
37
|
+
"if ($env:SASH_AUTOSTART_MODE -eq 'on') {",
|
|
38
|
+
" $runKey.SetValue($name, $env:SASH_AUTOSTART_COMMAND, [Microsoft.Win32.RegistryValueKind]::String);",
|
|
39
|
+
"} else { $runKey.DeleteValue($name, $false) };",
|
|
40
|
+
"if ($approvalKey) { $approvalKey.DeleteValue($name, $false) };",
|
|
41
|
+
"} catch {",
|
|
42
|
+
"$failure = $_.Exception.Message;",
|
|
43
|
+
"if ($captured) { try {",
|
|
44
|
+
" if ($null -eq $oldRun) { $runKey.DeleteValue($name, $false) } else { $runKey.SetValue($name, $oldRun, $oldRunKind) };",
|
|
45
|
+
" if ($approvalKey) { if ($null -eq $oldApproval) { $approvalKey.DeleteValue($name, $false) } else { $approvalKey.SetValue($name, $oldApproval, $oldApprovalKind) } };",
|
|
46
|
+
"} catch { $failure += '; registry rollback failed: ' + $_.Exception.Message } };",
|
|
47
|
+
"[Console]::Error.Write($failure); exit 1;",
|
|
48
|
+
"} finally { if ($runKey) { $runKey.Dispose() }; if ($approvalKey) { $approvalKey.Dispose() } }",
|
|
49
|
+
].join(" ");
|
|
50
|
+
export function windowsSystemPath(ctx, executable) {
|
|
51
|
+
const root = ctx.env.SystemRoot?.trim() || ctx.env.WINDIR?.trim() || "C:\\Windows";
|
|
52
|
+
if (!path.isAbsolute(root))
|
|
53
|
+
throw new Error("Windows system directory must be absolute");
|
|
54
|
+
return path.join(root, "System32", executable);
|
|
55
|
+
}
|
|
56
|
+
async function runRegistryScript(ctx, script, env) {
|
|
57
|
+
const shell = findExecutableOnPath("pwsh.exe", ctx.env) ??
|
|
58
|
+
windowsSystemPath(ctx, path.join("WindowsPowerShell", "v1.0", "powershell.exe"));
|
|
59
|
+
const result = await ctx.run(shell, [
|
|
60
|
+
"-NoProfile",
|
|
61
|
+
"-NonInteractive",
|
|
62
|
+
"-EncodedCommand",
|
|
63
|
+
Buffer.from(script, "utf16le").toString("base64"),
|
|
64
|
+
], env);
|
|
65
|
+
requireCommandSuccess(result);
|
|
66
|
+
return result.stdout;
|
|
67
|
+
}
|
|
68
|
+
function nullableBase64(value) {
|
|
69
|
+
return (value === null ||
|
|
70
|
+
(typeof value === "string" && Buffer.from(value, "base64").toString("base64") === value));
|
|
71
|
+
}
|
|
72
|
+
export async function readWindowsRegistration(ctx) {
|
|
73
|
+
const value = JSON.parse(await runRegistryScript(ctx, INSPECT_SCRIPT));
|
|
74
|
+
if (!isPlainObject(value) || !nullableBase64(value.run) || !nullableBase64(value.approval)) {
|
|
75
|
+
throw new Error("Invalid Windows autostart registry response");
|
|
76
|
+
}
|
|
77
|
+
const approval = value.approval === null ? undefined : Buffer.from(value.approval, "base64");
|
|
78
|
+
return {
|
|
79
|
+
command: value.run === null ? null : Buffer.from(value.run, "base64").toString("utf8"),
|
|
80
|
+
disabled: approval !== undefined && (approval.length < 12 || ![2, 6].includes(approval[0] ?? 0)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export async function setWindowsRegistration(ctx, command) {
|
|
84
|
+
await runRegistryScript(ctx, SET_SCRIPT, {
|
|
85
|
+
SASH_AUTOSTART_MODE: command === null ? "off" : "on",
|
|
86
|
+
SASH_AUTOSTART_COMMAND: command ?? "",
|
|
87
|
+
});
|
|
88
|
+
}
|