Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
56 changes: 56 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@ import {
} from "electron";
import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import { Schema } from "effect";
import type {
DesktopSettings,
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateState,
} from "@t3tools/contracts";
import {
DesktopSettings as DesktopSettingsSchema,
DesktopSettingsInput as DesktopSettingsInputSchema,
DesktopSettingsSchemaVersion,
} from "@t3tools/contracts";
import { autoUpdater } from "electron-updater";

import type { ContextMenuItem } from "@t3tools/contracts";
Expand Down Expand Up @@ -49,6 +56,8 @@ syncShellEnvironment();
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const GET_INITIAL_SETTINGS_CHANNEL = "desktop:get-initial-settings";
const SET_SETTINGS_CHANNEL = "desktop:set-settings";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
Expand All @@ -70,6 +79,7 @@ const COMMIT_HASH_DISPLAY_LENGTH = 12;
const LOG_DIR = Path.join(STATE_DIR, "logs");
const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
const LOG_FILE_MAX_FILES = 10;
const SETTINGS_FILE_NAME = "settings.json";
const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
Expand Down Expand Up @@ -109,6 +119,42 @@ function logScope(scope: string): string {
return `${scope} run=${APP_RUN_ID}`;
}

function getSettingsPath(): string {
return Path.join(STATE_DIR, SETTINGS_FILE_NAME);
}

function decodeSettingsFile(raw: string): DesktopSettings {
return Schema.decodeSync(Schema.fromJsonString(DesktopSettingsSchema))(raw);
}

function readSettingsFileSync(): DesktopSettings | null {
try {
const raw = FS.readFileSync(getSettingsPath(), "utf8");
return decodeSettingsFile(raw);
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
console.warn("[desktop-settings] Failed to read settings.json", error);
}
return null;
}
}

async function writeSettingsFile(rawInput: unknown): Promise<DesktopSettings> {
const input = Schema.decodeUnknownSync(DesktopSettingsInputSchema)(rawInput);
const settings: DesktopSettings = {
version: app.getVersion(),
schemaVersion: DesktopSettingsSchemaVersion,
theme: input.theme,
appSettings: input.appSettings,
};
const settingsPath = getSettingsPath();
const tempPath = `${settingsPath}.tmp`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low src/main.ts:151

Using a fixed temp file name ${settingsPath}.tmp creates a race condition when concurrent calls to writeSettingsFile occur. If two IPC SET_SETTINGS_CHANNEL calls overlap, the second write can overwrite the temp file before the first rename, causing incorrect data to be persisted. The second call's rename may also fail with ENOENT if the first call already moved the file. Consider using a unique temp file name per call (e.g., with crypto.randomUUID() or Date.now()) to ensure atomic writes.

-  const tempPath = `${settingsPath}.tmp`;
+  const tempPath = `${settingsPath}.tmp-${Date.now()}-${Crypto.randomBytes(4).toString('hex')}`;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/main.ts around line 151:

Using a fixed temp file name `${settingsPath}.tmp` creates a race condition when concurrent calls to `writeSettingsFile` occur. If two IPC `SET_SETTINGS_CHANNEL` calls overlap, the second write can overwrite the temp file before the first rename, causing incorrect data to be persisted. The second call's rename may also fail with ENOENT if the first call already moved the file. Consider using a unique temp file name per call (e.g., with `crypto.randomUUID()` or `Date.now()`) to ensure atomic writes.

Evidence trail:
apps/desktop/src/main.ts lines 141-156 at REVIEWED_COMMIT:
- Line 151: `const tempPath = `${settingsPath}.tmp`;` confirms fixed temp file name
- Line 153: `await FS.promises.writeFile(tempPath, ...)` writes to temp
- Line 154: `await FS.promises.rename(tempPath, settingsPath);` renames temp to final
- No locking mechanism present in the async function

await FS.promises.mkdir(Path.dirname(settingsPath), { recursive: true });
await FS.promises.writeFile(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
await FS.promises.rename(tempPath, settingsPath);
return settings;
}

function sanitizeLogValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
Expand Down Expand Up @@ -1106,6 +1152,16 @@ function registerIpcHandlers(): void {
nativeTheme.themeSource = theme;
});

ipcMain.removeAllListeners(GET_INITIAL_SETTINGS_CHANNEL);
ipcMain.on(GET_INITIAL_SETTINGS_CHANNEL, (event) => {
event.returnValue = readSettingsFileSync();
});

ipcMain.removeHandler(SET_SETTINGS_CHANNEL);
ipcMain.handle(SET_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) =>
writeSettingsFile(rawSettings),
);

ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
ipcMain.handle(
CONTEXT_MENU_CHANNEL,
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { DesktopBridge } from "@t3tools/contracts";
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const GET_INITIAL_SETTINGS_CHANNEL = "desktop:get-initial-settings";
const SET_SETTINGS_CHANNEL = "desktop:set-settings";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
Expand All @@ -12,12 +14,17 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
const initialSettings = ipcRenderer.sendSync(GET_INITIAL_SETTINGS_CHANNEL) as
| DesktopBridge["initialSettings"]
| undefined;

contextBridge.exposeInMainWorld("desktopBridge", {
getWsUrl: () => wsUrl,
initialSettings: initialSettings ?? null,
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
setSettings: (settings) => ipcRenderer.invoke(SET_SETTINGS_CHANNEL, settings),
showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
onMenuAction: (listener) => {
Expand Down
57 changes: 26 additions & 31 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,31 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { useCallback, useEffect } from "react";
import {
AppSettings as AppSettingsSchema,
DEFAULT_TIMESTAMP_FORMAT,
type ProviderKind,
type TimestampFormat,
} from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { getBootDesktopAppSettings, persistDesktopSettings } from "./desktopSettings";
import { useLocalStorage } from "./hooks/useLocalStorage";

const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
export const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
export const MAX_CUSTOM_MODEL_LENGTH = 256;
export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const;
export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
export { DEFAULT_TIMESTAMP_FORMAT };
export type { TimestampFormat };
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};

const AppSettingsSchema = Schema.Struct({
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
Schema.withConstructorDefault(() => Option.some("")),
),
defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe(
Schema.withConstructorDefault(() => Option.some("local")),
),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe(
Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)),
),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
});
export type AppSettings = typeof AppSettingsSchema.Type;
export interface AppModelOption {
slug: string;
name: string;
isCustom: boolean;
}

const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
export const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});

export function normalizeCustomModelSlugs(
models: Iterable<string | null | undefined>,
Expand Down Expand Up @@ -73,6 +56,14 @@ export function normalizeCustomModelSlugs(
return normalizedModels;
}

export function normalizeAppSettings(settings: AppSettings | null | undefined): AppSettings {
return {
...DEFAULT_APP_SETTINGS,
...settings,
customCodexModels: normalizeCustomModelSlugs(settings?.customCodexModels ?? [], "codex"),
};
}

export function getAppModelOptions(
provider: ProviderKind,
customModels: readonly string[],
Expand Down Expand Up @@ -145,7 +136,7 @@ export function resolveAppModelSelection(
export function useAppSettings() {
const [settings, setSettings] = useLocalStorage(
APP_SETTINGS_STORAGE_KEY,
DEFAULT_APP_SETTINGS,
getBootDesktopAppSettings() ?? DEFAULT_APP_SETTINGS,
AppSettingsSchema,
);

Expand All @@ -163,8 +154,12 @@ export function useAppSettings() {
setSettings(DEFAULT_APP_SETTINGS);
}, [setSettings]);

useEffect(() => {
void persistDesktopSettings({ appSettings: normalizeAppSettings(settings) }).catch(() => {});
}, [settings]);

return {
settings,
settings: normalizeAppSettings(settings),
updateSettings,
resetSettings,
defaults: DEFAULT_APP_SETTINGS,
Expand Down
139 changes: 139 additions & 0 deletions apps/web/src/desktopSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { DesktopSettings, DesktopSettingsInput, ThemePreference } from "@t3tools/contracts";
import {
AppSettings as AppSettingsSchema,
DesktopSettingsSchemaVersion,
ThemePreference as ThemePreferenceSchema,
} from "@t3tools/contracts";
import { Schema } from "effect";
import { APP_VERSION } from "./branding";
import { isElectron } from "./env";
import {
dispatchLocalStorageChange,
getLocalStorageItem,
setLocalStorageItem,
} from "./hooks/useLocalStorage";
import type { AppSettings } from "./appSettings";

const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const THEME_STORAGE_KEY = "t3code:theme";
const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
const DEFAULT_THEME: ThemePreference = "system";
const INITIAL_DESKTOP_SETTINGS = isElectron
? (window.desktopBridge?.initialSettings ?? null)
: null;

let desktopSettingsHydrated = false;
let lastPersistedSerialized: string | null = null;
let latestTheme: ThemePreference = DEFAULT_THEME;
let latestAppSettings: AppSettings = DEFAULT_APP_SETTINGS;

function readThemeFromLocalStorage(): ThemePreference {
const raw = localStorage.getItem(THEME_STORAGE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") {
return raw;
}
return "system";
}

function buildDesktopSettingsInput(): DesktopSettingsInput {
return {
theme: latestTheme,
appSettings: latestAppSettings,
};
}

function serializeSettingsInput(input: DesktopSettingsInput): string {
return JSON.stringify(input);
}

function hasLegacySettingsInLocalStorage(): boolean {
return (
localStorage.getItem(THEME_STORAGE_KEY) !== null ||
localStorage.getItem(APP_SETTINGS_STORAGE_KEY) !== null
);
}

function applyDesktopSettings(settings: DesktopSettings): void {
localStorage.setItem(THEME_STORAGE_KEY, settings.theme);
setLocalStorageItem(APP_SETTINGS_STORAGE_KEY, settings.appSettings, AppSettingsSchema);
dispatchLocalStorageChange(APP_SETTINGS_STORAGE_KEY);
window.dispatchEvent(new StorageEvent("storage", { key: THEME_STORAGE_KEY }));
latestTheme = settings.theme;
latestAppSettings = settings.appSettings;
lastPersistedSerialized = serializeSettingsInput({
theme: settings.theme,
appSettings: settings.appSettings,
});
}

if (INITIAL_DESKTOP_SETTINGS) {
applyDesktopSettings(INITIAL_DESKTOP_SETTINGS);
desktopSettingsHydrated = true;
}

export async function hydrateDesktopSettings(): Promise<void> {
if (!isElectron || !window.desktopBridge) {
return;
}

if (INITIAL_DESKTOP_SETTINGS) {
if (
INITIAL_DESKTOP_SETTINGS.version !== APP_VERSION ||
INITIAL_DESKTOP_SETTINGS.schemaVersion !== DesktopSettingsSchemaVersion
) {
await persistDesktopSettings({ force: true }).catch(() => {});
}
return;
}

desktopSettingsHydrated = true;
latestTheme = Schema.decodeSync(ThemePreferenceSchema)(readThemeFromLocalStorage());
latestAppSettings =
getLocalStorageItem(APP_SETTINGS_STORAGE_KEY, AppSettingsSchema) ?? DEFAULT_APP_SETTINGS;
if (!hasLegacySettingsInLocalStorage()) {
lastPersistedSerialized = serializeSettingsInput({
theme: DEFAULT_THEME,
appSettings: DEFAULT_APP_SETTINGS,
});
return;
}

await persistDesktopSettings({ force: true }).catch(() => {});
}

export async function persistDesktopSettings(options?: {
force?: boolean;
theme?: ThemePreference;
appSettings?: AppSettings;
}): Promise<void> {
if (!isElectron || !window.desktopBridge || !desktopSettingsHydrated) {
return;
}

if (options?.theme) {
latestTheme = Schema.decodeSync(ThemePreferenceSchema)(options.theme);
}
if (options?.appSettings) {
latestAppSettings = options.appSettings;
}

const next = buildDesktopSettingsInput();
const serialized = serializeSettingsInput(next);
if (!options?.force && serialized === lastPersistedSerialized) {
return;
}

const persisted = await window.desktopBridge.setSettings(next);
lastPersistedSerialized = serializeSettingsInput({
theme: persisted.theme,
appSettings: persisted.appSettings,
});
}

export function getBootDesktopTheme(): ThemePreference | null {
return desktopSettingsHydrated ? latestTheme : null;
}

export function getBootDesktopAppSettings(): AppSettings | null {
return desktopSettingsHydrated ? latestAppSettings : null;
}
4 changes: 2 additions & 2 deletions apps/web/src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ export const removeLocalStorageItem = (key: string) => {
isomorphicLocalStorage.removeItem(key);
};

const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";
export const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";

interface LocalStorageChangeDetail {
key: string;
}

function dispatchLocalStorageChange(key: string) {
export function dispatchLocalStorageChange(key: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
Expand Down
Loading