better app colors, more borders for easier window separation, allow for beta releases, app auto-updates

This commit is contained in:
lone-cloud 2025-09-18 00:04:13 -07:00
parent ae99ac77ae
commit 6d1d73fa5b
22 changed files with 464 additions and 177 deletions

View file

@ -37,7 +37,7 @@ jobs:
run: yarn run: yarn
- name: Run checks (lint, type check, spell check) - name: Run checks (lint, type check, spell check)
run: yarn check-all run: yarn test
- name: Test build - name: Test build
run: yarn build run: yarn build

View file

@ -90,6 +90,8 @@ jobs:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
outputs:
is_prerelease: ${{ steps.release_type.outputs.is_prerelease }}
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -112,12 +114,29 @@ jobs:
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi fi
- name: Determine release type
id: release_type
run: |
if [[ "${{ steps.tag.outputs.tag }}" =~ -alpha|-beta|-rc ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "📋 This will be marked as a pre-release"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "📋 This will be marked as a full release"
fi
- name: Create Release - name: Create Release
run: | run: |
if [ "${{ steps.release_type.outputs.is_prerelease }}" = "true" ]; then
gh release create ${{ steps.tag.outputs.tag }} \ gh release create ${{ steps.tag.outputs.tag }} \
--title "Gerbil ${{ steps.tag.outputs.tag }}" \ --title "Gerbil ${{ steps.tag.outputs.tag }}" \
--generate-notes \ --generate-notes \
--prerelease --prerelease
else
gh release create ${{ steps.tag.outputs.tag }} \
--title "Gerbil ${{ steps.tag.outputs.tag }}" \
--generate-notes
fi
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -129,6 +148,9 @@ jobs:
gh release upload ${{ steps.tag.outputs.tag }} "$file" --clobber gh release upload ${{ steps.tag.outputs.tag }} "$file" --clobber
fi fi
done done
if [ -f "artifacts/macos-release/latest-mac.yml" ]; then
gh release upload ${{ steps.tag.outputs.tag }} "artifacts/macos-release/latest-mac.yml" --clobber
fi
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -140,6 +162,9 @@ jobs:
gh release upload ${{ steps.tag.outputs.tag }} "$file" --clobber gh release upload ${{ steps.tag.outputs.tag }} "$file" --clobber
fi fi
done done
if [ -f "artifacts/windows-release/latest.yml" ]; then
gh release upload ${{ steps.tag.outputs.tag }} "artifacts/windows-release/latest.yml" --clobber
fi
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -156,6 +181,8 @@ jobs:
aur-release: aur-release:
needs: release needs: release
runs-on: ubuntu-latest
if: needs.release.outputs.is_prerelease != 'true'
uses: ./.github/workflows/aur-release.yml uses: ./.github/workflows/aur-release.yml
with: with:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}

2
.vscode/tasks.json vendored
View file

@ -48,7 +48,7 @@
{ {
"label": "Check All (Lint + Type)", "label": "Check All (Lint + Type)",
"type": "shell", "type": "shell",
"command": "yarn check-all", "command": "yarn test",
"group": "test", "group": "test",
"presentation": { "presentation": {
"echo": true, "echo": true,

View file

@ -185,9 +185,7 @@ You can use the CLI mode on Windows in exactly the same way as in the Linux/macO
## Future Considerations ## Future Considerations
- migrate the project to Tauri from Electron?
- transition to using llama.cpp binaries directly instead of running them indirectly through koboldcpp? - transition to using llama.cpp binaries directly instead of running them indirectly through koboldcpp?
- seamless support for more frontends?
## License ## License

View file

@ -135,18 +135,6 @@ const config = [
message: message:
'Direct setting of currentKoboldBinary config is forbidden. Use window.electronAPI.kobold.setCurrentVersion() instead.', 'Direct setting of currentKoboldBinary config is forbidden. Use window.electronAPI.kobold.setCurrentVersion() instead.',
}, },
{
selector:
'ExpressionStatement[expression.type="AwaitExpression"]:has(CallExpression[callee.name="ensureDir"]) + ExpressionStatement[expression.type="AwaitExpression"]:has(CallExpression[callee.name="ensureDir"])',
message:
'Sequential ensureDir() calls detected. These can run in parallel using Promise.all().',
},
{
selector:
'ExpressionStatement[expression.type="AwaitExpression"]:has(CallExpression[callee.object.name="fs"][callee.property.name=/^(unlink|rmdir|mkdir|writeFile)$/]) + ExpressionStatement[expression.type="AwaitExpression"]:has(CallExpression[callee.object.name="fs"][callee.property.name=/^(unlink|rmdir|mkdir|writeFile)$/])',
message:
'Sequential file system operations detected. Independent operations can run in parallel using Promise.all().',
},
], ],
'import/no-default-export': 'error', 'import/no-default-export': 'error',
@ -168,6 +156,7 @@ const config = [
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/return-await': ['error', 'never'], '@typescript-eslint/return-await': ['error', 'never'],
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/prefer-promise-reject-errors': 'error', '@typescript-eslint/prefer-promise-reject-errors': 'error',
'sonarjs/cognitive-complexity': ['warn', 25], 'sonarjs/cognitive-complexity': ['warn', 25],

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "1.3.4", "version": "1.4.0-beta.1",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",
@ -19,7 +19,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"compile": "tsc --noEmit", "compile": "tsc --noEmit",
"check-all": "yarn lint && yarn compile", "test": "yarn lint && yarn compile",
"release": "yarn dlx tsx scripts/release.ts" "release": "yarn dlx tsx scripts/release.ts"
}, },
"keywords": [ "keywords": [
@ -39,14 +39,14 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^9.35.0",
"@types/node": "^24.5.1", "@types/node": "^24.5.2",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/eslint-plugin": "^8.44.0",
"@typescript-eslint/parser": "^8.44.0", "@typescript-eslint/parser": "^8.44.0",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"electron": "^38.1.1", "electron": "^38.1.2",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^4.0.0", "electron-vite": "^4.0.0",
"eslint": "^9.35.0", "eslint": "^9.35.0",
@ -61,7 +61,7 @@
"prettier": "^3.6.2", "prettier": "^3.6.2",
"rollup-plugin-visualizer": "^6.0.3", "rollup-plugin-visualizer": "^6.0.3",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.5" "vite": "^7.1.6"
}, },
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
@ -69,6 +69,7 @@
"@mantine/hooks": "^8.3.1", "@mantine/hooks": "^8.3.1",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"axios": "^1.12.2", "axios": "^1.12.2",
"electron-updater": "6.6.2",
"execa": "^9.6.0", "execa": "^9.6.0",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"react": "^19.1.1", "react": "^19.1.1",
@ -84,7 +85,11 @@
"appId": "com.gerbil.app", "appId": "com.gerbil.app",
"productName": "Gerbil", "productName": "Gerbil",
"compression": "normal", "compression": "normal",
"publish": null, "publish": {
"provider": "github",
"owner": "lone-cloud",
"repo": "gerbil"
},
"electronLanguages": [ "electronLanguages": [
"en-US" "en-US"
], ],

View file

@ -222,9 +222,7 @@ export const App = () => {
/> />
</AppShell.Main> </AppShell.Main>
<AppShell.Footer>
<StatusBar /> <StatusBar />
</AppShell.Footer>
<EjectConfirmModal <EjectConfirmModal
opened={ejectConfirmModalOpen} opened={ejectConfirmModalOpen}

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Group, useComputedColorScheme } from '@mantine/core'; import { Group, useComputedColorScheme, AppShell } from '@mantine/core';
import type { import type {
CpuMetrics, CpuMetrics,
MemoryMetrics, MemoryMetrics,
@ -24,17 +24,17 @@ export const StatusBar = ({ maxDataPoints = 60 }: StatusBarProps) => {
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const handleCpuMetrics = async (metrics: CpuMetrics) => { const handleCpuMetrics = (metrics: CpuMetrics) => {
if (!isMounted) return; if (!isMounted) return;
setCpuMetrics(metrics); setCpuMetrics(metrics);
}; };
const handleMemoryMetrics = async (metrics: MemoryMetrics) => { const handleMemoryMetrics = (metrics: MemoryMetrics) => {
if (!isMounted) return; if (!isMounted) return;
setMemoryMetrics(metrics); setMemoryMetrics(metrics);
}; };
const handleGpuMetrics = async (metrics: GpuMetrics) => { const handleGpuMetrics = (metrics: GpuMetrics) => {
if (!isMounted) return; if (!isMounted) return;
setGpuMetrics(metrics); setGpuMetrics(metrics);
}; };
@ -54,12 +54,17 @@ export const StatusBar = ({ maxDataPoints = 60 }: StatusBarProps) => {
}, [maxDataPoints]); }, [maxDataPoints]);
return ( return (
<AppShell.Footer
style={{
border: '1px solid var(--mantine-color-default-border)',
}}
>
<Group <Group
px="xs" px="xs"
gap="xs" gap="xs"
justify="flex-end" justify="flex-end"
h="100%" h="100%"
bg={colorScheme === 'dark' ? 'dark.8' : 'gray.1'} bg={colorScheme === 'dark' ? 'dark.6' : 'gray.1'}
> >
{cpuMetrics && memoryMetrics && ( {cpuMetrics && memoryMetrics && (
<> <>
@ -93,5 +98,6 @@ export const StatusBar = ({ maxDataPoints = 60 }: StatusBarProps) => {
</Group> </Group>
))} ))}
</Group> </Group>
</AppShell.Footer>
); );
}; };

View file

@ -7,19 +7,12 @@ import {
useComputedColorScheme, useComputedColorScheme,
AppShell, AppShell,
} from '@mantine/core'; } from '@mantine/core';
import { import { Minus, Square, X, Copy, Settings } from 'lucide-react';
Minus,
Square,
X,
Copy,
CircleFadingArrowUp,
Settings,
} from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import { useInterfaceOptions } from '@/hooks/useInterfaceSelection'; import { useInterfaceOptions } from '@/hooks/useInterfaceSelection';
import { useLogoClickSounds } from '@/hooks/useLogoClickSounds'; import { useLogoClickSounds } from '@/hooks/useLogoClickSounds';
import { SettingsModal } from '@/components/settings/SettingsModal'; import { SettingsModal } from '@/components/settings/SettingsModal';
import { UpdateButton } from '@/components/UpdateButton';
import icon from '/icon.png'; import icon from '/icon.png';
import { PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants'; import { PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants';
import type { InterfaceTab, Screen } from '@/types'; import type { InterfaceTab, Screen } from '@/types';
@ -40,7 +33,6 @@ export const TitleBar = ({
const computedColorScheme = useComputedColorScheme('light', { const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: true, getInitialValueInEffect: true,
}); });
const { hasUpdate, releaseUrl } = useAppUpdateChecker();
const interfaceOptions = useInterfaceOptions(); const interfaceOptions = useInterfaceOptions();
const { handleLogoClick, getLogoStyles } = useLogoClickSounds(); const { handleLogoClick, getLogoStyles } = useLogoClickSounds();
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
@ -61,7 +53,9 @@ export const TitleBar = ({
}; };
return ( return (
<AppShell.Header style={{ display: 'flex', flexDirection: 'column' }}> <AppShell.Header
style={{ display: 'flex', flexDirection: 'column', border: 'none' }}
>
<Box <Box
style={{ style={{
height: TITLEBAR_HEIGHT, height: TITLEBAR_HEIGHT,
@ -73,7 +67,7 @@ export const TitleBar = ({
computedColorScheme === 'dark' computedColorScheme === 'dark'
? 'var(--mantine-color-dark-8)' ? 'var(--mantine-color-dark-8)'
: 'var(--mantine-color-gray-1)', : 'var(--mantine-color-gray-1)',
borderBottom: '1px solid var(--mantine-color-default-border)', border: '1px solid var(--mantine-color-default-border)',
WebkitAppRegion: isSelectOpen ? 'no-drag' : 'drag', WebkitAppRegion: isSelectOpen ? 'no-drag' : 'drag',
userSelect: 'none', userSelect: 'none',
position: 'relative', position: 'relative',
@ -151,25 +145,7 @@ export const TitleBar = ({
</Box> </Box>
<Group gap="0" style={{ WebkitAppRegion: 'no-drag' }}> <Group gap="0" style={{ WebkitAppRegion: 'no-drag' }}>
{hasUpdate && releaseUrl && ( <UpdateButton />
<ActionIcon
component="a"
href={releaseUrl}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="orange"
size={TITLEBAR_HEIGHT}
aria-label="New release available"
tabIndex={-1}
style={{
borderRadius: 0,
margin: 0,
}}
>
<CircleFadingArrowUp size="1.25rem" />
</ActionIcon>
)}
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@ -179,7 +155,7 @@ export const TitleBar = ({
tabIndex={-1} tabIndex={-1}
style={{ style={{
borderRadius: 0, borderRadius: 0,
margin: 0, margin: '2px 1px 1px',
outline: 'none', outline: 'none',
}} }}
> >
@ -225,7 +201,7 @@ export const TitleBar = ({
tabIndex={-1} tabIndex={-1}
style={{ style={{
borderRadius: 0, borderRadius: 0,
margin: 0, margin: '2px 1px 1px',
}} }}
> >
{button.icon} {button.icon}

View file

@ -0,0 +1,59 @@
import { ActionIcon } from '@mantine/core';
import { CircleFadingArrowUp } from 'lucide-react';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import { TITLEBAR_HEIGHT } from '@/constants';
export const UpdateButton = () => {
const {
hasUpdate,
releaseUrl,
canAutoUpdate,
isUpdateDownloaded,
isDownloading,
downloadUpdate,
installUpdate,
} = useAppUpdateChecker();
if (!hasUpdate) return null;
const isButton = canAutoUpdate;
const isLink = !canAutoUpdate && releaseUrl;
let color: 'green' | 'blue' | 'orange' = 'orange';
let label = 'New release available';
let onClick: (() => void) | undefined;
if (isUpdateDownloaded) {
color = 'green';
label = 'Install update and restart';
onClick = installUpdate;
} else if (isDownloading) {
color = 'blue';
label = 'Downloading update...';
} else if (canAutoUpdate) {
color = 'blue';
label = 'Download and install update';
onClick = downloadUpdate;
}
return (
<ActionIcon
component={(isButton ? 'button' : 'a') as 'button' | 'a'}
href={isLink ? releaseUrl : undefined}
target={isLink ? '_blank' : undefined}
rel={isLink ? 'noopener noreferrer' : undefined}
variant="subtle"
color={color}
size={TITLEBAR_HEIGHT}
aria-label={label}
tabIndex={-1}
onClick={onClick}
style={{
borderRadius: 0,
margin: 0,
}}
>
<CircleFadingArrowUp size="1.25rem" />
</ActionIcon>
);
};

View file

@ -1,6 +1,6 @@
import { Card, Container, Stack, Tabs, Group, Button } from '@mantine/core'; import { Card, Container, Stack, Tabs, Group, Button } from '@mantine/core';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { tryExecute, logError, safeExecute } from '@/utils/logger'; import { logError, safeExecute } from '@/utils/logger';
import { useLaunchConfig } from '@/hooks/useLaunchConfig'; import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { useLaunchLogic } from '@/hooks/useLaunchLogic'; import { useLaunchLogic } from '@/hooks/useLaunchLogic';
import { useWarnings } from '@/hooks/useWarnings'; import { useWarnings } from '@/hooks/useWarnings';
@ -92,8 +92,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
}, [backend, handleBackendChange]); }, [backend, handleBackendChange]);
const setInitialDefaults = useCallback( const setInitialDefaults = useCallback(
async (currentModel: string, currentSdModel: string) => { (currentModel: string, currentSdModel: string) => {
await tryExecute(async () => {
if ( if (
!defaultsSetRef.current && !defaultsSetRef.current &&
!currentModel.trim() && !currentModel.trim() &&
@ -102,7 +101,6 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
handleModelChange(DEFAULT_MODEL_URL); handleModelChange(DEFAULT_MODEL_URL);
defaultsSetRef.current = true; defaultsSetRef.current = true;
} }
}, 'Failed to set initial defaults:');
}, },
[handleModelChange] [handleModelChange]
); );

View file

@ -107,6 +107,11 @@ export const AppearanceTab = () => {
value={colorScheme} value={colorScheme}
onChange={handleColorSchemeChange} onChange={handleColorSchemeChange}
styles={(theme) => ({ styles={(theme) => ({
root: {
border: `0.5px solid ${
isDark ? theme.colors.dark[4] : theme.colors.gray[4]
}`,
},
indicator: { indicator: {
backgroundColor: isDark backgroundColor: isDark
? theme.colors.dark[5] ? theme.colors.dark[5]

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { logError } from '@/utils/logger'; import { logError, safeExecute } from '@/utils/logger';
import { compareVersions } from '@/utils/version'; import { compareVersions } from '@/utils/version';
import { GITHUB_API } from '@/constants'; import { GITHUB_API } from '@/constants';
@ -12,12 +12,53 @@ interface AppUpdateInfo {
export const useAppUpdateChecker = () => { export const useAppUpdateChecker = () => {
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
const [isChecking, setIsChecking] = useState(false); const [canAutoUpdate, setCanAutoUpdate] = useState(false);
const [lastChecked, setLastChecked] = useState<Date | null>(null); const [isUpdateDownloaded, setIsUpdateDownloaded] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
useEffect(() => {
const initializeUpdater = async () => {
const [canUpdate, isDownloaded] = await Promise.all([
safeExecute(
() => window.electronAPI.updater.canAutoUpdate(),
'Failed to check auto-update capability'
),
safeExecute(
() => window.electronAPI.updater.isUpdateDownloaded(),
'Failed to check update download status'
),
]);
if (canUpdate !== null) setCanAutoUpdate(canUpdate);
if (isDownloaded !== null) setIsUpdateDownloaded(isDownloaded);
};
void initializeUpdater();
}, []);
const downloadUpdate = useCallback(async () => {
if (!canAutoUpdate) return;
setIsDownloading(true);
try {
const success = await window.electronAPI.updater.downloadUpdate();
if (success) {
setIsUpdateDownloaded(true);
}
} catch (err) {
logError('Failed to download update:', err as Error);
} finally {
setIsDownloading(false);
}
}, [canAutoUpdate]);
const installUpdate = useCallback(() => {
if (isUpdateDownloaded) {
window.electronAPI.updater.quitAndInstall();
}
}, [isUpdateDownloaded]);
const checkForAppUpdates = useCallback(async () => { const checkForAppUpdates = useCallback(async () => {
setIsChecking(true);
try { try {
const currentVersion = await window.electronAPI.app.getVersion(); const currentVersion = await window.electronAPI.app.getVersion();
@ -46,27 +87,22 @@ export const useAppUpdateChecker = () => {
}; };
setUpdateInfo(updateInfo); setUpdateInfo(updateInfo);
setLastChecked(new Date());
return updateInfo;
} catch (err) { } catch (err) {
logError('Failed to check for app updates:', err as Error); logError('Failed to check for app updates:', err as Error);
return null;
} finally {
setIsChecking(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
checkForAppUpdates(); void checkForAppUpdates();
}, [checkForAppUpdates]); }, [checkForAppUpdates]);
return { return {
updateInfo,
isChecking,
lastChecked,
checkForAppUpdates,
releaseUrl: updateInfo?.releaseUrl, releaseUrl: updateInfo?.releaseUrl,
hasUpdate: updateInfo?.hasUpdate || false, hasUpdate: updateInfo?.hasUpdate || false,
canAutoUpdate,
isUpdateDownloaded,
isDownloading,
downloadUpdate,
installUpdate,
}; };
}; };

View file

@ -62,7 +62,7 @@ export async function initializeApp() {
app.exit(0); app.exit(0);
}); });
app.on('will-quit', async (event) => { app.on('will-quit', (event) => {
event.preventDefault(); event.preventDefault();
app.exit(0); app.exit(0);
}); });

View file

@ -55,6 +55,12 @@ import {
} from '@/main/modules/binary'; } from '@/main/modules/binary';
import { openPerformanceManager } from '@/main/modules/performance'; import { openPerformanceManager } from '@/main/modules/performance';
import { startMonitoring, stopMonitoring } from '@/main/modules/monitoring'; import { startMonitoring, stopMonitoring } from '@/main/modules/monitoring';
import {
checkForUpdates,
downloadUpdate,
quitAndInstall,
isUpdateDownloaded,
} from '@/main/modules/autoUpdater';
import type { FrontendPreference } from '@/types'; import type { FrontendPreference } from '@/types';
import { getAppVersion } from '@/utils/node/fs'; import { getAppVersion } from '@/utils/node/fs';
@ -195,10 +201,7 @@ export function setupIPCHandlers() {
} }
}); });
ipcMain.handle('app:minimizeWindow', () => { ipcMain.handle('app:minimizeWindow', () => getMainWindow()?.minimize());
const mainWindow = getMainWindow();
mainWindow?.minimize();
});
ipcMain.handle('app:maximizeWindow', () => { ipcMain.handle('app:maximizeWindow', () => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
@ -209,12 +212,9 @@ export function setupIPCHandlers() {
} }
}); });
ipcMain.handle('app:closeWindow', () => { ipcMain.handle('app:closeWindow', () => getMainWindow()?.close());
const mainWindow = getMainWindow();
mainWindow?.close();
});
ipcMain.handle('app:getZoomLevel', async () => { ipcMain.handle('app:getZoomLevel', () => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { if (mainWindow) {
return mainWindow.webContents.getZoomLevel(); return mainWindow.webContents.getZoomLevel();
@ -263,9 +263,9 @@ export function setupIPCHandlers() {
ipcMain.handle('app:openPerformanceManager', () => openPerformanceManager()); ipcMain.handle('app:openPerformanceManager', () => openPerformanceManager());
ipcMain.handle('logs:logError', (_, message: string, error?: Error) => { ipcMain.handle('logs:logError', (_, message: string, error?: Error) =>
logError(message, error); logError(message, error)
}); );
ipcMain.handle('dependencies:isNpxAvailable', () => isNpxAvailable()); ipcMain.handle('dependencies:isNpxAvailable', () => isNpxAvailable());
@ -278,7 +278,18 @@ export function setupIPCHandlers() {
} }
}); });
ipcMain.handle('monitoring:stop', () => { ipcMain.handle('monitoring:stop', () => stopMonitoring());
stopMonitoring();
}); ipcMain.handle('app:checkForUpdates', () => checkForUpdates());
ipcMain.handle('app:downloadUpdate', () => downloadUpdate());
ipcMain.handle('app:quitAndInstall', () => quitAndInstall());
ipcMain.handle('app:isUpdateDownloaded', () => isUpdateDownloaded());
ipcMain.handle(
'app:canAutoUpdate',
() => process.platform !== 'linux' || Boolean(process.env.APPIMAGE)
);
} }

View file

@ -0,0 +1,72 @@
import { autoUpdater } from 'electron-updater';
import { app } from 'electron';
import { logError } from '@/main/modules/logging';
export interface UpdateInfo {
version: string;
releaseNotes?: string;
releaseDate: string;
size?: number;
}
export interface UpdateProgress {
bytesPerSecond: number;
percent: number;
transferred: number;
total: number;
}
let updateDownloaded = false;
function setupAutoUpdater() {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('error', (error) => {
logError('Auto-updater error:', error);
});
autoUpdater.on('update-downloaded', () => {
updateDownloaded = true;
});
}
setupAutoUpdater();
export async function checkForUpdates() {
try {
const result = await autoUpdater.checkForUpdates();
return result !== null;
} catch (error) {
logError('Failed to check for updates:', error as Error);
return false;
}
}
export async function downloadUpdate() {
try {
await autoUpdater.downloadUpdate();
return true;
} catch (error) {
logError('Failed to download update:', error as Error);
return false;
}
}
export function quitAndInstall() {
if (updateDownloaded) {
autoUpdater.quitAndInstall(false, true);
} else {
logError('No update downloaded to install');
}
}
export function isUpdateDownloaded() {
return updateDownloaded;
}
app.on('ready', () => {
setTimeout(() => {
void checkForUpdates();
}, 5000);
});

View file

@ -196,7 +196,7 @@ async function tryVersionManagerPath(
return false; return false;
} }
async function tryAddPathToEnv( function tryAddPathToEnv(
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
path: string path: string
) { ) {

View file

@ -54,7 +54,7 @@ function getFallbackDataRoot() {
} }
} }
async function getSillyTavernDataRoot() { function getSillyTavernDataRoot() {
if (detectedDataRoot) { if (detectedDataRoot) {
return detectedDataRoot; return detectedDataRoot;
} }
@ -64,8 +64,8 @@ async function getSillyTavernDataRoot() {
return fallback; return fallback;
} }
async function getSillyTavernSettingsPath() { function getSillyTavernSettingsPath() {
const dataRoot = await getSillyTavernDataRoot(); const dataRoot = getSillyTavernDataRoot();
return join(dataRoot, 'default-user', 'settings.json'); return join(dataRoot, 'default-user', 'settings.json');
} }
@ -80,7 +80,7 @@ async function createNpxProcess(args: string[]) {
} }
async function ensureSillyTavernSettings() { async function ensureSillyTavernSettings() {
const settingsPath = await getSillyTavernSettingsPath(); const settingsPath = getSillyTavernSettingsPath();
if (await pathExists(settingsPath)) { if (await pathExists(settingsPath)) {
sendKoboldOutput(`SillyTavern settings found at ${settingsPath}`); sendKoboldOutput(`SillyTavern settings found at ${settingsPath}`);
@ -167,7 +167,7 @@ async function setupSillyTavernConfig(
isImageMode: boolean isImageMode: boolean
) { ) {
try { try {
const configPath = await getSillyTavernSettingsPath(); const configPath = getSillyTavernSettingsPath();
let settings: Record<string, unknown> = {}; let settings: Record<string, unknown> = {};
if (await pathExists(configPath)) { if (await pathExists(configPath)) {

View file

@ -6,6 +6,7 @@ import type {
LogsAPI, LogsAPI,
DependenciesAPI, DependenciesAPI,
MonitoringAPI, MonitoringAPI,
UpdaterAPI,
} from '@/types/electron'; } from '@/types/electron';
const koboldAPI: KoboldAPI = { const koboldAPI: KoboldAPI = {
@ -90,6 +91,10 @@ const appAPI: AppAPI = {
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url), openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
openPerformanceManager: () => openPerformanceManager: () =>
ipcRenderer.invoke('app:openPerformanceManager'), ipcRenderer.invoke('app:openPerformanceManager'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadUpdate: () => ipcRenderer.invoke('app:downloadUpdate'),
quitAndInstall: () => ipcRenderer.invoke('app:quitAndInstall'),
isUpdateDownloaded: () => ipcRenderer.invoke('app:isUpdateDownloaded'),
}; };
const configAPI: ConfigAPI = { const configAPI: ConfigAPI = {
@ -130,6 +135,14 @@ const monitoringAPI: MonitoringAPI = {
}, },
}; };
const updaterAPI: UpdaterAPI = {
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadUpdate: () => ipcRenderer.invoke('app:downloadUpdate'),
quitAndInstall: () => ipcRenderer.invoke('app:quitAndInstall'),
isUpdateDownloaded: () => ipcRenderer.invoke('app:isUpdateDownloaded'),
canAutoUpdate: () => ipcRenderer.invoke('app:canAutoUpdate'),
};
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
kobold: koboldAPI, kobold: koboldAPI,
app: appAPI, app: appAPI,
@ -137,4 +150,5 @@ contextBridge.exposeInMainWorld('electronAPI', {
logs: logsAPI, logs: logsAPI,
dependencies: dependenciesAPI, dependencies: dependenciesAPI,
monitoring: monitoringAPI, monitoring: monitoringAPI,
updater: updaterAPI,
}); });

View file

@ -3,6 +3,40 @@
@import '@fontsource/inter/latin-500.css'; @import '@fontsource/inter/latin-500.css';
@import '@fontsource/inter/latin-600.css'; @import '@fontsource/inter/latin-600.css';
:root {
--mantine-color-body: #fafafa;
--mantine-color-white: #fafafa;
--mantine-color-gray-0: #f8f9fa;
--mantine-color-gray-1: #f1f3f4;
--mantine-color-gray-2: #e9ecef;
--mantine-color-gray-3: #dee2e6;
--mantine-color-default-border: #e5e7eb;
}
[data-mantine-color-scheme='dark'] {
--mantine-color-body: #0f0f0f;
--mantine-color-dark-7: #1a1a1a;
--mantine-color-dark-6: #2d2d2d;
--mantine-color-dark-5: #3d3d3d;
--mantine-color-default-border: #2a2a2a;
}
#root {
height: 100vh;
width: 100vw;
border-left: 1px solid rgba(0, 0, 0, 0.15);
border-right: 1px solid rgba(0, 0, 0, 0.15);
box-sizing: border-box;
}
[data-mantine-color-scheme='dark'] #root {
border-left: 1px solid rgba(255, 255, 255, 0.15);
border-right: 1px solid rgba(255, 255, 255, 0.15);
}
/* Custom scrollbars */ /* Custom scrollbars */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 0.5rem; width: 0.5rem;
@ -29,19 +63,17 @@
} }
/* Dark mode scrollbar support */ /* Dark mode scrollbar support */
@media (prefers-color-scheme: dark) { [data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
::-webkit-scrollbar-thumb {
background: rgba(173, 181, 189, 0.5); background: rgba(173, 181, 189, 0.5);
} }
::-webkit-scrollbar-thumb:hover { [data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background: rgba(173, 181, 189, 0.8); background: rgba(173, 181, 189, 0.8);
} }
::-webkit-scrollbar-thumb:active { [data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:active {
background: rgba(206, 212, 218, 0.9); background: rgba(206, 212, 218, 0.9);
} }
}
/* TitleBar gerbil icon animations */ /* TitleBar gerbil icon animations */
@keyframes elephantShake { @keyframes elephantShake {

View file

@ -166,6 +166,10 @@ export interface AppAPI {
app?: string; app?: string;
error?: string; error?: string;
}>; }>;
checkForUpdates: () => Promise<boolean>;
downloadUpdate: () => Promise<boolean>;
quitAndInstall: () => Promise<void>;
isUpdateDownloaded: () => Promise<boolean>;
} }
export interface ConfigAPI { export interface ConfigAPI {
@ -193,6 +197,14 @@ export interface MonitoringAPI {
removeGpuMetricsListener: () => void; removeGpuMetricsListener: () => void;
} }
export interface UpdaterAPI {
checkForUpdates: () => Promise<boolean>;
downloadUpdate: () => Promise<boolean>;
quitAndInstall: () => void;
isUpdateDownloaded: () => Promise<boolean>;
canAutoUpdate: () => Promise<boolean>;
}
declare global { declare global {
interface Window { interface Window {
electronAPI: { electronAPI: {
@ -202,6 +214,7 @@ declare global {
logs: LogsAPI; logs: LogsAPI;
dependencies: DependenciesAPI; dependencies: DependenciesAPI;
monitoring: MonitoringAPI; monitoring: MonitoringAPI;
updater: UpdaterAPI;
}; };
} }
} }

View file

@ -1287,12 +1287,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/node@npm:*, @types/node@npm:^24.5.1": "@types/node@npm:*, @types/node@npm:^24.5.2":
version: 24.5.1 version: 24.5.2
resolution: "@types/node@npm:24.5.1" resolution: "@types/node@npm:24.5.2"
dependencies: dependencies:
undici-types: "npm:~7.12.0" undici-types: "npm:~7.12.0"
checksum: 10c0/5f0cb038be789b58170e616452ba1f8ebb85bf2fbce58a7e32b1eb08391f64f5e31a9cdbccefbfcd9e6d73b66b564b5e037a1d678ab20213559a32e1d7b6ce17 checksum: 10c0/96baaca6564d39c6f7f6eddd73ce41e2a7594ef37225cd52df3be36fad31712af8ae178387a72d0b80f2e2799e7fd30c014bc0ae9eb9f962d9079b691be00c48
languageName: node languageName: node
linkType: hard linkType: hard
@ -1976,6 +1976,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"builder-util-runtime@npm:9.3.1":
version: 9.3.1
resolution: "builder-util-runtime@npm:9.3.1"
dependencies:
debug: "npm:^4.3.4"
sax: "npm:^1.2.4"
checksum: 10c0/32de87e5f294154de707f40acf59a5600af9d1ce903ccbba53b81824de7a1dd9568c5f0c033ed765e14c4ea73347aac09ecbce686e1bc7fefbd7b4f64d2c9d68
languageName: node
linkType: hard
"builder-util-runtime@npm:9.4.0": "builder-util-runtime@npm:9.4.0":
version: 9.4.0 version: 9.4.0
resolution: "builder-util-runtime@npm:9.4.0" resolution: "builder-util-runtime@npm:9.4.0"
@ -2683,6 +2693,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"electron-updater@npm:6.6.2":
version: 6.6.2
resolution: "electron-updater@npm:6.6.2"
dependencies:
builder-util-runtime: "npm:9.3.1"
fs-extra: "npm:^10.1.0"
js-yaml: "npm:^4.1.0"
lazy-val: "npm:^1.0.5"
lodash.escaperegexp: "npm:^4.1.2"
lodash.isequal: "npm:^4.5.0"
semver: "npm:^7.6.3"
tiny-typed-emitter: "npm:^2.1.0"
checksum: 10c0/2b9ae5583b95f6772c4a2515ddba7ba52b65460ab81f09ae4f0b97c7e3d7b7e3d9426775eb9a53d3193bd4c3d5466bf30827c1a6ee75e4aca739c647f6ac46ff
languageName: node
linkType: hard
"electron-vite@npm:^4.0.0": "electron-vite@npm:^4.0.0":
version: 4.0.0 version: 4.0.0
resolution: "electron-vite@npm:4.0.0" resolution: "electron-vite@npm:4.0.0"
@ -2705,16 +2731,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"electron@npm:^38.1.1": "electron@npm:^38.1.2":
version: 38.1.1 version: 38.1.2
resolution: "electron@npm:38.1.1" resolution: "electron@npm:38.1.2"
dependencies: dependencies:
"@electron/get": "npm:^2.0.0" "@electron/get": "npm:^2.0.0"
"@types/node": "npm:^22.7.7" "@types/node": "npm:^22.7.7"
extract-zip: "npm:^2.0.1" extract-zip: "npm:^2.0.1"
bin: bin:
electron: cli.js electron: cli.js
checksum: 10c0/0b1e5955679de6b9a9a12fa7a51beaaa5dcf15655888670c39e3a49914f8dba580a1f94b46d83e7f8e5dc7fd22c16b130dd5040208d7a86431cf06e16933d9e8 checksum: 10c0/63f768e8ac396221db17c280e483ac838067e881ee61eb78c5c8f587017e51c587cc1eb687f29672b5e63c0af8a5d818d721d49aef20bac082544a8cf2d323ea
languageName: node languageName: node
linkType: hard linkType: hard
@ -3648,7 +3674,7 @@ __metadata:
"@fontsource/inter": "npm:^5.2.8" "@fontsource/inter": "npm:^5.2.8"
"@mantine/core": "npm:^8.3.1" "@mantine/core": "npm:^8.3.1"
"@mantine/hooks": "npm:^8.3.1" "@mantine/hooks": "npm:^8.3.1"
"@types/node": "npm:^24.5.1" "@types/node": "npm:^24.5.2"
"@types/react": "npm:^19.1.13" "@types/react": "npm:^19.1.13"
"@types/react-dom": "npm:^19.1.9" "@types/react-dom": "npm:^19.1.9"
"@types/yauzl": "npm:^2.10.3" "@types/yauzl": "npm:^2.10.3"
@ -3657,8 +3683,9 @@ __metadata:
"@vitejs/plugin-react": "npm:^5.0.3" "@vitejs/plugin-react": "npm:^5.0.3"
axios: "npm:^1.12.2" axios: "npm:^1.12.2"
cross-env: "npm:^10.0.0" cross-env: "npm:^10.0.0"
electron: "npm:^38.1.1" electron: "npm:^38.1.2"
electron-builder: "npm:^26.0.12" electron-builder: "npm:^26.0.12"
electron-updater: "npm:6.6.2"
electron-vite: "npm:^4.0.0" electron-vite: "npm:^4.0.0"
eslint: "npm:^9.35.0" eslint: "npm:^9.35.0"
eslint-plugin-import: "npm:^2.32.0" eslint-plugin-import: "npm:^2.32.0"
@ -3678,7 +3705,7 @@ __metadata:
rollup-plugin-visualizer: "npm:^6.0.3" rollup-plugin-visualizer: "npm:^6.0.3"
systeminformation: "npm:^5.27.10" systeminformation: "npm:^5.27.10"
typescript: "npm:^5.9.2" typescript: "npm:^5.9.2"
vite: "npm:^7.1.5" vite: "npm:^7.1.6"
winston: "npm:^3.17.0" winston: "npm:^3.17.0"
winston-daily-rotate-file: "npm:^5.0.0" winston-daily-rotate-file: "npm:^5.0.0"
yauzl: "npm:^3.2.0" yauzl: "npm:^3.2.0"
@ -4701,6 +4728,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lodash.escaperegexp@npm:^4.1.2":
version: 4.1.2
resolution: "lodash.escaperegexp@npm:4.1.2"
checksum: 10c0/484ad4067fa9119bb0f7c19a36ab143d0173a081314993fe977bd00cf2a3c6a487ce417a10f6bac598d968364f992153315f0dbe25c9e38e3eb7581dd333e087
languageName: node
linkType: hard
"lodash.isequal@npm:^4.5.0":
version: 4.5.0
resolution: "lodash.isequal@npm:4.5.0"
checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f
languageName: node
linkType: hard
"lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2": "lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2":
version: 4.6.2 version: 4.6.2
resolution: "lodash.merge@npm:4.6.2" resolution: "lodash.merge@npm:4.6.2"
@ -6231,7 +6272,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"semver@npm:7.7.2, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.6.0": "semver@npm:7.7.2, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.6.3":
version: 7.7.2 version: 7.7.2
resolution: "semver@npm:7.7.2" resolution: "semver@npm:7.7.2"
bin: bin:
@ -6765,6 +6806,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tiny-typed-emitter@npm:^2.1.0":
version: 2.1.0
resolution: "tiny-typed-emitter@npm:2.1.0"
checksum: 10c0/522bed4c579ee7ee16548540cb693a3d098b137496110f5a74bff970b54187e6b7343a359b703e33f77c5b4b90ec6cebc0d0ec3dbdf1bd418723c5c3ce36d8a2
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": "tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15":
version: 0.2.15 version: 0.2.15
resolution: "tinyglobby@npm:0.2.15" resolution: "tinyglobby@npm:0.2.15"
@ -7140,9 +7188,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"vite@npm:^7.1.5": "vite@npm:^7.1.6":
version: 7.1.5 version: 7.1.6
resolution: "vite@npm:7.1.5" resolution: "vite@npm:7.1.6"
dependencies: dependencies:
esbuild: "npm:^0.25.0" esbuild: "npm:^0.25.0"
fdir: "npm:^6.5.0" fdir: "npm:^6.5.0"
@ -7191,7 +7239,7 @@ __metadata:
optional: true optional: true
bin: bin:
vite: bin/vite.js vite: bin/vite.js
checksum: 10c0/782d2f20c25541b26d1fb39bef5f194149caff39dc25b7836e25f049ca919f2e2ce186bddb21f3f20f6195354b3579ec637a8ca08d65b117f8b6f81e3e730a9c checksum: 10c0/2cd8baec0054956dae61289dd1497109c762cfc27ec6f6b88f39a15d5ddc7e0cc4559b72fbdd701b296c739d2f734d051c28e365539462fb92f83b5e7908f9de
languageName: node languageName: node
linkType: hard linkType: hard