passclaude 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +194 -2
- package/assets/fonts/BricolageGrotesque-ExtraBold.ttf +0 -0
- package/assets/fonts/DMSans-Bold.ttf +0 -0
- package/assets/fonts/DMSans-Medium.ttf +0 -0
- package/assets/fonts/DMSans-Regular.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Medium.ttf +0 -0
- package/assets/fonts/OFL-BricolageGrotesque.txt +93 -0
- package/assets/fonts/OFL-DMSans.txt +93 -0
- package/assets/fonts/OFL-JetBrainsMono.txt +93 -0
- package/assets/passclaude.ico +0 -0
- package/docs/e2e.js +56 -0
- package/hook/guard.cjs +47 -0
- package/package.json +56 -4
- package/src/cli.js +31 -0
- package/src/install-guard.js +80 -0
- package/src/ntfy.js +42 -0
- package/src/phone.js +217 -0
- package/src/ps/brand.xaml +22 -0
- package/src/ps/chromeui.ps1 +55 -0
- package/src/ps/common.ps1 +218 -0
- package/src/ps/cred.ps1 +38 -0
- package/src/ps/focus.ps1 +16 -0
- package/src/ps/prompt-ui.ps1 +156 -0
- package/src/ps/prompt.ps1 +25 -0
- package/src/ps/qr-ui.ps1 +81 -0
- package/src/ps/qr.ps1 +11 -0
- package/src/ps/toast-ui.ps1 +70 -0
- package/src/ps/toast.ps1 +10 -0
- package/src/ps/type.ps1 +22 -0
- package/src/ps/ui-styles.xaml +169 -0
- package/src/ps/ui.ps1 +101 -0
- package/src/qr.js +298 -0
- package/src/redact.js +201 -0
- package/src/runner.js +174 -0
- package/src/server.js +112 -0
- package/src/sitelock.js +38 -0
- package/src/tools.js +186 -0
- package/src/typer.js +39 -0
- package/src/vault.js +109 -0
- package/src/windows.js +47 -0
- package/src/winops.js +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passclaude",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Let Claude Code log into websites and use your API keys without ever seeing them.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"passclaude": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"hook",
|
|
12
|
+
"assets",
|
|
13
|
+
"docs/e2e.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"os": [
|
|
21
|
+
"win32"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"brand": "node scripts/brand/build.js && node scripts/brand/phone.js",
|
|
26
|
+
"site": "node scripts/site/build.js"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"claude",
|
|
30
|
+
"claude-code",
|
|
31
|
+
"mcp",
|
|
32
|
+
"mcp-server",
|
|
33
|
+
"password",
|
|
34
|
+
"secrets",
|
|
35
|
+
"ai-agents",
|
|
36
|
+
"remote-control"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"passclaude": {
|
|
40
|
+
"pageUrl": "https://daniardel.github.io/passclaude-phone/"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
44
|
+
"zod": "^4.6.7"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/daniardel/passclaude.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/daniardel/passclaude#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/daniardel/passclaude/issues"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"jsqr": "^1.4.0",
|
|
56
|
+
"sharp": "^0.35.5"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point for `npx passclaude`. No arguments: run the MCP server over stdio (Claude Code starts this).
|
|
3
|
+
// Never print to stdout in server mode: stdout carries the MCP protocol.
|
|
4
|
+
|
|
5
|
+
const HELP = `passClaude - let Claude Code use your passwords and API keys without ever seeing them.
|
|
6
|
+
|
|
7
|
+
Add it to Claude Code (one time):
|
|
8
|
+
claude mcp add --scope user passclaude -- npx -y passclaude
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
npx passclaude run the MCP server (Claude Code does this for you)
|
|
12
|
+
npx passclaude install-guard optional: block shell commands that read saved secrets directly
|
|
13
|
+
npx passclaude help show this help`;
|
|
14
|
+
|
|
15
|
+
const [cmd] = process.argv.slice(2);
|
|
16
|
+
|
|
17
|
+
if (process.platform !== 'win32') {
|
|
18
|
+
console.error('passClaude currently works on Windows only.');
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
} else if (!cmd) {
|
|
21
|
+
const { startServer } = await import('./server.js');
|
|
22
|
+
await startServer();
|
|
23
|
+
} else if (cmd === 'install-guard') {
|
|
24
|
+
const { installGuard } = await import('./install-guard.js');
|
|
25
|
+
process.exitCode = await installGuard();
|
|
26
|
+
} else if (['help', '--help', '-h'].includes(cmd)) {
|
|
27
|
+
console.log(HELP);
|
|
28
|
+
} else {
|
|
29
|
+
console.error(`Unknown command "${cmd}".\n\n${HELP}`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `node src/cli.js install-guard`: copies the guard to ~/.claude/passclaude/ and registers it in
|
|
2
|
+
// ~/.claude/settings.json, after showing the exact change and asking first. Backs the file up before writing.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline/promises';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
export const GUARD_SOURCE = fileURLToPath(new URL('../hook/guard.cjs', import.meta.url));
|
|
10
|
+
// The MCP tool is listed too: a run_with_secret command can run passClaude's own cred.ps1 and print a saved value.
|
|
11
|
+
export const MATCHER = 'Bash|PowerShell|mcp__passclaude__run_with_secret';
|
|
12
|
+
|
|
13
|
+
export function guardCommand(guardPath) {
|
|
14
|
+
return `node "${guardPath.replace(/\\/g, '/')}"`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function addGuardHook(settings, command) {
|
|
18
|
+
const next = structuredClone(settings ?? {});
|
|
19
|
+
next.hooks ??= {};
|
|
20
|
+
next.hooks.PreToolUse ??= [];
|
|
21
|
+
const present = next.hooks.PreToolUse.some((entry) => entry?.hooks?.some((h) => h?.command === command));
|
|
22
|
+
if (present) return { settings: next, changed: false };
|
|
23
|
+
next.hooks.PreToolUse.push({ matcher: MATCHER, hooks: [{ type: 'command', command }] });
|
|
24
|
+
return { settings: next, changed: true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function askYesNo(question) {
|
|
28
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
try {
|
|
30
|
+
return /^y(es)?$/i.test((await rl.question(question)).trim());
|
|
31
|
+
} finally {
|
|
32
|
+
rl.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function installGuard({ home = os.homedir(), ask = askYesNo, log = console.log } = {}) {
|
|
37
|
+
const claudeDir = path.join(home, '.claude');
|
|
38
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
39
|
+
const guardPath = path.join(claudeDir, 'passclaude', 'guard.cjs');
|
|
40
|
+
const command = guardCommand(guardPath);
|
|
41
|
+
|
|
42
|
+
let current = {};
|
|
43
|
+
if (fs.existsSync(settingsPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const text = fs.readFileSync(settingsPath, 'utf8').replace(/^/, '');
|
|
46
|
+
current = text.trim() ? JSON.parse(text) : {};
|
|
47
|
+
} catch {
|
|
48
|
+
log(`Couldn't read ${settingsPath} (it is not valid JSON). Fix it and try again. Nothing changed.`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { settings, changed } = addGuardHook(current, command);
|
|
54
|
+
if (!changed) {
|
|
55
|
+
fs.mkdirSync(path.dirname(guardPath), { recursive: true }); // the hook entry can outlive this folder
|
|
56
|
+
fs.copyFileSync(GUARD_SOURCE, guardPath); // refresh the copy in case passClaude was updated
|
|
57
|
+
log('The passClaude guard is already installed.');
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entry = { matcher: MATCHER, hooks: [{ type: 'command', command }] };
|
|
62
|
+
log(`This adds the passClaude guard to ${settingsPath}:\n`);
|
|
63
|
+
log(JSON.stringify({ hooks: { PreToolUse: [entry] } }, null, 2));
|
|
64
|
+
log('\nIt blocks shell commands that try to read saved secrets directly. Your current settings are backed up first.');
|
|
65
|
+
if (!(await ask('Add it? (y/N) '))) {
|
|
66
|
+
log('Nothing changed.');
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fs.mkdirSync(path.dirname(guardPath), { recursive: true });
|
|
71
|
+
fs.copyFileSync(GUARD_SOURCE, guardPath);
|
|
72
|
+
if (fs.existsSync(settingsPath)) {
|
|
73
|
+
const backup = `${settingsPath}.passclaude-backup-${Date.now()}`;
|
|
74
|
+
fs.copyFileSync(settingsPath, backup);
|
|
75
|
+
log(`Backup saved: ${backup}`);
|
|
76
|
+
}
|
|
77
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
78
|
+
log('Guard installed. Restart Claude Code to turn it on.');
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
package/src/ntfy.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Minimal ntfy client: publish a message, stream messages from a topic.
|
|
2
|
+
|
|
3
|
+
export function createNtfy({ baseUrl = 'https://ntfy.sh', fetchImpl = globalThis.fetch } = {}) {
|
|
4
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
5
|
+
return {
|
|
6
|
+
baseUrl: base,
|
|
7
|
+
|
|
8
|
+
async publish(topic, message) {
|
|
9
|
+
const res = await fetchImpl(`${base}/${topic}`, { method: 'POST', body: message });
|
|
10
|
+
if (!res.ok) throw new Error(`Relay publish failed: HTTP ${res.status}`);
|
|
11
|
+
return res.json();
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Calls onMessage(text, id) for each message until the stream ends or `signal` aborts.
|
|
16
|
+
* Calls onOpen() once, as soon as the relay has accepted the subscription.
|
|
17
|
+
*/
|
|
18
|
+
async subscribe(topic, onMessage, { since, signal, onOpen } = {}) {
|
|
19
|
+
const query = since ? `?since=${encodeURIComponent(since)}` : '';
|
|
20
|
+
const res = await fetchImpl(`${base}/${topic}/json${query}`, { signal });
|
|
21
|
+
if (!res.ok) throw new Error(`Relay subscribe failed: HTTP ${res.status}`);
|
|
22
|
+
onOpen?.();
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
let buffer = '';
|
|
25
|
+
for await (const chunk of res.body) {
|
|
26
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
27
|
+
let nl;
|
|
28
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
29
|
+
const line = buffer.slice(0, nl);
|
|
30
|
+
buffer = buffer.slice(nl + 1);
|
|
31
|
+
let event;
|
|
32
|
+
try {
|
|
33
|
+
event = JSON.parse(line);
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (event.event === 'message') await onMessage(event.message, event.id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/phone.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Adding a secret from the phone: one-time link, end-to-end encrypted, relayed through ntfy.
|
|
2
|
+
import { aadFor, deriveKey, fromB64u, generateKeyPair, randomTopic, seal, toB64u, unseal } from '../docs/e2e.js';
|
|
3
|
+
|
|
4
|
+
export const LINK_TTL_MS = 5 * 60_000;
|
|
5
|
+
|
|
6
|
+
/** How long start() waits for the relay connection to open before telling the user to use the PC popup instead. */
|
|
7
|
+
export const RELAY_OPEN_WAIT_MS = 8000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How long close() waits for messages still being saved or acknowledged, before giving up on them. With CLOSE_DRAIN_MS it
|
|
11
|
+
* stays well under the 2 seconds the MCP client waits after closing stdin before it kills the server.
|
|
12
|
+
*/
|
|
13
|
+
export const CLOSE_ACK_WAIT_MS = 1500;
|
|
14
|
+
|
|
15
|
+
/** Pause after the wait, so aborted relay connections can finish closing before the caller exits (see close()). */
|
|
16
|
+
export const CLOSE_DRAIN_MS = 200;
|
|
17
|
+
|
|
18
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
|
|
20
|
+
/** Resolves once every promise has settled, or after `ms`. The timer is cleared as soon as either happens. */
|
|
21
|
+
function settledWithin(promises, ms) {
|
|
22
|
+
if (promises.length === 0) return Promise.resolve();
|
|
23
|
+
let timer;
|
|
24
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(resolve, ms); });
|
|
25
|
+
return Promise.race([Promise.allSettled(promises), timeout]).finally(() => clearTimeout(timer));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The relay's host, such as ntfy.sh, for messages to the user. */
|
|
29
|
+
function relayHost(baseUrl) {
|
|
30
|
+
try {
|
|
31
|
+
return new URL(baseUrl).host;
|
|
32
|
+
} catch {
|
|
33
|
+
return baseUrl;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createPhone({
|
|
38
|
+
vault, ntfy, pageUrl, now = Date.now, ttlMs = LINK_TTL_MS, retryMs = 2000, openTimeoutMs = RELAY_OPEN_WAIT_MS,
|
|
39
|
+
}) {
|
|
40
|
+
const pending = new Map(); // name -> request
|
|
41
|
+
const starting = new Set(); // names whose start() is still in progress
|
|
42
|
+
const inFlight = new Set(); // handle() calls still saving a secret or publishing its ack
|
|
43
|
+
let closed = false; // set by close(); nothing new may start after that
|
|
44
|
+
|
|
45
|
+
function finish(req, status) {
|
|
46
|
+
if (req.status !== 'waiting') return;
|
|
47
|
+
req.status = status;
|
|
48
|
+
req.keys = null;
|
|
49
|
+
clearTimeout(req.timer);
|
|
50
|
+
req.controller.abort();
|
|
51
|
+
for (const notify of req.waiters) notify(status);
|
|
52
|
+
req.waiters.clear();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Expiry and close() never interrupt a save in progress; the save decides the outcome. */
|
|
56
|
+
function expire(req) {
|
|
57
|
+
if (req.busy) req.expirePending = true;
|
|
58
|
+
else finish(req, 'expired');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function handle(req, text) {
|
|
62
|
+
if (req.status !== 'waiting' || req.busy) return;
|
|
63
|
+
let msg;
|
|
64
|
+
try {
|
|
65
|
+
msg = JSON.parse(text);
|
|
66
|
+
} catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (msg?.t !== 'sub' || typeof msg.pk !== 'string') return;
|
|
70
|
+
let key;
|
|
71
|
+
let plain;
|
|
72
|
+
try {
|
|
73
|
+
key = await deriveKey(req.keys.privateKey, fromB64u(msg.pk), req.topic);
|
|
74
|
+
plain = await unseal(key, msg, aadFor(req, 'sub'));
|
|
75
|
+
} catch {
|
|
76
|
+
return; // not sealed for this link
|
|
77
|
+
}
|
|
78
|
+
// The link may have expired while decrypting. Claim it synchronously, so nothing else can save to it.
|
|
79
|
+
if (req.status !== 'waiting' || req.busy) return;
|
|
80
|
+
req.busy = true;
|
|
81
|
+
const reply = async (body) => {
|
|
82
|
+
const box = await seal(key, body, aadFor(req, 'ack'));
|
|
83
|
+
await ntfy.publish(req.topic, JSON.stringify({ t: 'ack', ...box })).catch(() => {});
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
if (typeof plain?.value !== 'string') throw new Error('The secret must be text.');
|
|
87
|
+
await vault.put(req.name, { kind: req.kind, site: req.site || undefined, created: new Date(now()).toISOString() }, plain.value);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
req.busy = false;
|
|
90
|
+
if (req.expirePending) finish(req, 'expired');
|
|
91
|
+
await reply({ ok: false, error: e.message });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// The secret is in the vault: report it as saved before the ack goes out, so a slow relay cannot turn it into 'expired'.
|
|
95
|
+
req.busy = false;
|
|
96
|
+
finish(req, 'saved');
|
|
97
|
+
await reply({ ok: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Records a message that is still being saved or acknowledged, so close() can wait for it. */
|
|
101
|
+
async function track(work) {
|
|
102
|
+
inFlight.add(work);
|
|
103
|
+
try {
|
|
104
|
+
await work;
|
|
105
|
+
} finally {
|
|
106
|
+
inFlight.delete(work);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Keeps a relay connection open for req until it is finished, reconnecting when it drops. Calls reached(true) each time
|
|
112
|
+
* a connection opens and reached(false) each time one fails; start() only looks at the first call.
|
|
113
|
+
*/
|
|
114
|
+
async function listen(req, since, reached) {
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
while (req.status === 'waiting') {
|
|
117
|
+
try {
|
|
118
|
+
await ntfy.subscribe(req.topic, async (text, id) => {
|
|
119
|
+
if (seen.has(id)) return;
|
|
120
|
+
seen.add(id);
|
|
121
|
+
await track(handle(req, text));
|
|
122
|
+
}, { since, signal: req.controller.signal, onOpen: () => reached(true) });
|
|
123
|
+
} catch {
|
|
124
|
+
reached(false); // could not connect, dropped or aborted; the loop decides
|
|
125
|
+
}
|
|
126
|
+
if (req.status === 'waiting') await sleep(retryMs);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
async start({ name, kind, site }) {
|
|
132
|
+
if (closed) throw new Error('passClaude is shutting down.');
|
|
133
|
+
if (kind !== 'web' && kind !== 'command') throw new Error('Kind must be "web" or "command".');
|
|
134
|
+
if (kind === 'web' && !site) throw new Error('A web link needs a site, such as netflix.com.');
|
|
135
|
+
if (kind === 'command' && site) throw new Error('A command link cannot have a site.');
|
|
136
|
+
if (!pageUrl) throw new Error('Phone page not configured. Set PASSCLAUDE_PAGE_URL, or use add_secret (PC popup) instead.');
|
|
137
|
+
if (starting.has(name) || pending.get(name)?.status === 'waiting') {
|
|
138
|
+
throw new Error(`A phone link for "${name}" is already waiting. Use check_phone_secret.`);
|
|
139
|
+
}
|
|
140
|
+
starting.add(name); // reserved before the first await, so a concurrent start() is refused
|
|
141
|
+
try {
|
|
142
|
+
if (await vault.meta(name)) throw new Error(`"${name}" already exists. Delete it first if you want to replace it.`);
|
|
143
|
+
|
|
144
|
+
const keys = await generateKeyPair();
|
|
145
|
+
if (closed) throw new Error('passClaude is shutting down.'); // close() ran while we were waiting
|
|
146
|
+
const topic = randomTopic();
|
|
147
|
+
const created = now();
|
|
148
|
+
const expiresAt = created + ttlMs;
|
|
149
|
+
const req = {
|
|
150
|
+
topic, name, kind, site: site ?? '',
|
|
151
|
+
keys, status: 'waiting', busy: false, expirePending: false,
|
|
152
|
+
waiters: new Set(), controller: new AbortController(),
|
|
153
|
+
};
|
|
154
|
+
req.timer = setTimeout(() => expire(req), ttlMs);
|
|
155
|
+
req.timer.unref?.();
|
|
156
|
+
pending.set(name, req);
|
|
157
|
+
// Hand out the link only once the relay has accepted the connection: a link the PC can't hear would leave the user
|
|
158
|
+
// waiting for nothing. Only this first connection counts; later drops are retried quietly by listen().
|
|
159
|
+
let timer;
|
|
160
|
+
const opened = await new Promise((resolve) => {
|
|
161
|
+
timer = setTimeout(() => resolve(false), openTimeoutMs);
|
|
162
|
+
// 'all' returns only this link's messages (the topic is new and random), so no clock is involved.
|
|
163
|
+
listen(req, 'all', resolve).catch(() => {});
|
|
164
|
+
});
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
if (!opened || closed) {
|
|
167
|
+
finish(req, 'expired'); // stops listening
|
|
168
|
+
pending.delete(name);
|
|
169
|
+
if (closed) throw new Error('passClaude is shutting down.');
|
|
170
|
+
throw new Error(`Could not reach the relay (${relayHost(ntfy.baseUrl)}) from this PC. Use add_secret (PC popup) instead.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const params = new URLSearchParams({
|
|
174
|
+
v: '1', t: topic, k: toB64u(keys.publicKeyRaw), n: name, kind, s: site ?? '',
|
|
175
|
+
e: String(Math.floor(expiresAt / 1000)), r: ntfy.baseUrl,
|
|
176
|
+
});
|
|
177
|
+
return { link: `${pageUrl}#${params}`, expiresAt };
|
|
178
|
+
} finally {
|
|
179
|
+
starting.delete(name);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
check(name, timeoutMs = 60_000) {
|
|
184
|
+
const req = pending.get(name);
|
|
185
|
+
if (!req) return Promise.resolve('none');
|
|
186
|
+
if (req.status !== 'waiting') return Promise.resolve(req.status);
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const done = (status) => {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
resolve(status);
|
|
191
|
+
};
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
req.waiters.delete(done);
|
|
194
|
+
resolve('waiting');
|
|
195
|
+
}, timeoutMs);
|
|
196
|
+
req.waiters.add(done);
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/** Drops a finished link for name (after its secret is deleted), so check() no longer reports it as saved. */
|
|
201
|
+
forget(name) {
|
|
202
|
+
if (pending.get(name)?.status !== 'waiting') pending.delete(name);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Ends every waiting link (which aborts its relay connection). Resolves once messages already being saved have been
|
|
207
|
+
* acknowledged (or after CLOSE_ACK_WAIT_MS), then after CLOSE_DRAIN_MS. The drain is needed because on Windows,
|
|
208
|
+
* calling process.exit() right after a streaming fetch is aborted trips a libuv assertion (src\win\async.c) and the
|
|
209
|
+
* process dies with exit code 127. Callers that exit afterwards must await close() first.
|
|
210
|
+
*/
|
|
211
|
+
close() {
|
|
212
|
+
closed = true;
|
|
213
|
+
for (const req of pending.values()) expire(req);
|
|
214
|
+
return settledWithin([...inFlight], CLOSE_ACK_WAIT_MS).then(() => sleep(CLOSE_DRAIN_MS));
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!-- Generated from brand/tokens.json by `npm run brand`. Do not edit by hand. -->
|
|
2
|
+
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
|
3
|
+
<SolidColorBrush x:Key="Violet" Color="#5B2EFF"/>
|
|
4
|
+
<SolidColorBrush x:Key="VioletDeep" Color="#3D17D6"/>
|
|
5
|
+
<SolidColorBrush x:Key="Yellow" Color="#FFC92E"/>
|
|
6
|
+
<SolidColorBrush x:Key="Ink" Color="#17132E"/>
|
|
7
|
+
<SolidColorBrush x:Key="Paper" Color="#F3EFFF"/>
|
|
8
|
+
<SolidColorBrush x:Key="White" Color="#FFFFFF"/>
|
|
9
|
+
<SolidColorBrush x:Key="Muted" Color="#4A4370"/>
|
|
10
|
+
<SolidColorBrush x:Key="MutedOnDark" Color="#CFC6EE"/>
|
|
11
|
+
<SolidColorBrush x:Key="Lilac200" Color="#DCD3FF"/>
|
|
12
|
+
<SolidColorBrush x:Key="Lilac300" Color="#C9BCFF"/>
|
|
13
|
+
<SolidColorBrush x:Key="Mint" Color="#0E9F6E"/>
|
|
14
|
+
<SolidColorBrush x:Key="Coral" Color="#E5432A"/>
|
|
15
|
+
<CornerRadius x:Key="RadiusSm">12</CornerRadius>
|
|
16
|
+
<CornerRadius x:Key="RadiusMd">16</CornerRadius>
|
|
17
|
+
<CornerRadius x:Key="RadiusLg">24</CornerRadius>
|
|
18
|
+
<CornerRadius x:Key="RadiusPill">999</CornerRadius>
|
|
19
|
+
<Thickness x:Key="Outline">2</Thickness>
|
|
20
|
+
<Thickness x:Key="ShadowButton">5,5,0,0</Thickness>
|
|
21
|
+
<Thickness x:Key="ShadowCard">8,8,0,0</Thickness>
|
|
22
|
+
</ResourceDictionary>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Chrome helpers using UI Automation: read a window's address bar, check the focused element.
|
|
2
|
+
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
|
|
3
|
+
$AE = [System.Windows.Automation.AutomationElement]
|
|
4
|
+
$CT = [System.Windows.Automation.ControlType]
|
|
5
|
+
$Descendants = [System.Windows.Automation.TreeScope]::Descendants
|
|
6
|
+
|
|
7
|
+
function Test-InsideDocument($Element) {
|
|
8
|
+
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
9
|
+
$node = $walker.GetParent($Element)
|
|
10
|
+
while ($node) {
|
|
11
|
+
if ($node.Current.ControlType -eq $CT::Document) { return $true }
|
|
12
|
+
$node = $walker.GetParent($node)
|
|
13
|
+
}
|
|
14
|
+
return $false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Text in the address bar of a Chrome window, or $null if it cannot be found.
|
|
18
|
+
function Get-AddressBarText([IntPtr]$Hwnd) {
|
|
19
|
+
try {
|
|
20
|
+
$root = $AE::FromHandle($Hwnd)
|
|
21
|
+
$isEdit = [System.Windows.Automation.PropertyCondition]::new($AE::ControlTypeProperty, $CT::Edit)
|
|
22
|
+
$named = [System.Windows.Automation.PropertyCondition]::new($AE::NameProperty, 'Address and search bar')
|
|
23
|
+
$both = [System.Windows.Automation.AndCondition]::new([System.Windows.Automation.Condition[]]@($isEdit, $named))
|
|
24
|
+
$edit = $root.FindFirst($Descendants, $both)
|
|
25
|
+
# Never read a password box, whatever it is called, or a box in the web page that only calls itself the address bar
|
|
26
|
+
# (with aria-label, say). In non-English Chrome the real one has another name, so the page's would be the only match.
|
|
27
|
+
if ($edit -and ($edit.Current.IsPassword -or (Test-InsideDocument $edit))) { $edit = $null }
|
|
28
|
+
if (-not $edit) {
|
|
29
|
+
# Non-English Chrome: the first edit box that is neither a password box nor inside the web page is the address bar.
|
|
30
|
+
foreach ($candidate in $root.FindAll($Descendants, $isEdit)) {
|
|
31
|
+
if ($candidate.Current.IsPassword) { continue }
|
|
32
|
+
if (Test-InsideDocument $candidate) { continue }
|
|
33
|
+
$edit = $candidate
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (-not $edit) { return $null }
|
|
38
|
+
return $edit.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Current.Value
|
|
39
|
+
} catch {
|
|
40
|
+
return $null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# True if keyboard focus is on a password box in the window's process.
|
|
45
|
+
function Test-FocusIsPassword([IntPtr]$Hwnd) {
|
|
46
|
+
$windowPid = [PassClaude.Win]::ProcessId($Hwnd)
|
|
47
|
+
for ($i = 0; $i -lt 5; $i++) {
|
|
48
|
+
try {
|
|
49
|
+
$focused = $AE::FocusedElement
|
|
50
|
+
if ($focused -and $focused.Current.IsPassword -and $focused.Current.ProcessId -eq $windowPid) { return $true }
|
|
51
|
+
} catch { }
|
|
52
|
+
Start-Sleep -Milliseconds 200
|
|
53
|
+
}
|
|
54
|
+
return $false
|
|
55
|
+
}
|