ledung 1.0.0 → 1.2.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/CHANGELOG.md +9 -0
- package/README.md +42 -76
- package/dist/cli.js +165 -22
- package/dist/main.js +959 -522
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/web/dist/assets/{highlightCore-CFmgAbRi.js → highlightCore-g6NEEnZr.js} +1 -1
- package/web/dist/assets/{index-BGKFZPrt.css → index-D8eK9o3f.css} +1 -1
- package/web/dist/assets/index-DcsDS7Ir.js +108 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-hds__uKl.js +0 -108
package/dist/main.js
CHANGED
|
@@ -4,8 +4,8 @@ import os16 from "node:os";
|
|
|
4
4
|
import path40 from "node:path";
|
|
5
5
|
|
|
6
6
|
// src/config.ts
|
|
7
|
-
import
|
|
8
|
-
import
|
|
7
|
+
import fs10 from "node:fs";
|
|
8
|
+
import path13 from "node:path";
|
|
9
9
|
import { z as z6 } from "zod";
|
|
10
10
|
|
|
11
11
|
// src/platform/paths.ts
|
|
@@ -2065,30 +2065,55 @@ function readPublishedUsage(raw) {
|
|
|
2065
2065
|
var USAGE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
2066
2066
|
|
|
2067
2067
|
// src/accounts/store.ts
|
|
2068
|
-
import
|
|
2068
|
+
import fs9 from "node:fs";
|
|
2069
2069
|
import os5 from "node:os";
|
|
2070
|
-
import
|
|
2070
|
+
import path12 from "node:path";
|
|
2071
2071
|
|
|
2072
2072
|
// src/accounts/about-file.ts
|
|
2073
|
-
import
|
|
2074
|
-
import
|
|
2073
|
+
import fs8 from "node:fs";
|
|
2074
|
+
import path11 from "node:path";
|
|
2075
2075
|
|
|
2076
2076
|
// src/accounts/signin-store.ts
|
|
2077
2077
|
import { createHash } from "node:crypto";
|
|
2078
|
-
import
|
|
2078
|
+
import fs7 from "node:fs";
|
|
2079
2079
|
import os4 from "node:os";
|
|
2080
|
-
import
|
|
2080
|
+
import path10 from "node:path";
|
|
2081
2081
|
|
|
2082
2082
|
// src/accounts/signin.ts
|
|
2083
2083
|
import { execFile as execFile2 } from "node:child_process";
|
|
2084
2084
|
|
|
2085
2085
|
// src/runners/claude-program.ts
|
|
2086
2086
|
import { execFile } from "node:child_process";
|
|
2087
|
-
import
|
|
2087
|
+
import fs6 from "node:fs";
|
|
2088
2088
|
import { createRequire } from "node:module";
|
|
2089
2089
|
import os3 from "node:os";
|
|
2090
|
-
import
|
|
2090
|
+
import path9 from "node:path";
|
|
2091
2091
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2092
|
+
|
|
2093
|
+
// src/platform/exercised.ts
|
|
2094
|
+
import fs5 from "node:fs";
|
|
2095
|
+
import path8 from "node:path";
|
|
2096
|
+
var EXERCISED_FILE = "exercised.log";
|
|
2097
|
+
var MAX_BYTES = 256 * 1024;
|
|
2098
|
+
var DEFAULT_TARGET = {
|
|
2099
|
+
console: !PACKAGED,
|
|
2100
|
+
file: () => path8.join(userDataDir(), EXERCISED_FILE)
|
|
2101
|
+
};
|
|
2102
|
+
function exercisedLine(line, target = DEFAULT_TARGET) {
|
|
2103
|
+
if (target.console) console.log(line);
|
|
2104
|
+
try {
|
|
2105
|
+
const file = target.file();
|
|
2106
|
+
if (!file) return;
|
|
2107
|
+
const size = fs5.statSync(file, { throwIfNoEntry: false })?.size ?? 0;
|
|
2108
|
+
const text2 = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
2109
|
+
`;
|
|
2110
|
+
if (size > MAX_BYTES) fs5.writeFileSync(file, text2);
|
|
2111
|
+
else fs5.appendFileSync(file, text2);
|
|
2112
|
+
} catch {
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// src/runners/claude-program.ts
|
|
2092
2117
|
var SDK = "@anthropic-ai/claude-agent-sdk";
|
|
2093
2118
|
function sdkClaudeBinary(resolve = createRequire(import.meta.resolve(SDK)).resolve) {
|
|
2094
2119
|
const exe = process.platform === "win32" ? ".exe" : "";
|
|
@@ -2109,8 +2134,8 @@ function sdkClaudeBinary(resolve = createRequire(import.meta.resolve(SDK)).resol
|
|
|
2109
2134
|
}
|
|
2110
2135
|
function sdkClaudeVersion() {
|
|
2111
2136
|
try {
|
|
2112
|
-
const sdk =
|
|
2113
|
-
const v = JSON.parse(
|
|
2137
|
+
const sdk = path9.dirname(fileURLToPath2(import.meta.resolve(SDK)));
|
|
2138
|
+
const v = JSON.parse(fs6.readFileSync(path9.join(sdk, "package.json"), "utf8")).claudeCodeVersion;
|
|
2114
2139
|
return typeof v === "string" ? v : null;
|
|
2115
2140
|
} catch {
|
|
2116
2141
|
return null;
|
|
@@ -2134,6 +2159,26 @@ var runVersion = (program) => new Promise((resolve) => {
|
|
|
2134
2159
|
const { command, args } = commandFor(program);
|
|
2135
2160
|
execFile(command, [...args, "--version"], { windowsHide: true, timeout: 15e3 }, (err, stdout) => resolve(err ? null : String(stdout)));
|
|
2136
2161
|
});
|
|
2162
|
+
function regValue(text2, name, env) {
|
|
2163
|
+
const row = text2.split(/\r?\n/).map((l) => l.trim().split(/\s{2,}|\t/)).find((cols) => cols[0]?.toLowerCase() === name.toLowerCase() && /^REG_(EXPAND_)?SZ$/i.test(cols[1] ?? ""));
|
|
2164
|
+
const value = row?.slice(2).join(" ");
|
|
2165
|
+
if (!value) return null;
|
|
2166
|
+
return value.replace(/%([^%]+)%/g, (whole, v) => {
|
|
2167
|
+
const key = Object.keys(env).find((k) => k.toLowerCase() === v.toLowerCase());
|
|
2168
|
+
return key ? env[key] ?? whole : whole;
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
var regQuery = (key) => new Promise((resolve) => {
|
|
2172
|
+
execFile("reg", ["query", key, "/v", "Path"], { windowsHide: true, timeout: 5e3 }, (err, stdout) => resolve(err ? "" : String(stdout)));
|
|
2173
|
+
});
|
|
2174
|
+
async function windowsPath() {
|
|
2175
|
+
const [machine, user] = await Promise.all([
|
|
2176
|
+
regQuery("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"),
|
|
2177
|
+
regQuery("HKCU\\Environment")
|
|
2178
|
+
]);
|
|
2179
|
+
const parts2 = [regValue(machine, "Path", process.env), regValue(user, "Path", process.env)].filter((v) => !!v);
|
|
2180
|
+
return parts2.length ? parts2.join(";") : null;
|
|
2181
|
+
}
|
|
2137
2182
|
function realLookup() {
|
|
2138
2183
|
return {
|
|
2139
2184
|
platform: process.platform,
|
|
@@ -2141,7 +2186,7 @@ function realLookup() {
|
|
|
2141
2186
|
home: os3.homedir(),
|
|
2142
2187
|
stat: (file) => {
|
|
2143
2188
|
try {
|
|
2144
|
-
const s =
|
|
2189
|
+
const s = fs6.statSync(file);
|
|
2145
2190
|
return s.isFile() ? { size: s.size, mtimeMs: s.mtimeMs } : null;
|
|
2146
2191
|
} catch {
|
|
2147
2192
|
return null;
|
|
@@ -2149,19 +2194,27 @@ function realLookup() {
|
|
|
2149
2194
|
},
|
|
2150
2195
|
readText: (file) => {
|
|
2151
2196
|
try {
|
|
2152
|
-
return
|
|
2197
|
+
return fs6.readFileSync(file, "utf8");
|
|
2153
2198
|
} catch {
|
|
2154
2199
|
return null;
|
|
2155
2200
|
}
|
|
2156
2201
|
},
|
|
2157
2202
|
realpath: (file) => {
|
|
2158
2203
|
try {
|
|
2159
|
-
return
|
|
2204
|
+
return fs6.realpathSync(file);
|
|
2160
2205
|
} catch {
|
|
2161
2206
|
return file;
|
|
2162
2207
|
}
|
|
2163
2208
|
},
|
|
2164
2209
|
versionText: runVersion,
|
|
2210
|
+
listDir: (dir) => {
|
|
2211
|
+
try {
|
|
2212
|
+
return fs6.readdirSync(dir);
|
|
2213
|
+
} catch {
|
|
2214
|
+
return [];
|
|
2215
|
+
}
|
|
2216
|
+
},
|
|
2217
|
+
...process.platform === "win32" ? { freshPath: windowsPath } : {},
|
|
2165
2218
|
bundled: sdkClaudeBinary(),
|
|
2166
2219
|
required: sdkClaudeVersion(),
|
|
2167
2220
|
packaged: PACKAGED
|
|
@@ -2174,18 +2227,26 @@ function noBundledProblem(look) {
|
|
|
2174
2227
|
function shimTarget(shim, look) {
|
|
2175
2228
|
const text2 = look.readText(shim);
|
|
2176
2229
|
if (!text2) return null;
|
|
2177
|
-
const dir =
|
|
2178
|
-
const found = [...text2.matchAll(/"%~?dp0%?\\?([^"%]+?\.(?:exe|c?js|mjs))"/gi)].map((m) =>
|
|
2230
|
+
const dir = path9.win32.dirname(shim);
|
|
2231
|
+
const found = [...text2.matchAll(/"%~?dp0%?\\?([^"%]+?\.(?:exe|c?js|mjs))"/gi)].map((m) => path9.win32.join(dir, m[1]));
|
|
2179
2232
|
return found.reverse().find((f) => look.stat(f) !== null) ?? null;
|
|
2180
2233
|
}
|
|
2181
2234
|
function knownLocations(look) {
|
|
2182
2235
|
const { home, env } = look;
|
|
2183
2236
|
if (look.platform === "win32") {
|
|
2184
|
-
const p2 =
|
|
2237
|
+
const p2 = path9.win32;
|
|
2185
2238
|
const appData = env.APPDATA ?? p2.join(home, "AppData", "Roaming");
|
|
2186
|
-
|
|
2239
|
+
const localAppData = env.LOCALAPPDATA ?? p2.join(home, "AppData", "Local");
|
|
2240
|
+
return [
|
|
2241
|
+
p2.join(home, ".local", "bin", "claude.exe"),
|
|
2242
|
+
p2.join(appData, "npm", "claude.cmd"),
|
|
2243
|
+
...env.npm_config_prefix ? [p2.join(env.npm_config_prefix, "claude.cmd")] : [],
|
|
2244
|
+
// `winget install Anthropic.ClaudeCode` links it here, for the user or for the whole computer (#455).
|
|
2245
|
+
p2.join(localAppData, "Microsoft", "WinGet", "Links", "claude.exe"),
|
|
2246
|
+
p2.join(env.ProgramFiles ?? "C:\\Program Files", "WinGet", "Links", "claude.exe")
|
|
2247
|
+
];
|
|
2187
2248
|
}
|
|
2188
|
-
const p =
|
|
2249
|
+
const p = path9.posix;
|
|
2189
2250
|
return [
|
|
2190
2251
|
p.join(home, ".local", "bin", "claude"),
|
|
2191
2252
|
p.join(home, ".claude", "local", "claude"),
|
|
@@ -2204,24 +2265,65 @@ function programOf(file, look) {
|
|
|
2204
2265
|
const real2 = look.realpath(file);
|
|
2205
2266
|
return /\.(c|m)?js$/i.test(real2) ? real2 : file;
|
|
2206
2267
|
}
|
|
2207
|
-
function
|
|
2268
|
+
function isDesktopApp(file) {
|
|
2269
|
+
return /[\\/]AnthropicClaude[\\/](app-[^\\/]+[\\/])?claude\.exe$/i.test(file) || /[\\/]Claude\.app[\\/]Contents[\\/]MacOS[\\/]/.test(file);
|
|
2270
|
+
}
|
|
2271
|
+
function searchInstalled(look, pathVar = look.env.PATH ?? look.env.Path ?? "") {
|
|
2208
2272
|
const win = look.platform === "win32";
|
|
2209
2273
|
const sep = win ? ";" : ":";
|
|
2210
2274
|
const names = win ? ["claude.exe", "claude.cmd"] : ["claude"];
|
|
2211
|
-
const
|
|
2212
|
-
const onPath = pathVar.split(sep).filter(Boolean).flatMap((dir) => names.map((n) => (win ? path8.win32 : path8.posix).join(dir, n)));
|
|
2275
|
+
const onPath = pathVar.split(sep).filter(Boolean).flatMap((dir) => names.map((n) => (win ? path9.win32 : path9.posix).join(dir, n)));
|
|
2213
2276
|
const bundled = look.bundled ? look.realpath(look.bundled) : null;
|
|
2214
2277
|
const same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
2278
|
+
const looked = [];
|
|
2215
2279
|
for (const file of [...onPath, ...knownLocations(look)]) {
|
|
2280
|
+
if (looked.some((l) => same(l, file))) continue;
|
|
2281
|
+
looked.push(file);
|
|
2216
2282
|
if (!look.stat(file)) continue;
|
|
2217
2283
|
const program = programOf(file, look);
|
|
2218
2284
|
if (!program || !look.stat(program)) continue;
|
|
2219
2285
|
const real2 = look.realpath(program);
|
|
2220
2286
|
if (bundled && same(real2, bundled)) continue;
|
|
2221
2287
|
if (/[\\/]node_modules[\\/]@anthropic-ai[\\/]claude-agent-sdk/i.test(real2)) continue;
|
|
2222
|
-
|
|
2288
|
+
if (isDesktopApp(real2)) continue;
|
|
2289
|
+
return { program, looked };
|
|
2223
2290
|
}
|
|
2224
|
-
return null;
|
|
2291
|
+
return { program: null, looked };
|
|
2292
|
+
}
|
|
2293
|
+
function desktopAppFolders(look) {
|
|
2294
|
+
const { home, env } = look;
|
|
2295
|
+
if (look.platform === "win32") {
|
|
2296
|
+
const p2 = path9.win32;
|
|
2297
|
+
const appData = env.APPDATA ?? p2.join(home, "AppData", "Roaming");
|
|
2298
|
+
const packages = p2.join(env.LOCALAPPDATA ?? p2.join(home, "AppData", "Local"), "Packages");
|
|
2299
|
+
const store = (look.listDir?.(packages) ?? []).filter((n) => /^Claude_/i.test(n)).map((n) => p2.join(packages, n, "LocalCache", "Roaming", "Claude", "claude-code"));
|
|
2300
|
+
return [p2.join(appData, "Claude", "claude-code"), ...store];
|
|
2301
|
+
}
|
|
2302
|
+
const p = path9.posix;
|
|
2303
|
+
if (look.platform === "darwin") return [p.join(home, "Library", "Application Support", "Claude", "claude-code")];
|
|
2304
|
+
return [p.join(env.XDG_CONFIG_HOME ?? p.join(home, ".config"), "Claude", "claude-code")];
|
|
2305
|
+
}
|
|
2306
|
+
function appProgramIn(look, versionDir, hash2) {
|
|
2307
|
+
if (look.platform === "win32") return path9.win32.join(versionDir, "claude.exe");
|
|
2308
|
+
if (look.platform !== "darwin") return path9.posix.join(versionDir, "claude");
|
|
2309
|
+
const inBundle = (h) => path9.posix.join(versionDir, h, "claude.app", "Contents", "MacOS", "claude");
|
|
2310
|
+
if (hash2) return inBundle(hash2);
|
|
2311
|
+
return (look.listDir?.(versionDir) ?? []).sort().map(inBundle).find((f) => look.stat(f)) ?? null;
|
|
2312
|
+
}
|
|
2313
|
+
function desktopAppCopy(look) {
|
|
2314
|
+
const p = look.platform === "win32" ? path9.win32 : path9.posix;
|
|
2315
|
+
const looked = [];
|
|
2316
|
+
let best = null;
|
|
2317
|
+
for (const folder of desktopAppFolders(look)) {
|
|
2318
|
+
looked.push(appProgramIn(look, p.join(folder, "<version>"), "<hash>"));
|
|
2319
|
+
for (const name of look.listDir?.(folder) ?? []) {
|
|
2320
|
+
const version = parseVersion(name);
|
|
2321
|
+
const program = version ? appProgramIn(look, p.join(folder, name)) : null;
|
|
2322
|
+
if (!version || !program || !look.stat(program)) continue;
|
|
2323
|
+
if (!best || !atLeast(best.version, version)) best = { program, version };
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
return { program: best?.program ?? null, looked };
|
|
2225
2327
|
}
|
|
2226
2328
|
var versions = /* @__PURE__ */ new Map();
|
|
2227
2329
|
async function programVersion(program, look) {
|
|
@@ -2234,6 +2336,10 @@ async function programVersion(program, look) {
|
|
|
2234
2336
|
return version;
|
|
2235
2337
|
}
|
|
2236
2338
|
var INSTALL_LINK = "https://code.claude.com/docs/en/setup";
|
|
2339
|
+
function installCommand(platform) {
|
|
2340
|
+
return platform === "win32" ? "irm https://claude.ai/install.ps1 | iex" : "curl -fsSL https://claude.ai/install.sh | bash";
|
|
2341
|
+
}
|
|
2342
|
+
var appCopyLogged = false;
|
|
2237
2343
|
async function chooseClaude(setting = "installed", look = realLookup()) {
|
|
2238
2344
|
const required = look.required;
|
|
2239
2345
|
const bundledVersion = look.bundled ? await programVersion(look.bundled, look) ?? required : null;
|
|
@@ -2243,20 +2349,49 @@ async function chooseClaude(setting = "installed", look = realLookup()) {
|
|
|
2243
2349
|
return { ...base, installed: null, program: bundled, problem: bundled ? null : noBundledProblem(look) };
|
|
2244
2350
|
}
|
|
2245
2351
|
const given = setting !== "installed";
|
|
2246
|
-
|
|
2352
|
+
let found;
|
|
2353
|
+
let looked;
|
|
2354
|
+
let app = false;
|
|
2355
|
+
if (given) {
|
|
2356
|
+
found = look.stat(setting) ? programOf(setting, look) : null;
|
|
2357
|
+
looked = [setting];
|
|
2358
|
+
} else {
|
|
2359
|
+
({ program: found, looked } = searchInstalled(look));
|
|
2360
|
+
const fresh = found || !look.freshPath ? null : await look.freshPath();
|
|
2361
|
+
if (fresh) {
|
|
2362
|
+
const again = searchInstalled(look, fresh);
|
|
2363
|
+
found = again.program;
|
|
2364
|
+
looked = [...looked, ...again.looked.filter((l) => !looked.includes(l))];
|
|
2365
|
+
}
|
|
2366
|
+
if (!found) {
|
|
2367
|
+
const copy = desktopAppCopy(look);
|
|
2368
|
+
found = copy.program;
|
|
2369
|
+
looked = [...looked, ...copy.looked];
|
|
2370
|
+
app = found !== null;
|
|
2371
|
+
if (app && !appCopyLogged) {
|
|
2372
|
+
appCopyLogged = true;
|
|
2373
|
+
exercisedLine("EXERCISED #455 Claude Code found as the desktop app's own copy");
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2247
2377
|
const version = found ? await programVersion(found, look) : null;
|
|
2248
|
-
const installed = found ? { kind: given ? "path" : "installed", path: found, version } : null;
|
|
2378
|
+
const installed = found ? { kind: given ? "path" : "installed", path: found, version, ...app ? { app: true } : {} } : null;
|
|
2379
|
+
const install = `install Claude Code (\`${installCommand(look.platform)}\`, or see ${INSTALL_LINK}), then press Check again`;
|
|
2249
2380
|
let problem2 = null;
|
|
2250
2381
|
if (!required) {
|
|
2251
2382
|
problem2 = "Ledung could not read which Claude Code version its Agent SDK needs (the SDK's package.json). Install Ledung again (`npm install`).";
|
|
2252
2383
|
} else if (!found) {
|
|
2253
2384
|
problem2 = given ? `Ledung runs your Claude Code, and there is none at ${setting} (the config's "claudeExecutable"). Install it from ${INSTALL_LINK}, or correct the path.` : `Ledung runs your Claude Code, and it isn't installed on this computer. Install it from ${INSTALL_LINK}, then press Check again.`;
|
|
2385
|
+
} else if (app && !version) {
|
|
2386
|
+
problem2 = `Ledung runs your Claude Code, and the only one on this computer is the Claude desktop app's own copy at ${found}, whose version couldn't be read. The app keeps that copy for itself: ${install}.`;
|
|
2387
|
+
} else if (app && !atLeast(version, required)) {
|
|
2388
|
+
problem2 = `Ledung runs your Claude Code, and the only one on this computer is the Claude desktop app's own copy, ${version} at ${found}; Ledung needs ${required} or newer. The app keeps that copy for itself: ${install}.`;
|
|
2254
2389
|
} else if (!version) {
|
|
2255
2390
|
problem2 = `Ledung runs your Claude Code, and the version of the one at ${found} couldn't be read. Update it (\`claude update\`) or install it again from ${INSTALL_LINK}.`;
|
|
2256
2391
|
} else if (!atLeast(version, required)) {
|
|
2257
2392
|
problem2 = `Ledung runs your Claude Code, and yours is ${version}; Ledung needs ${required} or newer. Update it (\`claude update\`), then press Check again.`;
|
|
2258
2393
|
}
|
|
2259
|
-
return { ...base, installed, program: problem2 ? null : installed, problem: problem2 };
|
|
2394
|
+
return { ...base, installed, program: problem2 ? null : installed, problem: problem2, looked };
|
|
2260
2395
|
}
|
|
2261
2396
|
|
|
2262
2397
|
// src/accounts/signin.ts
|
|
@@ -2372,7 +2507,7 @@ async function removeSignIn(place, platform = process.platform, run8 = runWithIn
|
|
|
2372
2507
|
const { service, account } = keychainEntry(place.folder);
|
|
2373
2508
|
await run8("security", ["delete-generic-password", "-a", account, "-s", service], null, 2e4);
|
|
2374
2509
|
} else {
|
|
2375
|
-
|
|
2510
|
+
fs7.rmSync(path10.join(place.folder, CREDENTIALS), { force: true });
|
|
2376
2511
|
}
|
|
2377
2512
|
} catch {
|
|
2378
2513
|
}
|
|
@@ -2406,12 +2541,12 @@ function aboutText(a, platform = process.platform) {
|
|
|
2406
2541
|
return lines.join(platform === "win32" ? "\r\n" : "\n") + (platform === "win32" ? "\r\n" : "\n");
|
|
2407
2542
|
}
|
|
2408
2543
|
function writeAboutFile(a, platform = process.platform) {
|
|
2409
|
-
const file =
|
|
2544
|
+
const file = path11.join(a.configDir, ABOUT_FILE);
|
|
2410
2545
|
const text2 = aboutText(a, platform);
|
|
2411
2546
|
try {
|
|
2412
|
-
if (!
|
|
2413
|
-
if (
|
|
2414
|
-
|
|
2547
|
+
if (!fs8.statSync(a.configDir).isDirectory()) return false;
|
|
2548
|
+
if (fs8.existsSync(file) && fs8.readFileSync(file, "utf8") === text2) return false;
|
|
2549
|
+
fs8.writeFileSync(file, text2);
|
|
2415
2550
|
return true;
|
|
2416
2551
|
} catch {
|
|
2417
2552
|
return false;
|
|
@@ -2428,7 +2563,7 @@ function defaultContainerDir(name) {
|
|
|
2428
2563
|
return `~/.ledung-accounts/${name}`;
|
|
2429
2564
|
}
|
|
2430
2565
|
function canonicalFolder(folder) {
|
|
2431
|
-
return
|
|
2566
|
+
return path12.resolve(folder);
|
|
2432
2567
|
}
|
|
2433
2568
|
function canonicalContainerFolder(folder) {
|
|
2434
2569
|
const f = folder.trim().replace(/\/+$/, "");
|
|
@@ -2441,14 +2576,14 @@ function sameFolder(a, b, platform = process.platform) {
|
|
|
2441
2576
|
return platform === "win32" || platform === "darwin" ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
2442
2577
|
}
|
|
2443
2578
|
function sharedLoginFolders() {
|
|
2444
|
-
return [
|
|
2579
|
+
return [path12.join(os5.homedir(), ".claude"), ...process.env.CLAUDE_CONFIG_DIR ? [process.env.CLAUDE_CONFIG_DIR] : []];
|
|
2445
2580
|
}
|
|
2446
2581
|
var SHARED_CONTAINER_FOLDERS = ["~/.claude"];
|
|
2447
2582
|
var AccountStore = class {
|
|
2448
2583
|
db;
|
|
2449
2584
|
/** Ledung's own accounts folder: the default login folders. */
|
|
2450
2585
|
dir;
|
|
2451
|
-
constructor(db, dir =
|
|
2586
|
+
constructor(db, dir = path12.join(userDataDir(), "accounts")) {
|
|
2452
2587
|
this.db = db;
|
|
2453
2588
|
this.dir = dir;
|
|
2454
2589
|
}
|
|
@@ -2461,7 +2596,7 @@ var AccountStore = class {
|
|
|
2461
2596
|
}
|
|
2462
2597
|
/** The default login folder on this computer for an account of this name. */
|
|
2463
2598
|
defaultFolder(name) {
|
|
2464
|
-
return canonicalFolder(
|
|
2599
|
+
return canonicalFolder(path12.join(this.dir, name));
|
|
2465
2600
|
}
|
|
2466
2601
|
/** Adds an account: its login folder is made, empty until someone signs in to it. */
|
|
2467
2602
|
add(a, whatItIsFor) {
|
|
@@ -2584,7 +2719,7 @@ var AccountStore = class {
|
|
|
2584
2719
|
};
|
|
2585
2720
|
function makeFolder(folder) {
|
|
2586
2721
|
try {
|
|
2587
|
-
|
|
2722
|
+
fs9.mkdirSync(folder, { recursive: true });
|
|
2588
2723
|
} catch (e) {
|
|
2589
2724
|
throw new AccountError(`its sign-in folder ${folder} can't be made (${e.code ?? e.message}).`);
|
|
2590
2725
|
}
|
|
@@ -2709,7 +2844,7 @@ function withoutNotes(raw) {
|
|
|
2709
2844
|
}
|
|
2710
2845
|
var ClaudeAccountSetting = z6.preprocess(withoutNotes, z6.object({
|
|
2711
2846
|
whatItIsFor: z6.string().trim().max(200).optional(),
|
|
2712
|
-
signInFolderOnThisComputer: z6.string().trim().min(1, "give a folder, or leave the field out for Ledung's own").refine((f) =>
|
|
2847
|
+
signInFolderOnThisComputer: z6.string().trim().min(1, "give a folder, or leave the field out for Ledung's own").refine((f) => path13.isAbsolute(f), "give the whole path, such as D:\\Claude sign-ins\\work or /Users/you/claude-signins/work").refine((f) => !sharedLoginFolders().some((s) => sameFolder(s, f)), "that is this computer's own Claude Code folder; an account needs a folder of its own").optional(),
|
|
2713
2848
|
signInFolderInContainers: z6.string().trim().min(1, "give a folder, or leave the field out for ~/.ledung-accounts/<name>").refine((f) => canonicalContainerFolder(f) !== null, "starts with ~/ or /, of letters, digits and . _ / + - only").refine((f) => canonicalContainerFolder(f) !== "~/.claude", "that is the container user's own Claude Code folder; an account needs a folder of its own").optional()
|
|
2714
2849
|
}).strict());
|
|
2715
2850
|
var ClaudeAccountsSetting = z6.preprocess(withoutNotes, z6.record(z6.string(), ClaudeAccountSetting)).default({}).superRefine((accounts, ctx) => {
|
|
@@ -2758,7 +2893,7 @@ var LocalProject = z6.object({
|
|
|
2758
2893
|
* Folders outside the project folder where its sessions may also work, such as where its git worktrees
|
|
2759
2894
|
* go (start_session's cwd, decision of 2026-10-14). None: only the project folder and folders inside it.
|
|
2760
2895
|
*/
|
|
2761
|
-
extraFolders: z6.array(z6.string().min(1).refine((f) =>
|
|
2896
|
+
extraFolders: z6.array(z6.string().min(1).refine((f) => path13.isAbsolute(f), "Give each extra folder as a full path.")).max(20).optional(),
|
|
2762
2897
|
model: z6.string().optional(),
|
|
2763
2898
|
/** Its manager's model; none: the same as its sessions (decision of 2026-10-13). */
|
|
2764
2899
|
managerModel: z6.string().optional(),
|
|
@@ -2875,7 +3010,7 @@ var Config = z6.object({
|
|
|
2875
3010
|
* One that is missing or older than the SDK needs is never run: sessions are refused and the page says how to fix it
|
|
2876
3011
|
* (Martin, 2026-10-27). "bundled" only when set here. Containers run their own.
|
|
2877
3012
|
*/
|
|
2878
|
-
claudeExecutable: z6.string().min(1).refine((v) => v === "installed" || v === "bundled" ||
|
|
3013
|
+
claudeExecutable: z6.string().min(1).refine((v) => v === "installed" || v === "bundled" || path13.isAbsolute(v), 'claudeExecutable is "installed", "bundled" or a full path to Claude Code.').default("installed"),
|
|
2879
3014
|
/** The shell a "!" command from the message box runs in on Windows (issue #212): Git Bash, as Claude's Bash tool, or PowerShell. */
|
|
2880
3015
|
windowsShell: z6.enum(["bash", "powershell"]).default("bash"),
|
|
2881
3016
|
board: BoardSetting,
|
|
@@ -2908,9 +3043,9 @@ function boardConfig(config) {
|
|
|
2908
3043
|
}
|
|
2909
3044
|
function configPath(packaged = PACKAGED) {
|
|
2910
3045
|
const set = envSetting("CONFIG");
|
|
2911
|
-
if (set) return
|
|
2912
|
-
if (packaged) return
|
|
2913
|
-
return
|
|
3046
|
+
if (set) return path13.resolve(set);
|
|
3047
|
+
if (packaged) return path13.join(userDataDir(), "ledung.config.json");
|
|
3048
|
+
return path13.join(appDir(), "ledung.config.local.json");
|
|
2914
3049
|
}
|
|
2915
3050
|
function parseConfig(raw) {
|
|
2916
3051
|
const result = Config.safeParse(raw);
|
|
@@ -2949,9 +3084,9 @@ var FIRST_CONFIG = {
|
|
|
2949
3084
|
port: DEFAULT_PORT2
|
|
2950
3085
|
};
|
|
2951
3086
|
function createDefaultConfig(file = configPath()) {
|
|
2952
|
-
|
|
3087
|
+
fs10.mkdirSync(path13.dirname(file), { recursive: true });
|
|
2953
3088
|
try {
|
|
2954
|
-
|
|
3089
|
+
fs10.writeFileSync(file, `${JSON.stringify(FIRST_CONFIG, null, 2)}
|
|
2955
3090
|
`, { flag: "wx" });
|
|
2956
3091
|
} catch (e) {
|
|
2957
3092
|
if (e.code === "EEXIST") return false;
|
|
@@ -2960,12 +3095,12 @@ function createDefaultConfig(file = configPath()) {
|
|
|
2960
3095
|
return true;
|
|
2961
3096
|
}
|
|
2962
3097
|
function loadConfig(file = configPath()) {
|
|
2963
|
-
if (!
|
|
3098
|
+
if (!fs10.existsSync(file)) {
|
|
2964
3099
|
throw new Error(`No config file at ${file}. Copy ledung.config.example.json to ledung.config.local.json and fill it in.`);
|
|
2965
3100
|
}
|
|
2966
3101
|
let raw;
|
|
2967
3102
|
try {
|
|
2968
|
-
raw = JSON.parse(
|
|
3103
|
+
raw = JSON.parse(fs10.readFileSync(file, "utf8"));
|
|
2969
3104
|
} catch (e) {
|
|
2970
3105
|
throw new Error(`The config file ${file} is not valid JSON: ${e.message}`);
|
|
2971
3106
|
}
|
|
@@ -3029,31 +3164,6 @@ function configProblem(text2) {
|
|
|
3029
3164
|
// src/config-watch.ts
|
|
3030
3165
|
import fs11 from "node:fs";
|
|
3031
3166
|
import path14 from "node:path";
|
|
3032
|
-
|
|
3033
|
-
// src/platform/exercised.ts
|
|
3034
|
-
import fs10 from "node:fs";
|
|
3035
|
-
import path13 from "node:path";
|
|
3036
|
-
var EXERCISED_FILE = "exercised.log";
|
|
3037
|
-
var MAX_BYTES = 256 * 1024;
|
|
3038
|
-
var DEFAULT_TARGET = {
|
|
3039
|
-
console: !PACKAGED,
|
|
3040
|
-
file: () => path13.join(userDataDir(), EXERCISED_FILE)
|
|
3041
|
-
};
|
|
3042
|
-
function exercisedLine(line, target = DEFAULT_TARGET) {
|
|
3043
|
-
if (target.console) console.log(line);
|
|
3044
|
-
try {
|
|
3045
|
-
const file = target.file();
|
|
3046
|
-
if (!file) return;
|
|
3047
|
-
const size = fs10.statSync(file, { throwIfNoEntry: false })?.size ?? 0;
|
|
3048
|
-
const text2 = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
3049
|
-
`;
|
|
3050
|
-
if (size > MAX_BYTES) fs10.writeFileSync(file, text2);
|
|
3051
|
-
else fs10.appendFileSync(file, text2);
|
|
3052
|
-
} catch {
|
|
3053
|
-
}
|
|
3054
|
-
}
|
|
3055
|
-
|
|
3056
|
-
// src/config-watch.ts
|
|
3057
3167
|
var exercised358 = false;
|
|
3058
3168
|
var ConfigWatcher = class {
|
|
3059
3169
|
opts;
|
|
@@ -3224,344 +3334,81 @@ function lanNames(setting) {
|
|
|
3224
3334
|
|
|
3225
3335
|
// src/platform/shortcuts.ts
|
|
3226
3336
|
import { execFile as execFile4 } from "node:child_process";
|
|
3227
|
-
import
|
|
3337
|
+
import fs13 from "node:fs";
|
|
3228
3338
|
import os7 from "node:os";
|
|
3339
|
+
import path16 from "node:path";
|
|
3340
|
+
|
|
3341
|
+
// src/server/security.ts
|
|
3342
|
+
import crypto3 from "node:crypto";
|
|
3343
|
+
import fs12 from "node:fs";
|
|
3229
3344
|
import path15 from "node:path";
|
|
3230
3345
|
|
|
3231
|
-
// src/platform/
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
return
|
|
3236
|
-
}
|
|
3237
|
-
function icoFromPng(png) {
|
|
3238
|
-
const { width, height } = pngSize(png);
|
|
3239
|
-
if (width > 256 || height > 256) throw new Error(`An .ico image is at most 256 pixels, not ${width}\xD7${height}.`);
|
|
3240
|
-
const head = Buffer.alloc(6 + 16);
|
|
3241
|
-
head.writeUInt16LE(0, 0);
|
|
3242
|
-
head.writeUInt16LE(1, 2);
|
|
3243
|
-
head.writeUInt16LE(1, 4);
|
|
3244
|
-
head.writeUInt8(width % 256, 6);
|
|
3245
|
-
head.writeUInt8(height % 256, 7);
|
|
3246
|
-
head.writeUInt8(0, 8);
|
|
3247
|
-
head.writeUInt8(0, 9);
|
|
3248
|
-
head.writeUInt16LE(1, 10);
|
|
3249
|
-
head.writeUInt16LE(32, 12);
|
|
3250
|
-
head.writeUInt32LE(png.length, 14);
|
|
3251
|
-
head.writeUInt32LE(head.length, 18);
|
|
3252
|
-
return Buffer.concat([head, png]);
|
|
3253
|
-
}
|
|
3254
|
-
var ICNS_TYPES = { 256: "ic08", 512: "ic09", 1024: "ic10" };
|
|
3255
|
-
function icnsFromPngs(pngs) {
|
|
3256
|
-
const parts2 = pngs.map((png) => {
|
|
3257
|
-
const { width, height } = pngSize(png);
|
|
3258
|
-
const type = width === height ? ICNS_TYPES[width] : void 0;
|
|
3259
|
-
if (!type) throw new Error(`An .icns here takes square images of 256, 512 or 1024 pixels, not ${width}\xD7${height}.`);
|
|
3260
|
-
const head2 = Buffer.alloc(8);
|
|
3261
|
-
head2.write(type, 0, "latin1");
|
|
3262
|
-
head2.writeUInt32BE(8 + png.length, 4);
|
|
3263
|
-
return Buffer.concat([head2, png]);
|
|
3264
|
-
});
|
|
3265
|
-
const head = Buffer.alloc(8);
|
|
3266
|
-
head.write("icns", 0, "latin1");
|
|
3267
|
-
head.writeUInt32BE(8 + parts2.reduce((n, p) => n + p.length, 0), 4);
|
|
3268
|
-
return Buffer.concat([head, ...parts2]);
|
|
3346
|
+
// src/platform/running.ts
|
|
3347
|
+
import crypto from "node:crypto";
|
|
3348
|
+
var NONCE = /^[0-9a-f]{32}$/;
|
|
3349
|
+
function runningProof(token, nonce) {
|
|
3350
|
+
return crypto.createHmac("sha256", token).update(`ledung-running:${nonce}`).digest("hex");
|
|
3269
3351
|
}
|
|
3270
3352
|
|
|
3271
|
-
// src/
|
|
3272
|
-
|
|
3273
|
-
var
|
|
3274
|
-
var
|
|
3275
|
-
var
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
}
|
|
3300
|
-
function powershell(script, env) {
|
|
3301
|
-
return new Promise((resolve, reject) => {
|
|
3302
|
-
execFile4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `[Console]::OutputEncoding = [Text.Encoding]::UTF8; ${script}`], { env: { ...process.env, ...env }, windowsHide: true, timeout: 3e4 }, (error, stdout, stderr) => {
|
|
3303
|
-
if (error) reject(new Error(stderr.trim() || error.message));
|
|
3304
|
-
else resolve(stdout);
|
|
3305
|
-
});
|
|
3306
|
-
});
|
|
3307
|
-
}
|
|
3308
|
-
async function windowsFolders() {
|
|
3309
|
-
const out = await powershell("@{ desktop = [Environment]::GetFolderPath('Desktop'); programs = [Environment]::GetFolderPath('Programs') } | ConvertTo-Json -Compress", {});
|
|
3310
|
-
const { desktop, programs } = JSON.parse(out);
|
|
3311
|
-
return { desktop, "start-menu": programs };
|
|
3312
|
-
}
|
|
3313
|
-
function macFolders() {
|
|
3314
|
-
try {
|
|
3315
|
-
fs12.accessSync("/Applications", fs12.constants.W_OK);
|
|
3316
|
-
return { applications: "/Applications" };
|
|
3317
|
-
} catch {
|
|
3318
|
-
return { applications: path15.join(os7.homedir(), "Applications") };
|
|
3319
|
-
}
|
|
3353
|
+
// src/server/devices.ts
|
|
3354
|
+
import crypto2 from "node:crypto";
|
|
3355
|
+
var DEVICE_COOKIE = "__Host-ledung_device";
|
|
3356
|
+
var PAIR_CODE_MS = 10 * 60 * 1e3;
|
|
3357
|
+
var PAIR_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
3358
|
+
var PAIR_CODE_LENGTH = 20;
|
|
3359
|
+
var PASSKEY_WINDOW_MS = 10 * 60 * 1e3;
|
|
3360
|
+
var TOUCH_MS = 60 * 1e3;
|
|
3361
|
+
var COMPUTER_IDLE_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
3362
|
+
var COOKIE_RENEW_MS = 24 * 60 * 60 * 1e3;
|
|
3363
|
+
var OLD_COOKIE_GRACE_MS = 60 * 1e3;
|
|
3364
|
+
var hash = (secret) => crypto2.createHash("sha256").update(secret).digest("hex");
|
|
3365
|
+
function toDevice(r) {
|
|
3366
|
+
return {
|
|
3367
|
+
id: r.id,
|
|
3368
|
+
name: r.name,
|
|
3369
|
+
createdAt: Number(r.created_at),
|
|
3370
|
+
lastUsedAt: r.last_used_at == null ? null : Number(r.last_used_at),
|
|
3371
|
+
credentialId: r.credential_id ?? null,
|
|
3372
|
+
publicKey: r.public_key ? new Uint8Array(r.public_key) : null,
|
|
3373
|
+
counter: Number(r.counter ?? 0),
|
|
3374
|
+
passkeyAt: r.passkey_at == null ? null : Number(r.passkey_at),
|
|
3375
|
+
kind: r.kind === "computer" ? "computer" : "phone",
|
|
3376
|
+
partitioned: !!r.partitioned,
|
|
3377
|
+
loggedOnAt: r.logged_on_at == null ? null : Number(r.logged_on_at),
|
|
3378
|
+
cookieAt: r.cookie_at == null ? null : Number(r.cookie_at),
|
|
3379
|
+
pairedVia: r.paired_via ?? null
|
|
3380
|
+
};
|
|
3320
3381
|
}
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3382
|
+
var deviceView = (d) => ({ id: d.id, name: d.name, createdAt: d.createdAt, lastUsedAt: d.lastUsedAt, passkey: !!d.credentialId, passkeyAt: d.passkeyAt, kind: d.kind, pairedVia: d.pairedVia });
|
|
3383
|
+
function deviceName(raw) {
|
|
3384
|
+
const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60) : "";
|
|
3385
|
+
return name || "Phone";
|
|
3324
3386
|
}
|
|
3325
|
-
var
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
<key>CFBundleIconFile</key><string>Ledung</string>
|
|
3333
|
-
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
|
3334
|
-
<key>CFBundleName</key><string>Ledung</string>
|
|
3335
|
-
<key>CFBundleDisplayName</key><string>Ledung</string>
|
|
3336
|
-
<key>CFBundlePackageType</key><string>APPL</string>
|
|
3337
|
-
<key>CFBundleShortVersionString</key><string>${xmlEscape(version)}</string>
|
|
3338
|
-
<key>LSMinimumSystemVersion</key><string>10.13</string>
|
|
3339
|
-
</dict>
|
|
3340
|
-
</plist>
|
|
3341
|
-
`;
|
|
3342
|
-
var Shortcuts = class {
|
|
3343
|
-
opts;
|
|
3344
|
-
platform;
|
|
3345
|
-
folderCache = null;
|
|
3346
|
-
constructor(opts) {
|
|
3347
|
-
this.opts = opts;
|
|
3348
|
-
this.platform = opts.platform ?? process.platform;
|
|
3387
|
+
var Devices = class {
|
|
3388
|
+
db;
|
|
3389
|
+
now;
|
|
3390
|
+
code = null;
|
|
3391
|
+
constructor(db, now = Date.now) {
|
|
3392
|
+
this.db = db;
|
|
3393
|
+
this.now = now;
|
|
3349
3394
|
}
|
|
3350
|
-
|
|
3351
|
-
|
|
3395
|
+
/**
|
|
3396
|
+
* A new pairing code; any earlier one stops working. Letters and digits only, 20 of them (103 bits, plenty for
|
|
3397
|
+
* one code that lives 10 minutes): the QR code carries the link in capitals, in its denser alphanumeric mode,
|
|
3398
|
+
* so the code is matched in either case (#194).
|
|
3399
|
+
*/
|
|
3400
|
+
newPairCode(kind = "phone", via = null) {
|
|
3401
|
+
const code = Array.from({ length: PAIR_CODE_LENGTH }, () => PAIR_CODE_CHARS[crypto2.randomInt(PAIR_CODE_CHARS.length)]).join("");
|
|
3402
|
+
const expiresAt = this.now() + PAIR_CODE_MS;
|
|
3403
|
+
this.code = { value: Buffer.from(code), expiresAt, kind, via: via?.name ?? null, viaLink: via?.link ?? null };
|
|
3404
|
+
return { code, expiresAt, kind };
|
|
3352
3405
|
}
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
const kind = this.kind();
|
|
3360
|
-
if (kind === "other") return [];
|
|
3361
|
-
const folders = await this.folders();
|
|
3362
|
-
const ids = kind === "windows" ? ["desktop", "start-menu"] : ["applications"];
|
|
3363
|
-
return ids.flatMap((id) => {
|
|
3364
|
-
const folder = folders[id];
|
|
3365
|
-
if (!folder) return [];
|
|
3366
|
-
const file = path15.join(folder, kind === "windows" ? "Ledung.lnk" : "Ledung.app");
|
|
3367
|
-
return [{ id, label: LABELS[id], file, present: kind === "windows" ? fs12.existsSync(file) : isOurApp(file) }];
|
|
3368
|
-
});
|
|
3369
|
-
}
|
|
3370
|
-
async view() {
|
|
3371
|
-
const kind = this.kind();
|
|
3372
|
-
const asked = this.opts.meta.get(ASKED_KEY) !== null;
|
|
3373
|
-
if (kind === "other") {
|
|
3374
|
-
return { kind, available: false, reason: "Shortcuts are made on Windows and macOS. Here, start Ledung with ledung.sh or the ledung command.", asked, places: [] };
|
|
3375
|
-
}
|
|
3376
|
-
return { kind, available: true, reason: null, asked, places: await this.places() };
|
|
3377
|
-
}
|
|
3378
|
-
/** Add or Remove, or No to the first-run question. Add and No answer it for good; Remove leaves it answered. */
|
|
3379
|
-
async act(action) {
|
|
3380
|
-
if (action === "decline") {
|
|
3381
|
-
this.opts.meta.set(ASKED_KEY, "declined");
|
|
3382
|
-
return this.view();
|
|
3383
|
-
}
|
|
3384
|
-
if (this.kind() === "other") throw new Error("Shortcuts are made on Windows and macOS.");
|
|
3385
|
-
const places = await this.places();
|
|
3386
|
-
if (action === "add") {
|
|
3387
|
-
this.writeEnvFile();
|
|
3388
|
-
if (this.kind() === "windows") await this.addWindows(places.filter((p) => !p.present));
|
|
3389
|
-
else {
|
|
3390
|
-
for (const p of places.filter((p2) => !p2.present)) {
|
|
3391
|
-
if (fs12.existsSync(p.file)) throw new Error(`There is already a Ledung.app in ${path15.dirname(p.file)} that Ledung didn't make. Rename or remove it, then add the shortcut again.`);
|
|
3392
|
-
this.addMac(p.file);
|
|
3393
|
-
}
|
|
3394
|
-
}
|
|
3395
|
-
this.opts.meta.set(ASKED_KEY, "added");
|
|
3396
|
-
} else {
|
|
3397
|
-
for (const p of places.filter((p2) => p2.present)) this.remove(p.file);
|
|
3398
|
-
if (this.kind() === "windows") fs12.rmSync(this.icoFile(), { force: true });
|
|
3399
|
-
fs12.rmSync(this.envFile(), { force: true });
|
|
3400
|
-
}
|
|
3401
|
-
return this.view();
|
|
3402
|
-
}
|
|
3403
|
-
icoFile() {
|
|
3404
|
-
return path15.join(this.opts.iconDir, "Ledung.ico");
|
|
3405
|
-
}
|
|
3406
|
-
envFile() {
|
|
3407
|
-
return path15.join(this.opts.iconDir, ENV_FILE);
|
|
3408
|
-
}
|
|
3409
|
-
/** The env file for a launch that carries settings, else none; the shortcuts then pass it with --env-file. */
|
|
3410
|
-
writeEnvFile() {
|
|
3411
|
-
const env = this.opts.launch.env ?? {};
|
|
3412
|
-
if (!Object.keys(env).length) return void fs12.rmSync(this.envFile(), { force: true });
|
|
3413
|
-
fs12.mkdirSync(this.opts.iconDir, { recursive: true });
|
|
3414
|
-
fs12.writeFileSync(this.envFile(), envFileText(env));
|
|
3415
|
-
}
|
|
3416
|
-
/** Node's own options for a shortcut: --env-file when the launch carries settings. */
|
|
3417
|
-
nodeOptions() {
|
|
3418
|
-
return Object.keys(this.opts.launch.env ?? {}).length ? [`--env-file=${this.envFile()}`] : [];
|
|
3419
|
-
}
|
|
3420
|
-
async addWindows(places) {
|
|
3421
|
-
if (!places.length) return;
|
|
3422
|
-
fs12.mkdirSync(this.opts.iconDir, { recursive: true });
|
|
3423
|
-
fs12.writeFileSync(this.icoFile(), icoFromPng(fs12.readFileSync(this.opts.icons.png256)));
|
|
3424
|
-
for (const p of places) fs12.mkdirSync(path15.dirname(p.file), { recursive: true });
|
|
3425
|
-
const { node, script } = this.opts.launch;
|
|
3426
|
-
const args = [...this.nodeOptions(), script].map((a) => `"${a}"`).join(" ");
|
|
3427
|
-
const spec = { files: places.map((p) => p.file), target: node, arguments: args, workdir: os7.homedir(), icon: this.icoFile(), description: "Open Ledung, and start it if it is not running" };
|
|
3428
|
-
await powershell([
|
|
3429
|
-
"$spec = $env:SHORTCUT_SPEC | ConvertFrom-Json",
|
|
3430
|
-
"$shell = New-Object -ComObject WScript.Shell",
|
|
3431
|
-
"foreach ($file in $spec.files) {",
|
|
3432
|
-
" $s = $shell.CreateShortcut($file)",
|
|
3433
|
-
" $s.TargetPath = $spec.target; $s.Arguments = $spec.arguments; $s.WorkingDirectory = $spec.workdir",
|
|
3434
|
-
' $s.IconLocation = "$($spec.icon),0"; $s.WindowStyle = 7; $s.Description = $spec.description',
|
|
3435
|
-
" $s.Save()",
|
|
3436
|
-
"}"
|
|
3437
|
-
].join("\n"), { SHORTCUT_SPEC: JSON.stringify(spec) });
|
|
3438
|
-
}
|
|
3439
|
-
/**
|
|
3440
|
-
* Ledung.app: a script that opens a running Ledung's page (--open-only), or else opens Terminal on start.command,
|
|
3441
|
-
* which runs the launcher there, so Ledung has a window to stop it from. Made by Ledung on this computer, so it
|
|
3442
|
-
* carries no quarantine and Gatekeeper lets it open.
|
|
3443
|
-
*/
|
|
3444
|
-
addMac(app) {
|
|
3445
|
-
const { node, script } = this.opts.launch;
|
|
3446
|
-
const contents = path15.join(app, "Contents");
|
|
3447
|
-
fs12.mkdirSync(path15.join(contents, "MacOS"), { recursive: true });
|
|
3448
|
-
fs12.mkdirSync(path15.join(contents, "Resources"), { recursive: true });
|
|
3449
|
-
let version = "1";
|
|
3450
|
-
try {
|
|
3451
|
-
version = String(JSON.parse(fs12.readFileSync(path15.join(appDir(), "package.json"), "utf8")).version ?? "1");
|
|
3452
|
-
} catch {
|
|
3453
|
-
}
|
|
3454
|
-
fs12.writeFileSync(path15.join(contents, "Info.plist"), PLIST(version));
|
|
3455
|
-
fs12.writeFileSync(path15.join(contents, "Resources", "Ledung.icns"), icnsFromPngs([fs12.readFileSync(this.opts.icons.png256), fs12.readFileSync(this.opts.icons.png512)]));
|
|
3456
|
-
const run8 = [...this.nodeOptions(), script].map(shQuote).join(" ");
|
|
3457
|
-
fs12.writeFileSync(path15.join(contents, "Resources", "start.command"), [
|
|
3458
|
-
"#!/bin/sh",
|
|
3459
|
-
"# Starts Ledung in this Terminal window; closing it stops Ledung. Made by Ledung's Settings (issue #415).",
|
|
3460
|
-
`NODE=${shQuote(node)}`,
|
|
3461
|
-
'[ -x "$NODE" ] || NODE=node',
|
|
3462
|
-
`exec "$NODE" ${run8}`,
|
|
3463
|
-
""
|
|
3464
|
-
].join("\n"), { mode: 493 });
|
|
3465
|
-
fs12.writeFileSync(path15.join(contents, "MacOS", "Ledung"), [
|
|
3466
|
-
"#!/bin/sh",
|
|
3467
|
-
"# Opens a running Ledung's page, or starts Ledung in a Terminal window. Made by Ledung's Settings (issue #415).",
|
|
3468
|
-
`NODE=${shQuote(node)}`,
|
|
3469
|
-
`[ -x "$NODE" ] || NODE=$("\${SHELL:-/bin/zsh}" -lic 'command -v node' 2>/dev/null | tail -n 1)`,
|
|
3470
|
-
`[ -x "$NODE" ] || { osascript -e 'display alert "Ledung cannot find Node.js" message "Install Node.js 24, or add the shortcut again in Settings in Ledung."'; exit 1; }`,
|
|
3471
|
-
`"$NODE" ${run8} --open-only && exit 0`,
|
|
3472
|
-
'exec open -a Terminal "$(dirname "$0")/../Resources/start.command"',
|
|
3473
|
-
""
|
|
3474
|
-
].join("\n"), { mode: 493 });
|
|
3475
|
-
}
|
|
3476
|
-
/** Takes away Ledung.lnk, or a Ledung.app that is Ledung's own (its bundle id), never anything else. */
|
|
3477
|
-
remove(file) {
|
|
3478
|
-
if (file.endsWith(".lnk")) {
|
|
3479
|
-
fs12.rmSync(file, { force: true });
|
|
3480
|
-
return;
|
|
3481
|
-
}
|
|
3482
|
-
if (!isOurApp(file)) throw new Error(`${file} is not the Ledung.app that Ledung made; remove it yourself if you want it gone.`);
|
|
3483
|
-
fs12.rmSync(file, { recursive: true, force: true });
|
|
3484
|
-
}
|
|
3485
|
-
};
|
|
3486
|
-
function isOurApp(app) {
|
|
3487
|
-
try {
|
|
3488
|
-
return fs12.readFileSync(path15.join(app, "Contents", "Info.plist"), "utf8").includes(`<string>${BUNDLE_ID}</string>`);
|
|
3489
|
-
} catch {
|
|
3490
|
-
return false;
|
|
3491
|
-
}
|
|
3492
|
-
}
|
|
3493
|
-
|
|
3494
|
-
// src/server/security.ts
|
|
3495
|
-
import crypto3 from "node:crypto";
|
|
3496
|
-
import fs13 from "node:fs";
|
|
3497
|
-
import path16 from "node:path";
|
|
3498
|
-
|
|
3499
|
-
// src/platform/running.ts
|
|
3500
|
-
import crypto from "node:crypto";
|
|
3501
|
-
var NONCE = /^[0-9a-f]{32}$/;
|
|
3502
|
-
function runningProof(token, nonce) {
|
|
3503
|
-
return crypto.createHmac("sha256", token).update(`ledung-running:${nonce}`).digest("hex");
|
|
3504
|
-
}
|
|
3505
|
-
|
|
3506
|
-
// src/server/devices.ts
|
|
3507
|
-
import crypto2 from "node:crypto";
|
|
3508
|
-
var DEVICE_COOKIE = "__Host-ledung_device";
|
|
3509
|
-
var PAIR_CODE_MS = 10 * 60 * 1e3;
|
|
3510
|
-
var PAIR_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
3511
|
-
var PAIR_CODE_LENGTH = 20;
|
|
3512
|
-
var PASSKEY_WINDOW_MS = 10 * 60 * 1e3;
|
|
3513
|
-
var TOUCH_MS = 60 * 1e3;
|
|
3514
|
-
var COMPUTER_IDLE_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
3515
|
-
var COOKIE_RENEW_MS = 24 * 60 * 60 * 1e3;
|
|
3516
|
-
var OLD_COOKIE_GRACE_MS = 60 * 1e3;
|
|
3517
|
-
var hash = (secret) => crypto2.createHash("sha256").update(secret).digest("hex");
|
|
3518
|
-
function toDevice(r) {
|
|
3519
|
-
return {
|
|
3520
|
-
id: r.id,
|
|
3521
|
-
name: r.name,
|
|
3522
|
-
createdAt: Number(r.created_at),
|
|
3523
|
-
lastUsedAt: r.last_used_at == null ? null : Number(r.last_used_at),
|
|
3524
|
-
credentialId: r.credential_id ?? null,
|
|
3525
|
-
publicKey: r.public_key ? new Uint8Array(r.public_key) : null,
|
|
3526
|
-
counter: Number(r.counter ?? 0),
|
|
3527
|
-
passkeyAt: r.passkey_at == null ? null : Number(r.passkey_at),
|
|
3528
|
-
kind: r.kind === "computer" ? "computer" : "phone",
|
|
3529
|
-
partitioned: !!r.partitioned,
|
|
3530
|
-
loggedOnAt: r.logged_on_at == null ? null : Number(r.logged_on_at),
|
|
3531
|
-
cookieAt: r.cookie_at == null ? null : Number(r.cookie_at),
|
|
3532
|
-
pairedVia: r.paired_via ?? null
|
|
3533
|
-
};
|
|
3534
|
-
}
|
|
3535
|
-
var deviceView = (d) => ({ id: d.id, name: d.name, createdAt: d.createdAt, lastUsedAt: d.lastUsedAt, passkey: !!d.credentialId, passkeyAt: d.passkeyAt, kind: d.kind, pairedVia: d.pairedVia });
|
|
3536
|
-
function deviceName(raw) {
|
|
3537
|
-
const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60) : "";
|
|
3538
|
-
return name || "Phone";
|
|
3539
|
-
}
|
|
3540
|
-
var Devices = class {
|
|
3541
|
-
db;
|
|
3542
|
-
now;
|
|
3543
|
-
code = null;
|
|
3544
|
-
constructor(db, now = Date.now) {
|
|
3545
|
-
this.db = db;
|
|
3546
|
-
this.now = now;
|
|
3547
|
-
}
|
|
3548
|
-
/**
|
|
3549
|
-
* A new pairing code; any earlier one stops working. Letters and digits only, 20 of them (103 bits, plenty for
|
|
3550
|
-
* one code that lives 10 minutes): the QR code carries the link in capitals, in its denser alphanumeric mode,
|
|
3551
|
-
* so the code is matched in either case (#194).
|
|
3552
|
-
*/
|
|
3553
|
-
newPairCode(kind = "phone", via = null) {
|
|
3554
|
-
const code = Array.from({ length: PAIR_CODE_LENGTH }, () => PAIR_CODE_CHARS[crypto2.randomInt(PAIR_CODE_CHARS.length)]).join("");
|
|
3555
|
-
const expiresAt = this.now() + PAIR_CODE_MS;
|
|
3556
|
-
this.code = { value: Buffer.from(code), expiresAt, kind, via: via?.name ?? null, viaLink: via?.link ?? null };
|
|
3557
|
-
return { code, expiresAt, kind };
|
|
3558
|
-
}
|
|
3559
|
-
/**
|
|
3560
|
-
* Unlinking stops what the link gave (review 275, Important 1): an open code a linked computer asked for, and that
|
|
3561
|
-
* computer is no longer linked, is closed.
|
|
3562
|
-
*/
|
|
3563
|
-
closeCodeUnlessLinked(links) {
|
|
3564
|
-
if (this.code?.viaLink && !links.has(this.code.viaLink)) this.code = null;
|
|
3406
|
+
/**
|
|
3407
|
+
* Unlinking stops what the link gave (review 275, Important 1): an open code a linked computer asked for, and that
|
|
3408
|
+
* computer is no longer linked, is closed.
|
|
3409
|
+
*/
|
|
3410
|
+
closeCodeUnlessLinked(links) {
|
|
3411
|
+
if (this.code?.viaLink && !links.has(this.code.viaLink)) this.code = null;
|
|
3565
3412
|
}
|
|
3566
3413
|
/** The linked computer the open code was made for (issue #273), while `code` is that code; null otherwise. */
|
|
3567
3414
|
codeVia(code) {
|
|
@@ -3693,143 +3540,563 @@ var Devices = class {
|
|
|
3693
3540
|
|
|
3694
3541
|
// src/server/security.ts
|
|
3695
3542
|
var COOKIE_NAME = "ledung_auth";
|
|
3696
|
-
function loadOrCreateToken(file =
|
|
3697
|
-
if (
|
|
3698
|
-
const token2 =
|
|
3543
|
+
function loadOrCreateToken(file = path15.join(userDataDir(), "auth-token")) {
|
|
3544
|
+
if (fs12.existsSync(file)) {
|
|
3545
|
+
const token2 = fs12.readFileSync(file, "utf8").trim();
|
|
3699
3546
|
if (/^[0-9a-f]{64}$/.test(token2)) return token2;
|
|
3700
3547
|
}
|
|
3701
|
-
const token = crypto3.randomBytes(32).toString("hex");
|
|
3702
|
-
|
|
3703
|
-
return token;
|
|
3704
|
-
}
|
|
3705
|
-
var Guard = class {
|
|
3706
|
-
token;
|
|
3707
|
-
port;
|
|
3708
|
-
lanNames;
|
|
3709
|
-
hosts = /* @__PURE__ */ new Set();
|
|
3710
|
-
origins = /* @__PURE__ */ new Set();
|
|
3711
|
-
lanHosts = /* @__PURE__ */ new Set();
|
|
3712
|
-
lanOpen;
|
|
3713
|
-
remote = null;
|
|
3714
|
-
/** Sockets that came through the relay tunnel; see isPublic. */
|
|
3715
|
-
tunnel = /* @__PURE__ */ new WeakSet();
|
|
3716
|
-
/**
|
|
3717
|
-
* `lanNames`: extra names Ledung answers to on the local network (empty when it isn't opened to it).
|
|
3718
|
-
* `lanOpen`: requests through those names need no sign-in.
|
|
3719
|
-
* `remote`: the public name and how a device cookie is checked; null: no public name.
|
|
3720
|
-
*/
|
|
3721
|
-
constructor(token, port, lanNames2 = [], lanOpen = false, remote = null) {
|
|
3722
|
-
this.token = Buffer.from(token);
|
|
3723
|
-
this.port = port;
|
|
3724
|
-
this.lanNames = lanNames2.map((n) => n.toLowerCase());
|
|
3725
|
-
this.lanOpen = lanOpen;
|
|
3726
|
-
this.setRemote(remote);
|
|
3548
|
+
const token = crypto3.randomBytes(32).toString("hex");
|
|
3549
|
+
fs12.writeFileSync(file, token, { mode: 384 });
|
|
3550
|
+
return token;
|
|
3551
|
+
}
|
|
3552
|
+
var Guard = class {
|
|
3553
|
+
token;
|
|
3554
|
+
port;
|
|
3555
|
+
lanNames;
|
|
3556
|
+
hosts = /* @__PURE__ */ new Set();
|
|
3557
|
+
origins = /* @__PURE__ */ new Set();
|
|
3558
|
+
lanHosts = /* @__PURE__ */ new Set();
|
|
3559
|
+
lanOpen;
|
|
3560
|
+
remote = null;
|
|
3561
|
+
/** Sockets that came through the relay tunnel; see isPublic. */
|
|
3562
|
+
tunnel = /* @__PURE__ */ new WeakSet();
|
|
3563
|
+
/**
|
|
3564
|
+
* `lanNames`: extra names Ledung answers to on the local network (empty when it isn't opened to it).
|
|
3565
|
+
* `lanOpen`: requests through those names need no sign-in.
|
|
3566
|
+
* `remote`: the public name and how a device cookie is checked; null: no public name.
|
|
3567
|
+
*/
|
|
3568
|
+
constructor(token, port, lanNames2 = [], lanOpen = false, remote = null) {
|
|
3569
|
+
this.token = Buffer.from(token);
|
|
3570
|
+
this.port = port;
|
|
3571
|
+
this.lanNames = lanNames2.map((n) => n.toLowerCase());
|
|
3572
|
+
this.lanOpen = lanOpen;
|
|
3573
|
+
this.setRemote(remote);
|
|
3574
|
+
}
|
|
3575
|
+
/** The public name changed: joining or leaving a relay from Settings (issue #237), with no restart. */
|
|
3576
|
+
setRemote(remote) {
|
|
3577
|
+
this.remote = remote;
|
|
3578
|
+
const lan = this.lanNames.filter((n) => n !== remote?.publicName);
|
|
3579
|
+
const names = ["127.0.0.1", "localhost", ...lan];
|
|
3580
|
+
this.hosts = new Set(names.map((n) => `${n}:${this.port}`));
|
|
3581
|
+
this.origins = new Set(names.map((n) => `http://${n}:${this.port}`));
|
|
3582
|
+
this.lanHosts = new Set(lan.map((n) => `${n}:${this.port}`));
|
|
3583
|
+
}
|
|
3584
|
+
/**
|
|
3585
|
+
* The request came in through the public name: exactly the name, as browsers send it for HTTPS on 443.
|
|
3586
|
+
* No port and no other spelling, so a lookalike or "<name>:4317" is an unknown host.
|
|
3587
|
+
*/
|
|
3588
|
+
isPublic(req) {
|
|
3589
|
+
if (this.tunnelled(req)) return true;
|
|
3590
|
+
return !!this.remote && (req.headers.host ?? "").toLowerCase() === this.remote.publicName;
|
|
3591
|
+
}
|
|
3592
|
+
/** A connection the relay tunnel handed to the server (LedungServer.tunnelConnection), never a local one. */
|
|
3593
|
+
markTunnel(socket) {
|
|
3594
|
+
this.tunnel.add(socket);
|
|
3595
|
+
}
|
|
3596
|
+
tunnelled(req) {
|
|
3597
|
+
return this.tunnel.has(req.socket);
|
|
3598
|
+
}
|
|
3599
|
+
/** The live device signed in through the public name, or null (always null for local requests). */
|
|
3600
|
+
deviceOf(req) {
|
|
3601
|
+
if (!this.remote || !this.isPublic(req)) return null;
|
|
3602
|
+
for (const cookie of readCookies(req, DEVICE_COOKIE)) {
|
|
3603
|
+
const device = this.remote.device(cookie);
|
|
3604
|
+
if (device) return device;
|
|
3605
|
+
}
|
|
3606
|
+
return null;
|
|
3607
|
+
}
|
|
3608
|
+
/** Signed in, or come in through a trusted network address; through the public name, a live paired device only. */
|
|
3609
|
+
mayUse(req) {
|
|
3610
|
+
if (this.isPublic(req)) return this.deviceOf(req) !== null;
|
|
3611
|
+
return this.signedIn(req) || this.lanOpen && this.lanHosts.has((req.headers.host ?? "").toLowerCase());
|
|
3612
|
+
}
|
|
3613
|
+
hostAllowed(req) {
|
|
3614
|
+
if (this.isPublic(req)) return !!this.remote;
|
|
3615
|
+
return this.hosts.has((req.headers.host ?? "").toLowerCase());
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* True only when the request says it comes from Ledung's own page, on the kind of name it was sent to:
|
|
3619
|
+
* through the public name only https://<name>; locally never that one.
|
|
3620
|
+
*/
|
|
3621
|
+
originAllowed(req) {
|
|
3622
|
+
const origin = req.headers.origin;
|
|
3623
|
+
if (typeof origin !== "string") return false;
|
|
3624
|
+
if (this.isPublic(req)) return !!this.remote && origin.toLowerCase() === `https://${this.remote.publicName}`;
|
|
3625
|
+
return this.origins.has(origin.toLowerCase());
|
|
3626
|
+
}
|
|
3627
|
+
tokenMatches(candidate) {
|
|
3628
|
+
if (!candidate) return false;
|
|
3629
|
+
const given = Buffer.from(candidate);
|
|
3630
|
+
return given.length === this.token.length && crypto3.timingSafeEqual(given, this.token);
|
|
3631
|
+
}
|
|
3632
|
+
/** The launchers' proof that this Ledung holds the token, for their nonce (src/platform/running.ts). */
|
|
3633
|
+
runningProof(nonce) {
|
|
3634
|
+
return runningProof(this.token.toString(), nonce);
|
|
3635
|
+
}
|
|
3636
|
+
/** The install's token cookie; never counts through the public name. */
|
|
3637
|
+
signedIn(req) {
|
|
3638
|
+
return !this.isPublic(req) && this.tokenMatches(readCookie(req, COOKIE_NAME));
|
|
3639
|
+
}
|
|
3640
|
+
cookieHeader() {
|
|
3641
|
+
return `${COOKIE_NAME}=${this.token.toString()}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`;
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
function deviceCookieHeader(value, framed = false) {
|
|
3645
|
+
return `${DEVICE_COOKIE}=${value}; Secure; HttpOnly; ${framed ? "SameSite=None; Partitioned" : "SameSite=Strict"}; Path=/; Max-Age=31536000`;
|
|
3646
|
+
}
|
|
3647
|
+
function readCookie(req, name) {
|
|
3648
|
+
return readCookies(req, name)[0] ?? null;
|
|
3649
|
+
}
|
|
3650
|
+
function readCookies(req, name) {
|
|
3651
|
+
const found = [];
|
|
3652
|
+
for (const part of (req.headers.cookie ?? "").split(";")) {
|
|
3653
|
+
const [k, ...v] = part.trim().split("=");
|
|
3654
|
+
if (k === name) found.push(v.join("="));
|
|
3655
|
+
}
|
|
3656
|
+
return found;
|
|
3657
|
+
}
|
|
3658
|
+
function securityHeaders(port, lanNames2 = [], imageHosts = [], publicName, frames = {}) {
|
|
3659
|
+
const ancestors = frames.ancestors ?? [];
|
|
3660
|
+
const sources = frames.sources ?? [];
|
|
3661
|
+
const live = [...["127.0.0.1", "localhost", ...lanNames2].map((n) => `ws://${n}:${port}`), ...publicName ? [`wss://${publicName}`] : []];
|
|
3662
|
+
return {
|
|
3663
|
+
"Content-Security-Policy": [
|
|
3664
|
+
"default-src 'self'",
|
|
3665
|
+
`connect-src 'self' ${live.join(" ")}`,
|
|
3666
|
+
// Extra hosts only for the board's people list (their pictures, decision 5 of the message board).
|
|
3667
|
+
`img-src ${["'self'", "data:", ...imageHosts].join(" ")}`,
|
|
3668
|
+
"style-src 'self' 'unsafe-inline'",
|
|
3669
|
+
`frame-ancestors ${ancestors.length ? ancestors.join(" ") : "'none'"}`,
|
|
3670
|
+
...sources.length ? [`frame-src 'self' ${sources.join(" ")}`] : [],
|
|
3671
|
+
"base-uri 'none'",
|
|
3672
|
+
"form-action 'self'"
|
|
3673
|
+
].join("; "),
|
|
3674
|
+
// Older browsers know only this header; it can't list pages, so it goes only when some may frame this one.
|
|
3675
|
+
...ancestors.length ? {} : { "X-Frame-Options": "DENY" },
|
|
3676
|
+
"X-Content-Type-Options": "nosniff",
|
|
3677
|
+
"Referrer-Policy": "no-referrer",
|
|
3678
|
+
"Cache-Control": "no-store"
|
|
3679
|
+
};
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
// src/platform/background.ts
|
|
3683
|
+
var LOG_FILE = "ledung.log";
|
|
3684
|
+
var MAX_LOG_BYTES = 10 * 1024 * 1024;
|
|
3685
|
+
|
|
3686
|
+
// src/platform/icons.ts
|
|
3687
|
+
var PNG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
3688
|
+
function pngSize(png) {
|
|
3689
|
+
if (png.length < 24 || !png.subarray(0, 8).equals(PNG)) throw new Error("Not a PNG image.");
|
|
3690
|
+
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
3691
|
+
}
|
|
3692
|
+
function icoFromPng(png) {
|
|
3693
|
+
const { width, height } = pngSize(png);
|
|
3694
|
+
if (width > 256 || height > 256) throw new Error(`An .ico image is at most 256 pixels, not ${width}\xD7${height}.`);
|
|
3695
|
+
const head = Buffer.alloc(6 + 16);
|
|
3696
|
+
head.writeUInt16LE(0, 0);
|
|
3697
|
+
head.writeUInt16LE(1, 2);
|
|
3698
|
+
head.writeUInt16LE(1, 4);
|
|
3699
|
+
head.writeUInt8(width % 256, 6);
|
|
3700
|
+
head.writeUInt8(height % 256, 7);
|
|
3701
|
+
head.writeUInt8(0, 8);
|
|
3702
|
+
head.writeUInt8(0, 9);
|
|
3703
|
+
head.writeUInt16LE(1, 10);
|
|
3704
|
+
head.writeUInt16LE(32, 12);
|
|
3705
|
+
head.writeUInt32LE(png.length, 14);
|
|
3706
|
+
head.writeUInt32LE(head.length, 18);
|
|
3707
|
+
return Buffer.concat([head, png]);
|
|
3708
|
+
}
|
|
3709
|
+
var ICNS_TYPES = { 256: "ic08", 512: "ic09", 1024: "ic10" };
|
|
3710
|
+
function icnsFromPngs(pngs) {
|
|
3711
|
+
const parts2 = pngs.map((png) => {
|
|
3712
|
+
const { width, height } = pngSize(png);
|
|
3713
|
+
const type = width === height ? ICNS_TYPES[width] : void 0;
|
|
3714
|
+
if (!type) throw new Error(`An .icns here takes square images of 256, 512 or 1024 pixels, not ${width}\xD7${height}.`);
|
|
3715
|
+
const head2 = Buffer.alloc(8);
|
|
3716
|
+
head2.write(type, 0, "latin1");
|
|
3717
|
+
head2.writeUInt32BE(8 + png.length, 4);
|
|
3718
|
+
return Buffer.concat([head2, png]);
|
|
3719
|
+
});
|
|
3720
|
+
const head = Buffer.alloc(8);
|
|
3721
|
+
head.write("icns", 0, "latin1");
|
|
3722
|
+
head.writeUInt32BE(8 + parts2.reduce((n, p) => n + p.length, 0), 4);
|
|
3723
|
+
return Buffer.concat([head, ...parts2]);
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
// src/platform/shortcuts.ts
|
|
3727
|
+
var ASKED_KEY = "shortcuts-asked";
|
|
3728
|
+
var BUNDLE_ID = "org.ledung.launcher";
|
|
3729
|
+
var AGENT_LABEL = "org.ledung.signin";
|
|
3730
|
+
var STARTER_FILE = "Ledung-start.js";
|
|
3731
|
+
var LABELS = { desktop: "Desktop", "start-menu": "Start menu", applications: "Applications" };
|
|
3732
|
+
var ENV_FILE = "shortcut.env";
|
|
3733
|
+
var LOG_FILE_SETTING = "LEDUNG_LOG_FILE";
|
|
3734
|
+
function launchTarget(dataDir = userDataDir(), app = appDir(), packaged = PACKAGED) {
|
|
3735
|
+
const releaseLauncher = path16.join(dataDir, "launcher.mjs");
|
|
3736
|
+
const fromRelease = !path16.relative(path16.join(dataDir, "app"), app).startsWith("..") && fs13.existsSync(releaseLauncher);
|
|
3737
|
+
const script = fromRelease ? releaseLauncher : path16.join(app, packaged ? path16.join("dist", "cli.js") : path16.join("src", "cli.ts"));
|
|
3738
|
+
const env = {};
|
|
3739
|
+
for (const name of ["DATA_DIR", "CONFIG"]) {
|
|
3740
|
+
const value = envSetting(name);
|
|
3741
|
+
if (value) env[`LEDUNG_${name}`] = path16.resolve(value);
|
|
3742
|
+
}
|
|
3743
|
+
return Object.keys(env).length ? { node: process.execPath, script, env } : { node: process.execPath, script };
|
|
3744
|
+
}
|
|
3745
|
+
function startsInBackground(launch) {
|
|
3746
|
+
return path16.basename(launch.script) !== "launcher.mjs";
|
|
3747
|
+
}
|
|
3748
|
+
function envFileText(env) {
|
|
3749
|
+
return Object.entries(env).map(([name, value]) => {
|
|
3750
|
+
if (/[\r\n]/.test(value) || value.includes("'") && value.includes("`")) throw new Error(`${name} can't be passed to a shortcut: ${value}`);
|
|
3751
|
+
return value.includes("'") ? `${name}=\`${value}\`
|
|
3752
|
+
` : `${name}='${value}'
|
|
3753
|
+
`;
|
|
3754
|
+
}).join("");
|
|
3755
|
+
}
|
|
3756
|
+
function starterText(head, tail2) {
|
|
3757
|
+
const literal = (s) => JSON.stringify(s).replace(/[^\x20-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
3758
|
+
return [
|
|
3759
|
+
"// Starts Ledung with no window. Made by Ledung's Settings (issue #456).",
|
|
3760
|
+
"var shell = new ActiveXObject('WScript.Shell');",
|
|
3761
|
+
"var extra = '';",
|
|
3762
|
+
`for (var i = 0; i < WScript.Arguments.length; i++) extra += ' "' + WScript.Arguments(i) + '"';`,
|
|
3763
|
+
`shell.Run(${literal(head)} + extra + ${literal(tail2)}, 0, false);`,
|
|
3764
|
+
""
|
|
3765
|
+
].join("\r\n");
|
|
3766
|
+
}
|
|
3767
|
+
function iconFiles(app = appDir()) {
|
|
3768
|
+
const built = path16.join(app, "web", "dist");
|
|
3769
|
+
const dir = fs13.existsSync(path16.join(built, "icon-256.png")) ? built : path16.join(app, "web", "public");
|
|
3770
|
+
return { png256: path16.join(dir, "icon-256.png"), png512: path16.join(dir, "icon-512.png") };
|
|
3771
|
+
}
|
|
3772
|
+
function powershell(script, env) {
|
|
3773
|
+
return new Promise((resolve, reject) => {
|
|
3774
|
+
execFile4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `[Console]::OutputEncoding = [Text.Encoding]::UTF8; ${script}`], { env: { ...process.env, ...env }, windowsHide: true, timeout: 3e4 }, (error, stdout, stderr) => {
|
|
3775
|
+
if (error) reject(new Error(stderr.trim() || error.message));
|
|
3776
|
+
else resolve(stdout);
|
|
3777
|
+
});
|
|
3778
|
+
});
|
|
3779
|
+
}
|
|
3780
|
+
async function windowsFolders() {
|
|
3781
|
+
const out = await powershell("@{ desktop = [Environment]::GetFolderPath('Desktop'); programs = [Environment]::GetFolderPath('Programs'); startup = [Environment]::GetFolderPath('Startup') } | ConvertTo-Json -Compress", {});
|
|
3782
|
+
const { desktop, programs, startup } = JSON.parse(out);
|
|
3783
|
+
return { desktop, "start-menu": programs, startup };
|
|
3784
|
+
}
|
|
3785
|
+
function macFolders() {
|
|
3786
|
+
const startup = path16.join(os7.homedir(), "Library", "LaunchAgents");
|
|
3787
|
+
try {
|
|
3788
|
+
fs13.accessSync("/Applications", fs13.constants.W_OK);
|
|
3789
|
+
return { applications: "/Applications", startup };
|
|
3790
|
+
} catch {
|
|
3791
|
+
return { applications: path16.join(os7.homedir(), "Applications"), startup };
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
function linuxFolders() {
|
|
3795
|
+
return { startup: path16.join(process.env.XDG_CONFIG_HOME ?? path16.join(os7.homedir(), ".config"), "autostart") };
|
|
3796
|
+
}
|
|
3797
|
+
function ownDataFolders(platform) {
|
|
3798
|
+
const root = path16.join(userDataDir(), "shortcuts");
|
|
3799
|
+
return platform === "win32" ? { desktop: path16.join(root, "Desktop"), "start-menu": path16.join(root, "Start menu"), startup: path16.join(root, "Startup") } : platform === "darwin" ? { applications: path16.join(root, "Applications"), startup: path16.join(root, "LaunchAgents") } : { startup: path16.join(root, "autostart") };
|
|
3800
|
+
}
|
|
3801
|
+
var shQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
3802
|
+
var winQuote = (s) => `"${s}"`;
|
|
3803
|
+
var desktopArg = (s) => `"${s.replace(/(["`$\\])/g, "\\$1")}"`.replace(/\\/g, "\\\\").replace(/%/g, "%%");
|
|
3804
|
+
var xmlEscape = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3805
|
+
var PLIST = (version) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
3806
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3807
|
+
<plist version="1.0">
|
|
3808
|
+
<dict>
|
|
3809
|
+
<key>CFBundleExecutable</key><string>Ledung</string>
|
|
3810
|
+
<key>CFBundleIconFile</key><string>Ledung</string>
|
|
3811
|
+
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
|
3812
|
+
<key>CFBundleName</key><string>Ledung</string>
|
|
3813
|
+
<key>CFBundleDisplayName</key><string>Ledung</string>
|
|
3814
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
3815
|
+
<key>CFBundleShortVersionString</key><string>${xmlEscape(version)}</string>
|
|
3816
|
+
<key>LSMinimumSystemVersion</key><string>10.13</string>
|
|
3817
|
+
</dict>
|
|
3818
|
+
</plist>
|
|
3819
|
+
`;
|
|
3820
|
+
var AGENT_PLIST = (args, log) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
3821
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3822
|
+
<plist version="1.0">
|
|
3823
|
+
<dict>
|
|
3824
|
+
<key>Label</key><string>${AGENT_LABEL}</string>
|
|
3825
|
+
<key>ProgramArguments</key>
|
|
3826
|
+
<array>
|
|
3827
|
+
${args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n")}
|
|
3828
|
+
</array>
|
|
3829
|
+
<key>RunAtLoad</key><true/>
|
|
3830
|
+
<key>AbandonProcessGroup</key><true/>
|
|
3831
|
+
<key>StandardOutPath</key><string>${xmlEscape(log)}</string>
|
|
3832
|
+
<key>StandardErrorPath</key><string>${xmlEscape(log)}</string>
|
|
3833
|
+
<key>EnvironmentVariables</key>
|
|
3834
|
+
<dict>
|
|
3835
|
+
<key>${LOG_FILE_SETTING}</key><string>${xmlEscape(log)}</string>
|
|
3836
|
+
</dict>
|
|
3837
|
+
</dict>
|
|
3838
|
+
</plist>
|
|
3839
|
+
`;
|
|
3840
|
+
var SIGN_IN_FILES = {
|
|
3841
|
+
windows: "Ledung at sign-in.lnk",
|
|
3842
|
+
mac: `${AGENT_LABEL}.plist`,
|
|
3843
|
+
other: `${AGENT_LABEL}.desktop`
|
|
3844
|
+
};
|
|
3845
|
+
var SIGN_IN_LABELS = {
|
|
3846
|
+
windows: "Start Ledung when I sign in to Windows",
|
|
3847
|
+
mac: "Start Ledung when I log in to this Mac",
|
|
3848
|
+
other: "Start Ledung when I log in"
|
|
3849
|
+
};
|
|
3850
|
+
var Shortcuts = class {
|
|
3851
|
+
opts;
|
|
3852
|
+
platform;
|
|
3853
|
+
folderCache = null;
|
|
3854
|
+
constructor(opts) {
|
|
3855
|
+
this.opts = opts;
|
|
3856
|
+
this.platform = opts.platform ?? process.platform;
|
|
3857
|
+
}
|
|
3858
|
+
kind() {
|
|
3859
|
+
return this.platform === "win32" ? "windows" : this.platform === "darwin" ? "mac" : "other";
|
|
3860
|
+
}
|
|
3861
|
+
folders() {
|
|
3862
|
+
this.folderCache ??= this.opts.folders?.() ?? (envSetting("DATA_DIR") ? Promise.resolve(ownDataFolders(this.platform)) : this.platform === "win32" ? windowsFolders() : Promise.resolve(this.platform === "darwin" ? macFolders() : linuxFolders()));
|
|
3863
|
+
this.folderCache.catch(() => this.folderCache = null);
|
|
3864
|
+
return this.folderCache;
|
|
3865
|
+
}
|
|
3866
|
+
async places() {
|
|
3867
|
+
const kind = this.kind();
|
|
3868
|
+
if (kind === "other") return [];
|
|
3869
|
+
const folders = await this.folders();
|
|
3870
|
+
const ids = kind === "windows" ? ["desktop", "start-menu"] : ["applications"];
|
|
3871
|
+
return ids.flatMap((id) => {
|
|
3872
|
+
const folder = folders[id];
|
|
3873
|
+
if (!folder) return [];
|
|
3874
|
+
const file = path16.join(folder, kind === "windows" ? "Ledung.lnk" : "Ledung.app");
|
|
3875
|
+
return [{ id, label: LABELS[id], file, present: kind === "windows" ? fs13.existsSync(file) : isOurApp(file) }];
|
|
3876
|
+
});
|
|
3877
|
+
}
|
|
3878
|
+
/** The sign-in start's file in Startup, LaunchAgents or autostart (SIGN_IN_FILES). */
|
|
3879
|
+
async signInFile() {
|
|
3880
|
+
const folder = (await this.folders()).startup;
|
|
3881
|
+
if (!folder) return null;
|
|
3882
|
+
const kind = this.kind();
|
|
3883
|
+
return path16.join(folder, SIGN_IN_FILES[kind]);
|
|
3884
|
+
}
|
|
3885
|
+
async signInView() {
|
|
3886
|
+
const label = SIGN_IN_LABELS[this.kind()];
|
|
3887
|
+
const file = await this.signInFile();
|
|
3888
|
+
if (!file) return { available: false, reason: "Ledung cannot start itself at login on this system.", label, on: false, file: null };
|
|
3889
|
+
return { available: true, reason: null, label, on: fs13.existsSync(file), file };
|
|
3727
3890
|
}
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
this.
|
|
3731
|
-
const
|
|
3732
|
-
const
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3891
|
+
async view() {
|
|
3892
|
+
const kind = this.kind();
|
|
3893
|
+
const asked = this.opts.meta.get(ASKED_KEY) !== null;
|
|
3894
|
+
const log = { file: this.logFile(), active: envSetting("LOG_FILE") !== void 0 && path16.resolve(envSetting("LOG_FILE")) === path16.resolve(this.logFile()) };
|
|
3895
|
+
const signIn = await this.signInView();
|
|
3896
|
+
if (kind === "other") {
|
|
3897
|
+
return { kind, available: false, reason: "Shortcuts are made on Windows and macOS. Here, start Ledung with ledung.sh or the ledung command.", asked, places: [], signIn, log };
|
|
3898
|
+
}
|
|
3899
|
+
return { kind, available: true, reason: null, asked, places: await this.places(), signIn, log };
|
|
3736
3900
|
}
|
|
3737
3901
|
/**
|
|
3738
|
-
*
|
|
3739
|
-
*
|
|
3902
|
+
* Add or Remove, or No to the first-run question. Add and No answer it for good; Remove leaves it answered.
|
|
3903
|
+
* sign-in-on and sign-in-off switch the start at sign-in (#456).
|
|
3740
3904
|
*/
|
|
3741
|
-
|
|
3742
|
-
if (
|
|
3743
|
-
|
|
3905
|
+
async act(action) {
|
|
3906
|
+
if (action === "decline") {
|
|
3907
|
+
this.opts.meta.set(ASKED_KEY, "declined");
|
|
3908
|
+
return this.view();
|
|
3909
|
+
}
|
|
3910
|
+
if (action === "sign-in-on" || action === "sign-in-off") {
|
|
3911
|
+
await this.signIn(action === "sign-in-on");
|
|
3912
|
+
return this.view();
|
|
3913
|
+
}
|
|
3914
|
+
if (this.kind() === "other") throw new Error("Shortcuts are made on Windows and macOS.");
|
|
3915
|
+
const places = await this.places();
|
|
3916
|
+
if (action === "add") {
|
|
3917
|
+
this.writeEnvFile();
|
|
3918
|
+
if (this.kind() === "windows") await this.addWindows(places.filter((p) => !p.present));
|
|
3919
|
+
else {
|
|
3920
|
+
for (const p of places.filter((p2) => !p2.present)) {
|
|
3921
|
+
if (fs13.existsSync(p.file)) throw new Error(`There is already a Ledung.app in ${path16.dirname(p.file)} that Ledung didn't make. Rename or remove it, then add the shortcut again.`);
|
|
3922
|
+
this.addMac(p.file);
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
this.opts.meta.set(ASKED_KEY, "added");
|
|
3926
|
+
} else {
|
|
3927
|
+
for (const p of places.filter((p2) => p2.present)) this.remove(p.file);
|
|
3928
|
+
if (this.kind() === "windows") fs13.rmSync(this.icoFile(), { force: true });
|
|
3929
|
+
if (!(await this.signInView()).on) this.removeShared();
|
|
3930
|
+
}
|
|
3931
|
+
return this.view();
|
|
3744
3932
|
}
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
this.tunnel.add(socket);
|
|
3933
|
+
icoFile() {
|
|
3934
|
+
return path16.join(this.opts.iconDir, "Ledung.ico");
|
|
3748
3935
|
}
|
|
3749
|
-
|
|
3750
|
-
return this.
|
|
3936
|
+
envFile() {
|
|
3937
|
+
return path16.join(this.opts.iconDir, ENV_FILE);
|
|
3751
3938
|
}
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
if (!this.remote || !this.isPublic(req)) return null;
|
|
3755
|
-
for (const cookie of readCookies(req, DEVICE_COOKIE)) {
|
|
3756
|
-
const device = this.remote.device(cookie);
|
|
3757
|
-
if (device) return device;
|
|
3758
|
-
}
|
|
3759
|
-
return null;
|
|
3939
|
+
starterFile() {
|
|
3940
|
+
return path16.join(this.opts.iconDir, STARTER_FILE);
|
|
3760
3941
|
}
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
if (this.isPublic(req)) return this.deviceOf(req) !== null;
|
|
3764
|
-
return this.signedIn(req) || this.lanOpen && this.lanHosts.has((req.headers.host ?? "").toLowerCase());
|
|
3942
|
+
logFile() {
|
|
3943
|
+
return this.opts.logFile ?? path16.join(this.opts.iconDir, LOG_FILE);
|
|
3765
3944
|
}
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3945
|
+
/** The env file and the start script, which the icons and the start at sign-in share. */
|
|
3946
|
+
removeShared() {
|
|
3947
|
+
fs13.rmSync(this.envFile(), { force: true });
|
|
3948
|
+
fs13.rmSync(this.starterFile(), { force: true });
|
|
3949
|
+
}
|
|
3950
|
+
/** The env file for a launch that carries settings, else none; the shortcuts then pass it with --env-file. */
|
|
3951
|
+
writeEnvFile() {
|
|
3952
|
+
const env = this.opts.launch.env ?? {};
|
|
3953
|
+
if (!Object.keys(env).length) return void fs13.rmSync(this.envFile(), { force: true });
|
|
3954
|
+
fs13.mkdirSync(this.opts.iconDir, { recursive: true });
|
|
3955
|
+
fs13.writeFileSync(this.envFile(), envFileText(env));
|
|
3956
|
+
}
|
|
3957
|
+
/** Node's own options for a shortcut: --env-file when the launch carries settings. */
|
|
3958
|
+
nodeOptions() {
|
|
3959
|
+
return Object.keys(this.opts.launch.env ?? {}).length ? [`--env-file=${this.envFile()}`] : [];
|
|
3769
3960
|
}
|
|
3770
3961
|
/**
|
|
3771
|
-
*
|
|
3772
|
-
* through
|
|
3962
|
+
* The start script for Windows Script Host (#456). The `ledung` command goes to the background by itself; the
|
|
3963
|
+
* release launcher runs through cmd.exe, hidden too, so its output goes to the log, and LEDUNG_LOG_FILE says so to
|
|
3964
|
+
* Settings.
|
|
3773
3965
|
*/
|
|
3774
|
-
|
|
3775
|
-
const
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3966
|
+
writeStarter() {
|
|
3967
|
+
const { node, script } = this.opts.launch;
|
|
3968
|
+
const run8 = [node, ...this.nodeOptions(), script].map(winQuote).join(" ");
|
|
3969
|
+
const text2 = startsInBackground(this.opts.launch) ? starterText(run8, "") : starterText(`cmd.exe /d /s /c "set ${winQuote(`${LOG_FILE_SETTING}=${this.logFile()}`)}&& ${run8}`, ` >> ${winQuote(this.logFile())} 2>&1"`);
|
|
3970
|
+
fs13.mkdirSync(this.opts.iconDir, { recursive: true });
|
|
3971
|
+
fs13.writeFileSync(this.starterFile(), text2);
|
|
3972
|
+
}
|
|
3973
|
+
/** The Windows Script Host that runs the start script with no window, and its arguments. */
|
|
3974
|
+
windowsStart(extra) {
|
|
3975
|
+
const wscript = path16.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
|
|
3976
|
+
return { target: wscript, arguments: ["//Nologo", this.starterFile(), ...extra].map(winQuote).join(" ") };
|
|
3977
|
+
}
|
|
3978
|
+
async writeLinks(files, extra, icon, description) {
|
|
3979
|
+
for (const file of files) fs13.mkdirSync(path16.dirname(file), { recursive: true });
|
|
3980
|
+
const spec = { files, ...this.windowsStart(extra), workdir: os7.homedir(), icon: icon ? `${icon},0` : "", description };
|
|
3981
|
+
await powershell([
|
|
3982
|
+
"$spec = $env:SHORTCUT_SPEC | ConvertFrom-Json",
|
|
3983
|
+
"$shell = New-Object -ComObject WScript.Shell",
|
|
3984
|
+
"foreach ($file in $spec.files) {",
|
|
3985
|
+
" $s = $shell.CreateShortcut($file)",
|
|
3986
|
+
" $s.TargetPath = $spec.target; $s.Arguments = $spec.arguments; $s.WorkingDirectory = $spec.workdir",
|
|
3987
|
+
" if ($spec.icon) { $s.IconLocation = $spec.icon }",
|
|
3988
|
+
" $s.Description = $spec.description",
|
|
3989
|
+
" $s.Save()",
|
|
3990
|
+
"}"
|
|
3991
|
+
].join("\n"), { SHORTCUT_SPEC: JSON.stringify(spec) });
|
|
3784
3992
|
}
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3993
|
+
async addWindows(places) {
|
|
3994
|
+
if (!places.length) return;
|
|
3995
|
+
fs13.mkdirSync(this.opts.iconDir, { recursive: true });
|
|
3996
|
+
fs13.writeFileSync(this.icoFile(), icoFromPng(fs13.readFileSync(this.opts.icons.png256)));
|
|
3997
|
+
this.writeStarter();
|
|
3998
|
+
await this.writeLinks(places.map((p) => p.file), [], this.icoFile(), "Open Ledung, and start it if it is not running");
|
|
3999
|
+
}
|
|
4000
|
+
/** Turns the start at sign-in on or off (#456). On starts Ledung without opening its page. */
|
|
4001
|
+
async signIn(on) {
|
|
4002
|
+
const file = await this.signInFile();
|
|
4003
|
+
if (!file) throw new Error("Ledung cannot start itself at login on this system.");
|
|
4004
|
+
if (!on) {
|
|
4005
|
+
fs13.rmSync(file, { force: true });
|
|
4006
|
+
if (!(await this.places()).some((p) => p.present)) this.removeShared();
|
|
4007
|
+
return;
|
|
4008
|
+
}
|
|
4009
|
+
this.writeEnvFile();
|
|
4010
|
+
fs13.mkdirSync(path16.dirname(file), { recursive: true });
|
|
4011
|
+
const { node, script } = this.opts.launch;
|
|
4012
|
+
const kind = this.kind();
|
|
4013
|
+
if (kind === "windows") {
|
|
4014
|
+
this.writeStarter();
|
|
4015
|
+
await this.writeLinks([file], ["--no-open"], null, "Start Ledung in the background");
|
|
4016
|
+
} else if (kind === "mac") {
|
|
4017
|
+
const run8 = [...this.nodeOptions(), script, ...startsInBackground(this.opts.launch) ? ["--foreground"] : [], "--no-open"].map(shQuote).join(" ");
|
|
4018
|
+
fs13.writeFileSync(file, AGENT_PLIST(["/bin/sh", "-c", signInScript(node, run8, "/bin/zsh").join("\n")], this.logFile()));
|
|
4019
|
+
} else {
|
|
4020
|
+
const run8 = [...this.nodeOptions(), script, "--no-open"].map(shQuote).join(" ");
|
|
4021
|
+
fs13.writeFileSync(file, [
|
|
4022
|
+
"[Desktop Entry]",
|
|
4023
|
+
"Type=Application",
|
|
4024
|
+
"Name=Ledung",
|
|
4025
|
+
"Comment=Start Ledung in the background. Made by Ledung's Settings (issue #456).",
|
|
4026
|
+
`Exec=${["/bin/sh", "-c", signInScript(node, run8, "/bin/sh").join("; ")].map(desktopArg).join(" ")}`,
|
|
4027
|
+
"Terminal=false",
|
|
4028
|
+
"X-GNOME-Autostart-enabled=true",
|
|
4029
|
+
""
|
|
4030
|
+
].join("\n"));
|
|
4031
|
+
}
|
|
3788
4032
|
}
|
|
3789
|
-
/**
|
|
3790
|
-
|
|
3791
|
-
|
|
4033
|
+
/**
|
|
4034
|
+
* Ledung.app: a script that opens a running Ledung's page (--open-only), or else starts Ledung in the background
|
|
4035
|
+
* (#456): the `ledung` command goes there by itself; the release launcher is started with nohup, its output in the
|
|
4036
|
+
* log. Made by Ledung on this computer, so it carries no quarantine and Gatekeeper lets it open.
|
|
4037
|
+
*/
|
|
4038
|
+
addMac(app) {
|
|
4039
|
+
const { node, script } = this.opts.launch;
|
|
4040
|
+
const contents = path16.join(app, "Contents");
|
|
4041
|
+
fs13.mkdirSync(path16.join(contents, "MacOS"), { recursive: true });
|
|
4042
|
+
fs13.mkdirSync(path16.join(contents, "Resources"), { recursive: true });
|
|
4043
|
+
let version = "1";
|
|
4044
|
+
try {
|
|
4045
|
+
version = String(JSON.parse(fs13.readFileSync(path16.join(appDir(), "package.json"), "utf8")).version ?? "1");
|
|
4046
|
+
} catch {
|
|
4047
|
+
}
|
|
4048
|
+
fs13.writeFileSync(path16.join(contents, "Info.plist"), PLIST(version));
|
|
4049
|
+
fs13.writeFileSync(path16.join(contents, "Resources", "Ledung.icns"), icnsFromPngs([fs13.readFileSync(this.opts.icons.png256), fs13.readFileSync(this.opts.icons.png512)]));
|
|
4050
|
+
const run8 = [...this.nodeOptions(), script].map(shQuote).join(" ");
|
|
4051
|
+
fs13.writeFileSync(path16.join(contents, "MacOS", "Ledung"), [
|
|
4052
|
+
"#!/bin/sh",
|
|
4053
|
+
"# Opens a running Ledung's page, or starts Ledung in the background. Made by Ledung's Settings (issues #415, #456).",
|
|
4054
|
+
...findNode(node),
|
|
4055
|
+
`[ -x "$NODE" ] || { osascript -e 'display alert "Ledung cannot find Node.js" message "Install Node.js 24, or add the shortcut again in Settings in Ledung."'; exit 1; }`,
|
|
4056
|
+
`"$NODE" ${run8} --open-only && exit 0`,
|
|
4057
|
+
// Only for a start: opening a running Ledung's page doesn't wait for the login shell.
|
|
4058
|
+
...loginPath("/bin/zsh"),
|
|
4059
|
+
...nodeOnPath(),
|
|
4060
|
+
startsInBackground(this.opts.launch) ? `exec "$NODE" ${run8}` : `${LOG_FILE_SETTING}=${shQuote(this.logFile())} nohup "$NODE" ${run8} >> ${shQuote(this.logFile())} 2>&1 &`,
|
|
4061
|
+
""
|
|
4062
|
+
].join("\n"), { mode: 493 });
|
|
3792
4063
|
}
|
|
3793
|
-
|
|
3794
|
-
|
|
4064
|
+
/** Takes away Ledung.lnk, or a Ledung.app that is Ledung's own (its bundle id), never anything else. */
|
|
4065
|
+
remove(file) {
|
|
4066
|
+
if (file.endsWith(".lnk")) {
|
|
4067
|
+
fs13.rmSync(file, { force: true });
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
if (!isOurApp(file)) throw new Error(`${file} is not the Ledung.app that Ledung made; remove it yourself if you want it gone.`);
|
|
4071
|
+
fs13.rmSync(file, { recursive: true, force: true });
|
|
3795
4072
|
}
|
|
3796
4073
|
};
|
|
3797
|
-
function
|
|
3798
|
-
return
|
|
4074
|
+
function findNode(node) {
|
|
4075
|
+
return [`NODE=${shQuote(node)}`, `[ -x "$NODE" ] || NODE=$("\${SHELL:-/bin/zsh}" -lic 'command -v node' 2>/dev/null | tail -n 1)`];
|
|
3799
4076
|
}
|
|
3800
|
-
|
|
3801
|
-
|
|
4077
|
+
var LOGIN_PATH_TICKS = 40;
|
|
4078
|
+
var PATH_MARKER = "LEDUNG_PATH=";
|
|
4079
|
+
function loginPath(fallbackShell) {
|
|
4080
|
+
const ask = `"\${SHELL:-${fallbackShell}}" -lic 'printf "\\n${PATH_MARKER}%s\\n" "$PATH"' </dev/null >"$T" 2>/dev/null & P=$!`;
|
|
4081
|
+
return [
|
|
4082
|
+
'L=; T=$(mktemp "${TMPDIR:-/tmp}/ledung-path.XXXXXX" 2>/dev/null) || T=',
|
|
4083
|
+
`[ -z "$T" ] || { ${ask}; I=0; while kill -0 "$P" 2>/dev/null && [ "$I" -lt ${LOGIN_PATH_TICKS} ]; do sleep 0.2; I=$((I + 1)); done; kill -KILL "$P" 2>/dev/null; L=$(sed -n 's/^${PATH_MARKER}//p' "$T" | tail -n 1); rm -f "$T"; } 2>/dev/null`,
|
|
4084
|
+
'if [ -n "$L" ]; then PATH=$L; LEDUNG_START_PATH=login; else LEDUNG_START_PATH=fallback; fi'
|
|
4085
|
+
];
|
|
3802
4086
|
}
|
|
3803
|
-
function
|
|
3804
|
-
|
|
3805
|
-
for (const part of (req.headers.cookie ?? "").split(";")) {
|
|
3806
|
-
const [k, ...v] = part.trim().split("=");
|
|
3807
|
-
if (k === name) found.push(v.join("="));
|
|
3808
|
-
}
|
|
3809
|
-
return found;
|
|
4087
|
+
function nodeOnPath() {
|
|
4088
|
+
return ['case ":$PATH:" in *":${NODE%/*}:"*) ;; *) PATH="$PATH:${NODE%/*}" ;; esac', "export PATH LEDUNG_START_PATH"];
|
|
3810
4089
|
}
|
|
3811
|
-
function
|
|
3812
|
-
const
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
"style-src 'self' 'unsafe-inline'",
|
|
3822
|
-
`frame-ancestors ${ancestors.length ? ancestors.join(" ") : "'none'"}`,
|
|
3823
|
-
...sources.length ? [`frame-src 'self' ${sources.join(" ")}`] : [],
|
|
3824
|
-
"base-uri 'none'",
|
|
3825
|
-
"form-action 'self'"
|
|
3826
|
-
].join("; "),
|
|
3827
|
-
// Older browsers know only this header; it can't list pages, so it goes only when some may frame this one.
|
|
3828
|
-
...ancestors.length ? {} : { "X-Frame-Options": "DENY" },
|
|
3829
|
-
"X-Content-Type-Options": "nosniff",
|
|
3830
|
-
"Referrer-Policy": "no-referrer",
|
|
3831
|
-
"Cache-Control": "no-store"
|
|
3832
|
-
};
|
|
4090
|
+
function signInScript(node, run8, fallbackShell) {
|
|
4091
|
+
const missing = 'echo "Ledung cannot find Node.js. Install Node.js 24, or switch Start at sign-in off and on in Settings in Ledung." >&2; exit 1';
|
|
4092
|
+
return [...loginPath(fallbackShell), ...findNode(node), `[ -x "$NODE" ] || { ${missing}; }`, ...nodeOnPath(), `exec "$NODE" ${run8}`];
|
|
4093
|
+
}
|
|
4094
|
+
function isOurApp(app) {
|
|
4095
|
+
try {
|
|
4096
|
+
return fs13.readFileSync(path16.join(app, "Contents", "Info.plist"), "utf8").includes(`<string>${BUNDLE_ID}</string>`);
|
|
4097
|
+
} catch {
|
|
4098
|
+
return false;
|
|
4099
|
+
}
|
|
3833
4100
|
}
|
|
3834
4101
|
|
|
3835
4102
|
// src/server/server.ts
|
|
@@ -3852,6 +4119,22 @@ function displayVersion(version) {
|
|
|
3852
4119
|
if (!m) return `v${version}`;
|
|
3853
4120
|
return m[2] === "0" ? `v${m[1]}` : `v${m[1]}.${m[2]}`;
|
|
3854
4121
|
}
|
|
4122
|
+
var CHANNELS = ["public", "dev"];
|
|
4123
|
+
var CHANNEL_TAG = { public: "latest", dev: "dev" };
|
|
4124
|
+
function parseVersion2(version) {
|
|
4125
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
4126
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
4127
|
+
}
|
|
4128
|
+
function compareVersions(a, b) {
|
|
4129
|
+
const x = parseVersion2(a);
|
|
4130
|
+
const y = parseVersion2(b);
|
|
4131
|
+
if (!x || !y) return (x ? 1 : 0) - (y ? 1 : 0);
|
|
4132
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
4133
|
+
}
|
|
4134
|
+
function offeredVersion(channel, tags) {
|
|
4135
|
+
const candidates = (channel === "dev" ? [tags.dev, tags.latest] : [tags.latest]).filter((v) => !!v && !!parseVersion2(v));
|
|
4136
|
+
return candidates.sort(compareVersions).at(-1) ?? null;
|
|
4137
|
+
}
|
|
3855
4138
|
|
|
3856
4139
|
// src/server/source.ts
|
|
3857
4140
|
var ANTHROPIC_NOTICE = "Ledung runs Anthropic's Claude Agent SDK and Claude Code, installed from npm under Anthropic's own terms (https://code.claude.com/docs/en/legal-and-compliance). They are not covered by Ledung's license (AGPL-3.0-only, see LICENSE).";
|
|
@@ -12509,6 +12792,7 @@ var SessionManager = class _SessionManager {
|
|
|
12509
12792
|
choices: this.accountChoices(),
|
|
12510
12793
|
signedOut: this.signedOut(),
|
|
12511
12794
|
claudeProblem: this.claudeProblem,
|
|
12795
|
+
claudeLooked: this.claudeProblem ? this.claudeLooked : [],
|
|
12512
12796
|
configFileProblems: this.accountFileProblems
|
|
12513
12797
|
};
|
|
12514
12798
|
}
|
|
@@ -12542,9 +12826,12 @@ var SessionManager = class _SessionManager {
|
|
|
12542
12826
|
);
|
|
12543
12827
|
/** Claude Code missing or too old on this computer (issue #341), for the page's banner; null: none. */
|
|
12544
12828
|
claudeProblem = null;
|
|
12829
|
+
/** Where that last look went (#455), shown under the banner so a "not found" can be acted on. */
|
|
12830
|
+
claudeLooked = [];
|
|
12545
12831
|
/** Looks for the Claude Code sessions run, and tells the page when its problem changed. Never throws. */
|
|
12546
12832
|
async checkClaude() {
|
|
12547
12833
|
const view = await this.claudeCodeView();
|
|
12834
|
+
this.claudeLooked = view.looked ?? [];
|
|
12548
12835
|
if (view.problem !== this.claudeProblem) {
|
|
12549
12836
|
this.claudeProblem = view.problem;
|
|
12550
12837
|
this.publishAccounts();
|
|
@@ -12564,6 +12851,7 @@ var SessionManager = class _SessionManager {
|
|
|
12564
12851
|
const all = names ?? [DEFAULT_ACCOUNT, ...this.accounts.list().map((a) => a.name)];
|
|
12565
12852
|
const [claude] = await Promise.all([this.claudeCodeView(), ...all.map((n) => this.checkSignInOf(n))]);
|
|
12566
12853
|
this.claudeProblem = claude.problem;
|
|
12854
|
+
this.claudeLooked = claude.looked ?? [];
|
|
12567
12855
|
this.publishAccounts();
|
|
12568
12856
|
}
|
|
12569
12857
|
/**
|
|
@@ -14205,6 +14493,10 @@ var GIT_LIMIT_MS = 2 * 6e4;
|
|
|
14205
14493
|
var INSTALL_LIMIT_MS = 10 * 6e4;
|
|
14206
14494
|
var RELEASE_LIMIT_MS = 15 * 6e4;
|
|
14207
14495
|
var NPM_REASON = "Ledung was installed from npm, so updates come from npm: run npm install -g ledung, then press Restart Ledung.";
|
|
14496
|
+
var NPM_DEV_REASON = "Ledung was installed from npm, so updates come from npm: run npm install -g ledung@dev, then press Restart Ledung.";
|
|
14497
|
+
var PACKAGE = "ledung";
|
|
14498
|
+
var CHANNEL_KEY = "update-channel";
|
|
14499
|
+
var devCheckTraced = false;
|
|
14208
14500
|
function readRunningRelease(appDir2) {
|
|
14209
14501
|
const own = readJson(path30.join(appDir2, "release.json"));
|
|
14210
14502
|
if (own && typeof own.source === "string") {
|
|
@@ -14215,9 +14507,12 @@ function readRunningRelease(appDir2) {
|
|
|
14215
14507
|
var npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
14216
14508
|
var NPM_INSTALL = { cmd: npm, args: ["install", "--no-audit", "--no-fund"], shell: process.platform === "win32", limitMs: INSTALL_LIMIT_MS };
|
|
14217
14509
|
var NPM_RELEASE = { cmd: npm, args: ["run", "release"], shell: process.platform === "win32", limitMs: RELEASE_LIMIT_MS };
|
|
14510
|
+
var NPM_DIST_TAGS = { cmd: npm, args: ["view", PACKAGE, "dist-tags", "--json"], shell: process.platform === "win32" };
|
|
14511
|
+
var npmGlobalInstall = (spec) => ({ cmd: npm, args: ["install", "-g", spec, "--no-audit", "--no-fund"], shell: process.platform === "win32", limitMs: INSTALL_LIMIT_MS });
|
|
14218
14512
|
var Updater = class {
|
|
14219
14513
|
opts;
|
|
14220
14514
|
check = null;
|
|
14515
|
+
npmCheck = null;
|
|
14221
14516
|
checkError = null;
|
|
14222
14517
|
checking = false;
|
|
14223
14518
|
steps = [];
|
|
@@ -14231,16 +14526,30 @@ var Updater = class {
|
|
|
14231
14526
|
running() {
|
|
14232
14527
|
return this.opts.npmVersion ? null : readRunningRelease(this.opts.appDir);
|
|
14233
14528
|
}
|
|
14529
|
+
/** Public or Dev (#461); Public until chosen otherwise. A clone install keeps updating from main either way. */
|
|
14530
|
+
channel() {
|
|
14531
|
+
return this.opts.meta?.get(CHANNEL_KEY) === "dev" ? "dev" : "public";
|
|
14532
|
+
}
|
|
14533
|
+
setChannel(channel) {
|
|
14534
|
+
if (!CHANNELS.includes(channel)) throw new Error(`The channel is ${CHANNELS.join(" or ")}.`);
|
|
14535
|
+
if (!this.opts.meta) throw new Error("The update channel cannot be kept here.");
|
|
14536
|
+
this.opts.meta.set(CHANNEL_KEY, channel);
|
|
14537
|
+
this.npmCheck = null;
|
|
14538
|
+
return this.view();
|
|
14539
|
+
}
|
|
14234
14540
|
view() {
|
|
14235
14541
|
const running = this.running();
|
|
14236
14542
|
const npm2 = this.opts.npmVersion;
|
|
14543
|
+
const channel = this.channel();
|
|
14237
14544
|
return {
|
|
14238
14545
|
available: !!running,
|
|
14239
|
-
reason: running ? void 0 : npm2 ? NPM_REASON : "This Ledung runs straight from a source folder, not from a release, so it has nothing to update. Start it from a release (npm run release).",
|
|
14546
|
+
reason: running ? void 0 : npm2 ? channel === "dev" ? NPM_DEV_REASON : NPM_REASON : "This Ledung runs straight from a source folder, not from a release, so it has nothing to update. Start it from a release (npm run release).",
|
|
14240
14547
|
npm: npm2 ? { version: npm2 } : void 0,
|
|
14548
|
+
channel,
|
|
14241
14549
|
canRestart: !!this.opts.onRestart,
|
|
14242
14550
|
running: running ? { commit: running.commit, source: running.source, releasedAt: running.releasedAt } : null,
|
|
14243
14551
|
check: this.check,
|
|
14552
|
+
npmCheck: this.npmCheck,
|
|
14244
14553
|
checkError: this.checkError,
|
|
14245
14554
|
checking: this.checking,
|
|
14246
14555
|
steps: this.steps,
|
|
@@ -14249,6 +14558,102 @@ var Updater = class {
|
|
|
14249
14558
|
restarting: this.restarting
|
|
14250
14559
|
};
|
|
14251
14560
|
}
|
|
14561
|
+
/** Check for updates: on npm the channel's version (#461), otherwise origin/main in the source folder. */
|
|
14562
|
+
checkForUpdates() {
|
|
14563
|
+
return this.opts.npmVersion ? this.runNpmCheck() : this.runCheck();
|
|
14564
|
+
}
|
|
14565
|
+
/** Update Ledung: on npm installs the channel's version and restarts (#461), otherwise pulls main and releases. */
|
|
14566
|
+
runUpdate() {
|
|
14567
|
+
return this.opts.npmVersion ? this.startNpmUpdate() : this.startUpdate();
|
|
14568
|
+
}
|
|
14569
|
+
/** Asks npm for the channel's version and says whether it is newer than the installed one. */
|
|
14570
|
+
async runNpmCheck() {
|
|
14571
|
+
const installed = this.opts.npmVersion;
|
|
14572
|
+
if (!installed || this.checking || this.updating) return this.view();
|
|
14573
|
+
this.checking = true;
|
|
14574
|
+
this.checkError = null;
|
|
14575
|
+
try {
|
|
14576
|
+
const channel = this.channel();
|
|
14577
|
+
const offered = offeredVersion(channel, await this.distTags());
|
|
14578
|
+
this.npmCheck = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), channel, offered, upToDate: !offered || compareVersions(offered, installed) <= 0 };
|
|
14579
|
+
if (channel === "dev" && !devCheckTraced) {
|
|
14580
|
+
devCheckTraced = true;
|
|
14581
|
+
exercisedLine(`EXERCISED #461 npm update check followed the dev tag (${offered ?? "none"})`);
|
|
14582
|
+
}
|
|
14583
|
+
} catch (e) {
|
|
14584
|
+
this.checkError = e.message;
|
|
14585
|
+
} finally {
|
|
14586
|
+
this.checking = false;
|
|
14587
|
+
}
|
|
14588
|
+
return this.view();
|
|
14589
|
+
}
|
|
14590
|
+
async distTags() {
|
|
14591
|
+
const r = await this.run(this.opts.distTags ?? NPM_DIST_TAGS, this.opts.dataDir);
|
|
14592
|
+
if (r.timedOut || r.code !== 0) throw new Error(`npm view ${PACKAGE} dist-tags failed:
|
|
14593
|
+
${r.tail}`);
|
|
14594
|
+
try {
|
|
14595
|
+
const tags = JSON.parse(r.out);
|
|
14596
|
+
if (tags && typeof tags === "object") {
|
|
14597
|
+
return Object.fromEntries(Object.entries(tags).filter((e) => typeof e[1] === "string"));
|
|
14598
|
+
}
|
|
14599
|
+
} catch {
|
|
14600
|
+
}
|
|
14601
|
+
throw new Error(`npm view ${PACKAGE} dist-tags did not answer with its tags:
|
|
14602
|
+
${r.tail}`);
|
|
14603
|
+
}
|
|
14604
|
+
/** Installs the channel's version globally with npm, then restarts into it; refusals as for a git update. */
|
|
14605
|
+
startNpmUpdate() {
|
|
14606
|
+
const installed = this.opts.npmVersion;
|
|
14607
|
+
if (!installed || this.updating) return this.view();
|
|
14608
|
+
if (this.checking) {
|
|
14609
|
+
this.refused = "A check for updates is still running. Update again when it has finished.";
|
|
14610
|
+
this.steps = [];
|
|
14611
|
+
return this.view();
|
|
14612
|
+
}
|
|
14613
|
+
this.refused = null;
|
|
14614
|
+
this.steps = [];
|
|
14615
|
+
if (!this.opts.onRestart) {
|
|
14616
|
+
this.refused = "This Ledung was not started by the ledung command, so it cannot restart itself into the new version. Start it with ledung first.";
|
|
14617
|
+
return this.view();
|
|
14618
|
+
}
|
|
14619
|
+
this.updating = true;
|
|
14620
|
+
void this.npmUpdate(installed).finally(() => {
|
|
14621
|
+
this.updating = false;
|
|
14622
|
+
});
|
|
14623
|
+
return this.view();
|
|
14624
|
+
}
|
|
14625
|
+
async npmUpdate(installed) {
|
|
14626
|
+
const channel = this.channel();
|
|
14627
|
+
this.steps = [`Ask npm for the ${channel === "dev" ? "Dev" : "Public"} version`, `npm install -g ${PACKAGE}@${CHANNEL_TAG[channel]}`, "Restart Ledung"].map((label) => ({ label, state: "waiting" }));
|
|
14628
|
+
const step = (i, state, output, label) => {
|
|
14629
|
+
this.steps = this.steps.map((s, j) => j === i ? { ...s, state, output, label: label ?? s.label } : s);
|
|
14630
|
+
};
|
|
14631
|
+
const refuse = (i, reason, output) => {
|
|
14632
|
+
step(i, "failed", output);
|
|
14633
|
+
this.refused = `${reason} The running Ledung was not changed.`;
|
|
14634
|
+
};
|
|
14635
|
+
step(0, "running");
|
|
14636
|
+
let offered;
|
|
14637
|
+
try {
|
|
14638
|
+
offered = offeredVersion(channel, await this.distTags());
|
|
14639
|
+
} catch (e) {
|
|
14640
|
+
return refuse(0, "npm could not be asked for the version.", e.message);
|
|
14641
|
+
}
|
|
14642
|
+
if (!offered) return refuse(0, `npm has no ${channel === "dev" ? "Dev" : "Public"} version of ${PACKAGE}.`);
|
|
14643
|
+
this.npmCheck = { checkedAt: (/* @__PURE__ */ new Date()).toISOString(), channel, offered, upToDate: compareVersions(offered, installed) <= 0 };
|
|
14644
|
+
if (this.npmCheck.upToDate) return refuse(0, `${displayVersion(installed)} is already the newest on this channel.`);
|
|
14645
|
+
step(0, "done");
|
|
14646
|
+
const spec = `${PACKAGE}@${offered}`;
|
|
14647
|
+
step(1, "running", void 0, `npm install -g ${spec}`);
|
|
14648
|
+
const command = this.opts.npmInstall?.(spec) ?? npmGlobalInstall(spec);
|
|
14649
|
+
const result = await this.run(command, this.opts.dataDir);
|
|
14650
|
+
if (result.timedOut) return refuse(1, `npm install -g ${spec} did not finish within ${Math.round((command.limitMs ?? GIT_LIMIT_MS) / 6e4)} minutes and was stopped.`, result.tail);
|
|
14651
|
+
if (result.code !== 0) return refuse(1, `npm install -g ${spec} failed (exit code ${result.code}).`, result.tail);
|
|
14652
|
+
step(1, "done", result.tail);
|
|
14653
|
+
step(2, "running");
|
|
14654
|
+
this.restarting = true;
|
|
14655
|
+
setTimeout(() => this.opts.onRestart?.(), 1500);
|
|
14656
|
+
}
|
|
14252
14657
|
/** Fetches origin/main in the source folder and says what is new since the running release. */
|
|
14253
14658
|
async runCheck() {
|
|
14254
14659
|
const running = this.running();
|
|
@@ -16376,6 +16781,14 @@ var LedungServer = class {
|
|
|
16376
16781
|
if (!this.opts.onRestart) throw new HttpError(404, "Restarting is not available.");
|
|
16377
16782
|
setTimeout(() => this.opts.onRestart?.(), 100);
|
|
16378
16783
|
return;
|
|
16784
|
+
// Stop Ledung (issue #456): only from this computer, whose icon or `ledung` starts it again; a phone or another
|
|
16785
|
+
// computer could stop it but never start it. The cookie and Origin as for every change; `ledung stop` sends both.
|
|
16786
|
+
case "POST stop":
|
|
16787
|
+
this.requireLocal(req, "Ledung is stopped");
|
|
16788
|
+
if (!fromThisComputer(req)) throw new HttpError(403, "Ledung is stopped on the computer it runs on.");
|
|
16789
|
+
if (!this.opts.onStop) throw new HttpError(404, "Stopping is not available.");
|
|
16790
|
+
setTimeout(() => this.opts.onStop?.(), 100);
|
|
16791
|
+
return;
|
|
16379
16792
|
// Shortcuts on this computer's Desktop, Start menu or Applications (issue #415): only from a page on this computer
|
|
16380
16793
|
// itself, not a phone or another computer on the network, and changes only from the open page.
|
|
16381
16794
|
case "GET shortcuts":
|
|
@@ -16385,7 +16798,9 @@ var LedungServer = class {
|
|
|
16385
16798
|
this.requireThisComputer(req);
|
|
16386
16799
|
await this.requirePage(req, url);
|
|
16387
16800
|
const action = (await readJson2(req)).action;
|
|
16388
|
-
if (action !== "add" && action !== "remove" && action !== "decline"
|
|
16801
|
+
if (action !== "add" && action !== "remove" && action !== "decline" && action !== "sign-in-on" && action !== "sign-in-off") {
|
|
16802
|
+
throw new HttpError(400, "The action is add, remove or decline, or sign-in-on or sign-in-off for the start at sign-in.");
|
|
16803
|
+
}
|
|
16389
16804
|
try {
|
|
16390
16805
|
return await this.shortcuts().act(action);
|
|
16391
16806
|
} catch (e) {
|
|
@@ -16395,9 +16810,19 @@ var LedungServer = class {
|
|
|
16395
16810
|
case "GET update":
|
|
16396
16811
|
return this.updater().view();
|
|
16397
16812
|
case "POST update-check":
|
|
16398
|
-
return this.updater().
|
|
16813
|
+
return this.updater().checkForUpdates();
|
|
16399
16814
|
case "POST update":
|
|
16400
|
-
return this.updater().
|
|
16815
|
+
return this.updater().runUpdate();
|
|
16816
|
+
// Settings → About's Public / Dev (#461): guarded as the update it steers (sign-in, origin, an unlocked device).
|
|
16817
|
+
case "POST update-channel": {
|
|
16818
|
+
const channel = (await readJson2(req)).channel;
|
|
16819
|
+
if (channel !== "public" && channel !== "dev") throw new HttpError(400, "The channel is public or dev.");
|
|
16820
|
+
try {
|
|
16821
|
+
return this.updater().setChannel(channel);
|
|
16822
|
+
} catch (e) {
|
|
16823
|
+
throw new HttpError(400, e.message);
|
|
16824
|
+
}
|
|
16825
|
+
}
|
|
16401
16826
|
case "POST certificate-check":
|
|
16402
16827
|
if (this.opts.onCertificateCheck) this.opts.onCertificateCheck();
|
|
16403
16828
|
else if (!this.remoteLink?.checkNow()) throw new HttpError(404, "This Ledung gets no certificate by ACME.");
|
|
@@ -20737,6 +21162,8 @@ async function main() {
|
|
|
20737
21162
|
}
|
|
20738
21163
|
if (PACKAGED && createDefaultConfig()) console.log(`Wrote a default config to ${configPath()}.`);
|
|
20739
21164
|
const config = loadConfig();
|
|
21165
|
+
const startPath = envSetting("START_PATH");
|
|
21166
|
+
if (startPath) exercisedLine(`EXERCISED #464 started with the ${startPath === "login" ? "login shell's" : "fallback"} PATH`);
|
|
20740
21167
|
const remote = config.remote ?? savedRemote(remoteDir());
|
|
20741
21168
|
if (!fs38.existsSync(databaseFile())) console.log(ANTHROPIC_NOTICE);
|
|
20742
21169
|
const db = openDatabase();
|
|
@@ -20800,9 +21227,19 @@ async function main() {
|
|
|
20800
21227
|
store,
|
|
20801
21228
|
manager,
|
|
20802
21229
|
onRestart,
|
|
21230
|
+
// Stop Ledung in Settings and `ledung stop` (issue #456): the same stop as Ctrl+C in its window.
|
|
21231
|
+
onStop: () => void shutdown(false),
|
|
20803
21232
|
// Made only when asked, in the first-run question or Settings (issue #415).
|
|
20804
21233
|
shortcuts: new Shortcuts({ launch: launchTarget(), iconDir: userDataDir(), icons: iconFiles(), meta: { get: (k) => store.getMeta(k), set: (k, v) => store.setMeta(k, v) } }),
|
|
20805
|
-
updater: new Updater({
|
|
21234
|
+
updater: new Updater({
|
|
21235
|
+
appDir: appDir(),
|
|
21236
|
+
dataDir: userDataDir(),
|
|
21237
|
+
configPath: configPath(),
|
|
21238
|
+
onRestart,
|
|
21239
|
+
npmVersion: PACKAGED ? appVersion() : void 0,
|
|
21240
|
+
// The update channel (#461) is this computer's, like its shortcuts.
|
|
21241
|
+
meta: { get: (k) => store.getMeta(k), set: (k, v) => store.setMeta(k, v) }
|
|
21242
|
+
}),
|
|
20806
21243
|
lanNames: lan,
|
|
20807
21244
|
lanBind: config.lan === true ? "0.0.0.0" : config.lan || void 0,
|
|
20808
21245
|
lanOpen: !config.lanSignIn,
|