feat: sentry integration (#51)
* feat: integrate sentry error reporting * feat: report logger errors to sentry with 30s log context
This commit is contained in:
22 files changed
+2101
-185
No files matched your search
@@ -39,4 +39,8 @@ jobs:
|
|||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
run: npm run publish
|
run: npm run publish
|
||||||
@@ -102,3 +102,6 @@ out/
|
|||||||
|
|
||||||
# Tanstack
|
# Tanstack
|
||||||
.tanstack/
|
.tanstack/
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
Generated
+1648
-148
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,7 @@
|
|||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@semantic-release/github": "^12.0.2",
|
"@semantic-release/github": "^12.0.2",
|
||||||
|
"@sentry/vite-plugin": "^4.8.0",
|
||||||
"@swc/core": "^1.15.8",
|
"@swc/core": "^1.15.8",
|
||||||
"@tanstack/router-plugin": "^1.135.0",
|
"@tanstack/router-plugin": "^1.135.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -108,6 +109,8 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.9",
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@sentry/electron": "^7.7.0",
|
||||||
|
"@sentry/integrations": "^7.114.0",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tanstack/react-query": "^5.90.7",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"@tanstack/react-router": "^1.139.3",
|
"@tanstack/react-router": "^1.139.3",
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { AppConfig } from '@/types/config';
|
import { AppConfig } from '@/types/config';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
|
const SAVE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
export function useAppConfig() {
|
export function useAppConfig() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -20,28 +25,105 @@ export function useAppConfig() {
|
|||||||
const updateConfig = useMutation({
|
const updateConfig = useMutation({
|
||||||
mutationFn: async (newConfig: AppConfig) => {
|
mutationFn: async (newConfig: AppConfig) => {
|
||||||
await ipc.client.config.save(newConfig);
|
await ipc.client.config.save(newConfig);
|
||||||
|
return newConfig;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
|
||||||
|
const latestConfigRef = useRef<AppConfig | null>(null);
|
||||||
|
const lastStableConfigRef = useRef<AppConfig | null>(null);
|
||||||
|
const pendingResolversRef = useRef<
|
||||||
|
Array<{ resolve: () => void; reject: (error: Error) => void }>
|
||||||
|
>([]);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
lastStableConfigRef.current = config;
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const flushPendingSave = useCallback(async () => {
|
||||||
|
const pendingBatch = pendingResolversRef.current.splice(0);
|
||||||
|
const nextConfig = latestConfigRef.current;
|
||||||
|
if (!nextConfig) {
|
||||||
|
for (const item of pendingBatch) {
|
||||||
|
item.resolve();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedConfig = await updateConfig.mutateAsync(nextConfig);
|
||||||
|
queryClient.setQueryData(['appConfig'], savedConfig);
|
||||||
|
lastStableConfigRef.current = savedConfig;
|
||||||
toast({
|
toast({
|
||||||
title: 'Settings saved',
|
title: 'Settings saved',
|
||||||
description: 'Your configuration has been updated.',
|
description: 'Your configuration has been updated.',
|
||||||
});
|
});
|
||||||
},
|
for (const item of pendingBatch) {
|
||||||
onError: (err) => {
|
item.resolve();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err : new Error('Failed to save settings');
|
||||||
|
const fallback = lastStableConfigRef.current;
|
||||||
|
if (fallback) {
|
||||||
|
queryClient.setQueryData(['appConfig'], fallback);
|
||||||
|
} else {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: 'Error saving settings',
|
title: 'Error saving settings',
|
||||||
description: err.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
for (const item of pendingBatch) {
|
||||||
|
item.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [queryClient, updateConfig]);
|
||||||
|
|
||||||
|
const saveConfig = useCallback(
|
||||||
|
(newConfig: AppConfig) => {
|
||||||
|
latestConfigRef.current = newConfig;
|
||||||
|
queryClient.setQueryData(['appConfig'], newConfig);
|
||||||
|
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
pendingResolversRef.current.push({ resolve, reject });
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
flushPendingSave();
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
[flushPendingSave, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
if (!debounceTimerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
void flushPendingSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
handleBeforeUnload();
|
||||||
|
};
|
||||||
|
}, [flushPendingSave]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
saveConfig: updateConfig.mutateAsync,
|
saveConfig,
|
||||||
isSaving: updateConfig.isPending,
|
isSaving: updateConfig.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import * as Sentry from '@sentry/electron/main';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { getAppDataDir } from './utils/paths';
|
||||||
|
import { logger } from './utils/logger';
|
||||||
|
|
||||||
|
function getQuickConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = path.join(getAppDataDir(), 'gui_config.json');
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const content = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(content);
|
||||||
|
// Default to false (privacy by default)
|
||||||
|
return config.error_reporting_enabled;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read config for Sentry init:', e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getQuickConfig()) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
release: `antigravity-manager@${app.getVersion()}`,
|
||||||
|
beforeSend(event) {
|
||||||
|
if (event.exception?.values?.[0]?.value) {
|
||||||
|
event.exception.values[0].value = event.exception.values[0].value.replace(
|
||||||
|
/Users\\\\[^\\\\]+/g,
|
||||||
|
'Users\\\\***',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.setErrorReportingEnabled(true);
|
||||||
|
logger.setSentryReporter((payload) => {
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
scope.setTag('log_level', payload.level);
|
||||||
|
scope.setContext('recent_logs', {
|
||||||
|
entries: payload.logs.map((entry) => ({
|
||||||
|
timestamp: new Date(entry.timestamp).toISOString(),
|
||||||
|
level: entry.level,
|
||||||
|
message: entry.message,
|
||||||
|
formatted: entry.formatted,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
scope.setExtra('log_message', payload.message);
|
||||||
|
if (payload.error) {
|
||||||
|
Sentry.captureException(payload.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.captureMessage(payload.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.setErrorReportingEnabled(false);
|
||||||
|
logger.setSentryReporter(null);
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { AppConfig } from '../../types/config';
|
import { AppConfig } from '../../types/config';
|
||||||
import { ConfigManager } from './manager';
|
import { ConfigManager } from './manager';
|
||||||
import { syncAutoStart } from '../../utils/autoStart';
|
import { syncAutoStart } from '../../utils/autoStart';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
return ConfigManager.loadConfig();
|
return ConfigManager.loadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveConfig(config: AppConfig): void {
|
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||||
// Logic to notify proxy server if configuration changes (hot update)
|
// Logic to notify proxy server if configuration changes (hot update)
|
||||||
// Logic to update Tray if language changes
|
// Logic to update Tray if language changes
|
||||||
// For now just save
|
// For now just save
|
||||||
ConfigManager.saveConfig(config);
|
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||||
|
await ConfigManager.saveConfig(config);
|
||||||
|
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||||
|
if (previous.auto_startup !== config.auto_startup) {
|
||||||
syncAutoStart(config);
|
syncAutoStart(config);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ import { logger } from '../../utils/logger';
|
|||||||
const CONFIG_FILENAME = 'gui_config.json';
|
const CONFIG_FILENAME = 'gui_config.json';
|
||||||
|
|
||||||
export class ConfigManager {
|
export class ConfigManager {
|
||||||
|
private static cachedConfig: AppConfig | null = null;
|
||||||
|
private static saveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
private static getConfigPath(): string {
|
private static getConfigPath(): string {
|
||||||
const appDataDir = getAppDataDir();
|
const appDataDir = getAppDataDir();
|
||||||
if (!fs.existsSync(appDataDir)) {
|
if (!fs.existsSync(appDataDir)) {
|
||||||
@@ -20,6 +23,7 @@ export class ConfigManager {
|
|||||||
const configPath = this.getConfigPath();
|
const configPath = this.getConfigPath();
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||||
|
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||||
return DEFAULT_APP_CONFIG;
|
return DEFAULT_APP_CONFIG;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ export class ConfigManager {
|
|||||||
|
|
||||||
// Merge with default to ensure new fields are present
|
// Merge with default to ensure new fields are present
|
||||||
// Zod parse helps validate
|
// Zod parse helps validate
|
||||||
const merged = {
|
const merged: AppConfig = {
|
||||||
...DEFAULT_APP_CONFIG,
|
...DEFAULT_APP_CONFIG,
|
||||||
...raw,
|
...raw,
|
||||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||||
@@ -45,22 +49,35 @@ export class ConfigManager {
|
|||||||
// Handle Anthropic Mapping Map vs Object
|
// Handle Anthropic Mapping Map vs Object
|
||||||
// In JSON it's object
|
// In JSON it's object
|
||||||
|
|
||||||
return merged as AppConfig;
|
this.cachedConfig = merged;
|
||||||
|
return merged;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Config: Failed to load config', e);
|
logger.error('Config: Failed to load config', e);
|
||||||
|
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||||
return DEFAULT_APP_CONFIG;
|
return DEFAULT_APP_CONFIG;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static saveConfig(config: AppConfig): void {
|
static getCachedConfig(): AppConfig | null {
|
||||||
try {
|
return this.cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async saveConfig(config: AppConfig): Promise<void> {
|
||||||
const configPath = this.getConfigPath();
|
const configPath = this.getConfigPath();
|
||||||
const content = JSON.stringify(config, null, 2);
|
const content = JSON.stringify(config, null, 2);
|
||||||
fs.writeFileSync(configPath, content, 'utf-8');
|
|
||||||
|
this.saveQueue = this.saveQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
await fs.promises.writeFile(configPath, content, 'utf-8');
|
||||||
|
this.cachedConfig = config;
|
||||||
logger.info(`Config: Saved to ${configPath}`);
|
logger.info(`Config: Saved to ${configPath}`);
|
||||||
} catch (e) {
|
})
|
||||||
|
.catch((e) => {
|
||||||
logger.error('Config: Failed to save config', e);
|
logger.error('Config: Failed to save config', e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
});
|
||||||
|
|
||||||
|
return this.saveQueue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,6 @@ export const configRouter = os.router({
|
|||||||
.input(AppConfigSchema)
|
.input(AppConfigSchema)
|
||||||
.output(z.void())
|
.output(z.void())
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
saveConfig(input);
|
await saveConfig(input);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -53,7 +53,7 @@ export const generateApiKey = async (): Promise<string> => {
|
|||||||
// Save to config
|
// Save to config
|
||||||
const config = ConfigManager.loadConfig();
|
const config = ConfigManager.loadConfig();
|
||||||
config.proxy.api_key = newKey;
|
config.proxy.api_key = newKey;
|
||||||
ConfigManager.saveConfig(config);
|
await ConfigManager.saveConfig(config);
|
||||||
|
|
||||||
return newKey;
|
return newKey;
|
||||||
};
|
};
|
||||||
@@ -57,8 +57,6 @@ export const MainLayout: React.FC = () => {
|
|||||||
isCollapsed ? 'w-[70px]' : 'w-64',
|
isCollapsed ? 'w-[70px]' : 'w-64',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Toggle Button - Visible on hover or always if preferred.
|
|
||||||
Positioned absolute on the border line */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ i18n
|
|||||||
macos_hint:
|
macos_hint:
|
||||||
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
||||||
},
|
},
|
||||||
|
privacy: {
|
||||||
|
title: 'Privacy',
|
||||||
|
description: 'Control how your data is used to improve the application.',
|
||||||
|
error_reporting: 'Error Reporting',
|
||||||
|
error_reporting_desc:
|
||||||
|
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
||||||
|
restart_note:
|
||||||
|
'Changes to error reporting will take effect after restarting the application.',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'Upstream Proxy',
|
title: 'Upstream Proxy',
|
||||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||||
@@ -409,6 +418,14 @@ i18n
|
|||||||
macos_hint:
|
macos_hint:
|
||||||
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
|
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
|
||||||
},
|
},
|
||||||
|
privacy: {
|
||||||
|
title: '隐私',
|
||||||
|
description: '控制你的数据如何用于改进应用。',
|
||||||
|
error_reporting: '错误上报',
|
||||||
|
error_reporting_desc:
|
||||||
|
'发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||||
|
restart_note: '错误上报设置会在重启应用后生效。',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: '上游代理',
|
title: '上游代理',
|
||||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||||
|
|||||||
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
|
import './instrument'; // MUST be the first import to ensure Sentry initializes before app ready
|
||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
// import { installExtension, REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
|
||||||
import { ipcMain } from 'electron/main';
|
import { ipcMain } from 'electron/main';
|
||||||
import { ipcContext } from '@/ipc/context';
|
import { ipcContext } from '@/ipc/context';
|
||||||
import { IPC_CHANNELS } from './constants';
|
import { IPC_CHANNELS } from './constants';
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
import { ipcRenderer, contextBridge } from 'electron';
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
|
import * as Sentry from '@sentry/electron/renderer';
|
||||||
import { IPC_CHANNELS } from './constants';
|
import { IPC_CHANNELS } from './constants';
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
// Config check logic (duplicate of instrument.ts logic but adapted for preload)
|
||||||
|
let sentryEnabled = false;
|
||||||
|
try {
|
||||||
|
const home = os.homedir();
|
||||||
|
let appDataPath = '';
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
appDataPath = path.join(
|
||||||
|
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
||||||
|
'Antigravity',
|
||||||
|
);
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
appDataPath = path.join(home, 'Library', 'Application Support', 'Antigravity');
|
||||||
|
} else {
|
||||||
|
appDataPath = path.join(home, '.config', 'Antigravity');
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(appDataPath, 'gui_config.json');
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const content = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(content);
|
||||||
|
sentryEnabled = config.error_reporting_enabled === true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('Preload: Failed to read config', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentryEnabled) {
|
||||||
|
// Defer Sentry init to avoid blocking main thread during startup (white screen fix)
|
||||||
|
setTimeout(() => {
|
||||||
|
// console.log('[Preload] Initializing Sentry (Deferred)');
|
||||||
|
Sentry.init({});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (event.data === IPC_CHANNELS.START_ORPC_SERVER) {
|
if (event.data === IPC_CHANNELS.START_ORPC_SERVER) {
|
||||||
const [serverPort] = event.ports;
|
const [serverPort] = event.ports;
|
||||||
@@ -10,6 +48,7 @@ window.addEventListener('message', (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
|
SENTRY_ENABLED: sentryEnabled,
|
||||||
onGoogleAuthCode: (callback: (code: string) => void) => {
|
onGoogleAuthCode: (callback: (code: string) => void) => {
|
||||||
const handler = (_event: any, code: string) => callback(code);
|
const handler = (_event: any, code: string) => callback(code);
|
||||||
ipcRenderer.on('GOOGLE_AUTH_CODE', handler);
|
ipcRenderer.on('GOOGLE_AUTH_CODE', handler);
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
|
import * as Sentry from '@sentry/electron/renderer';
|
||||||
|
|
||||||
|
if (window.electron.SENTRY_ENABLED) {
|
||||||
|
// Defer initialization to prioritize initial render
|
||||||
|
setTimeout(() => {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.SENTRY_DSN,
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
import '@/App';
|
import '@/App';
|
||||||
+41
-1
@@ -216,7 +216,7 @@ function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isMac && (
|
{isMac && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{t(
|
{t(
|
||||||
'settings.startup.macos_hint',
|
'settings.startup.macos_hint',
|
||||||
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
||||||
@@ -254,6 +254,46 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Privacy & Error Reporting Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
'settings.privacy.description',
|
||||||
|
'Control how your data is used to improve the application.',
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('settings.privacy.error_reporting', 'Error Reporting')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t(
|
||||||
|
'settings.privacy.error_reporting_desc',
|
||||||
|
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config?.error_reporting_enabled || false}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (config) {
|
||||||
|
await saveConfig({ ...config, error_reporting_enabled: checked });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
'settings.privacy.restart_note',
|
||||||
|
'Changes to error reporting will take effect after restarting the application.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||||
|
|||||||
Vendored
+1
@@ -7,6 +7,7 @@ declare global {
|
|||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: {
|
electron: {
|
||||||
|
SENTRY_ENABLED: boolean;
|
||||||
onGoogleAuthCode: (callback: (code: string) => void) => () => void;
|
onGoogleAuthCode: (callback: (code: string) => void) => () => void;
|
||||||
changeLanguage: (lang: string) => void;
|
changeLanguage: (lang: string) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-2
@@ -20,9 +20,11 @@ export const AppConfigSchema = z.object({
|
|||||||
theme: z.string(),
|
theme: z.string(),
|
||||||
auto_refresh: z.boolean(),
|
auto_refresh: z.boolean(),
|
||||||
refresh_interval: z.number(), // minutes
|
refresh_interval: z.number(), // minutes
|
||||||
auto_sync: z.boolean(), // 是否自动同步
|
auto_sync: z.boolean(),
|
||||||
sync_interval: z.number(), // minutes
|
sync_interval: z.number(), // minutes
|
||||||
auto_startup: z.boolean(), // 是否随系统启动
|
auto_startup: z.boolean(),
|
||||||
|
error_reporting_enabled: z.boolean(),
|
||||||
|
privacy_consent_asked: z.boolean().optional().default(false), // Optional for backward compatibility
|
||||||
default_export_path: z.string().nullable().optional(), // 导出路径
|
default_export_path: z.string().nullable().optional(), // 导出路径
|
||||||
proxy: ProxyConfigSchema,
|
proxy: ProxyConfigSchema,
|
||||||
});
|
});
|
||||||
@@ -39,6 +41,8 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
auto_sync: false,
|
auto_sync: false,
|
||||||
sync_interval: 5,
|
sync_interval: 5,
|
||||||
auto_startup: false,
|
auto_startup: false,
|
||||||
|
error_reporting_enabled: true, // Default to disabled for privacy
|
||||||
|
privacy_consent_asked: false, // Whether the user has been asked for consent
|
||||||
default_export_path: null,
|
default_export_path: null,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { getAgentDir } from './paths';
|
|||||||
|
|
||||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
|
||||||
|
const LOG_WINDOW_MS = 30_000;
|
||||||
|
const MAX_LOG_ENTRIES = 200;
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: number;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
formatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentryReporter = (payload: {
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
error?: Error;
|
||||||
|
logs: LogEntry[];
|
||||||
|
}) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely stringify an object, handling circular references
|
* Safely stringify an object, handling circular references
|
||||||
* This prevents "Converting circular structure to JSON" errors
|
* This prevents "Converting circular structure to JSON" errors
|
||||||
@@ -33,6 +50,9 @@ function safeStringify(obj: unknown): string {
|
|||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private logFilePath: string;
|
private logFilePath: string;
|
||||||
|
private recentLogs: LogEntry[] = [];
|
||||||
|
private sentryReporter: SentryReporter | null = null;
|
||||||
|
private sentryEnabled = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const agentDir = getAgentDir();
|
const agentDir = getAgentDir();
|
||||||
@@ -75,10 +95,55 @@ class Logger {
|
|||||||
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
|
console.log(`${color}[${level.toUpperCase()}]${reset} ${message}`, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pruneLogs(now: number) {
|
||||||
|
while (this.recentLogs.length > 0 && now - this.recentLogs[0].timestamp > LOG_WINDOW_MS) {
|
||||||
|
this.recentLogs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.recentLogs.length > MAX_LOG_ENTRIES) {
|
||||||
|
this.recentLogs = this.recentLogs.slice(-MAX_LOG_ENTRIES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractError(args: unknown[]): Error | undefined {
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSentryReporter(reporter: SentryReporter | null) {
|
||||||
|
this.sentryReporter = reporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorReportingEnabled(enabled: boolean) {
|
||||||
|
this.sentryEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
log(level: LogLevel, message: string, ...args: unknown[]) {
|
log(level: LogLevel, message: string, ...args: unknown[]) {
|
||||||
const formattedMessage = this.formatMessage(level, message, ...args);
|
const formattedMessage = this.formatMessage(level, message, ...args);
|
||||||
|
const now = Date.now();
|
||||||
|
this.recentLogs.push({
|
||||||
|
timestamp: now,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
formatted: formattedMessage,
|
||||||
|
});
|
||||||
|
this.pruneLogs(now);
|
||||||
|
|
||||||
this.writeToConsole(level, message, ...args);
|
this.writeToConsole(level, message, ...args);
|
||||||
this.writeToFile(formattedMessage);
|
this.writeToFile(formattedMessage);
|
||||||
|
|
||||||
|
if (level === 'error' && this.sentryEnabled && this.sentryReporter) {
|
||||||
|
this.sentryReporter({
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
error: this.extractError(args),
|
||||||
|
logs: [...this.recentLogs],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, ...args: unknown[]) {
|
info(message: string, ...args: unknown[]) {
|
||||||
|
|||||||
+21
-6
@@ -1,8 +1,25 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||||
|
|
||||||
// https://vitejs.dev/config
|
// https://vitejs.dev/config
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
sentryVitePlugin({
|
||||||
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||||
|
release: {
|
||||||
|
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
define: {
|
||||||
|
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(process.cwd(), './src'),
|
'@': path.resolve(process.cwd(), './src'),
|
||||||
@@ -21,10 +38,8 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: ['better-sqlite3', 'keytar'],
|
||||||
'better-sqlite3',
|
|
||||||
'keytar',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
+30
-3
@@ -1,4 +1,31 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
// https://vitejs.dev/config
|
export default defineConfig(({ mode }) => {
|
||||||
export default defineConfig({});
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
sentryVitePlugin({
|
||||||
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||||
|
release: {
|
||||||
|
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(process.cwd(), './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['better-sqlite3', 'keytar'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,12 +1,33 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { codeInspectorPlugin } from 'code-inspector-plugin';
|
import { codeInspectorPlugin } from 'code-inspector-plugin';
|
||||||
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
sentryVitePlugin({
|
||||||
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN || env.SENTRY_AUTH_TOKEN,
|
||||||
|
release: {
|
||||||
|
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||||
|
},
|
||||||
|
// Electron loads files from app://, so we need to normalize paths for Source Map matching
|
||||||
|
sourcemaps: {
|
||||||
|
// Rewrite the source paths to strip the Electron app:// protocol
|
||||||
|
rewriteSources: (source) => {
|
||||||
|
// Transform: app:///.vite/renderer/main_window/assets/index.js
|
||||||
|
// Into: ~/.vite/renderer/main_window/assets/index.js
|
||||||
|
return source.replace(/^app:\/\/\//, '~/');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
tanstackRouter({
|
tanstackRouter({
|
||||||
target: 'react',
|
target: 'react',
|
||||||
autoCodeSplitting: true,
|
autoCodeSplitting: true,
|
||||||
@@ -19,10 +40,14 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
codeInspectorPlugin({ bundler: 'vite' }),
|
codeInspectorPlugin({ bundler: 'vite' }),
|
||||||
],
|
],
|
||||||
|
define: {
|
||||||
|
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
preserveSymlinks: true,
|
preserveSymlinks: true,
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(process.cwd(), './src'),
|
'@': path.resolve(process.cwd(), './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user