Skip to content

Commit cb17681

Browse files
committed
feat: improve updater UX (closes #16)
1 parent 16030f2 commit cb17681

20 files changed

Lines changed: 224 additions & 29 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ If PromptHub is helpful to your work, feel free to buy the author a coffee!
389389
<b>微信支付 / WeChat Pay</b>
390390
</td>
391391
<td align="center">
392-
<img src="./docs/imgs/donate/alipay.jpg" width="200" alt="Alipay"/>
392+
<img src="./docs/imgs/donate/alipay.png" width="200" alt="Alipay"/>
393393
<br/>
394394
<b>支付宝 / Alipay</b>
395395
</td>

electron-builder.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@
9090
"publish": {
9191
"provider": "github",
9292
"owner": "legeling",
93-
"repo": "PromptHub"
93+
"repo": "PromptHub",
94+
"releaseType": "release"
95+
},
96+
"releaseInfo": {
97+
"releaseNotesFile": "CHANGELOG.md"
9498
}
9599
}

src/main/updater.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BrowserWindow, ipcMain, app } from 'electron';
1+
import { BrowserWindow, ipcMain, app, shell } from 'electron';
22
import type { UpdateInfo as ElectronUpdateInfo } from 'electron-updater';
33
import { autoUpdater } from 'electron-updater';
44

@@ -52,6 +52,9 @@ function toSimpleInfo(info: ElectronUpdateInfo): SimpleUpdateInfo {
5252
}
5353

5454
let mainWindow: BrowserWindow | null = null;
55+
let lastPercent = 0; // 跟踪上次进度,防止进度回退
56+
57+
const isMac = process.platform === 'darwin';
5558

5659
export interface UpdateStatus {
5760
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
@@ -107,6 +110,13 @@ export function initUpdater(win: BrowserWindow) {
107110

108111
// 下载进度
109112
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
113+
// 防止进度回退(electron-updater 下载多个文件时会重置进度)
114+
if (progress.percent < lastPercent && lastPercent < 99) {
115+
// 进度回退时,保持上次进度
116+
console.info(`Download progress (ignored regression): ${progress.percent.toFixed(2)}% -> keeping ${lastPercent.toFixed(2)}%`);
117+
return;
118+
}
119+
lastPercent = progress.percent;
110120
console.info(`Download progress: ${progress.percent.toFixed(2)}%`);
111121
sendStatusToWindow({
112122
status: 'downloading',
@@ -159,6 +169,7 @@ export function registerUpdaterIPC() {
159169
return { success: false, error: 'Download disabled in development mode' };
160170
}
161171
try {
172+
lastPercent = 0; // 重置进度跟踪
162173
await autoUpdater.downloadUpdate();
163174
return { success: true };
164175
} catch (error) {
@@ -170,7 +181,27 @@ export function registerUpdaterIPC() {
170181
// 安装更新并重启
171182
ipcMain.handle('updater:install', async () => {
172183
if (!isDev) {
173-
autoUpdater.quitAndInstall(false, true);
184+
if (isMac) {
185+
// macOS: 打开下载目录让用户手动安装
186+
// 因为没有代码签名,自动安装会失败
187+
const downloadDir = app.getPath('downloads');
188+
shell.openPath(downloadDir);
189+
return { success: true, manual: true };
190+
} else {
191+
// Windows/Linux: 自动安装
192+
autoUpdater.quitAndInstall(false, true);
193+
return { success: true, manual: false };
194+
}
174195
}
175196
});
197+
198+
// 获取平台信息
199+
ipcMain.handle('updater:platform', () => {
200+
return process.platform;
201+
});
202+
203+
// 打开 GitHub Releases 页面
204+
ipcMain.handle('updater:openReleases', () => {
205+
shell.openExternal('https://github.com/legeling/PromptHub/releases');
206+
});
176207
}

src/preload/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ contextBridge.exposeInMainWorld('electron', {
119119
download: () => ipcRenderer.invoke('updater:download'),
120120
install: () => ipcRenderer.invoke('updater:install'),
121121
getVersion: () => ipcRenderer.invoke('updater:version'),
122+
getPlatform: () => ipcRenderer.invoke('updater:platform'),
123+
openReleases: () => ipcRenderer.invoke('updater:openReleases'),
122124
onStatus: (callback: (status: any) => void) => {
123125
ipcRenderer.on('updater:status', (_event, status) => callback(status));
124126
},
@@ -183,8 +185,10 @@ declare global {
183185
updater?: {
184186
check: () => Promise<{ success: boolean; result?: any; error?: string }>;
185187
download: () => Promise<{ success: boolean; error?: string }>;
186-
install: () => Promise<void>;
188+
install: () => Promise<{ success: boolean; manual?: boolean } | void>;
187189
getVersion: () => Promise<string>;
190+
getPlatform: () => Promise<string>;
191+
openReleases: () => Promise<void>;
188192
onStatus: (callback: (status: any) => void) => void;
189193
offStatus: () => void;
190194
};

src/renderer/App.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ function App() {
3232
// Close dialog state (Windows)
3333
const [showCloseDialog, setShowCloseDialog] = useState(false);
3434

35+
// 更新状态(用于顶部栏显示更新提示)
36+
const [updateAvailable, setUpdateAvailable] = useState<UpdateStatus | null>(null);
37+
3538
useEffect(() => {
3639
// Listen for update status
3740
const handleStatus = (status: UpdateStatus) => {
38-
// If update available, show dialog
41+
// If update available, save status for TopBar indicator (don't auto-show dialog)
3942
if (status.status === 'available') {
43+
setUpdateAvailable(status);
4044
setInitialUpdateStatus(status);
41-
setShowUpdateDialog(true);
45+
// 不再自动弹窗,用户点击顶部栏提示后才显示
46+
// setShowUpdateDialog(true);
4247
}
4348
};
4449

@@ -251,7 +256,11 @@ function App() {
251256
{/* 主内容区 */}
252257
<div className="flex flex-1 flex-col overflow-hidden">
253258
{/* 顶部栏 */}
254-
<TopBar onOpenSettings={() => setCurrentPage('settings')} />
259+
<TopBar
260+
onOpenSettings={() => setCurrentPage('settings')}
261+
updateAvailable={updateAvailable}
262+
onShowUpdateDialog={() => setShowUpdateDialog(true)}
263+
/>
255264

256265
{/* 页面内容 */}
257266
{currentPage === 'home' ? (

src/renderer/components/UpdateDialog.tsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useEffect } from 'react';
22
import { useTranslation } from 'react-i18next';
3-
import { DownloadIcon, CheckCircleIcon, XIcon, Loader2Icon, RefreshCwIcon } from 'lucide-react';
3+
import { DownloadIcon, CheckCircleIcon, XIcon, Loader2Icon, RefreshCwIcon, FolderOpenIcon, ExternalLinkIcon } from 'lucide-react';
44
import ReactMarkdown from 'react-markdown';
55
import remarkGfm from 'remark-gfm';
66

@@ -35,6 +35,7 @@ export function UpdateDialog({ isOpen, onClose, initialStatus }: UpdateDialogPro
3535
const { t } = useTranslation();
3636
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(initialStatus || null);
3737
const [currentVersion, setCurrentVersion] = useState<string>('');
38+
const [platform, setPlatform] = useState<string>('');
3839

3940
useEffect(() => {
4041
if (initialStatus) {
@@ -43,8 +44,9 @@ export function UpdateDialog({ isOpen, onClose, initialStatus }: UpdateDialogPro
4344
}, [initialStatus]);
4445

4546
useEffect(() => {
46-
// 获取当前版本
47+
// 获取当前版本和平台
4748
window.electron?.updater?.getVersion().then(setCurrentVersion);
49+
window.electron?.updater?.getPlatform?.().then(setPlatform);
4850

4951
// 监听更新状态
5052
const handleStatus = (status: UpdateStatus) => {
@@ -216,6 +218,7 @@ export function UpdateDialog({ isOpen, onClose, initialStatus }: UpdateDialogPro
216218
);
217219

218220
case 'downloaded':
221+
const isMac = platform === 'darwin';
219222
return (
220223
<div className="py-4">
221224
<div className="flex items-center gap-3 mb-4">
@@ -225,16 +228,30 @@ export function UpdateDialog({ isOpen, onClose, initialStatus }: UpdateDialogPro
225228
<div>
226229
<h3 className="font-semibold text-lg">{t('settings.downloadComplete')}</h3>
227230
<p className="text-sm text-muted-foreground">
228-
{t('settings.downloadCompleteDesc')}
231+
{isMac ? '' : t('settings.downloadCompleteDesc')}
229232
</p>
230233
</div>
231234
</div>
235+
{isMac && (
236+
<div className="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
237+
<p className="text-sm text-amber-600 dark:text-amber-400 whitespace-pre-line">
238+
{t('settings.macManualInstall')}
239+
</p>
240+
</div>
241+
)}
232242
<div className="flex gap-2">
233243
<button
234244
onClick={handleInstall}
235245
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
236246
>
237-
{t('settings.installNow')}
247+
{isMac ? (
248+
<>
249+
<FolderOpenIcon className="w-4 h-4" />
250+
{t('settings.openDownloadFolder')}
251+
</>
252+
) : (
253+
t('settings.installNow')
254+
)}
238255
</button>
239256
<button
240257
onClick={onClose}
@@ -253,12 +270,22 @@ export function UpdateDialog({ isOpen, onClose, initialStatus }: UpdateDialogPro
253270
<XIcon className="w-6 h-6 text-red-500" />
254271
</div>
255272
<h3 className="font-semibold text-lg mb-1 text-red-500">{t('common.error')}</h3>
256-
<p className="text-sm text-muted-foreground break-all whitespace-pre-wrap max-h-40 overflow-y-auto">
273+
<p className="text-sm text-muted-foreground break-all whitespace-pre-wrap max-h-32 overflow-y-auto mb-4">
257274
{updateStatus.error}
258275
</p>
276+
<div className="p-3 rounded-lg bg-muted/50 mb-4">
277+
<p className="text-sm text-muted-foreground mb-2">{t('settings.manualDownloadHint')}</p>
278+
<button
279+
onClick={() => window.electron?.updater?.openReleases()}
280+
className="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
281+
>
282+
<ExternalLinkIcon className="w-4 h-4" />
283+
{t('settings.manualDownload')}
284+
</button>
285+
</div>
259286
<button
260287
onClick={handleCheckUpdate}
261-
className="mt-4 px-4 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors"
288+
className="px-4 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors"
262289
>
263290
{t('settings.checkUpdate')}
264291
</button>

src/renderer/components/layout/MainContent.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { StarIcon, CopyIcon, HistoryIcon, HashIcon, SparklesIcon, EditIcon, Tras
66
import { EditPromptModal, VersionHistoryModal, VariableInputModal, PromptListHeader, PromptListView, PromptTableView, AiTestModal, PromptDetailModal, PromptGalleryView } from '../prompt';
77
import { ContextMenu, ContextMenuItem } from '../ui/ContextMenu';
88
import { ImagePreviewModal } from '../ui/ImagePreviewModal';
9+
import { LocalImage } from '../ui/LocalImage';
910
import { ConfirmDialog } from '../ui/ConfirmDialog';
1011
import { useToast } from '../ui/Toast';
1112
import { chatCompletion, buildMessagesFromPrompt, multiModelCompare, AITestResult, StreamCallbacks } from '../../services/ai';
@@ -832,10 +833,11 @@ export function MainContent() {
832833
<div className="flex flex-wrap gap-3">
833834
{selectedPrompt.images.map((img, index) => (
834835
<div key={index} className="rounded-lg overflow-hidden border border-border shadow-sm">
835-
<img
836-
src={`local-image://${img}`}
836+
<LocalImage
837+
src={img}
837838
alt={`image-${index}`}
838839
className="max-w-[160px] max-h-[160px] object-cover hover:scale-105 transition-transform duration-300 cursor-pointer"
840+
fallbackClassName="w-[160px] h-[120px]"
839841
onClick={() => setPreviewImage(img)}
840842
/>
841843
</div>

src/renderer/components/layout/TopBar.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { SearchIcon, PlusIcon, SettingsIcon, SunIcon, MoonIcon } from 'lucide-react';
1+
import { SearchIcon, PlusIcon, SettingsIcon, SunIcon, MoonIcon, DownloadIcon } from 'lucide-react';
2+
import { UpdateStatus } from '../UpdateDialog';
23
import { usePromptStore } from '../../stores/prompt.store';
34
import { useSettingsStore } from '../../stores/settings.store';
45
import { useFolderStore } from '../../stores/folder.store';
@@ -8,9 +9,11 @@ import { useTranslation } from 'react-i18next';
89

910
interface TopBarProps {
1011
onOpenSettings: () => void;
12+
updateAvailable?: UpdateStatus | null;
13+
onShowUpdateDialog?: () => void;
1114
}
1215

13-
export function TopBar({ onOpenSettings }: TopBarProps) {
16+
export function TopBar({ onOpenSettings, updateAvailable, onShowUpdateDialog }: TopBarProps) {
1417
const { t } = useTranslation();
1518
const searchQuery = usePromptStore((state) => state.searchQuery);
1619
const setSearchQuery = usePromptStore((state) => state.setSearchQuery);
@@ -94,6 +97,19 @@ export function TopBar({ onOpenSettings }: TopBarProps) {
9497

9598
{/* 右侧操作按钮 - 只有按钮本身不可拖动 */}
9699
<div className="flex items-center gap-1 ml-4">
100+
{/* 更新提示 */}
101+
{updateAvailable && updateAvailable.status === 'available' && (
102+
<button
103+
onClick={onShowUpdateDialog}
104+
className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-green-500/10 text-green-600 dark:text-green-400 text-sm font-medium hover:bg-green-500/20 transition-colors animate-pulse"
105+
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
106+
title={t('settings.updateAvailable')}
107+
>
108+
<DownloadIcon className="w-4 h-4" />
109+
<span className="hidden sm:inline">{t('settings.newVersion', { version: updateAvailable.info?.version })}</span>
110+
</button>
111+
)}
112+
97113
{/* 新建按钮 */}
98114
<button
99115
onClick={() => setIsCreateModalOpen(true)}

src/renderer/components/prompt/PromptDetailModal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
22
import { StarIcon, HashIcon, ClockIcon, CopyIcon, CheckIcon, SparklesIcon, EditIcon, MaximizeIcon, MinimizeIcon, GlobeIcon } from 'lucide-react';
33
import { Modal } from '../ui/Modal';
44
import { ImagePreviewModal } from '../ui/ImagePreviewModal';
5+
import { LocalImage } from '../ui/LocalImage';
56
import type { Prompt } from '../../../shared/types';
67
import { useEffect, useMemo, useState } from 'react';
78
import ReactMarkdown from 'react-markdown';
@@ -237,10 +238,11 @@ export function PromptDetailModal({
237238
<div className="flex flex-wrap gap-4">
238239
{prompt.images.map((img, index) => (
239240
<div key={index} className="rounded-lg overflow-hidden border border-border shadow-sm">
240-
<img
241-
src={`local-image://${img}`}
241+
<LocalImage
242+
src={img}
242243
alt={`image-${index}`}
243244
className="max-w-[200px] max-h-[200px] object-cover hover:scale-105 transition-transform duration-300 cursor-pointer"
245+
fallbackClassName="w-[200px] h-[150px]"
244246
onClick={() => setPreviewImage(img)}
245247
/>
246248
</div>

src/renderer/components/prompt/PromptGalleryView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
import { useRef, memo } from 'react';
2+
import { useRef, memo, useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44
import { Prompt } from '../../../shared/types';
55
import { ImageIcon, FolderIcon, HashIcon, MoreHorizontalIcon, StarIcon, EditIcon, TrashIcon, CopyIcon, PlayIcon, HistoryIcon } from 'lucide-react';
@@ -29,7 +29,8 @@ const GalleryCard = memo(({
2929
onToggleFavorite: (e: React.MouseEvent) => void;
3030
folderName?: string;
3131
}) => {
32-
const imageSrc = prompt.images && prompt.images.length > 0
32+
const [imageError, setImageError] = useState(false);
33+
const imageSrc = prompt.images && prompt.images.length > 0 && !imageError
3334
? `local-image://${prompt.images[0]}`
3435
: null;
3536

@@ -46,6 +47,7 @@ const GalleryCard = memo(({
4647
alt={prompt.title}
4748
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
4849
loading="lazy"
50+
onError={() => setImageError(true)}
4951
/>
5052
) : (
5153
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground/30">

0 commit comments

Comments
 (0)