Skip to content

Commit 7a0dad0

Browse files
committed
Add automatic app update check setting
1 parent aecee91 commit 7a0dad0

11 files changed

Lines changed: 155 additions & 6 deletions

File tree

src-tauri/src/types.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,11 @@ pub(crate) struct AppSettings {
504504
pub(crate) chat_history_scrollback_items: Option<u32>,
505505
#[serde(default, rename = "threadTitleAutogenerationEnabled")]
506506
pub(crate) thread_title_autogeneration_enabled: bool,
507+
#[serde(
508+
default = "default_automatic_app_update_checks_enabled",
509+
rename = "automaticAppUpdateChecksEnabled"
510+
)]
511+
pub(crate) automatic_app_update_checks_enabled: bool,
507512
#[serde(default = "default_ui_font_family", rename = "uiFontFamily")]
508513
pub(crate) ui_font_family: String,
509514
#[serde(default = "default_code_font_family", rename = "codeFontFamily")]
@@ -710,6 +715,10 @@ fn default_chat_history_scrollback_items() -> Option<u32> {
710715
Some(200)
711716
}
712717

718+
fn default_automatic_app_update_checks_enabled() -> bool {
719+
true
720+
}
721+
713722
fn default_ui_font_family() -> String {
714723
"system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif".to_string()
715724
}
@@ -1146,6 +1155,7 @@ impl Default for AppSettings {
11461155
show_message_file_path: default_show_message_file_path(),
11471156
chat_history_scrollback_items: default_chat_history_scrollback_items(),
11481157
thread_title_autogeneration_enabled: false,
1158+
automatic_app_update_checks_enabled: true,
11491159
ui_font_family: default_ui_font_family(),
11501160
code_font_family: default_code_font_family(),
11511161
code_font_size: default_code_font_size(),
@@ -1310,6 +1320,7 @@ mod tests {
13101320
assert!(settings.show_message_file_path);
13111321
assert_eq!(settings.chat_history_scrollback_items, Some(200));
13121322
assert!(!settings.thread_title_autogeneration_enabled);
1323+
assert!(settings.automatic_app_update_checks_enabled);
13131324
assert!(settings.ui_font_family.contains("system-ui"));
13141325
assert!(settings.code_font_family.contains("ui-monospace"));
13151326
assert_eq!(settings.code_font_size, 11);

src/features/app/components/MainApp.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ export default function MainApp() {
631631
handleTestSystemNotification,
632632
} = useUpdaterController({
633633
enabled: updaterEnabled,
634+
autoCheckOnMount: appSettings.automaticAppUpdateChecksEnabled,
634635
notificationSoundsEnabled: appSettings.notificationSoundsEnabled,
635636
systemNotificationsEnabled: appSettings.systemNotificationsEnabled,
636637
subagentSystemNotificationsEnabled:

src/features/app/hooks/useUpdaterController.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { DebugEntry } from "../../../types";
1111

1212
type Params = {
1313
enabled?: boolean;
14+
autoCheckOnMount?: boolean;
1415
notificationSoundsEnabled: boolean;
1516
systemNotificationsEnabled: boolean;
1617
subagentSystemNotificationsEnabled: boolean;
@@ -24,6 +25,7 @@ type Params = {
2425

2526
export function useUpdaterController({
2627
enabled = true,
28+
autoCheckOnMount = true,
2729
notificationSoundsEnabled,
2830
systemNotificationsEnabled,
2931
subagentSystemNotificationsEnabled,
@@ -43,6 +45,7 @@ export function useUpdaterController({
4345
dismissPostUpdateNotice,
4446
} = useUpdater({
4547
enabled,
48+
autoCheckOnMount,
4649
onDebug,
4750
});
4851
const isWindowFocused = useWindowFocusState();

src/features/settings/components/SettingsView.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import { describe, expect, it, vi } from "vitest";
1313
import type { AppSettings, WorkspaceInfo } from "@/types";
1414
import {
1515
connectWorkspace,
16+
getAppBuildType,
1617
getAgentsSettings,
1718
getConfigModel,
1819
getExperimentalFeatureList,
20+
isMobileRuntime,
1921
getModelList,
2022
listWorkspaces,
2123
} from "@services/tauri";
@@ -34,22 +36,28 @@ vi.mock("@services/tauri", async () => {
3436
return {
3537
...actual,
3638
connectWorkspace: vi.fn(),
39+
getAppBuildType: vi.fn(),
3740
getModelList: vi.fn(),
3841
getConfigModel: vi.fn(),
3942
getExperimentalFeatureList: vi.fn(),
4043
getAgentsSettings: vi.fn(),
44+
isMobileRuntime: vi.fn(),
4145
listWorkspaces: vi.fn(),
4246
};
4347
});
4448

4549
const connectWorkspaceMock = vi.mocked(connectWorkspace);
50+
const getAppBuildTypeMock = vi.mocked(getAppBuildType);
4651
const getConfigModelMock = vi.mocked(getConfigModel);
4752
const getModelListMock = vi.mocked(getModelList);
4853
const getExperimentalFeatureListMock = vi.mocked(getExperimentalFeatureList);
4954
const getAgentsSettingsMock = vi.mocked(getAgentsSettings);
55+
const isMobileRuntimeMock = vi.mocked(isMobileRuntime);
5056
const listWorkspacesMock = vi.mocked(listWorkspaces);
5157
connectWorkspaceMock.mockResolvedValue(undefined);
58+
getAppBuildTypeMock.mockResolvedValue("release");
5259
getConfigModelMock.mockResolvedValue(null);
60+
isMobileRuntimeMock.mockResolvedValue(false);
5361
listWorkspacesMock.mockResolvedValue([]);
5462
getAgentsSettingsMock.mockResolvedValue({
5563
configPath: "/Users/me/.codex/config.toml",
@@ -105,6 +113,7 @@ const baseSettings: AppSettings = {
105113
showMessageFilePath: true,
106114
chatHistoryScrollbackItems: 200,
107115
threadTitleAutogenerationEnabled: false,
116+
automaticAppUpdateChecksEnabled: true,
108117
uiFontFamily:
109118
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
110119
codeFontFamily:
@@ -266,6 +275,50 @@ const renderComposerSection = (
266275
return { onUpdateAppSettings };
267276
};
268277

278+
const renderAboutSection = (
279+
options: {
280+
appSettings?: Partial<AppSettings>;
281+
onUpdateAppSettings?: ComponentProps<typeof SettingsView>["onUpdateAppSettings"];
282+
} = {},
283+
) => {
284+
cleanup();
285+
const onUpdateAppSettings =
286+
options.onUpdateAppSettings ?? vi.fn().mockResolvedValue(undefined);
287+
const props: ComponentProps<typeof SettingsView> = {
288+
reduceTransparency: false,
289+
onToggleTransparency: vi.fn(),
290+
appSettings: { ...baseSettings, ...options.appSettings },
291+
openAppIconById: {},
292+
onUpdateAppSettings,
293+
workspaceGroups: [],
294+
groupedWorkspaces: [],
295+
ungroupedLabel: "Ungrouped",
296+
onClose: vi.fn(),
297+
onMoveWorkspace: vi.fn(),
298+
onDeleteWorkspace: vi.fn(),
299+
onCreateWorkspaceGroup: vi.fn().mockResolvedValue(null),
300+
onRenameWorkspaceGroup: vi.fn().mockResolvedValue(null),
301+
onMoveWorkspaceGroup: vi.fn().mockResolvedValue(null),
302+
onDeleteWorkspaceGroup: vi.fn().mockResolvedValue(null),
303+
onAssignWorkspaceGroup: vi.fn().mockResolvedValue(null),
304+
onRunDoctor: vi.fn().mockResolvedValue(createDoctorResult()),
305+
onUpdateWorkspaceSettings: vi.fn().mockResolvedValue(undefined),
306+
scaleShortcutTitle: "Scale shortcut",
307+
scaleShortcutText: "Use Command +/-",
308+
onTestNotificationSound: vi.fn(),
309+
onTestSystemNotification: vi.fn(),
310+
dictationModelStatus: null,
311+
onDownloadDictationModel: vi.fn(),
312+
onCancelDictationDownload: vi.fn(),
313+
onRemoveDictationModel: vi.fn(),
314+
};
315+
316+
render(<SettingsView {...props} />);
317+
fireEvent.click(screen.getByRole("button", { name: "About" }));
318+
319+
return { onUpdateAppSettings };
320+
};
321+
269322
const renderFeaturesSection = (
270323
options: {
271324
appSettings?: Partial<AppSettings>;
@@ -672,6 +725,30 @@ describe("SettingsView Display", () => {
672725
});
673726
});
674727

728+
describe("SettingsView About", () => {
729+
it("toggles automatic app update checks", async () => {
730+
const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined);
731+
renderAboutSection({
732+
onUpdateAppSettings,
733+
appSettings: { automaticAppUpdateChecksEnabled: false },
734+
});
735+
736+
const row = screen
737+
.getByText("Automatically check for app updates")
738+
.closest(".settings-toggle-row") as HTMLElement | null;
739+
if (!row) {
740+
throw new Error("Expected automatic app update checks row");
741+
}
742+
fireEvent.click(within(row).getByRole("button"));
743+
744+
await waitFor(() => {
745+
expect(onUpdateAppSettings).toHaveBeenCalledWith(
746+
expect.objectContaining({ automaticAppUpdateChecksEnabled: true }),
747+
);
748+
});
749+
});
750+
});
751+
675752
describe("SettingsView Environments", () => {
676753
it("saves the setup script for the selected project", async () => {
677754
const onUpdateWorkspaceSettings = vi.fn().mockResolvedValue(undefined);

src/features/settings/components/sections/SettingsAboutSection.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import { useEffect, useState } from "react";
2+
import type { AppSettings } from "@/types";
23
import {
34
getAppBuildType,
45
isMobileRuntime,
56
type AppBuildType,
67
} from "@services/tauri";
78
import { useUpdater } from "@/features/update/hooks/useUpdater";
8-
import { SettingsSection } from "@/features/design-system/components/settings/SettingsPrimitives";
9+
import {
10+
SettingsSection,
11+
SettingsToggleRow,
12+
SettingsToggleSwitch,
13+
} from "@/features/design-system/components/settings/SettingsPrimitives";
14+
15+
type SettingsAboutSectionProps = {
16+
appSettings: AppSettings;
17+
onUpdateAppSettings: (next: AppSettings) => Promise<void>;
18+
};
919

1020
function formatBytes(value: number) {
1121
if (!Number.isFinite(value) || value <= 0) {
@@ -21,11 +31,15 @@ function formatBytes(value: number) {
2131
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
2232
}
2333

24-
export function SettingsAboutSection() {
34+
export function SettingsAboutSection({
35+
appSettings,
36+
onUpdateAppSettings,
37+
}: SettingsAboutSectionProps) {
2538
const [appBuildType, setAppBuildType] = useState<AppBuildType | "unknown">("unknown");
2639
const [updaterEnabled, setUpdaterEnabled] = useState(false);
2740
const { state: updaterState, checkForUpdates, startUpdate } = useUpdater({
2841
enabled: updaterEnabled,
42+
autoCheckOnMount: false,
2943
});
3044

3145
useEffect(() => {
@@ -96,6 +110,21 @@ export function SettingsAboutSection() {
96110
</div>
97111
<div className="settings-field">
98112
<div className="settings-label">App Updates</div>
113+
<SettingsToggleRow
114+
title="Automatically check for app updates"
115+
subtitle="When enabled, CodexMonitor checks for new app versions on launch."
116+
>
117+
<SettingsToggleSwitch
118+
pressed={appSettings.automaticAppUpdateChecksEnabled}
119+
onClick={() =>
120+
void onUpdateAppSettings({
121+
...appSettings,
122+
automaticAppUpdateChecksEnabled:
123+
!appSettings.automaticAppUpdateChecksEnabled,
124+
})
125+
}
126+
/>
127+
</SettingsToggleRow>
99128
<div className="settings-help">
100129
Currently running version <code>{__APP_VERSION__}</code>
101130
</div>

src/features/settings/components/sections/SettingsSectionContainers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function SettingsSectionContainers({
3333
return <SettingsDisplaySection {...orchestration.displaySectionProps} />;
3434
}
3535
if (activeSection === "about") {
36-
return <SettingsAboutSection />;
36+
return <SettingsAboutSection {...orchestration.aboutSectionProps} />;
3737
}
3838
if (activeSection === "composer") {
3939
return <SettingsComposerSection {...orchestration.composerSectionProps} />;

src/features/settings/hooks/useAppSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ function buildDefaultSettings(): AppSettings {
171171
showMessageFilePath: true,
172172
chatHistoryScrollbackItems: CHAT_SCROLLBACK_DEFAULT,
173173
threadTitleAutogenerationEnabled: false,
174+
automaticAppUpdateChecksEnabled: true,
174175
uiFontFamily: DEFAULT_UI_FONT_FAMILY,
175176
codeFontFamily: DEFAULT_CODE_FONT_FAMILY,
176177
codeFontSize: CODE_FONT_SIZE_DEFAULT,

src/features/settings/hooks/useSettingsViewOrchestration.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ export function useSettingsViewOrchestration({
211211
const agentsSectionProps = useSettingsAgentsSection({ projects });
212212

213213
return {
214+
aboutSectionProps: {
215+
appSettings,
216+
onUpdateAppSettings,
217+
},
214218
projectsSectionProps,
215219
environmentsSectionProps,
216220
displaySectionProps,

src/features/update/hooks/useUpdater.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,23 @@ describe("useUpdater", () => {
199199
expect(result.current.state.stage).toBe("idle");
200200
});
201201

202+
it("skips automatic startup checks when auto-check is disabled but still allows manual checks", async () => {
203+
checkMock.mockResolvedValue(null);
204+
205+
const { result } = renderHook(() =>
206+
useUpdater({ autoCheckOnMount: false }),
207+
);
208+
209+
expect(checkMock).not.toHaveBeenCalled();
210+
211+
await act(async () => {
212+
await result.current.checkForUpdates({ announceNoUpdate: true });
213+
});
214+
215+
expect(checkMock).toHaveBeenCalledTimes(1);
216+
expect(result.current.state.stage).toBe("latest");
217+
});
218+
202219
it("loads post-update release notes after restart when marker matches current version", async () => {
203220
window.localStorage.setItem(
204221
STORAGE_KEY_PENDING_POST_UPDATE_VERSION,

src/features/update/hooks/useUpdater.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,15 @@ export type PostUpdateNoticeState = PostUpdateNotice | null;
5757

5858
type UseUpdaterOptions = {
5959
enabled?: boolean;
60+
autoCheckOnMount?: boolean;
6061
onDebug?: (entry: DebugEntry) => void;
6162
};
6263

63-
export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) {
64+
export function useUpdater({
65+
enabled = true,
66+
autoCheckOnMount = true,
67+
onDebug,
68+
}: UseUpdaterOptions) {
6469
const [state, setState] = useState<UpdateState>({ stage: "idle" });
6570
const [postUpdateNotice, setPostUpdateNotice] = useState<PostUpdateNoticeState>(
6671
null,
@@ -205,11 +210,11 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) {
205210
}, [checkForUpdates, enabled, onDebug]);
206211

207212
useEffect(() => {
208-
if (!enabled || import.meta.env.DEV || !isTauri()) {
213+
if (!enabled || !autoCheckOnMount || import.meta.env.DEV || !isTauri()) {
209214
return;
210215
}
211216
void checkForUpdates();
212-
}, [checkForUpdates, enabled]);
217+
}, [autoCheckOnMount, checkForUpdates, enabled]);
213218

214219
useEffect(() => {
215220
if (!enabled || !isTauri()) {

0 commit comments

Comments
 (0)