Ferndiagnose (MCP): Verbindungscode + abgesicherter Fernzugriff + stdio-Bridge
Neue Funktion, um Diagnose eines laufenden Servers aus der Ferne zu ermoeglichen:
Hilfe -> Remote-Support -> "Ferndiagnose (MCP)". Erzeugt einen Verbindungscode,
den der Assistent nutzt, um Status, Logs, Fehler und Accounts read-only zu lesen.
App-Seite:
- Live (re)startbarer Debug-Server ohne App-Neustart (restartDebugServer wartet auf
'close' + closeAllConnections, behandelt EADDRINUSE).
- IP-Allowlist (debug_allowlist.txt, exakte IP + CIDR), erzwungen VOR der Auth.
Fail-closed: Netzwerk-Bind (0.0.0.0) ohne Allowlist akzeptiert nur Loopback.
- One-Click Aktivieren/Aktualisieren/Deaktivieren + Token-Rotation (alter Code sofort
ungueltig). Sichtbarkeit waehlbar: "Nur lokal" (Tunnel-Empfehlung) vs "Im Netzwerk".
- Verbindungscode rddiag:v1:base64url({v,h,p,t,n?,fp?,s?}); oeffentlicher Host frei
waehlbar, Netzwerk-IPs als Schnellauswahl.
- Neue IPC: get/enable/disable/rotate Remote-Diagnostics; Controller-Methoden; Typen.
Bridge (tools/rd-diagnostics-mcp, standalone, KEINE App-Dependency):
- stdio MCP-Server (@modelcontextprotocol/sdk) mit 14 Tools, proxyt die bestehende
HTTP-Debug-API. Multi-Server ueber code/server/RDDIAG_CODE/RDDIAG_SERVERS.
- TLS-Fingerprint-Pinning auf secureConnect (vor Token-Versand), falls https genutzt.
- test/harness.mjs: faehrt einen Fake-Debug-Server hoch und treibt die Bridge als
echten stdio-Child per JSON-RPC -> voller Protokollpfad gruen.
Sicherheit (Audit): keine persistenten Secrets in den Logs der Debug-API (Passwoerter
redigiert, keine Debrid-Keys/aufgeloesten Download-URLs geloggt; settings/accounts
redigiert). Empfohlener Transport: Loopback + privater Tunnel; Direkt-Bind nur mit
Allowlist in vertrauenswuerdigen Netzen.
Tests: connection-code-Cross-Check (App-Encoder <-> Bridge-Decoder), Allowlist-Matrix
(Loopback, exakt, CIDR, fail-closed, Live-Restart). Volle Suite 905 gruen, tsc=6.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
import { app } from "electron";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
@@ -9,9 +10,11 @@ import {
|
||||
DebridAccountStatus,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
ParsedPackageInput,
|
||||
RemoteDiagnosticsInfo,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@@ -39,7 +42,8 @@ import { MegaWebFallback } from "./mega-web-fallback";
|
||||
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||
import { runInstallWithResume } from "./update-install-flow";
|
||||
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
||||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||||
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||
@@ -291,6 +295,83 @@ export class AppController {
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private getSuggestedRemoteHosts(): string[] {
|
||||
const hosts: string[] = [];
|
||||
try {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const entry of Object.values(interfaces)) {
|
||||
for (const net of entry || []) {
|
||||
if (net.family === "IPv4" && !net.internal && net.address) {
|
||||
hosts.push(net.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return [...new Set(hosts)];
|
||||
}
|
||||
|
||||
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
|
||||
const status = getDebugServerRuntimeStatus();
|
||||
const meta = loadRemoteMeta(this.storagePaths.baseDir);
|
||||
const token = getActiveDebugToken();
|
||||
const allowlist = getDebugAllowlist();
|
||||
const suggestedHosts = this.getSuggestedRemoteHosts();
|
||||
const host = meta.publicHost
|
||||
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
|
||||
const code = (status.hasToken && token && host)
|
||||
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
|
||||
: null;
|
||||
return {
|
||||
status,
|
||||
code,
|
||||
publicHost: meta.publicHost,
|
||||
name: meta.name,
|
||||
allowlist,
|
||||
suggestedHosts
|
||||
};
|
||||
}
|
||||
|
||||
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
|
||||
const baseDir = this.storagePaths.baseDir;
|
||||
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
|
||||
? input.port
|
||||
: 9868;
|
||||
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
|
||||
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||
if (input.hostMode === "network" && allowlist.length === 0) {
|
||||
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
|
||||
}
|
||||
let token = getActiveDebugToken();
|
||||
if (!token || input.rotateToken) {
|
||||
token = rotateDebugToken(baseDir).token;
|
||||
}
|
||||
writeDebugServerConfig({ host: bindHost, port, allowlist });
|
||||
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
|
||||
await restartDebugServer();
|
||||
this.audit("WARN", "Ferndiagnose aktiviert", {
|
||||
host: bindHost,
|
||||
port,
|
||||
allowlistCount: allowlist.length,
|
||||
localOnly: input.hostMode === "local"
|
||||
});
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
|
||||
clearDebugToken();
|
||||
await restartDebugServer();
|
||||
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
|
||||
rotateDebugToken(this.storagePaths.baseDir);
|
||||
await restartDebugServer();
|
||||
this.audit("WARN", "Ferndiagnose-Token rotiert");
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const PREFIX = "rddiag:v1:";
|
||||
|
||||
function base64urlEncode(value: string): string {
|
||||
return Buffer.from(value, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export interface ConnectionCodeInput {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
name?: string;
|
||||
scheme?: "http" | "https";
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
||||
const host = String(input.host || "").trim();
|
||||
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
||||
const port = Number(input.port);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
|
||||
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
|
||||
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
||||
if (input.name) payload.n = String(input.name);
|
||||
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
||||
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export interface RemoteMeta {
|
||||
publicHost: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function remoteMetaPath(baseDir: string): string {
|
||||
return path.join(baseDir, "debug_remote.json");
|
||||
}
|
||||
|
||||
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
||||
return {
|
||||
publicHost: String(parsed.publicHost || ""),
|
||||
name: String(parsed.name || "")
|
||||
};
|
||||
} catch {
|
||||
return { publicHost: "", name: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
||||
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
||||
}
|
||||
+212
-16
@@ -65,6 +65,16 @@ let authToken = "";
|
||||
let bindHost = DEFAULT_HOST;
|
||||
let bindPort = DEFAULT_PORT;
|
||||
let runtimeBaseDir = "";
|
||||
let allowlist: string[] = [];
|
||||
|
||||
export interface DebugServerRuntimeStatus {
|
||||
running: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
hasToken: boolean;
|
||||
localOnly: boolean;
|
||||
allowlistCount: number;
|
||||
}
|
||||
|
||||
function getStoragePaths() {
|
||||
return createStoragePaths(runtimeBaseDir);
|
||||
@@ -140,6 +150,86 @@ function getHost(baseDir: string): string {
|
||||
return DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function getAllowlistPath(baseDir: string = runtimeBaseDir): string {
|
||||
return path.join(baseDir, "debug_allowlist.txt");
|
||||
}
|
||||
|
||||
function loadAllowlist(baseDir: string): string[] {
|
||||
try {
|
||||
return fs.readFileSync(getAllowlistPath(baseDir), "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIp(ip: string): string {
|
||||
return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isLoopbackIp(ip: string): boolean {
|
||||
const x = normalizeIp(ip);
|
||||
return x === "::1" || x === "localhost" || x.startsWith("127.");
|
||||
}
|
||||
|
||||
function ipv4ToInt(ip: string): number | null {
|
||||
const parts = ip.split(".");
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
let result = 0;
|
||||
for (const part of parts) {
|
||||
const value = Number(part);
|
||||
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
||||
return null;
|
||||
}
|
||||
result = (result * 256) + value;
|
||||
}
|
||||
return result >>> 0;
|
||||
}
|
||||
|
||||
function matchIpRule(clientIp: string, rule: string): boolean {
|
||||
const client = normalizeIp(clientIp);
|
||||
const r = rule.trim().toLowerCase();
|
||||
if (!r) {
|
||||
return false;
|
||||
}
|
||||
if (r === "*" || r === "0.0.0.0/0") {
|
||||
return true;
|
||||
}
|
||||
if (r === client) {
|
||||
return true;
|
||||
}
|
||||
const slash = r.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const baseInt = ipv4ToInt(r.slice(0, slash));
|
||||
const clientInt = ipv4ToInt(client);
|
||||
const bits = Number(r.slice(slash + 1));
|
||||
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
|
||||
return false;
|
||||
}
|
||||
if (bits === 0) {
|
||||
return true;
|
||||
}
|
||||
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
|
||||
return (clientInt & mask) === (baseInt & mask);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isClientAllowed(clientIp: string): boolean {
|
||||
const client = normalizeIp(clientIp);
|
||||
if (isLoopbackIp(client) || client === "") {
|
||||
return true;
|
||||
}
|
||||
if (allowlist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return allowlist.some((rule) => matchIpRule(client, rule));
|
||||
}
|
||||
|
||||
function checkAuth(req: http.IncomingMessage): boolean {
|
||||
if (!authToken) {
|
||||
return false;
|
||||
@@ -461,6 +551,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = extractDebugClientIp(req);
|
||||
if (!isClientAllowed(clientIp)) {
|
||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
|
||||
clientIp,
|
||||
url: sanitizeRequestUrlForTrace(req.url || "/")
|
||||
});
|
||||
}
|
||||
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkAuth(req)) {
|
||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
||||
@@ -927,32 +1029,126 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
});
|
||||
}
|
||||
|
||||
function openServerSocket(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
authToken = loadToken(runtimeBaseDir);
|
||||
bindPort = getPort(runtimeBaseDir);
|
||||
bindHost = getHost(runtimeBaseDir);
|
||||
allowlist = loadAllowlist(runtimeBaseDir);
|
||||
writeAiManifest(runtimeBaseDir);
|
||||
if (!authToken) {
|
||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (bindHost === "0.0.0.0" && allowlist.length === 0) {
|
||||
logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)");
|
||||
}
|
||||
const srv = http.createServer(handleRequest);
|
||||
let settled = false;
|
||||
const settle = (): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
srv.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`);
|
||||
} else {
|
||||
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
||||
}
|
||||
if (server === srv) {
|
||||
server = null;
|
||||
}
|
||||
settle();
|
||||
});
|
||||
srv.listen(bindPort, bindHost, () => {
|
||||
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`);
|
||||
settle();
|
||||
});
|
||||
server = srv;
|
||||
});
|
||||
}
|
||||
|
||||
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
||||
runtimeBaseDir = baseDir;
|
||||
authToken = loadToken(baseDir);
|
||||
bindPort = getPort(baseDir);
|
||||
bindHost = getHost(baseDir);
|
||||
writeAiManifest(baseDir);
|
||||
if (!authToken) {
|
||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||
return;
|
||||
}
|
||||
|
||||
manager = mgr;
|
||||
void openServerSocket();
|
||||
}
|
||||
|
||||
server = http.createServer(handleRequest);
|
||||
server.listen(bindPort, bindHost, () => {
|
||||
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
|
||||
});
|
||||
server.on("error", (err) => {
|
||||
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
||||
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
|
||||
const old = server;
|
||||
if (old) {
|
||||
server = null;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const done = (): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
old.close(() => done());
|
||||
try {
|
||||
old.closeAllConnections?.();
|
||||
} catch {
|
||||
}
|
||||
setTimeout(done, 1500);
|
||||
});
|
||||
}
|
||||
await openServerSocket();
|
||||
return getDebugServerRuntimeStatus();
|
||||
}
|
||||
|
||||
export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus {
|
||||
return {
|
||||
running: Boolean(server && server.listening),
|
||||
host: bindHost,
|
||||
port: bindPort,
|
||||
hasToken: Boolean(authToken),
|
||||
localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1",
|
||||
allowlistCount: allowlist.length
|
||||
};
|
||||
}
|
||||
|
||||
export function getActiveDebugToken(): string {
|
||||
return authToken || loadToken(runtimeBaseDir);
|
||||
}
|
||||
|
||||
export function getDebugAllowlist(): string[] {
|
||||
return [...allowlist];
|
||||
}
|
||||
|
||||
export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void {
|
||||
if (opts.host !== undefined) {
|
||||
fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8");
|
||||
}
|
||||
if (opts.port !== undefined) {
|
||||
fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8");
|
||||
}
|
||||
if (opts.allowlist !== undefined) {
|
||||
const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : "";
|
||||
fs.writeFileSync(getAllowlistPath(), body, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDebugToken(): void {
|
||||
try {
|
||||
fs.unlinkSync(getDebugTokenPath());
|
||||
} catch {
|
||||
}
|
||||
authToken = "";
|
||||
writeAiManifest(runtimeBaseDir);
|
||||
}
|
||||
|
||||
export function stopDebugServer(): void {
|
||||
if (server) {
|
||||
server.close();
|
||||
try {
|
||||
server.closeAllConnections?.();
|
||||
} catch {
|
||||
}
|
||||
server = null;
|
||||
logger.info("Debug-Server gestoppt");
|
||||
}
|
||||
|
||||
+28
-1
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
||||
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types";
|
||||
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
|
||||
import { AppController } from "./app-controller";
|
||||
import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { getLogFilePath, logger } from "./logger";
|
||||
@@ -671,6 +671,33 @@ function registerIpcHandlers(): void {
|
||||
return { path: rotated.path };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
|
||||
return controller.getRemoteDiagnostics();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => {
|
||||
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
|
||||
throw new Error("hostMode muss 'local' oder 'network' sein");
|
||||
}
|
||||
const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : [];
|
||||
return controller.enableRemoteDiagnostics({
|
||||
hostMode: input.hostMode,
|
||||
publicHost: String(input.publicHost || ""),
|
||||
port: input.port ? Number(input.port) : undefined,
|
||||
allowlist,
|
||||
name: input.name ? String(input.name) : undefined,
|
||||
rotateToken: Boolean(input.rotateToken)
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => {
|
||||
return controller.disableRemoteDiagnostics();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
|
||||
return controller.rotateRemoteDiagnosticsToken();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
|
||||
validateString(itemId, "itemId");
|
||||
const logPath = controller.getItemLogPath(itemId);
|
||||
|
||||
Reference in New Issue
Block a user