|
1 | | -import { readFile } from 'node:fs/promises' |
2 | | -import { readdir, stat } from 'node:fs/promises' |
| 1 | +import { lstat, mkdir, open, readdir, readFile, rename, rm, stat, unlink } from 'node:fs/promises' |
3 | 2 | import { homedir } from 'node:os' |
4 | | -import { isAbsolute, join, resolve } from 'node:path' |
| 3 | +import { isAbsolute, join, posix, resolve } from 'node:path' |
5 | 4 | import { ipcMain, shell, type IpcMainInvokeEvent } from 'electron' |
6 | | -import { probeConnection, parseSshConfig, sshConfigConnections } from '@diskpush/ssh-core' |
| 5 | +import { probeConnection, parseSshConfig, sshConfigConnections, type SftpBrowser } from '@diskpush/ssh-core' |
7 | 6 | import { z } from 'zod' |
8 | 7 | import { |
9 | 8 | ConnectionInputSchema, |
| 9 | + CreateEntryRequestSchema, |
| 10 | + DeleteEntryRequestSchema, |
10 | 11 | ExternalUrlSchema, |
11 | 12 | IPC, |
12 | 13 | JobIdSchema, |
13 | 14 | PathSchema, |
14 | 15 | RemotePathRequestSchema, |
15 | | - RenameRequestSchema, |
| 16 | + RenameEntryRequestSchema, |
16 | 17 | TransferRequestSchema, |
17 | 18 | type IpcResult, |
18 | 19 | } from '../shared/contract.js' |
@@ -51,6 +52,32 @@ function resolveLocalPath(input: string): string { |
51 | 52 | return isAbsolute(expanded) ? expanded : resolve(expanded) |
52 | 53 | } |
53 | 54 |
|
| 55 | +/** Whether a path exists, without making the caller catch ENOENT. */ |
| 56 | +async function exists(path: string): Promise<boolean> { |
| 57 | + try { |
| 58 | + await lstat(path) |
| 59 | + return true |
| 60 | + } catch { |
| 61 | + return false |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * Runs `fn` against an SFTP browser for `connectionId` and always closes it. |
| 67 | + * |
| 68 | + * Every remote mutation had its own copy of this, and a `finally` that is |
| 69 | + * written six times is a `finally` that eventually is not. |
| 70 | + */ |
| 71 | +async function withBrowser<T>(connectionId: string | undefined, fn: (browser: SftpBrowser) => Promise<T>): Promise<T> { |
| 72 | + if (!connectionId) throw new Error('That operation needs a server.') |
| 73 | + const browser = await browserFor(await requireConnection(connectionId)) |
| 74 | + try { |
| 75 | + return await fn(browser) |
| 76 | + } finally { |
| 77 | + browser.close() |
| 78 | + } |
| 79 | +} |
| 80 | + |
54 | 81 | export function registerIpc(): void { |
55 | 82 | // --- connections --------------------------------------------------------- |
56 | 83 |
|
@@ -164,42 +191,87 @@ export function registerIpc(): void { |
164 | 191 | } |
165 | 192 | }) |
166 | 193 |
|
167 | | - handle(IPC.fsMkdirRemote, RemotePathRequestSchema, async ({ connectionId, path }) => { |
168 | | - const connection = await requireConnection(connectionId) |
169 | | - const browser = await browserFor(connection) |
170 | | - try { |
171 | | - await browser.mkdir(path) |
172 | | - return true |
173 | | - } finally { |
174 | | - browser.close() |
175 | | - } |
| 194 | + /** |
| 195 | + * The mutating operations, local and remote. |
| 196 | + * |
| 197 | + * Each takes a directory and a bare entry name and joins them here, so the |
| 198 | + * renderer names a thing inside the folder it is showing rather than handing |
| 199 | + * the main process a path to act on. `resolveLocalPath` still expands `~`, |
| 200 | + * but it is applied to the directory only. |
| 201 | + */ |
| 202 | + handle(IPC.fsMkdirLocal, CreateEntryRequestSchema, async ({ directory, name }) => { |
| 203 | + await mkdir(join(resolveLocalPath(directory), name)) |
| 204 | + return true |
176 | 205 | }) |
177 | 206 |
|
178 | | - handle(IPC.fsRenameRemote, RenameRequestSchema, async ({ connectionId, from, to }) => { |
179 | | - const connection = await requireConnection(connectionId) |
180 | | - const browser = await browserFor(connection) |
181 | | - try { |
182 | | - await browser.rename(from, to) |
| 207 | + handle(IPC.fsCreateFileLocal, CreateEntryRequestSchema, async ({ directory, name }) => { |
| 208 | + // `wx` fails when the file exists rather than truncating it. |
| 209 | + const handle = await open(join(resolveLocalPath(directory), name), 'wx') |
| 210 | + await handle.close() |
| 211 | + return true |
| 212 | + }) |
| 213 | + |
| 214 | + handle(IPC.fsRenameLocal, RenameEntryRequestSchema, async ({ directory, from, to }) => { |
| 215 | + const root = resolveLocalPath(directory) |
| 216 | + const target = join(root, to) |
| 217 | + // Renaming onto an existing name silently destroys it, so refuse. There is |
| 218 | + // an unavoidable race here; it narrows a footgun rather than closing it. |
| 219 | + if (await exists(target)) throw new Error(`“${to}” already exists here.`) |
| 220 | + await rename(join(root, from), target) |
| 221 | + return true |
| 222 | + }) |
| 223 | + |
| 224 | + handle(IPC.fsDeleteLocal, DeleteEntryRequestSchema, async ({ directory, name, isDirectory }) => { |
| 225 | + const target = join(resolveLocalPath(directory), name) |
| 226 | + const stats = await lstat(target) |
| 227 | + // A symlink to a directory reports as a directory to the caller; deleting |
| 228 | + // it must still unlink the link rather than recurse into what it points at. |
| 229 | + if (stats.isSymbolicLink()) { |
| 230 | + await unlink(target) |
183 | 231 | return true |
184 | | - } finally { |
185 | | - browser.close() |
186 | 232 | } |
| 233 | + if (stats.isDirectory() !== isDirectory) throw new Error('That item changed on disk; refresh and try again.') |
| 234 | + if (isDirectory) await rm(target, { recursive: true }) |
| 235 | + else await unlink(target) |
| 236 | + return true |
187 | 237 | }) |
188 | 238 |
|
189 | | - handle( |
190 | | - IPC.fsDeleteRemote, |
191 | | - z.object({ connectionId: z.string().min(1), path: PathSchema, isDirectory: z.boolean() }), |
192 | | - async ({ connectionId, path, isDirectory }) => { |
193 | | - const connection = await requireConnection(connectionId) |
194 | | - const browser = await browserFor(connection) |
195 | | - try { |
196 | | - if (isDirectory) await browser.rmdir(path) |
197 | | - else await browser.unlink(path) |
| 239 | + handle(IPC.fsMkdirRemote, CreateEntryRequestSchema, async ({ connectionId, directory, name }) => |
| 240 | + withBrowser(connectionId, async (browser) => { |
| 241 | + await browser.mkdir(posix.join(directory, name)) |
| 242 | + return true |
| 243 | + }), |
| 244 | + ) |
| 245 | + |
| 246 | + handle(IPC.fsCreateFileRemote, CreateEntryRequestSchema, async ({ connectionId, directory, name }) => |
| 247 | + withBrowser(connectionId, async (browser) => { |
| 248 | + await browser.createFile(posix.join(directory, name)) |
| 249 | + return true |
| 250 | + }), |
| 251 | + ) |
| 252 | + |
| 253 | + handle(IPC.fsRenameRemote, RenameEntryRequestSchema, async ({ connectionId, directory, from, to }) => |
| 254 | + withBrowser(connectionId, async (browser) => { |
| 255 | + await browser.rename(posix.join(directory, from), posix.join(directory, to)) |
| 256 | + return true |
| 257 | + }), |
| 258 | + ) |
| 259 | + |
| 260 | + handle(IPC.fsDeleteRemote, DeleteEntryRequestSchema, async ({ connectionId, directory, name, isDirectory }) => |
| 261 | + withBrowser(connectionId, async (browser) => { |
| 262 | + const target = posix.join(directory, name) |
| 263 | + const stats = await browser.stat(target) |
| 264 | + if (stats.type === 'symlink') { |
| 265 | + await browser.unlink(target) |
198 | 266 | return true |
199 | | - } finally { |
200 | | - browser.close() |
201 | 267 | } |
202 | | - }, |
| 268 | + if ((stats.type === 'directory') !== isDirectory) { |
| 269 | + throw new Error('That item changed on the server; refresh and try again.') |
| 270 | + } |
| 271 | + if (isDirectory) await browser.removeRecursive(target) |
| 272 | + else await browser.unlink(target) |
| 273 | + return true |
| 274 | + }), |
203 | 275 | ) |
204 | 276 |
|
205 | 277 | // --- transfers ----------------------------------------------------------- |
|
0 commit comments