Adds an AppData/Desktop log storage selector with controlled migration of known log files only. Keeps runtime data and credentials in AppData, consolidates desktop rename logs into the chosen directory, and exposes the active log folder through the desktop UI.\n\nFlushes early recovery diagnostics into the runtime log before a location migration, preserves the active trace configuration as valid JSON during moves, and prevents backup imports from failing when the requested log location is unavailable. Adds regression coverage for desktop paths, legacy log migration, secret exclusion, and trace-config replacement.
171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { LogStorageLocation } from "../shared/types";
|
|
|
|
export const DESKTOP_LOG_DIRECTORY_NAME = "Downloader Log";
|
|
export const LEGACY_DESKTOP_LOG_DIRECTORY_NAME = "Downloader-Log";
|
|
|
|
const LOG_FILE_NAMES = new Set([
|
|
"rd_downloader.log",
|
|
"audit.log",
|
|
"account-rotation.log",
|
|
"conversion.log",
|
|
"rename.log",
|
|
"trace.log",
|
|
"trace_config.json"
|
|
]);
|
|
|
|
const LOG_DIRECTORY_NAMES = new Set(["session-logs", "package-logs", "item-logs"]);
|
|
|
|
export interface LogMigrationResult {
|
|
copiedFiles: number;
|
|
skippedFiles: number;
|
|
}
|
|
|
|
function samePath(left: string, right: string): boolean {
|
|
const normalizedLeft = path.resolve(left);
|
|
const normalizedRight = path.resolve(right);
|
|
return process.platform === "win32"
|
|
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
: normalizedLeft === normalizedRight;
|
|
}
|
|
|
|
function mergeTextFile(sourcePath: string, targetPath: string): boolean {
|
|
try {
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
if (!fs.existsSync(targetPath)) {
|
|
fs.writeFileSync(targetPath, source, "utf8");
|
|
return true;
|
|
}
|
|
const target = fs.readFileSync(targetPath, "utf8");
|
|
if (target === source || target.startsWith(source)) {
|
|
return false;
|
|
}
|
|
if (source.startsWith(target)) {
|
|
fs.appendFileSync(targetPath, source.slice(target.length), "utf8");
|
|
return true;
|
|
}
|
|
const separator = target.endsWith("\n") ? "" : "\n";
|
|
fs.appendFileSync(targetPath, `${separator}${source}`, "utf8");
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function copyTraceConfig(sourcePath: string, targetPath: string): boolean {
|
|
try {
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
if (fs.existsSync(targetPath) && fs.readFileSync(targetPath, "utf8") === source) {
|
|
return false;
|
|
}
|
|
fs.writeFileSync(targetPath, source, "utf8");
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isRootLogFile(fileName: string): boolean {
|
|
const baseName = fileName.endsWith(".old") ? fileName.slice(0, -4) : fileName;
|
|
return LOG_FILE_NAMES.has(baseName) || /^rename-session_.*\.txt$/i.test(fileName);
|
|
}
|
|
|
|
function copyAllowedEntries(sourceDirectory: string, targetDirectory: string, result: LogMigrationResult): void {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(sourceDirectory, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const sourcePath = path.join(sourceDirectory, entry.name);
|
|
const targetPath = path.join(targetDirectory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!LOG_DIRECTORY_NAMES.has(entry.name)) {
|
|
continue;
|
|
}
|
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
copyDirectoryFiles(sourcePath, targetPath, result);
|
|
continue;
|
|
}
|
|
if (!entry.isFile() || !isRootLogFile(entry.name)) {
|
|
result.skippedFiles += 1;
|
|
continue;
|
|
}
|
|
const copied = entry.name === "trace_config.json"
|
|
? copyTraceConfig(sourcePath, targetPath)
|
|
: mergeTextFile(sourcePath, targetPath);
|
|
if (copied) {
|
|
result.copiedFiles += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyDirectoryFiles(sourceDirectory: string, targetDirectory: string, result: LogMigrationResult): void {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(sourceDirectory, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const sourcePath = path.join(sourceDirectory, entry.name);
|
|
const targetPath = path.join(targetDirectory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
copyDirectoryFiles(sourcePath, targetPath, result);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
if (mergeTextFile(sourcePath, targetPath)) {
|
|
result.copiedFiles += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveLogDirectory(
|
|
runtimeDirectory: string,
|
|
desktopDirectory: string | null | undefined,
|
|
location: LogStorageLocation
|
|
): string {
|
|
const runtimePath = path.resolve(runtimeDirectory);
|
|
const desktopPath = String(desktopDirectory || "").trim();
|
|
if (location === "desktop" && desktopPath) {
|
|
return path.resolve(desktopPath, DESKTOP_LOG_DIRECTORY_NAME);
|
|
}
|
|
return runtimePath;
|
|
}
|
|
|
|
export function getLegacyDesktopLogDirectory(desktopDirectory: string | null | undefined): string | null {
|
|
const desktopPath = String(desktopDirectory || "").trim();
|
|
return desktopPath ? path.resolve(desktopPath, LEGACY_DESKTOP_LOG_DIRECTORY_NAME) : null;
|
|
}
|
|
|
|
export function prepareLogDirectory(directory: string): boolean {
|
|
try {
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
return fs.statSync(directory).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function migrateLogDirectories(sourceDirectories: readonly string[], targetDirectory: string): LogMigrationResult {
|
|
const result: LogMigrationResult = { copiedFiles: 0, skippedFiles: 0 };
|
|
if (!prepareLogDirectory(targetDirectory)) {
|
|
return result;
|
|
}
|
|
for (const sourceDirectory of sourceDirectories) {
|
|
if (!sourceDirectory || samePath(sourceDirectory, targetDirectory)) {
|
|
continue;
|
|
}
|
|
copyAllowedEntries(sourceDirectory, targetDirectory, result);
|
|
}
|
|
return result;
|
|
}
|