Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 98 additions & 5 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Clock from "effect/Clock";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import { beforeEach, vi } from "vite-plus/test";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as TestClock from "effect/testing/TestClock";
import { FetchHttpClient } from "effect/unstable/http";
import { afterEach, beforeEach, expect, vi } from "vite-plus/test";

const { openExternalMock, writeTextMock } = vi.hoisted(() => ({
const { openExternalMock, showItemInFolderMock, writeTextMock } = vi.hoisted(() => ({
openExternalMock: vi.fn(),
showItemInFolderMock: vi.fn(),
writeTextMock: vi.fn(),
}));

vi.mock("electron", () => ({
shell: {
openExternal: openExternalMock,
showItemInFolder: showItemInFolderMock,
},
clipboard: {
writeText: writeTextMock,
Expand All @@ -18,12 +28,27 @@ vi.mock("electron", () => ({

import * as ElectronShell from "./ElectronShell.ts";

const testLayer = Layer.unwrap(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const temporaryDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3code-electron-shell-test-",
});
return ElectronShell.layer({ temporaryDirectory });
}),
).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(FetchHttpClient.layer));

describe("ElectronShell", () => {
beforeEach(() => {
openExternalMock.mockReset();
showItemInFolderMock.mockReset();
writeTextMock.mockReset();
});

afterEach(() => {
vi.unstubAllGlobals();
});

it.effect("opens safe external URLs", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand All @@ -33,7 +58,7 @@ describe("ElectronShell", () => {

assert.equal(result, true);
assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]);
}).pipe(Effect.provide(ElectronShell.layer)),
}).pipe(Effect.provide(testLayer)),
);

it.effect("does not open unsafe external URLs", () =>
Expand All @@ -43,7 +68,7 @@ describe("ElectronShell", () => {

assert.equal(result, false);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
}).pipe(Effect.provide(testLayer)),
);

it.effect("returns false when Electron rejects openExternal", () =>
Expand All @@ -54,6 +79,74 @@ describe("ElectronShell", () => {
const result = yield* electronShell.openExternal("https://example.com/path");

assert.equal(result, false);
}).pipe(Effect.provide(ElectronShell.layer)),
}).pipe(Effect.provide(testLayer)),
);

it.effect("downloads files with spaces and unicode names before revealing them", () =>
Effect.gen(function* () {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("remote contents", { status: 200 })),
);

const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const electronShell = yield* ElectronShell.ElectronShell;
const filePath = yield* electronShell.downloadAndReveal(
"https://example.com/signed-asset",
String.raw`C:\Users\TestUser\notes 😀 with spaces.txt`,
);

expect(path.basename(filePath)).toBe("notes 😀 with spaces.txt");
expect(yield* fileSystem.readFileString(filePath)).toBe("remote contents");
expect(showItemInFolderMock).toHaveBeenCalledExactlyOnceWith(filePath);
yield* fileSystem.remove(path.dirname(filePath), { recursive: true, force: true });
}).pipe(Effect.provide(testLayer)),
);

it.effect("rejects unsafe download URLs", () =>
Effect.gen(function* () {
const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* Effect.exit(
electronShell.downloadAndReveal("file:///etc/passwd", "passwd"),
);

expect(result._tag).toBe("Failure");
expect(showItemInFolderMock).not.toHaveBeenCalled();
}).pipe(Effect.provide(testLayer)),
);

it.effect("removes only remote-file download directories older than three days", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const temporaryDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3code-electron-shell-cleanup-test-",
});
const staleDirectory = path.join(temporaryDirectory, "t3code-remote-file-stale");
const freshDirectory = path.join(temporaryDirectory, "t3code-remote-file-fresh");
const unrelatedDirectory = path.join(temporaryDirectory, "other-app-stale");
yield* Effect.forEach(
[staleDirectory, freshDirectory, unrelatedDirectory],
(directory) => fileSystem.makeDirectory(directory),
{ discard: true },
);

yield* TestClock.adjust(ElectronShell.REMOTE_FILE_RETENTION_MS * 2);
const nowMillis = yield* Clock.currentTimeMillis;
const staleTimestamp = nowMillis - ElectronShell.REMOTE_FILE_RETENTION_MS - 1;
const freshTimestamp = nowMillis - ElectronShell.REMOTE_FILE_RETENTION_MS + 1_000;
const staleDate = DateTime.toDateUtc(DateTime.makeUnsafe(staleTimestamp));
const freshDate = DateTime.toDateUtc(DateTime.makeUnsafe(freshTimestamp));
yield* fileSystem.utimes(staleDirectory, staleDate, staleDate);
yield* fileSystem.utimes(freshDirectory, freshDate, freshDate);
yield* fileSystem.utimes(unrelatedDirectory, staleDate, staleDate);

yield* ElectronShell.cleanupStaleRemoteFileDownloads(temporaryDirectory, nowMillis);

expect(yield* fileSystem.exists(staleDirectory)).toBe(false);
expect(yield* fileSystem.exists(freshDirectory)).toBe(true);
expect(yield* fileSystem.exists(unrelatedDirectory)).toBe(true);
}).pipe(Effect.provide(NodeServices.layer)),
);
});
195 changes: 178 additions & 17 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { HttpClient } from "effect/unstable/http";

import * as Electron from "electron";

const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]);
const REMOTE_FILE_DIRECTORY_PREFIX = "t3code-remote-file-";
export const REMOTE_FILE_RETENTION_MS = 3 * 24 * 60 * 60 * 1000;

export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
if (typeof rawUrl !== "string") {
Expand All @@ -20,30 +28,183 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
}
}

export function sanitizeDownloadFileName(rawFileName: string): string {
const baseName = (rawFileName.split(/[\\/]/).pop() ?? "").trim();
const sanitized = Array.from(baseName, (character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 || codePoint === 127 || character === ":" ? "_" : character;
}).join("");
if (sanitized === "" || sanitized === "." || sanitized === "..") {
return "download";
}
return Array.from(sanitized).slice(0, 200).join("");
}

const ElectronShellDownloadOperation = Schema.Literals([
"validate-url",
"create-temp-directory",
"download",
"reveal",
]);

export class ElectronShellDownloadError extends Schema.TaggedErrorClass<ElectronShellDownloadError>()(
"ElectronShellDownloadError",
{
operation: ElectronShellDownloadOperation,
cause: Schema.Defect(),
},
) {
override get message(): string {
switch (this.operation) {
case "validate-url":
return "The download URL is invalid.";
case "create-temp-directory":
return "Unable to create a temporary download folder.";
case "download":
return "Unable to download the remote file.";
case "reveal":
return "The file was downloaded, but Finder could not reveal it.";
}
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}

const isElectronShellDownloadError = Schema.is(ElectronShellDownloadError);

export const cleanupStaleRemoteFileDownloads = Effect.fn(
"ElectronShell.cleanupStaleRemoteFileDownloads",
)(function* (temporaryDirectory: string, nowMillis: number) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const entries = yield* fileSystem.readDirectory(temporaryDirectory);

yield* Effect.forEach(
entries,
(entry) => {
if (!entry.startsWith(REMOTE_FILE_DIRECTORY_PREFIX)) {
return Effect.void;
}
const candidate = path.join(temporaryDirectory, entry);
return fileSystem.stat(candidate).pipe(
Effect.flatMap((info) => {
if (info.type !== "Directory" || Option.isNone(info.mtime)) {
return Effect.void;
}
const ageMillis = nowMillis - info.mtime.value.getTime();
return ageMillis > REMOTE_FILE_RETENTION_MS
? fileSystem.remove(candidate, { recursive: true, force: true })
: Effect.void;
}),
Effect.catch((cause) =>
Effect.logWarning("Failed to inspect or remove a stale remote-file download.", {
candidate,
cause,
}),
),
);
},
{ concurrency: 4, discard: true },
);
});

export class ElectronShell extends Context.Service<
ElectronShell,
{
readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
readonly downloadAndReveal: (
rawUrl: unknown,
fileName: string,
) => Effect.Effect<string, ElectronShellDownloadError>;
readonly copyText: (text: string) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronShell") {}

export const make = ElectronShell.of({
openExternal: (rawUrl) =>
Option.match(parseSafeExternalUrl(rawUrl), {
onNone: () => Effect.succeed(false),
onSome: (externalUrl) =>
Effect.promise(() =>
Electron.shell.openExternal(externalUrl).then(
() => true,
() => false,
),
export const layer = (options: { readonly temporaryDirectory: string }) =>
Layer.effect(
ElectronShell,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
const nowMillis = yield* Clock.currentTimeMillis;

yield* cleanupStaleRemoteFileDownloads(options.temporaryDirectory, nowMillis).pipe(
Effect.catch((cause) =>
Effect.logWarning("Failed to clean stale remote-file downloads.", { cause }),
),
}),
copyText: (text) =>
Effect.sync(() => {
Electron.clipboard.writeText(text);
}),
});
);

export const layer = Layer.succeed(ElectronShell, make);
const downloadAndReveal = Effect.fn("ElectronShell.downloadAndReveal")(function* (
rawUrl: unknown,
rawFileName: string,
) {
const url = Option.getOrElse(parseSafeExternalUrl(rawUrl), () => "");
if (url === "") {
return yield* new ElectronShellDownloadError({
operation: "validate-url",
cause: rawUrl,
});
}

const directory = yield* fileSystem
.makeTempDirectory({
directory: options.temporaryDirectory,
prefix: REMOTE_FILE_DIRECTORY_PREFIX,
})
.pipe(
Effect.mapError(
(cause) =>
new ElectronShellDownloadError({
operation: "create-temp-directory",
cause,
}),
),
);
const filePath = path.join(directory, sanitizeDownloadFileName(rawFileName));

return yield* Effect.gen(function* () {
const response = yield* httpClient.get(url);
yield* Stream.run(response.stream, fileSystem.sink(filePath, { flag: "wx" }));
yield* Effect.try({
try: () => Electron.shell.showItemInFolder(filePath),
catch: (cause) =>
new ElectronShellDownloadError({
operation: "reveal",
cause,
}),
});
return filePath;
}).pipe(
Effect.mapError((cause) =>
isElectronShellDownloadError(cause)
? cause
: new ElectronShellDownloadError({
operation: "download",
cause,
}),
),
Effect.tapError(() =>
fileSystem.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
),
);
});

return ElectronShell.of({
openExternal: (rawUrl) =>
Option.match(parseSafeExternalUrl(rawUrl), {
onNone: () => Effect.succeed(false),
onSome: (externalUrl) =>
Effect.promise(() =>
Electron.shell.openExternal(externalUrl).then(
() => true,
() => false,
),
),
}),
downloadAndReveal,
copyText: (text) =>
Effect.sync(() => {
Electron.clipboard.writeText(text);
}),
});
}),
);
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "./methods/updates.ts";
import {
confirm,
downloadAndReveal,
getAppBranding,
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
Expand Down Expand Up @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(downloadAndReveal);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
yield* ipc.handle(downloadUpdate);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const CONFIRM_CHANNEL = "desktop:confirm";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const DOWNLOAD_AND_REVEAL_CHANNEL = "desktop:download-and-reveal";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
Expand Down
Loading
Loading