feat(settings): custom temp directory for SFTP staging (#4035)

Fixes #4033

User-configurable Netcatty temp root (Settings → System → Temporary Files) with backward-compatible default, Choose folder / manual path / Reset. Electron smoke PASS tip f6f32b69.
This commit is contained in:
陈大猫 authored and GitHub committed 2026-10-28 03:02:39 -07:00
1 parent f0483488e1
commit 6da42ebc53
16 files changed
+361 -9

No files matched your search

+6 -1
View File
@@ -161,7 +161,12 @@ export const enCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.tempDirChoose': 'Choose folder',
'settings.system.tempDirManual': 'Enter an absolute folder path',
'settings.system.tempDirApply': 'Apply',
'settings.system.tempDirReset': 'Reset to default',
'settings.system.tempDirError': 'Unable to use this folder.',
'settings.system.tempDirectoryHint': 'Netcatty creates a Netcatty temp subfolder in the chosen folder for SFTP staging and external editing. Changing the folder during a transfer may affect that transfer.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
+6 -1
View File
@@ -161,7 +161,12 @@ export const esCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
'settings.system.clearing': 'Limpiando...',
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
'settings.system.tempDirectoryHint': 'Los archivos temporales se crean al abrir archivos remotos con aplicaciones externas. Se limpian automáticamente cuando las sesiones SFTP se cierran.',
'settings.system.tempDirChoose': 'Elegir carpeta',
'settings.system.tempDirManual': 'Introduce una ruta de carpeta absoluta',
'settings.system.tempDirApply': 'Aplicar',
'settings.system.tempDirReset': 'Restablecer predeterminado',
'settings.system.tempDirError': 'No se puede usar esta carpeta.',
'settings.system.tempDirectoryHint': 'Se usa para el almacenamiento temporal de transferencias SFTP y archivos abiertos con aplicaciones externas. Cambiar la carpeta durante una transferencia puede afectar esa transferencia.',
'settings.system.credentials.title': 'Protección de credenciales',
'settings.system.credentials.status': 'Estado',
'settings.system.credentials.checking': 'Comprobando...',
+6 -1
View File
@@ -134,7 +134,12 @@ export const ruCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.tempDirChoose': 'Выбрать папку',
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
'settings.system.tempDirApply': 'Применить',
'settings.system.tempDirReset': 'Сбросить по умолчанию',
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...',
+6 -1
View File
@@ -165,7 +165,12 @@ export const zhCNCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理临时文件',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.tempDirChoose': '选择文件夹',
'settings.system.tempDirManual': '输入绝对文件夹路径',
'settings.system.tempDirApply': '应用',
'settings.system.tempDirReset': '恢复默认',
'settings.system.tempDirError': '无法使用此文件夹。',
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
+6 -1
View File
@@ -167,7 +167,12 @@ export const zhTWCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理臨時檔案',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
'settings.system.tempDirChoose': '選擇資料夾',
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
'settings.system.tempDirApply': '套用',
'settings.system.tempDirReset': '恢復預設',
'settings.system.tempDirError': '無法使用此資料夾。',
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
'settings.system.credentials.title': '憑證保護',
'settings.system.credentials.status': '狀態',
'settings.system.credentials.checking': '檢查中...',
@@ -47,6 +47,8 @@ interface TempDirInfo {
path: string;
fileCount: number;
totalSize: number;
isCustom: boolean;
defaultPath: string;
}
interface SshDebugLogInfo {
@@ -196,6 +198,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const [tempDirInput, setTempDirInput] = useState("");
const [tempDirError, setTempDirError] = useState("");
const [tempDirBusy, setTempDirBusy] = useState(false);
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
@@ -235,6 +240,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
try {
const info = await bridge.getTempDirInfo();
setTempDirInfo(info);
if (info?.isCustom && info.path) setTempDirInput(info.path);
else if (info && !info.isCustom) setTempDirInput("");
} catch (err) {
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
} finally {
@@ -363,6 +370,37 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
}
}, [loadTempDirInfo]);
const applyTempDir = useCallback(async (dir: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.setCustomTempDir) return;
setTempDirBusy(true);
setTempDirError("");
try {
const result = await bridge.setCustomTempDir(dir);
if (!result.ok) setTempDirError(result.error || t("settings.system.tempDirError"));
else { setTempDirInput(result.path || dir); await loadTempDirInfo(); }
} catch { setTempDirError(t("settings.system.tempDirError")); }
finally { setTempDirBusy(false); }
}, [loadTempDirInfo, t]);
const chooseTempDir = useCallback(async () => {
try {
const dir = await netcattyBridge.get()?.chooseTempDir?.();
if (dir) { setTempDirInput(dir); await applyTempDir(dir); }
} catch { setTempDirError(t("settings.system.tempDirError")); }
}, [applyTempDir, t]);
const resetTempDir = useCallback(async () => {
setTempDirBusy(true);
setTempDirError("");
try {
const result = await netcattyBridge.get()?.resetCustomTempDir?.();
if (!result?.ok) setTempDirError(result?.error || t("settings.system.tempDirError"));
else { setTempDirInput(""); await loadTempDirInfo(); }
} catch { setTempDirError(t("settings.system.tempDirError")); }
finally { setTempDirBusy(false); }
}, [loadTempDirInfo, t]);
const handleOpenTempDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
@@ -925,6 +963,18 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</Tooltip>
</div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={chooseTempDir} disabled={tempDirBusy}>{t("settings.system.tempDirChoose")}</Button>
{tempDirInfo?.isCustom && <Button variant="outline" size="sm" onClick={resetTempDir} disabled={tempDirBusy}>{t("settings.system.tempDirReset")}</Button>}
</div>
<div className="flex gap-2">
<input className="min-w-0 flex-1 rounded-md border border-input bg-background px-3 py-1 text-sm" aria-label={t("settings.system.tempDirManual")} placeholder={t("settings.system.tempDirManual")} value={tempDirInput} onChange={e => setTempDirInput(e.target.value)} />
<Button variant="outline" size="sm" onClick={() => applyTempDir(tempDirInput)} disabled={tempDirBusy}>{t("settings.system.tempDirApply")}</Button>
</div>
{tempDirError && <p role="alert" className="text-sm text-destructive">{t("settings.system.tempDirError")} {tempDirError}</p>}
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div>
@@ -50,6 +50,7 @@ function createBridgeRegistrarForTest({
ensureTempDir() {},
registerHandlers() {},
getTempDir: () => tempDir,
getCustomTempDir: () => null,
getTempFilePath: (fileName) => Promise.resolve(path.join(tempDir, fileName)),
};
+145 -2
View File
@@ -14,6 +14,7 @@ const crypto = require("node:crypto");
// root. Shared temp roots fall back to a stable directory under the user's home
// so another OS user cannot claim Netcatty's path before startup.
const NETCATTY_TEMP_DIR_NAME = "Netcatty";
const MANAGED_TEMP_MARKER = ".netcatty-managed-temp";
const MAX_TOOL_OUTPUT_TEMP_CHARS = 4_000_000;
const MAX_TOOL_OUTPUT_TEMP_BYTES = 8_000_000;
const TOOL_OUTPUT_ORPHAN_TTL_MS = 30 * 60 * 1_000;
@@ -30,6 +31,8 @@ const TOOL_OUTPUT_SIGNING_KEY_FILE = ".tool-output-signing-key";
const TOOL_OUTPUT_TEMP_DIR_REBOUND = "NETCATTY_TEMP_DIR_REBOUND";
// Cached temp directory path
let customTempDir = null;
let tempDirPreferenceFile = null;
let cachedTempDir = null;
let cachedTempDirIdentity = null;
let tempFileCounter = 0;
@@ -294,6 +297,115 @@ function resolvePrivateTempDir(systemTempDir = os.tmpdir(), homeDir = os.homedir
return path.join(homeDir, ".netcatty", "tmp", NETCATTY_TEMP_DIR_NAME);
}
function validateTempRoot(input) {
if (typeof input !== "string" || !input.trim() || input.includes("\0") || !path.isAbsolute(input.trim())) {
throw new Error("Please enter an absolute directory path.");
}
const selected = path.normalize(path.resolve(input.trim()));
const dir = path.basename(selected) === NETCATTY_TEMP_DIR_NAME
? selected : path.join(selected, NETCATTY_TEMP_DIR_NAME);
// Reject symlinked ancestors before creating anything beneath them.
let parent = dir;
while (!fs.existsSync(parent)) {
const next = path.dirname(parent);
if (next === parent) break;
parent = next;
}
const realParent = fs.realpathSync(parent);
if (path.normalize(realParent) !== path.normalize(parent)) {
throw new Error("Temporary directory must not traverse symbolic links.");
}
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const entries = fs.readdirSync(dir);
const hasMarker = entries.includes(MANAGED_TEMP_MARKER)
&& fs.lstatSync(path.join(dir, MANAGED_TEMP_MARKER)).isFile();
if (entries.length && !hasMarker) {
throw new Error("Existing Netcatty directory is not empty and is not managed by Netcatty.");
}
const stat = assertSafeTempDir(dir);
if (!hasMarker) {
fs.writeFileSync(path.join(dir, MANAGED_TEMP_MARKER), "", { flag: "wx", mode: 0o600 });
}
return { dir, identity: { dev: stat.dev, ino: stat.ino } };
}
function rebindTempRoot(custom, validated) {
customTempDir = custom;
cachedTempDir = validated.dir;
cachedTempDirIdentity = validated.identity;
tempDirRebindGeneration += 1;
toolOutputSigningKeyPromise = Promise.resolve(null);
}
function isCurrentTempRoot(custom, validated) {
return customTempDir === custom && cachedTempDir === validated.dir
&& cachedTempDirIdentity?.dev === validated.identity.dev
&& cachedTempDirIdentity?.ino === validated.identity.ino;
}
function configureTempDirPreference(electronModule) {
tempDirPreferenceFile = path.join(electronModule.app.getPath("userData"), "temp-dir-pref.json");
customTempDir = null;
cachedTempDir = null;
cachedTempDirIdentity = null;
try {
const pref = JSON.parse(fs.readFileSync(tempDirPreferenceFile, "utf8"));
if (pref.customTempDir != null) {
const validated = validateTempRoot(pref.customTempDir);
rebindTempRoot(validated.dir, validated);
}
} catch (error) {
if (error.code !== "ENOENT") console.error("[TempDir] Invalid temp directory preference:", error.message);
}
}
function writeTempDirPreference(custom) {
if (!tempDirPreferenceFile) throw new Error("Temp directory preferences are unavailable.");
const pending = `${tempDirPreferenceFile}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.pending`;
try {
fs.writeFileSync(pending, JSON.stringify({ customTempDir: custom }), { mode: 0o600, flag: "wx" });
fs.renameSync(pending, tempDirPreferenceFile);
} finally {
try { fs.unlinkSync(pending); } catch {}
}
}
function applyCustomTempDir(input) {
if (input) {
const validated = validateTempRoot(input);
if (isCurrentTempRoot(validated.dir, validated)) return validated.dir;
rebindTempRoot(validated.dir, validated);
return validated.dir;
}
const dir = resolvePrivateTempDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const stat = assertSafeTempDir(dir);
rebindTempRoot(null, { dir, identity: { dev: stat.dev, ino: stat.ino } });
return dir;
}
function setCustomTempDir(input) {
const validated = validateTempRoot(input);
assertSafeTempDir(validated.dir, validated.identity);
if (isCurrentTempRoot(validated.dir, validated)) return validated.dir;
writeTempDirPreference(validated.dir);
rebindTempRoot(validated.dir, validated);
return validated.dir;
}
function resetCustomTempDir() {
const dir = resolvePrivateTempDir();
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const stat = assertSafeTempDir(dir);
const validated = { dir, identity: { dev: stat.dev, ino: stat.ino } };
writeTempDirPreference(null);
rebindTempRoot(null, validated);
return dir;
}
function getCustomTempDir() { return customTempDir; }
function getDefaultTempDir() { return resolvePrivateTempDir(); }
/**
* Get the Netcatty temp directory path
* Creates the directory if it doesn't exist
@@ -318,13 +430,19 @@ function getTempDir() {
}
}
const netcattyTempDir = resolvePrivateTempDir();
const netcattyTempDir = customTempDir || resolvePrivateTempDir();
try {
if (!fs.existsSync(netcattyTempDir)) {
fs.mkdirSync(netcattyTempDir, { recursive: true, mode: 0o700 });
console.log(`[TempDir] Created Netcatty temp directory: ${netcattyTempDir}`);
}
if (customTempDir) {
const validated = validateTempRoot(customTempDir);
cachedTempDir = validated.dir;
cachedTempDirIdentity = validated.identity;
return validated.dir;
}
const safeStat = assertSafeTempDir(netcattyTempDir);
cachedTempDir = netcattyTempDir;
cachedTempDirIdentity = { dev: safeStat.dev, ino: safeStat.ino };
@@ -377,6 +495,7 @@ async function getTempDirInfo() {
let fileCount = 0;
for (const file of files) {
if (file === MANAGED_TEMP_MARKER) continue;
if (file === TOOL_OUTPUT_SIGNING_KEY_FILE) continue;
try {
const filePath = path.join(tempDir, file);
@@ -392,6 +511,8 @@ async function getTempDirInfo() {
return {
path: tempDir,
isCustom: Boolean(customTempDir),
defaultPath: resolvePrivateTempDir(),
totalSize,
fileCount,
};
@@ -399,6 +520,8 @@ async function getTempDirInfo() {
console.error(`[TempDir] Failed to get temp dir info:`, err.message);
return {
path: tempDir,
isCustom: Boolean(customTempDir),
defaultPath: resolvePrivateTempDir(),
totalSize: 0,
fileCount: 0,
};
@@ -420,6 +543,7 @@ async function clearTempDir() {
const files = await fs.promises.readdir(tempDir);
for (const file of files) {
if (file === MANAGED_TEMP_MARKER) continue;
if (file === TOOL_OUTPUT_SIGNING_KEY_FILE && !resetUnavailableSigningKey) continue;
try {
const filePath = path.join(tempDir, file);
@@ -1004,13 +1128,26 @@ async function readToolOutputChunk(content, request) {
/**
* Register IPC handlers
*/
function registerHandlers(ipcMain, shell, electronModule) {
function registerHandlers(ipcMain, shell, electronModule, terminalWorkerManager) {
configureToolOutputSigningKey(electronModule);
void cleanupExpiredToolOutputFiles();
ipcMain.handle("netcatty:tempdir:getInfo", async () => {
return getTempDirInfo();
});
ipcMain.handle("netcatty:tempdir:choose", async () => {
const result = await electronModule.dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });
return result.canceled ? null : (result.filePaths[0] || null);
});
ipcMain.handle("netcatty:tempdir:setCustom", (_event, dir) => {
try { const result = setCustomTempDir(dir); terminalWorkerManager?.setCustomTempDir(result); return { ok: true, path: result }; }
catch (error) { return { ok: false, error: error.message }; }
});
ipcMain.handle("netcatty:tempdir:resetCustom", () => {
try { const result = resetCustomTempDir(); terminalWorkerManager?.setCustomTempDir(null); return { ok: true, path: result }; }
catch (error) { return { ok: false, error: error.message }; }
});
ipcMain.handle("netcatty:tempdir:clear", async () => {
return clearTempDir();
});
@@ -1324,6 +1461,12 @@ function registerHandlers(ipcMain, shell, electronModule) {
module.exports = {
getTempDir,
configureTempDirPreference,
applyCustomTempDir,
setCustomTempDir,
resetCustomTempDir,
getCustomTempDir,
getDefaultTempDir,
getTempDirRebindGeneration,
ensureTempDir,
getTempDirInfo,
+82
View File
@@ -1038,3 +1038,85 @@ test("Linux basic_text and unknown backends are not treated as secure storage",
assert.equal(tempDirBridge.isSecureToolOutputStorageAvailable(storage("gnome_libsecret"), "linux"), true);
assert.equal(tempDirBridge.isSecureToolOutputStorageAvailable(storage("basic_text"), "darwin"), true);
});
test("reapplying the same custom temp root does not rebind it", () => {
const fixture = fs.mkdtempSync(tempDirBridge.getTempFilePath("pref-noop-"));
const userData = path.join(fixture, "prefs");
fs.mkdirSync(userData);
try {
tempDirBridge.configureTempDirPreference({ app: { getPath: () => userData } });
const managed = tempDirBridge.setCustomTempDir(path.join(fixture, "custom"));
const generation = tempDirBridge.getTempDirRebindGeneration();
assert.equal(tempDirBridge.setCustomTempDir(managed), managed);
assert.equal(tempDirBridge.getTempDirRebindGeneration(), generation);
assert.equal(tempDirBridge.applyCustomTempDir(managed), managed);
assert.equal(tempDirBridge.getTempDirRebindGeneration(), generation);
} finally {
tempDirBridge.configureTempDirPreference({ app: { getPath: () => fixture } });
fs.rmSync(fixture, { recursive: true, force: true });
}
});
test("custom temp preference validates, creates, persists, and resets roots", async () => {
const bridge = require("./tempDirBridge.cjs");
const fixture = fs.mkdtempSync(tempDirBridge.getTempFilePath("pref-test-"));
const userData = path.join(fixture, "prefs");
fs.mkdirSync(userData);
try {
bridge.configureTempDirPreference({ app: { getPath: () => userData } });
assert.equal(bridge.getTempDir(), bridge.resolvePrivateTempDir());
const existing = path.join(fixture, "existing");
fs.mkdirSync(existing);
fs.chmodSync(existing, 0o755);
const sibling = path.join(existing, "keep.txt");
fs.writeFileSync(sibling, "keep");
const managed = path.join(existing, "Netcatty");
bridge.setCustomTempDir(existing);
assert.equal(bridge.getTempDir(), managed);
if (process.platform !== "win32") {
assert.equal(fs.statSync(existing).mode & 0o777, 0o755);
assert.equal(fs.statSync(managed).mode & 0o777, 0o700);
}
fs.writeFileSync(path.join(managed, "staged.txt"), "staged");
await bridge.clearTempDir();
assert.equal(fs.readFileSync(sibling, "utf8"), "keep");
assert.equal(fs.existsSync(path.join(managed, "staged.txt")), false);
assert.equal(fs.existsSync(path.join(managed, ".netcatty-managed-temp")), true);
const unsafeParent = path.join(fixture, "unsafe");
const unsafe = path.join(unsafeParent, "Netcatty");
fs.mkdirSync(unsafe, { recursive: true });
fs.writeFileSync(path.join(unsafe, "user.txt"), "keep");
fs.writeFileSync(path.join(unsafeParent, "sibling.txt"), "keep");
assert.throws(() => bridge.setCustomTempDir(unsafeParent), /not empty/);
assert.equal(bridge.getTempDir(), managed);
assert.equal(fs.readFileSync(path.join(unsafe, "user.txt"), "utf8"), "keep");
assert.equal(fs.readFileSync(path.join(unsafeParent, "sibling.txt"), "utf8"), "keep");
bridge.applyCustomTempDir(managed);
assert.equal(bridge.getTempDir(), managed);
assert.equal(bridge.setCustomTempDir(managed), managed);
assert.equal((await bridge.getTempDirInfo()).isCustom, true);
const created = path.join(fixture, "new", "nested");
bridge.setCustomTempDir(created);
assert.equal(bridge.getTempDir(), path.join(created, "Netcatty"));
assert.equal(fs.statSync(created).isDirectory(), true);
const file = path.join(fixture, "file");
fs.writeFileSync(file, "x");
for (const invalid of ["", "relative", file, path.join(file, "child")]) {
assert.throws(() => bridge.setCustomTempDir(invalid));
assert.equal(bridge.getTempDir(), path.join(created, "Netcatty"));
}
if (process.platform !== "win32") {
const link = path.join(fixture, "link");
fs.symlinkSync(existing, link);
assert.throws(() => bridge.setCustomTempDir(link));
assert.equal(bridge.getTempDir(), path.join(created, "Netcatty"));
}
bridge.configureTempDirPreference({ app: { getPath: () => userData } });
assert.equal(bridge.getTempDir(), path.join(created, "Netcatty"));
bridge.resetCustomTempDir();
assert.equal(bridge.getTempDir(), bridge.resolvePrivateTempDir());
assert.equal((await bridge.getTempDirInfo()).isCustom, false);
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
});
@@ -440,6 +440,7 @@ function createTerminalWorkerManager(options = {}) {
? Math.max(1, Math.floor(Number(options.maxClosedSessionTombstones)))
: DEFAULT_MAX_CLOSED_SESSION_TOMBSTONES;
let transportIdleTtlMs = resolveInitialTransportIdleTtlMs(options);
let customTempDir = null;
function rejectAllPending(error) {
for (const { reject } of pending.values()) {
@@ -1855,6 +1856,7 @@ function createTerminalWorkerManager(options = {}) {
env: {
...process.env,
[SSH_TRANSPORT_IDLE_TTL_ENV]: String(transportIdleTtlMs),
NETCATTY_CUSTOM_TEMP_DIR: customTempDir || "",
},
});
child = worker;
@@ -1868,6 +1870,16 @@ function createTerminalWorkerManager(options = {}) {
return worker;
}
function setCustomTempDir(value) {
customTempDir = value || null;
if (!child) return;
try {
child.postMessage({ kind: "set-custom-temp-dir", value: customTempDir });
} catch (error) {
retireWorkerAfterIpcFailure(child, error);
}
}
function setDefaultTransportIdleTtlMs(value) {
if (!Number.isFinite(value) || value < 0) return transportIdleTtlMs;
const normalized = Number(value);
@@ -2215,6 +2227,7 @@ function createTerminalWorkerManager(options = {}) {
return {
ensureStarted,
setCustomTempDir,
/** True when the utilityProcess child is currently alive. */
isRunning() {
return Boolean(child);
@@ -5283,3 +5283,26 @@ test("backend-assigned session ID is closed if its renderer disappeared before t
await assert.rejects(start, /closed before its output route opened/);
assert.deepEqual(f.closes().map(m => m.payload), [{ sessionId: "assigned" }]);
});
test("custom temp directory is seeded and delivered to the worker", () => {
const child = new FakeChild();
let env;
const manager = createTerminalWorkerManager({
utilityProcess: { fork(_script, _args, options) { env = options.env; return child; } },
workerScriptPath: "/worker.cjs",
});
manager.setCustomTempDir("/custom/Netcatty");
manager.ensureStarted();
assert.equal(env.NETCATTY_CUSTOM_TEMP_DIR, "/custom/Netcatty");
manager.setCustomTempDir(null);
assert.deepEqual(child.messages.at(-1), { kind: "set-custom-temp-dir", value: null });
const parentPort = new EventEmitter();
parentPort.postMessage = () => {};
const values = [];
const runtime = createTerminalWorkerRuntime({ parentPort, registerBridges() {}, applyCustomTempDir: value => values.push(value) });
runtime.start();
parentPort.emit("message", { kind: "set-custom-temp-dir", value: "/custom/Netcatty" });
parentPort.emit("message", { kind: "set-custom-temp-dir", value: null });
assert.deepEqual(values, ["/custom/Netcatty", null]);
});
+3 -1
View File
@@ -464,7 +464,9 @@ function createBridgeRegistrar(context) {
});
// Initialize temp directory (synchronously)
tempDirBridge.configureTempDirPreference?.(electronModule);
tempDirBridge.ensureTempDir();
terminalWorkerManager?.setCustomTempDir(tempDirBridge.getCustomTempDir());
// Register all IPC handlers
sshBridge.registerHandlers(ipcMain, {
@@ -533,7 +535,7 @@ function createBridgeRegistrar(context) {
onedriveAuthBridge.registerHandlers(ipcMain, electronModule);
cloudSyncBridge.registerHandlers(ipcMain, electronModule);
fileWatcherBridge.registerHandlers(ipcMain, { terminalWorkerManager });
tempDirBridge.registerHandlers(ipcMain, shell, electronModule);
tempDirBridge.registerHandlers(ipcMain, shell, electronModule, terminalWorkerManager);
sessionLogsBridge.registerHandlers(ipcMain, { terminalWorkerManager });
compressUploadBridge.registerHandlers(ipcMain, { terminalWorkerManager });
globalShortcutBridge.registerHandlers(ipcMain);
+3
View File
@@ -1717,6 +1717,9 @@ function createPreloadApi(ctx) {
// Temp directory management
getTempDirInfo: () =>
ipcRenderer.invoke("netcatty:tempdir:getInfo"),
chooseTempDir: () => ipcRenderer.invoke("netcatty:tempdir:choose"),
setCustomTempDir: (dir) => ipcRenderer.invoke("netcatty:tempdir:setCustom", dir),
resetCustomTempDir: () => ipcRenderer.invoke("netcatty:tempdir:resetCustom"),
clearTempDir: () =>
ipcRenderer.invoke("netcatty:tempdir:clear"),
getTempDirPath: () =>
+3
View File
@@ -5,6 +5,9 @@ const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { createTerminalWorkerRuntime } = require("./runtime.cjs");
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
if (process.env.NETCATTY_CUSTOM_TEMP_DIR) {
tempDirBridge.applyCustomTempDir(process.env.NETCATTY_CUSTOM_TEMP_DIR);
}
// The worker owns SSH sessions in the default runtime path. Install the same
// DH compatibility shim as the main process before loading ssh2-backed bridges.
+4
View File
@@ -795,6 +795,10 @@ function createTerminalWorkerRuntime(options = {}) {
if (entry?.requestId === message.requestId) entry.finish(message.ok === true);
return;
}
if (message?.kind === "set-custom-temp-dir") {
(options.applyCustomTempDir || require("../bridges/tempDirBridge.cjs").applyCustomTempDir)(message.value);
return;
}
if (message?.kind === "set-ssh-transport-idle-ttl") {
setDefaultTransportIdleTtlMs(message.value);
return;
+4 -1
View File
@@ -57,7 +57,10 @@ declare global {
openCrashLogsDir?(): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number; isCustom: boolean; defaultPath: string }>;
chooseTempDir?(): Promise<string | null>;
setCustomTempDir?(dir: string): Promise<{ ok: boolean; path?: string; error?: string }>;
resetCustomTempDir?(): Promise<{ ok: boolean; path?: string; error?: string }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
getTempDirPath?(): Promise<string>;
openTempDir?(): Promise<{ success: boolean }>;