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:
16 files changed
+361
-9
No files matched your search
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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': 'Проверка...',
|
||||
|
||||
@@ -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': '检查中...',
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 }>;
|
||||
|
||||
Reference in new issue
Block a user