authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-16 13:28:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 13:28:28-07:00
logd1a644897119300688cbd8bf26e0b64aeb92c382
tree64ec7e49d0db70f8aa4a01dda30d8b2eaec2f1a1
parenteff44be433dc9683da7764a20334598e47b127b2
signature Signed by PGP key B5690EEEBB952194

fix(web): dropped folders become path chips on the local environment and are refused on remote ones (#12001)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

12 files changed, 228 insertions(+), 4 deletions(-)

apps/desktop/scripts/verify-preload-bundle.mjs+4
...@@ -9,6 +9,7 @@ import { parse } from "acorn";...@@ -9,6 +9,7 @@ import { parse } from "acorn";
9const expectedDesktopBridgeApis = [9const expectedDesktopBridgeApis = [
10 "getClientPlatform",10 "getClientPlatform",
11 "getLocalEnvironmentBootstraps",11 "getLocalEnvironmentBootstraps",
12 "getPathForFile",
12 "pickFolder",13 "pickFolder",
13];14];
14const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";15const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";
...@@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => {...@@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => {
68 exposeInMainWorld: (name, api) => exposedGlobals.set(name, api),69 exposeInMainWorld: (name, api) => exposedGlobals.set(name, api),
69 },70 },
70 ipcRenderer,71 ipcRenderer,
72 webUtils: {
73 getPathForFile: () => "",
74 },
71 };75 };
7276
73 return new Map([77 return new Map([
apps/desktop/scripts/verify-preload-bundle.test.mjs+1
...@@ -9,6 +9,7 @@ const validPreload = `...@@ -9,6 +9,7 @@ const validPreload = `
9 electron.contextBridge.exposeInMainWorld("desktopBridge", {9 electron.contextBridge.exposeInMainWorld("desktopBridge", {
10 getClientPlatform: () => process.platform,10 getClientPlatform: () => process.platform,
11 getLocalEnvironmentBootstraps: () => [],11 getLocalEnvironmentBootstraps: () => [],
12 getPathForFile: () => "",
12 pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),13 pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
13 });14 });
14`;15`;
apps/desktop/src/preload.ts+2-1
...@@ -6,7 +6,7 @@ import type {...@@ -6,7 +6,7 @@ import type {
6 DesktopSnapShotEvent,6 DesktopSnapShotEvent,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { exposeClerkBridge } from "@clerk/electron/preload";8import { exposeClerkBridge } from "@clerk/electron/preload";
9import { contextBridge, ipcRenderer, webFrame } from "electron";9import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron";
1010
11import * as IpcChannels from "./ipc/channels.ts";11import * as IpcChannels from "./ipc/channels.ts";
1212
...@@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
69 }69 }
70 return result as ReturnType<DesktopBridge["getAppBranding"]>;70 return result as ReturnType<DesktopBridge["getAppBranding"]>;
71 },71 },
72 getPathForFile: (file: File) => webUtils.getPathForFile(file),
72 getClientPlatform: () => clientPlatform,73 getClientPlatform: () => clientPlatform,
73 setNotificationBadge: (badge) =>74 setNotificationBadge: (badge) =>
74 ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),75 ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),
apps/web/src/components/ChatView.tsx+1
...@@ -9318,6 +9318,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9318,6 +9318,7 @@ export default function ChatView(props: ChatViewProps) {
9318 const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({9318 const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({
9319 setDragActive: setIsWorkspaceFileDragActive,9319 setDragActive: setIsWorkspaceFileDragActive,
9320 addFiles: (files) => composerRef.current?.addDroppedFiles(files),9320 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
9321 addFolders: (folders) => composerRef.current?.addDroppedFolders(folders),
9321 });9322 });
93229323
9323 return (9324 return (
apps/web/src/components/LegacySidebar.tsx+1
...@@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
391 addFiles: (files) => {391 addFiles: (files) => {
392 onFileDropThreads(threadRef, files);392 onFileDropThreads(threadRef, files);
393 },393 },
394 addFolders: () => {},
394 }),395 }),
395 [onFileDropThreads, threadRef],396 [onFileDropThreads, threadRef],
396 );397 );
apps/web/src/components/Sidebar.tsx+2
...@@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1278 addFiles: (files) => {1278 addFiles: (files) => {
1279 onFileDropThreads(threadRef, files);1279 onFileDropThreads(threadRef, files);
1280 },1280 },
1281 addFolders: () => {},
1281 })1282 })
1282 : null,1283 : null,
1283 [onFileDropThreads, threadRef],1284 [onFileDropThreads, threadRef],
...@@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {...@@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
2057 addFiles: (files) => {2058 addFiles: (files) => {
2058 props.onFileDropThreads(threadRef, files);2059 props.onFileDropThreads(threadRef, files);
2059 },2060 },
2061 addFolders: () => {},
2060 }),2062 }),
2061 [props.onFileDropThreads, threadRef],2063 [props.onFileDropThreads, threadRef],
2062 );2064 );
apps/web/src/components/chat/ChatComposer.tsx+36
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";1import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
2import { isLocalEnvironmentDisabled } from "../../localEnvironment";
3import { usePrimaryEnvironmentId } from "../../state/environments";
2import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";4import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
3import { useRightPanelStore } from "~/rightPanelStore";5import { useRightPanelStore } from "~/rightPanelStore";
4import { AttachmentFilePreview } from "../files/AttachmentFilePreview";6import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
...@@ -47,6 +49,7 @@ import {...@@ -47,6 +49,7 @@ import {
47 wouldTextPasteExceedLimit,49 wouldTextPasteExceedLimit,
48} from "@t3tools/client-runtime/text-paste";50} from "@t3tools/client-runtime/text-paste";
49import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";51import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
52import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";
50import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";53import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
51import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";54import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
52import {55import {
...@@ -1220,6 +1223,7 @@ export interface ChatComposerHandle {...@@ -1220,6 +1223,7 @@ export interface ChatComposerHandle {
1220 restoreAfterTimelineReachedEnd: () => void;1223 restoreAfterTimelineReachedEnd: () => void;
1221 collapseForTimelineScrollKey: (key: string) => void;1224 collapseForTimelineScrollKey: (key: string) => void;
1222 addDroppedFiles: (files: File[]) => void;1225 addDroppedFiles: (files: File[]) => void;
1226 addDroppedFolders: (folders: File[]) => void;
1223 hasPendingAttachments: () => boolean;1227 hasPendingAttachments: () => boolean;
1224 insertTextAtEnd: (1228 insertTextAtEnd: (
1225 text: string,1229 text: string,
...@@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1525 onExpandImage,1529 onExpandImage,
1526 onFileOpen,1530 onFileOpen,
1527 } = props;1531 } = props;
1532 const primaryEnvironmentId = usePrimaryEnvironmentId();
1528 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;1533 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
1529 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;1534 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
1530 // ------------------------------------------------------------------1535 // ------------------------------------------------------------------
...@@ -5715,6 +5720,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5715,6 +5720,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5715 if (!inserted) focusComposer();5720 if (!inserted) focusComposer();
5716 });5721 });
5717 },5722 },
5723 addDroppedFolders: (folders: File[]) => {
5724 const target = folderDropTarget({
5725 localEnvironmentDisabled: isLocalEnvironmentDisabled(),
5726 environmentId,
5727 primaryEnvironmentId,
5728 });
5729 if (target === "remote") {
5730 toastManager.add({
5731 type: "error",
5732 title: "Folders can't be dropped into remote environments",
5733 });
5734 return;
5735 }
5736 for (const folder of folders) {
5737 const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile);
5738 if (path === null) {
5739 toastManager.add({
5740 type: "error",
5741 title: `Couldn't get the path of "${folder.name}"`,
5742 description: "Type the folder path with @ instead.",
5743 });
5744 continue;
5745 }
5746 insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, {
5747 ensureLeadingBoundary: true,
5748 });
5749 }
5750 focusComposer();
5751 },
5718 hasPendingAttachments: () =>5752 hasPendingAttachments: () =>
5719 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0,5753 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0,
5720 insertTextAtEnd: insertComposerTextAtEnd,5754 insertTextAtEnd: insertComposerTextAtEnd,
...@@ -5871,6 +5905,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5871,6 +5905,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5871 composerPreviewAnnotations,5905 composerPreviewAnnotations,
5872 composerReviewComments,5906 composerReviewComments,
5873 focusComposer,5907 focusComposer,
5908 environmentId,
5909 primaryEnvironmentId,
5874 isConnecting,5910 isConnecting,
5875 isComposerApprovalState,5911 isComposerApprovalState,
5876 isChoiceOnlyPendingQuestion,5912 isChoiceOnlyPendingQuestion,
apps/web/src/components/chat/folderDrop.test.ts created+66
...@@ -0,0 +1,66 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";
4
5const environmentId = EnvironmentId.make("environment-1");
6
7describe("folderDropTarget", () => {
8 it("targets local when the thread is on the primary environment", () => {
9 expect(
10 folderDropTarget({
11 localEnvironmentDisabled: false,
12 environmentId,
13 primaryEnvironmentId: environmentId,
14 }),
15 ).toBe("local");
16 });
17
18 it("targets remote when Electron has no local environment", () => {
19 expect(
20 folderDropTarget({
21 localEnvironmentDisabled: true,
22 environmentId,
23 primaryEnvironmentId: environmentId,
24 }),
25 ).toBe("remote");
26 });
27
28 it("targets remote when the thread lives on another environment", () => {
29 expect(
30 folderDropTarget({
31 localEnvironmentDisabled: false,
32 environmentId: EnvironmentId.make("environment-2"),
33 primaryEnvironmentId: environmentId,
34 }),
35 ).toBe("remote");
36 });
37
38 it("targets remote when no primary environment is known", () => {
39 expect(
40 folderDropTarget({
41 localEnvironmentDisabled: false,
42 environmentId,
43 primaryEnvironmentId: null,
44 }),
45 ).toBe("remote");
46 });
47});
48
49describe("resolveDroppedFolderPath", () => {
50 it("returns the native path when the bridge provides it", () => {
51 const folder = new File([], "contracts");
52 expect(resolveDroppedFolderPath(folder, () => "/tmp/project/contracts")).toBe(
53 "/tmp/project/contracts",
54 );
55 });
56
57 it("returns null for an outside-folder drop even when the folder name matches a project directory", () => {
58 const folder = new File([], "contracts");
59 expect(resolveDroppedFolderPath(folder, undefined)).toBeNull();
60 });
61
62 it("returns null when the bridge returns an empty path", () => {
63 const folder = new File([], "contracts");
64 expect(resolveDroppedFolderPath(folder, () => "")).toBeNull();
65 });
66});
apps/web/src/components/chat/folderDrop.ts created+24
...@@ -0,0 +1,24 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3export function folderDropTarget(input: {
4 localEnvironmentDisabled: boolean;
5 environmentId: EnvironmentId;
6 primaryEnvironmentId: EnvironmentId | null;
7}): "local" | "remote" {
8 if (
9 input.localEnvironmentDisabled ||
10 input.primaryEnvironmentId === null ||
11 input.environmentId !== input.primaryEnvironmentId
12 ) {
13 return "remote";
14 }
15 return "local";
16}
17
18export function resolveDroppedFolderPath(
19 folder: File,
20 getPathForFile: ((file: File) => string) | undefined,
21): string | null {
22 const path = getPathForFile?.(folder);
23 return typeof path === "string" && path.length > 0 ? path : null;
24}
apps/web/src/components/chat/workspaceFileDrop.test.ts+58-2
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8function makeDragEvent(options?: {8function makeDragEvent(options?: {
9 types?: string[];9 types?: string[];
10 files?: File[];10 files?: File[];
11 items?: NonNullable<WorkspaceFileDragEvent["dataTransfer"]["items"]>;
11 movedWithinTarget?: boolean;12 movedWithinTarget?: boolean;
12}) {13}) {
13 const preventDefault = vi.fn();14 const preventDefault = vi.fn();
...@@ -16,6 +17,7 @@ function makeDragEvent(options?: {...@@ -16,6 +17,7 @@ function makeDragEvent(options?: {
16 types: options?.types ?? ["Files"],17 types: options?.types ?? ["Files"],
17 files: options?.files ?? [],18 files: options?.files ?? [],
18 dropEffect: "none",19 dropEffect: "none",
20 ...(options?.items === undefined ? {} : { items: options.items }),
19 },21 },
20 relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null,22 relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null,
21 currentTarget: {23 currentTarget: {
...@@ -29,8 +31,9 @@ function makeDragEvent(options?: {...@@ -29,8 +31,9 @@ function makeDragEvent(options?: {
29function makeHost() {31function makeHost() {
30 const setDragActive = vi.fn();32 const setDragActive = vi.fn();
31 const addFiles = vi.fn();33 const addFiles = vi.fn();
32 const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost;34 const addFolders = vi.fn();
33 return { host, setDragActive, addFiles };35 const host = { setDragActive, addFiles, addFolders } satisfies WorkspaceFileDropHost;
36 return { host, setDragActive, addFiles, addFolders };
34}37}
3538
36describe("makeWorkspaceFileDropHandlers", () => {39describe("makeWorkspaceFileDropHandlers", () => {
...@@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => {...@@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => {
75 expect(setDragActive).toHaveBeenCalledWith(false);78 expect(setDragActive).toHaveBeenCalledWith(false);
76 expect(addFiles).toHaveBeenCalledWith([file]);79 expect(addFiles).toHaveBeenCalledWith([file]);
77 });80 });
81
82 it("routes mixed drops to files and folders", () => {
83 const file = new File(["contents"], "example.txt", { type: "text/plain" });
84 const folder = new File([], "project", { type: "" });
85 const directory = {
86 kind: "file",
87 getAsFile: () => folder,
88 webkitGetAsEntry: () => ({ isDirectory: true }),
89 };
90 const { host, addFiles, addFolders } = makeHost();
91 const { event } = makeDragEvent({
92 items: [
93 directory,
94 {
95 kind: "file",
96 getAsFile: () => file,
97 webkitGetAsEntry: () => ({ isDirectory: false }),
98 },
99 ],
100 });
101
102 makeWorkspaceFileDropHandlers(host).onDrop(event);
103
104 expect(addFiles).toHaveBeenCalledWith([file]);
105 expect(addFolders).toHaveBeenCalledWith([folder]);
106 });
107
108 it("routes a folder-only drop without attaching files", () => {
109 const folder = new File([], "project", { type: "" });
110 const directory = {
111 kind: "file",
112 getAsFile: () => folder,
113 webkitGetAsEntry: () => ({ isDirectory: true }),
114 };
115 const { host, addFiles, addFolders } = makeHost();
116 const { event } = makeDragEvent({ items: [directory] });
117
118 makeWorkspaceFileDropHandlers(host).onDrop(event);
119
120 expect(addFiles).not.toHaveBeenCalled();
121 expect(addFolders).toHaveBeenCalledWith([folder]);
122 });
123
124 it("uses files when the browser does not expose drag items", () => {
125 const file = new File(["contents"], "example.txt", { type: "text/plain" });
126 const { host, addFiles, addFolders } = makeHost();
127 const { event } = makeDragEvent({ files: [file] });
128
129 makeWorkspaceFileDropHandlers(host).onDrop(event);
130
131 expect(addFiles).toHaveBeenCalledWith([file]);
132 expect(addFolders).not.toHaveBeenCalled();
133 });
78});134});
apps/web/src/components/chat/workspaceFileDrop.ts+31-1
...@@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent {...@@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent {
2 readonly dataTransfer: {2 readonly dataTransfer: {
3 readonly types: ReadonlyArray<string>;3 readonly types: ReadonlyArray<string>;
4 readonly files: Iterable<File>;4 readonly files: Iterable<File>;
5 readonly items?: Iterable<{
6 readonly kind: string;
7 getAsFile(): File | null;
8 webkitGetAsEntry(): { readonly isDirectory: boolean } | null;
9 }>;
5 dropEffect: string;10 dropEffect: string;
6 };11 };
7 readonly relatedTarget: EventTarget | null;12 readonly relatedTarget: EventTarget | null;
...@@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent {...@@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent {
14export interface WorkspaceFileDropHost {19export interface WorkspaceFileDropHost {
15 setDragActive(active: boolean): void;20 setDragActive(active: boolean): void;
16 addFiles(files: File[]): void;21 addFiles(files: File[]): void;
22 addFolders(folders: File[]): void;
17}23}
1824
19function isFileDrag(event: WorkspaceFileDragEvent): boolean {25function isFileDrag(event: WorkspaceFileDragEvent): boolean {
...@@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean {...@@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean {
24 return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node);30 return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node);
25}31}
2632
33function splitDroppedItems(dataTransfer: WorkspaceFileDragEvent["dataTransfer"]): {
34 files: File[];
35 folders: File[];
36} {
37 if (dataTransfer.items === undefined)
38 return { files: Array.from(dataTransfer.files), folders: [] };
39
40 const files: File[] = [];
41 const folders: File[] = [];
42 for (const item of dataTransfer.items) {
43 if (item.kind !== "file") continue;
44 const file = item.getAsFile();
45 if (file === null) continue;
46 if (item.webkitGetAsEntry()?.isDirectory === true) {
47 folders.push(file);
48 } else {
49 files.push(file);
50 }
51 }
52 return { files, folders };
53}
54
27export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) {55export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) {
28 return {56 return {
29 onDragEnter(event: WorkspaceFileDragEvent) {57 onDragEnter(event: WorkspaceFileDragEvent) {
...@@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) {...@@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) {
48 if (!isFileDrag(event)) return;76 if (!isFileDrag(event)) return;
49 event.preventDefault();77 event.preventDefault();
50 host.setDragActive(false);78 host.setDragActive(false);
51 host.addFiles(Array.from(event.dataTransfer.files));79 const { files, folders } = splitDroppedItems(event.dataTransfer);
80 if (files.length > 0) host.addFiles(files);
81 if (folders.length > 0) host.addFolders(folders);
52 },82 },
53 };83 };
54}84}
packages/contracts/src/ipc.ts+2
...@@ -1213,6 +1213,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;...@@ -1213,6 +1213,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;
12131213
1214export interface DesktopBridge {1214export interface DesktopBridge {
1215 getAppBranding: () => DesktopAppBranding | null;1215 getAppBranding: () => DesktopAppBranding | null;
1216 /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */
1217 getPathForFile?: (file: File) => string;
1216 /** The desktop client's OS platform, read from Electron's preload process. */1218 /** The desktop client's OS platform, read from Electron's preload process. */
1217 getClientPlatform?: () => string;1219 getClientPlatform?: () => string;
1218 setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>;1220 setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>;