@join-pantheon/cli 0.32.1 → 0.33.0-rc.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/dist/accounts.js +1 -1
- package/dist/board.js +1417 -0
- package/dist/claude-config.js +189 -0
- package/dist/cli.js +351 -101
- package/dist/client.js +16 -0
- package/dist/command-state.js +2 -1
- package/dist/dbus.js +92 -13
- package/dist/doctor.js +36 -5
- package/dist/folder-guard.js +89 -0
- package/dist/git-safe.js +3 -3
- package/dist/host-env.js +47 -1
- package/dist/install.js +76 -10
- package/dist/installer.js +2 -1
- package/dist/ledger.js +2 -2
- package/dist/machine.js +174 -3
- package/dist/managed.js +27 -1
- package/dist/notice-linux.js +501 -0
- package/dist/notice-windows.js +323 -0
- package/dist/notifier.js +12 -3
- package/dist/quickstart.js +28 -11
- package/dist/relay.js +64 -9
- package/dist/service.js +455 -29
- package/dist/setup-guide.js +22 -5
- package/dist/store.js +18 -2
- package/dist/uninstall.js +1 -1
- package/dist/upgrade.js +249 -4
- package/dist/uri-handler.js +335 -0
- package/dist/win-host.js +993 -0
- package/dist/worker-policy.js +3 -0
- package/dist/worker.js +11 -11
- package/helper/macos/prebuilt/manifest.json +2 -2
- package/helper/macos/prebuilt/pantheon-hud +0 -0
- package/helper/windows/pantheon-host.cs +170 -0
- package/helper/windows/pantheon-notice.ps1 +127 -0
- package/helper/windows/pantheon-tray.ps1 +731 -0
- package/helper/windows/pantheon.ico +0 -0
- package/installer/install.ps1 +238 -19
- package/installer/install.sh +36 -1
- package/package.json +2 -2
package/dist/worker-policy.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { CONFIG_PATH } from "./config.js";
|
|
4
4
|
import { trustWrite } from "./home-guard.js";
|
|
5
5
|
import { parseJsonText } from "./text-file.js";
|
|
6
|
+
import { compareVersions, isVersion } from "./installer.js";
|
|
6
7
|
export const WAKE_NO_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
7
8
|
export const wakeKey = (team, member) => `${team}|${member}`;
|
|
8
9
|
export function mayAskWake(p, teams, member, now = Date.now()) {
|
|
@@ -311,6 +312,8 @@ export function needsSelfUpgrade(localPkg, relayPkg, lastAttempt, now = Date.now
|
|
|
311
312
|
return lastAttempt === null || now - lastAttempt >= UPGRADE_RETRY_MS;
|
|
312
313
|
}
|
|
313
314
|
export function isNewer(a, b) {
|
|
315
|
+
if (isVersion(a) && isVersion(b))
|
|
316
|
+
return compareVersions(a, b) > 0;
|
|
314
317
|
const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
|
|
315
318
|
const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
|
|
316
319
|
for (let i = 0; i < 3; i++) {
|
package/dist/worker.js
CHANGED
|
@@ -61,7 +61,7 @@ function configuredSshCommand(cwd) {
|
|
|
61
61
|
return hit;
|
|
62
62
|
let set = false;
|
|
63
63
|
try {
|
|
64
|
-
set = execFileSync("git", safeGitArgs(cwd, ["-C", cwd, "config", "--get", "core.sshCommand"]), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim().length > 0;
|
|
64
|
+
set = execFileSync("git", safeGitArgs(cwd, ["-C", cwd, "config", "--get", "core.sshCommand"]), { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim().length > 0;
|
|
65
65
|
}
|
|
66
66
|
catch { }
|
|
67
67
|
sshCommandSeen.set(cwd, set);
|
|
@@ -78,7 +78,7 @@ export function agentBaseEnv(base, proxy) {
|
|
|
78
78
|
const GIT_TIMEOUT_MS = 60_000;
|
|
79
79
|
export const WORKTREE_ADD_TIMEOUT_MS = 15 * 60_000;
|
|
80
80
|
export const FETCH_TIMEOUT_MS = 90_000;
|
|
81
|
-
const git = (cwd, ...args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL" }).trim();
|
|
81
|
+
const git = (cwd, ...args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL" }).trim();
|
|
82
82
|
const gitVersions = new Map();
|
|
83
83
|
export function gitVersion() {
|
|
84
84
|
const key = process.env.PATH ?? "";
|
|
@@ -86,7 +86,7 @@ export function gitVersion() {
|
|
|
86
86
|
return gitVersions.get(key);
|
|
87
87
|
let v = null;
|
|
88
88
|
try {
|
|
89
|
-
const m = /(\d+)\.(\d+)/.exec(execFileSync("git", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }));
|
|
89
|
+
const m = /(\d+)\.(\d+)/.exec(execFileSync("git", ["--version"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }));
|
|
90
90
|
if (m)
|
|
91
91
|
v = [Number(m[1]), Number(m[2])];
|
|
92
92
|
}
|
|
@@ -101,7 +101,7 @@ export function fetchOrigin(project, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
|
101
101
|
return new Promise((res) => {
|
|
102
102
|
let child;
|
|
103
103
|
try {
|
|
104
|
-
child = procSpawn("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, env: gitEnv(process.env, proxyEnvFor(), project), stdio: ["ignore", "ignore", "pipe"] });
|
|
104
|
+
child = procSpawn("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, env: gitEnv(process.env, proxyEnvFor(), project), windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
105
105
|
}
|
|
106
106
|
catch (e) {
|
|
107
107
|
res({ ok: false, why: e.message });
|
|
@@ -117,7 +117,7 @@ export function fetchOrigin(project, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
|
117
117
|
}
|
|
118
118
|
function fetchOriginSync(project) {
|
|
119
119
|
try {
|
|
120
|
-
execFileSync("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, stdio: ["ignore", "ignore", "pipe"], env: gitEnv(process.env, proxyEnvFor(), project), timeout: FETCH_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
120
|
+
execFileSync("git", safeGitArgs(project, ["fetch", "--quiet", "origin"]), { cwd: project, windowsHide: true, stdio: ["ignore", "ignore", "pipe"], env: gitEnv(process.env, proxyEnvFor(), project), timeout: FETCH_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
121
121
|
return { ok: true };
|
|
122
122
|
}
|
|
123
123
|
catch (e) {
|
|
@@ -140,7 +140,7 @@ const isRepo = (cwd) => {
|
|
|
140
140
|
export const worktreeLabel = (wt) => `${basename(dirname(wt))}/${basename(wt)}`;
|
|
141
141
|
const LINK_SCAN_BUDGET = 1_000_000;
|
|
142
142
|
const GIT_SCAN_MAXBUFFER = 1 << 28;
|
|
143
|
-
const gitScan = (cwd, args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: GIT_SCAN_MAXBUFFER });
|
|
143
|
+
const gitScan = (cwd, args) => execFileSync("git", safeGitArgs(cwd, args), { cwd, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: GIT_SCAN_MAXBUFFER });
|
|
144
144
|
const ENV_DIRS = new Set([".venv", "venv", "env", ".tox", "__pypackages__", ".direnv"]);
|
|
145
145
|
const AGENT_FILE = /^(CLAUDE.*\.md|AGENTS?.*\.md|GEMINI\.md|CONVENTIONS\.md|\.mcp\.json|\.cursorrules|\.windsurfrules|\.clinerules)$/i;
|
|
146
146
|
const AGENT_DIRS = new Set([".claude", ".cursor", ".github", ".kiro", ".codex", ".vscode", ".agents", ".gemini", ".junie", ".amazonq", ".continue", ".clinerules", ".windsurf"]);
|
|
@@ -430,7 +430,7 @@ export function prepareWorktree(project, scratch, goal, log, branch = `pantheon/
|
|
|
430
430
|
if ("done" in p)
|
|
431
431
|
return p.done;
|
|
432
432
|
try {
|
|
433
|
-
execFileSync("git", safeGitArgs(project, p.add), { cwd: project, stdio: ["ignore", "ignore", "pipe"], env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), timeout: opts.addTimeoutMs ?? WORKTREE_ADD_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
433
|
+
execFileSync("git", safeGitArgs(project, p.add), { cwd: project, windowsHide: true, stdio: ["ignore", "ignore", "pipe"], env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), timeout: opts.addTimeoutMs ?? WORKTREE_ADD_TIMEOUT_MS, killSignal: "SIGKILL" });
|
|
434
434
|
}
|
|
435
435
|
catch (e) {
|
|
436
436
|
const err = e;
|
|
@@ -451,7 +451,7 @@ export async function prepareWorktreeAsync(project, scratch, goal, log, branch =
|
|
|
451
451
|
const why = await new Promise((res) => {
|
|
452
452
|
let child;
|
|
453
453
|
try {
|
|
454
|
-
child = procSpawn("git", safeGitArgs(project, p.add), { cwd: project, env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), stdio: ["ignore", "ignore", "pipe"] });
|
|
454
|
+
child = procSpawn("git", safeGitArgs(project, p.add), { cwd: project, env: gitCallEnv(p.add, gitEnv(process.env, proxyEnvFor(), project)), windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
455
455
|
}
|
|
456
456
|
catch (e) {
|
|
457
457
|
res(e.message);
|
|
@@ -589,7 +589,7 @@ export function gitOut(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
|
|
|
589
589
|
return new Promise((res) => {
|
|
590
590
|
let child;
|
|
591
591
|
try {
|
|
592
|
-
child = procSpawn("git", argv, { cwd, env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), stdio: ["ignore", "pipe", "ignore"] });
|
|
592
|
+
child = procSpawn("git", argv, { cwd, env: gitCallEnv(args, gitEnv(process.env, proxyEnvFor(), cwd)), windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
593
593
|
}
|
|
594
594
|
catch {
|
|
595
595
|
res(null);
|
|
@@ -680,7 +680,7 @@ export function massDeleteGuardEnv(runHome, worktree, allow, env = process.env)
|
|
|
680
680
|
}
|
|
681
681
|
let orig = "";
|
|
682
682
|
try {
|
|
683
|
-
const out = execFileSync("git", repoHooksArgs(worktree), { cwd: worktree, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(["rev-parse"], gitEnv(process.env, proxyEnvFor(), worktree)), timeout: GIT_TIMEOUT_MS }).trim();
|
|
683
|
+
const out = execFileSync("git", repoHooksArgs(worktree), { cwd: worktree, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: gitCallEnv(["rev-parse"], gitEnv(process.env, proxyEnvFor(), worktree)), timeout: GIT_TIMEOUT_MS }).trim();
|
|
684
684
|
orig = resolve(worktree, out);
|
|
685
685
|
}
|
|
686
686
|
catch (e) {
|
|
@@ -2690,7 +2690,7 @@ export class Worker {
|
|
|
2690
2690
|
detail = await new Promise((res) => {
|
|
2691
2691
|
let child;
|
|
2692
2692
|
try {
|
|
2693
|
-
child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options, detached: process.platform !== "win32" });
|
|
2693
|
+
child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options, windowsHide: true, detached: process.platform !== "win32" });
|
|
2694
2694
|
}
|
|
2695
2695
|
catch (e) {
|
|
2696
2696
|
res(`could not start "${command}": ${e.message}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": 1,
|
|
3
|
-
"built_at": "2026-10-
|
|
3
|
+
"built_at": "2026-10-05T04:55:37.913Z",
|
|
4
4
|
"swiftc": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)",
|
|
5
5
|
"helpers": {
|
|
6
6
|
"pantheon-tray": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"pantheon-hud": {
|
|
18
18
|
"source": "main.swift",
|
|
19
19
|
"source_sha256": "bcf55a19b658d1f09a9d117c76030393ab08a2569062dd7413f884ca05f3a0eb",
|
|
20
|
-
"sha256": "
|
|
20
|
+
"sha256": "0414b397a533940e3a5c324add6250e1ed71919e67e462d11dcb2a1d0c2f9b41",
|
|
21
21
|
"archs": [
|
|
22
22
|
"arm64",
|
|
23
23
|
"x86_64"
|
|
Binary file
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// pantheon-host.exe: starts Pantheon's background host on Windows with no
|
|
2
|
+
// window, and keeps it running.
|
|
3
|
+
//
|
|
4
|
+
// Task Scheduler starts this at logon (the "Pantheon-<your SID>" task, current
|
|
5
|
+
// user, interactive, no elevation). It is a GUI-subsystem program
|
|
6
|
+
// (/target:winexe), so Windows never gives it a console: nothing flashes on
|
|
7
|
+
// screen at logon, as powershell.exe -WindowStyle Hidden or node.exe started
|
|
8
|
+
// directly would.
|
|
9
|
+
//
|
|
10
|
+
// It does one thing: start the host with CreateNoWindow and wait for it. The
|
|
11
|
+
// host is `node.exe [flags] cli.js host`, the supervisor that runs the worker,
|
|
12
|
+
// the notifier and the tray (src/win-host.ts). Exit 0 means it was asked to
|
|
13
|
+
// stop, and this exits too. Exit 75 means "start me again now" (it saw a new
|
|
14
|
+
// Pantheon version on disk). Anything else is a crash: started again after
|
|
15
|
+
// 2 s, doubling to 60 s, back to 2 s after ten healthy minutes.
|
|
16
|
+
//
|
|
17
|
+
// WHAT it starts is read again before every start, from the launch file
|
|
18
|
+
// (--launch, one argument per line, written by `pantheon service install` and
|
|
19
|
+
// by every upgrade), so a host that exits 75 comes back on whatever Node and
|
|
20
|
+
// CLI the new version named, never on the argv this shim was registered with.
|
|
21
|
+
// The argv after "--" is the fallback when that file is missing or names a
|
|
22
|
+
// program that is not there. When neither program exists (an installer
|
|
23
|
+
// swapping the runtime folder, a Node upgrade in progress) it waits and looks
|
|
24
|
+
// again, with the same backoff; only after --missing-grace seconds (default
|
|
25
|
+
// ten minutes) of nothing to start does it conclude Pantheon was removed, and
|
|
26
|
+
// exit 3.
|
|
27
|
+
//
|
|
28
|
+
// Each start of this shim is one session (PANTHEON_HOST_SESSION, a new GUID):
|
|
29
|
+
// the host uses it to tell a restart (same session) from a new logon.
|
|
30
|
+
//
|
|
31
|
+
// Compiled on the person's machine by the .NET Framework's own csc.exe (part of
|
|
32
|
+
// every Windows 10 and 11), from this file as shipped in the npm package.
|
|
33
|
+
//
|
|
34
|
+
// pantheon-host.exe [--env NAME=VALUE]... [--launch FILE] [--missing-grace SECONDS] -- <program> [args...]
|
|
35
|
+
using System;
|
|
36
|
+
using System.Collections.Generic;
|
|
37
|
+
using System.Diagnostics;
|
|
38
|
+
using System.IO;
|
|
39
|
+
using System.Text;
|
|
40
|
+
using System.Threading;
|
|
41
|
+
|
|
42
|
+
[assembly: System.Reflection.AssemblyTitle("Pantheon background host")]
|
|
43
|
+
[assembly: System.Reflection.AssemblyDescription("Keeps Pantheon's worker, alerts and tray running while you are logged on")]
|
|
44
|
+
[assembly: System.Reflection.AssemblyProduct("Pantheon")]
|
|
45
|
+
[assembly: System.Reflection.AssemblyVersion("1.1.0.0")]
|
|
46
|
+
|
|
47
|
+
static class PantheonHost
|
|
48
|
+
{
|
|
49
|
+
const int RestartNow = 75;
|
|
50
|
+
const int BaseDelayMs = 2000;
|
|
51
|
+
const int MaxDelayMs = 60000;
|
|
52
|
+
|
|
53
|
+
static int Main(string[] args)
|
|
54
|
+
{
|
|
55
|
+
var env = new List<KeyValuePair<string, string>>();
|
|
56
|
+
string launchFile = null;
|
|
57
|
+
int graceSeconds = 600;
|
|
58
|
+
int i = 0;
|
|
59
|
+
for (; i < args.Length; i++)
|
|
60
|
+
{
|
|
61
|
+
if (args[i] == "--") { i++; break; }
|
|
62
|
+
if (args[i] == "--env" && i + 1 < args.Length)
|
|
63
|
+
{
|
|
64
|
+
string kv = args[++i];
|
|
65
|
+
int eq = kv.IndexOf('=');
|
|
66
|
+
if (eq > 0) env.Add(new KeyValuePair<string, string>(kv.Substring(0, eq), kv.Substring(eq + 1)));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (args[i] == "--launch" && i + 1 < args.Length) { launchFile = args[++i]; continue; }
|
|
70
|
+
if (args[i] == "--missing-grace" && i + 1 < args.Length)
|
|
71
|
+
{
|
|
72
|
+
if (!int.TryParse(args[++i], out graceSeconds) || graceSeconds < 0) return 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
if (i >= args.Length) return 2;
|
|
78
|
+
var baked = new List<string>();
|
|
79
|
+
for (int j = i; j < args.Length; j++) baked.Add(args[j]);
|
|
80
|
+
|
|
81
|
+
string session = Guid.NewGuid().ToString("N");
|
|
82
|
+
int delay = BaseDelayMs;
|
|
83
|
+
DateTime missingSince = DateTime.MinValue;
|
|
84
|
+
while (true)
|
|
85
|
+
{
|
|
86
|
+
List<string> argv = ReadLaunch(launchFile);
|
|
87
|
+
if (argv == null || !File.Exists(argv[0])) argv = baked;
|
|
88
|
+
if (!File.Exists(argv[0]))
|
|
89
|
+
{
|
|
90
|
+
// Nothing to start right now: wait for it to come back, and give up
|
|
91
|
+
// only once it has been gone longer than any install or upgrade takes.
|
|
92
|
+
if (missingSince == DateTime.MinValue) missingSince = DateTime.UtcNow;
|
|
93
|
+
if ((DateTime.UtcNow - missingSince).TotalSeconds >= graceSeconds) return 3;
|
|
94
|
+
Thread.Sleep(delay);
|
|
95
|
+
delay = Math.Min(delay * 2, MaxDelayMs);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
missingSince = DateTime.MinValue;
|
|
99
|
+
DateTime started = DateTime.UtcNow;
|
|
100
|
+
int code;
|
|
101
|
+
try
|
|
102
|
+
{
|
|
103
|
+
var rest = new StringBuilder();
|
|
104
|
+
for (int j = 1; j < argv.Count; j++)
|
|
105
|
+
{
|
|
106
|
+
if (rest.Length > 0) rest.Append(' ');
|
|
107
|
+
rest.Append(Quote(argv[j]));
|
|
108
|
+
}
|
|
109
|
+
var psi = new ProcessStartInfo(argv[0], rest.ToString());
|
|
110
|
+
psi.UseShellExecute = false;
|
|
111
|
+
psi.CreateNoWindow = true;
|
|
112
|
+
psi.WindowStyle = ProcessWindowStyle.Hidden;
|
|
113
|
+
psi.WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
114
|
+
foreach (var kv in env) psi.EnvironmentVariables[kv.Key] = kv.Value;
|
|
115
|
+
psi.EnvironmentVariables["PANTHEON_HOST_SESSION"] = session;
|
|
116
|
+
using (Process p = Process.Start(psi))
|
|
117
|
+
{
|
|
118
|
+
p.WaitForExit();
|
|
119
|
+
code = p.ExitCode;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (Exception)
|
|
123
|
+
{
|
|
124
|
+
code = 1;
|
|
125
|
+
}
|
|
126
|
+
if (code == 0) return 0;
|
|
127
|
+
if (code == RestartNow) { delay = BaseDelayMs; continue; }
|
|
128
|
+
if ((DateTime.UtcNow - started).TotalMinutes >= 10) delay = BaseDelayMs;
|
|
129
|
+
Thread.Sleep(delay);
|
|
130
|
+
delay = Math.Min(delay * 2, MaxDelayMs);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The launch file: one argument per line (UTF-8), the program first. null
|
|
135
|
+
// when there is none, or it cannot be read, or it names nothing.
|
|
136
|
+
static List<string> ReadLaunch(string file)
|
|
137
|
+
{
|
|
138
|
+
if (file == null) return null;
|
|
139
|
+
try
|
|
140
|
+
{
|
|
141
|
+
if (!File.Exists(file)) return null;
|
|
142
|
+
var argv = new List<string>();
|
|
143
|
+
foreach (string line in File.ReadAllLines(file, Encoding.UTF8))
|
|
144
|
+
{
|
|
145
|
+
string a = line.TrimEnd('\r');
|
|
146
|
+
if (a.Length > 0) argv.Add(a);
|
|
147
|
+
}
|
|
148
|
+
return argv.Count > 0 ? argv : null;
|
|
149
|
+
}
|
|
150
|
+
catch (Exception) { return null; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// One argument as CommandLineToArgvW reads it back (the MSVC rules node.exe uses).
|
|
154
|
+
static string Quote(string a)
|
|
155
|
+
{
|
|
156
|
+
if (a.Length > 0 && a.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return a;
|
|
157
|
+
var sb = new StringBuilder("\"");
|
|
158
|
+
int backslashes = 0;
|
|
159
|
+
foreach (char c in a)
|
|
160
|
+
{
|
|
161
|
+
if (c == '\\') { backslashes++; continue; }
|
|
162
|
+
if (c == '"') { sb.Append('\\', backslashes * 2 + 1); sb.Append('"'); }
|
|
163
|
+
else { sb.Append('\\', backslashes); sb.Append(c); }
|
|
164
|
+
backslashes = 0;
|
|
165
|
+
}
|
|
166
|
+
sb.Append('\\', backslashes * 2);
|
|
167
|
+
sb.Append('"');
|
|
168
|
+
return sb.ToString();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<#
|
|
2
|
+
Pantheon's desktop alerts on Windows: a toast, or a tray balloon where toasts
|
|
3
|
+
cannot be shown. One fixed file, the same bytes for every alert (src/notice-windows.ts).
|
|
4
|
+
|
|
5
|
+
Two ways it runs:
|
|
6
|
+
|
|
7
|
+
1. Loaded by the tray (helper/windows/pantheon-tray.ps1), which defines the
|
|
8
|
+
functions below and shows every alert from its own, long-running process.
|
|
9
|
+
The notifier drops each alert as a file in <home>\host\notices; the tray
|
|
10
|
+
picks it up, shows it, and deletes it. No process is started per alert.
|
|
11
|
+
|
|
12
|
+
2. When no tray is running (quit, or not started yet), the notifier starts
|
|
13
|
+
Windows PowerShell for one alert:
|
|
14
|
+
powershell -NoProfile -NonInteractive -Command
|
|
15
|
+
"if (<not FullLanguage>) { exit 64 }; & ([scriptblock]::Create([IO.File]::ReadAllText('<this file>'))) -Mode toast"
|
|
16
|
+
hidden by the way it is started (CREATE_NO_WINDOW), never -WindowStyle
|
|
17
|
+
Hidden or an encoded command. The alert travels in environment variables
|
|
18
|
+
(base64), never on the command line:
|
|
19
|
+
PANTHEON_TOAST_XML the toast (UTF-8)
|
|
20
|
+
PANTHEON_TOAST_TAG a tag that lets a reminder replace its alert
|
|
21
|
+
PANTHEON_TOAST_AUMIDS the app ids to try, ";"-separated
|
|
22
|
+
PANTHEON_BALLOON_TITLE / _TEXT / _ICON for -Mode balloon
|
|
23
|
+
-Mode toast exits 0 shown, 3 no toast API here, 4 toasts off or refused
|
|
24
|
+
for every app id. -Mode balloon shows a balloon for twenty seconds and
|
|
25
|
+
says in <home>\logs\alerts.log whether it could.
|
|
26
|
+
|
|
27
|
+
Like the tray it is run as a script block, so it never relies on $PSScriptRoot.
|
|
28
|
+
This file is kept ASCII (Windows PowerShell reads a .ps1 without a BOM in the
|
|
29
|
+
system code page).
|
|
30
|
+
#>
|
|
31
|
+
param([string]$Mode = '')
|
|
32
|
+
|
|
33
|
+
# One toast: the XML, tried under each app id in turn. Returns @{ code; said }.
|
|
34
|
+
function global:Show-PantheonToast([string]$Xml, [string]$Tag, [string[]]$Aumids) {
|
|
35
|
+
$said = New-Object System.Collections.Generic.List[string]
|
|
36
|
+
try {
|
|
37
|
+
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
38
|
+
[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
|
|
39
|
+
$doc = [Windows.Data.Xml.Dom.XmlDocument]::new()
|
|
40
|
+
$doc.LoadXml($Xml)
|
|
41
|
+
} catch {
|
|
42
|
+
$said.Add('no-toast-api ' + $_.Exception.Message)
|
|
43
|
+
return @{ code = 3; said = $said }
|
|
44
|
+
}
|
|
45
|
+
$ids = @($Aumids | Where-Object { $_ })
|
|
46
|
+
for ($i = 0; $i -lt $ids.Count; $i++) {
|
|
47
|
+
$id = $ids[$i]
|
|
48
|
+
$last = ($i -eq $ids.Count - 1)
|
|
49
|
+
try {
|
|
50
|
+
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id)
|
|
51
|
+
if ([string]$notifier.Setting -ne 'Enabled') { $said.Add('off ' + $id + ' ' + $notifier.Setting); continue }
|
|
52
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($doc)
|
|
53
|
+
if ($Tag) { $toast.Tag = $Tag; $toast.Group = 'pantheon' }
|
|
54
|
+
$notifier.Show($toast)
|
|
55
|
+
# Pantheon's own app id is used only when Windows then has the toast in
|
|
56
|
+
# its history for that id: a build that ignores the registration falls
|
|
57
|
+
# through to the next id instead of swallowing the alert.
|
|
58
|
+
if (-not $last -and $Tag) {
|
|
59
|
+
Start-Sleep -Milliseconds 400
|
|
60
|
+
$seen = $false
|
|
61
|
+
foreach ($h in [Windows.UI.Notifications.ToastNotificationManager]::History.GetHistory($id)) { if ($h.Tag -eq $Tag) { $seen = $true } }
|
|
62
|
+
if (-not $seen) {
|
|
63
|
+
try { [Windows.UI.Notifications.ToastNotificationManager]::History.Remove($Tag, 'pantheon', $id) } catch { }
|
|
64
|
+
$said.Add('unseen ' + $id)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
$said.Add('shown ' + $id)
|
|
69
|
+
return @{ code = 0; said = $said }
|
|
70
|
+
} catch { $said.Add('failed ' + $id + ' ' + $_.Exception.Message) }
|
|
71
|
+
}
|
|
72
|
+
return @{ code = 4; said = $said }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function global:ConvertFrom-PantheonB64([string]$s) {
|
|
76
|
+
if (-not $s) { return '' }
|
|
77
|
+
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function global:Write-PantheonAlertLog([string]$line) {
|
|
81
|
+
try {
|
|
82
|
+
$h = [string]$env:PANTHEON_HOME
|
|
83
|
+
if (-not $h) { $h = Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
|
|
84
|
+
$dir = Join-Path $h 'logs'
|
|
85
|
+
if (-not (Test-Path -LiteralPath $dir)) { [void](New-Item -ItemType Directory -Path $dir -Force) }
|
|
86
|
+
Add-Content -LiteralPath (Join-Path $dir 'alerts.log') -Value ((Get-Date).ToString('o') + ' ' + $line) -Encoding UTF8
|
|
87
|
+
} catch { }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if ($Mode -eq 'toast') {
|
|
91
|
+
$ErrorActionPreference = 'Stop'
|
|
92
|
+
$xml = ''
|
|
93
|
+
try { $xml = ConvertFrom-PantheonB64 ([string]$env:PANTHEON_TOAST_XML) } catch { [Console]::Out.WriteLine('bad-input ' + $_.Exception.Message); exit 3 }
|
|
94
|
+
$r = Show-PantheonToast $xml ([string]$env:PANTHEON_TOAST_TAG) (([string]$env:PANTHEON_TOAST_AUMIDS) -split ';')
|
|
95
|
+
foreach ($l in $r.said) { [Console]::Out.WriteLine($l) }
|
|
96
|
+
exit $r.code
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if ($Mode -eq 'balloon') {
|
|
100
|
+
# A balloon of its own, for twenty seconds; a click opens the board through
|
|
101
|
+
# the same pantheon: link. What happened is logged here, where it is known.
|
|
102
|
+
try {
|
|
103
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
104
|
+
Add-Type -AssemblyName System.Drawing
|
|
105
|
+
$title = ConvertFrom-PantheonB64 ([string]$env:PANTHEON_BALLOON_TITLE)
|
|
106
|
+
$text = ConvertFrom-PantheonB64 ([string]$env:PANTHEON_BALLOON_TEXT)
|
|
107
|
+
$tray = New-Object System.Windows.Forms.NotifyIcon
|
|
108
|
+
$ico = [string]$env:PANTHEON_BALLOON_ICON
|
|
109
|
+
if ($ico -and (Test-Path -LiteralPath $ico)) { $tray.Icon = New-Object System.Drawing.Icon -ArgumentList $ico } else { $tray.Icon = [System.Drawing.SystemIcons]::Information }
|
|
110
|
+
$tray.Text = 'Pantheon'
|
|
111
|
+
$global:PantheonBalloonClicked = $false
|
|
112
|
+
$tray.add_BalloonTipClicked({ $global:PantheonBalloonClicked = $true })
|
|
113
|
+
$tray.add_Click({ $global:PantheonBalloonClicked = $true })
|
|
114
|
+
$tray.Visible = $true
|
|
115
|
+
$tray.ShowBalloonTip(15000, $title, $text, [System.Windows.Forms.ToolTipIcon]::Info)
|
|
116
|
+
Write-PantheonAlertLog 'balloon: shown'
|
|
117
|
+
$until = (Get-Date).AddSeconds(20)
|
|
118
|
+
while ((Get-Date) -lt $until -and -not $global:PantheonBalloonClicked) { [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 100 }
|
|
119
|
+
if ($global:PantheonBalloonClicked) { try { Start-Process 'pantheon://board' } catch { } }
|
|
120
|
+
$tray.Visible = $false
|
|
121
|
+
$tray.Dispose()
|
|
122
|
+
exit 0
|
|
123
|
+
} catch {
|
|
124
|
+
Write-PantheonAlertLog ('balloon: not shown (' + $_.Exception.Message + ')')
|
|
125
|
+
exit 5
|
|
126
|
+
}
|
|
127
|
+
}
|