diff --git a/electron/main.ts b/electron/main.ts index bde6a50..a13ab2b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -95,11 +95,6 @@ let isDownloadInProgress = false let downloadProgressHandler: ((progress: any) => void) | null = null let downloadedHandler: (() => void) | null = null -interface ExportTaskControlState { - pauseRequested: boolean - stopRequested: boolean -} - type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid' type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' @@ -126,31 +121,11 @@ interface AnnualReportYearsTaskState { updatedAt: number } -const exportTaskControlMap = new Map() -const pendingExportTaskControlMap = new Map() const annualReportYearsLoadTasks = new Map() const annualReportYearsTaskByCacheKey = new Map() const annualReportYearsSnapshotCache = new Map() const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000 -const getTaskControlState = (taskId?: string): ExportTaskControlState | null => { - const normalized = typeof taskId === 'string' ? taskId.trim() : '' - if (!normalized) return null - return exportTaskControlMap.get(normalized) || null -} - -const createTaskControlState = (taskId?: string): string | null => { - const normalized = typeof taskId === 'string' ? taskId.trim() : '' - if (!normalized) return null - const pending = pendingExportTaskControlMap.get(normalized) - exportTaskControlMap.set(normalized, { - pauseRequested: Boolean(pending?.pauseRequested), - stopRequested: Boolean(pending?.stopRequested) - }) - pendingExportTaskControlMap.delete(normalized) - return normalized -} - const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => { const years = Array.isArray(snapshot.years) ? [...snapshot.years] : [] return { ...snapshot, years } @@ -212,22 +187,6 @@ const isYearsLoadCanceled = (taskId: string): boolean => { return task?.canceled === true } -const clearTaskControlState = (taskId?: string): void => { - const normalized = typeof taskId === 'string' ? taskId.trim() : '' - if (!normalized) return - exportTaskControlMap.delete(normalized) - pendingExportTaskControlMap.delete(normalized) -} - -const queueTaskControlRequest = (taskId: string, action: 'pause' | 'stop'): void => { - const normalized = taskId.trim() - if (!normalized) return - const existing = pendingExportTaskControlMap.get(normalized) || { pauseRequested: false, stopRequested: false } - if (action === 'pause') existing.pauseRequested = true - if (action === 'stop') existing.stopRequested = true - pendingExportTaskControlMap.set(normalized, existing) -} - function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options @@ -1269,27 +1228,17 @@ function registerIpcHandlers() { }) ipcMain.handle('sns:exportTimeline', async (event, options: any) => { - const taskId = typeof options?.taskId === 'string' ? options.taskId : undefined - const controlId = createTaskControlState(taskId) const exportOptions = { ...(options || {}) } delete exportOptions.taskId - try { - return snsService.exportTimeline( - exportOptions, - (progress) => { - if (!event.sender.isDestroyed()) { - event.sender.send('sns:exportProgress', progress) - } - }, - { - shouldPause: () => Boolean(getTaskControlState(controlId || undefined)?.pauseRequested), - shouldStop: () => Boolean(getTaskControlState(controlId || undefined)?.stopRequested) + return snsService.exportTimeline( + exportOptions, + (progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('sns:exportProgress', progress) } - ) - } finally { - clearTaskControlState(controlId || undefined) - } + } + ) }) ipcMain.handle('sns:selectExportDir', async () => { @@ -1412,42 +1361,14 @@ function registerIpcHandlers() { return exportService.getExportStats(sessionIds, options) }) - ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, taskId?: string) => { - const controlId = createTaskControlState(taskId) + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { const onProgress = (progress: ExportProgress) => { if (!event.sender.isDestroyed()) { event.sender.send('export:progress', progress) } } - try { - return exportService.exportSessions(sessionIds, outputDir, options, onProgress, { - shouldPause: () => Boolean(getTaskControlState(controlId || undefined)?.pauseRequested), - shouldStop: () => Boolean(getTaskControlState(controlId || undefined)?.stopRequested) - }) - } finally { - clearTaskControlState(controlId || undefined) - } - }) - - ipcMain.handle('export:pauseTask', async (_, taskId: string) => { - const state = getTaskControlState(taskId) - if (!state) { - queueTaskControlRequest(taskId, 'pause') - return { success: true, queued: true } - } - state.pauseRequested = true - return { success: true } - }) - - ipcMain.handle('export:stopTask', async (_, taskId: string) => { - const state = getTaskControlState(taskId) - if (!state) { - queueTaskControlRequest(taskId, 'stop') - return { success: true, queued: true } - } - state.stopRequested = true - return { success: true } + return exportService.exportSessions(sessionIds, outputDir, options, onProgress) }) ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { diff --git a/electron/preload.ts b/electron/preload.ts index 800e2e7..5aa8731 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -306,16 +306,12 @@ contextBridge.exposeInMainWorld('electronAPI', { export: { getExportStats: (sessionIds: string[], options: any) => ipcRenderer.invoke('export:getExportStats', sessionIds, options), - exportSessions: (sessionIds: string[], outputDir: string, options: any, taskId?: string) => - ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, taskId), + exportSessions: (sessionIds: string[], outputDir: string, options: any) => + ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), exportSession: (sessionId: string, outputPath: string, options: any) => ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), exportContacts: (outputDir: string, options: any) => ipcRenderer.invoke('export:exportContacts', outputDir, options), - pauseTask: (taskId: string) => - ipcRenderer.invoke('export:pauseTask', taskId), - stopTask: (taskId: string) => - ipcRenderer.invoke('export:stopTask', taskId), onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index fa26455..346362b 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -6331,15 +6331,15 @@ class ExportService { total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, - phase: 'exporting', + phase: 'complete', phaseLabel: '该会话没有消息,已跳过' }) return 'done' } if (emptySessionIds.has(sessionId)) { - failCount++ - failedSessionIds.push(sessionId) + successCount++ + successSessionIds.push(sessionId) activeSessionRatios.delete(sessionId) completedCount++ onProgress?.({ @@ -6347,7 +6347,7 @@ class ExportService { total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, - phase: 'exporting', + phase: 'complete', phaseLabel: '该会话没有消息,已跳过' }) return 'done' @@ -6419,7 +6419,7 @@ class ExportService { total: sessionIds.length, currentSession: sessionInfo.displayName, currentSessionId: sessionId, - phase: 'exporting', + phase: 'complete', phaseLabel: '无变化,已跳过' }) return 'done' @@ -6463,6 +6463,14 @@ class ExportService { failCount++ failedSessionIds.push(sessionId) console.error(`导出 ${sessionId} 失败:`, result.error) + onProgress?.({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: '导出失败' + }) } activeSessionRatios.delete(sessionId) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7d09b17..f84805c 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -40,8 +40,7 @@ import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' -type TaskStatus = 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' -type TaskControlState = 'pausing' | 'stopping' +type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' type ContentCardType = ContentType | 'sns' @@ -124,7 +123,6 @@ interface ExportTask { id: string title: string status: TaskStatus - controlState?: TaskControlState createdAt: number startedAt?: number finishedAt?: number @@ -353,13 +351,7 @@ const formatDurationMs = (ms: number): string => { const getTaskStatusLabel = (task: ExportTask): string => { if (task.status === 'queued') return '排队中' - if (task.status === 'running') { - if (task.controlState === 'pausing') return '暂停中' - if (task.controlState === 'stopping') return '停止中' - return '进行中' - } - if (task.status === 'paused') return '已暂停' - if (task.status === 'stopped') return '已停止' + if (task.status === 'running') return '进行中' if (task.status === 'success') return '已完成' return '失败' } @@ -429,6 +421,7 @@ const parseDateInput = (value: string, endOfDay: boolean): Date => { const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { if (session.username.endsWith('@chatroom')) return 'group' + if (session.username.startsWith('gh_')) return 'official' if (contact?.type === 'official') return 'official' if (contact?.type === 'former_friend') return 'former_friend' return 'private' @@ -445,6 +438,13 @@ const isContentScopeSession = (session: SessionRow): boolean => ( session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' ) +const exportKindPriority: Record = { + private: 0, + group: 1, + former_friend: 2, + official: 3 +} + const getAvatarLetter = (name: string): string => { if (!name) return '?' return [...name][0] || '?' @@ -2277,7 +2277,6 @@ function ExportPage() { updateTask(next.id, task => ({ ...task, status: 'running', - controlState: undefined, startedAt: Date.now(), finishedAt: undefined, error: undefined, @@ -2338,43 +2337,17 @@ function ExportPage() { exportLivePhotos: snsOptions.exportLivePhotos, exportVideos: snsOptions.exportVideos, startTime: snsOptions.startTime, - endTime: snsOptions.endTime, - taskId: next.id + endTime: snsOptions.endTime }) if (!result.success) { updateTask(next.id, task => ({ ...task, status: 'error', - controlState: undefined, finishedAt: Date.now(), error: result.error || '朋友圈导出失败', performance: finalizeTaskPerformance(task, Date.now()) })) - } else if (result.stopped) { - updateTask(next.id, task => ({ - ...task, - status: 'stopped', - controlState: undefined, - finishedAt: Date.now(), - progress: { - ...task.progress, - phaseLabel: '已停止' - }, - performance: finalizeTaskPerformance(task, Date.now()) - })) - } else if (result.paused) { - updateTask(next.id, task => ({ - ...task, - status: 'paused', - controlState: undefined, - finishedAt: Date.now(), - progress: { - ...task.progress, - phaseLabel: '已暂停' - }, - performance: finalizeTaskPerformance(task, Date.now()) - })) } else { const doneAt = Date.now() const exportedPosts = Math.max(0, result.postCount || 0) @@ -2386,7 +2359,6 @@ function ExportPage() { updateTask(next.id, task => ({ ...task, status: 'success', - controlState: undefined, finishedAt: doneAt, progress: { ...task.progress, @@ -2407,23 +2379,19 @@ function ExportPage() { const result = await window.electronAPI.export.exportSessions( next.payload.sessionIds, next.payload.outputDir, - next.payload.options, - next.id + next.payload.options ) if (!result.success) { updateTask(next.id, task => ({ ...task, status: 'error', - controlState: undefined, finishedAt: Date.now(), error: result.error || '导出失败', performance: finalizeTaskPerformance(task, Date.now()) })) } else { const doneAt = Date.now() - const successCount = result.successCount ?? 0 - const failCount = result.failCount ?? 0 const contentTypes = next.payload.contentType ? [next.payload.contentType] : inferContentTypesFromOptions(next.payload.options) @@ -2435,83 +2403,20 @@ function ExportPage() { markContentExported(successSessionIds, contentTypes, doneAt) } - if (result.stopped) { - updateTask(next.id, task => ({ - ...task, - status: 'stopped', - controlState: undefined, - finishedAt: doneAt, - progress: { - ...task.progress, - current: successCount + failCount, - total: task.progress.total || next.payload.sessionIds.length, - phaseLabel: '已停止' - }, - performance: finalizeTaskPerformance(task, doneAt) - })) - } else if (result.paused) { - const pendingSessionIds = Array.isArray(result.pendingSessionIds) - ? result.pendingSessionIds - : [] - const sessionNameMap = new Map() - next.payload.sessionIds.forEach((sessionId, index) => { - sessionNameMap.set(sessionId, next.payload.sessionNames[index] || sessionId) - }) - const pendingSessionNames = pendingSessionIds.map(sessionId => sessionNameMap.get(sessionId) || sessionId) - - if (pendingSessionIds.length === 0) { - updateTask(next.id, task => ({ - ...task, - status: 'success', - controlState: undefined, - finishedAt: doneAt, - progress: { - ...task.progress, - current: task.progress.total || next.payload.sessionIds.length, - total: task.progress.total || next.payload.sessionIds.length, - phaseLabel: '完成', - phaseProgress: 1, - phaseTotal: 1 - }, - performance: finalizeTaskPerformance(task, doneAt) - })) - } else { - updateTask(next.id, task => ({ - ...task, - status: 'paused', - controlState: undefined, - finishedAt: doneAt, - payload: { - ...task.payload, - sessionIds: pendingSessionIds, - sessionNames: pendingSessionNames - }, - progress: { - ...task.progress, - current: successCount + failCount, - total: task.progress.total || next.payload.sessionIds.length, - phaseLabel: '已暂停' - }, - performance: finalizeTaskPerformance(task, doneAt) - })) - } - } else { - updateTask(next.id, task => ({ - ...task, - status: 'success', - controlState: undefined, - finishedAt: doneAt, - progress: { - ...task.progress, - current: task.progress.total || next.payload.sessionIds.length, - total: task.progress.total || next.payload.sessionIds.length, - phaseLabel: '完成', - phaseProgress: 1, - phaseTotal: 1 - }, - performance: finalizeTaskPerformance(task, doneAt) - })) - } + updateTask(next.id, task => ({ + ...task, + status: 'success', + finishedAt: doneAt, + progress: { + ...task.progress, + current: task.progress.total || next.payload.sessionIds.length, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '完成', + phaseProgress: 1, + phaseTotal: 1 + }, + performance: finalizeTaskPerformance(task, doneAt) + })) } } } catch (error) { @@ -2519,7 +2424,6 @@ function ExportPage() { updateTask(next.id, task => ({ ...task, status: 'error', - controlState: undefined, finishedAt: doneAt, error: String(error), performance: finalizeTaskPerformance(task, doneAt) @@ -2543,91 +2447,6 @@ function ExportPage() { } }, []) - const pauseTask = useCallback(async (taskId: string) => { - const target = tasksRef.current.find(task => task.id === taskId) - if (!target) return - - if (target.status === 'queued') { - updateTask(taskId, task => ({ - ...task, - status: 'paused', - controlState: undefined, - performance: finalizeTaskPerformance(task, Date.now()) - })) - return - } - - if (target.status !== 'running') return - - updateTask(taskId, task => ( - task.status === 'running' - ? { ...task, controlState: 'pausing' } - : task - )) - - const result = await window.electronAPI.export.pauseTask(taskId) - if (!result.success) { - updateTask(taskId, task => ( - task.status === 'running' - ? { ...task, controlState: undefined } - : task - )) - window.alert(result.error || '暂停任务失败,请重试') - } - }, [updateTask]) - - const resumeTask = useCallback((taskId: string) => { - updateTask(taskId, task => { - if (task.status !== 'paused') return task - return { - ...task, - status: 'queued', - controlState: undefined - } - }) - }, [updateTask]) - - const stopTask = useCallback(async (taskId: string) => { - const target = tasksRef.current.find(task => task.id === taskId) - if (!target) return - const shouldStop = window.confirm('确认停止该导出任务吗?') - if (!shouldStop) return - - if (target.status === 'queued' || target.status === 'paused') { - const doneAt = Date.now() - updateTask(taskId, task => ({ - ...task, - status: 'stopped', - controlState: undefined, - finishedAt: doneAt, - progress: { - ...task.progress, - phaseLabel: '已停止' - }, - performance: finalizeTaskPerformance(task, doneAt) - })) - return - } - - if (target.status !== 'running') return - - updateTask(taskId, task => ( - task.status === 'running' - ? { ...task, controlState: 'stopping' } - : task - )) - - const result = await window.electronAPI.export.stopTask(taskId) - if (!result.success) { - updateTask(taskId, task => ( - task.status === 'running' - ? { ...task, controlState: undefined } - : task - )) - window.alert(result.error || '停止任务失败,请重试') - } - }, [updateTask]) - const createTask = async () => { if (!exportDialog.open || !exportFolder) return if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return @@ -2688,12 +2507,45 @@ function ExportPage() { }) } + const resolveSessionExistingMessageCount = useCallback((session: SessionRow): number => { + const counted = normalizeMessageCount(sessionMessageCounts[session.username]) + if (typeof counted === 'number') return counted + const hinted = normalizeMessageCount(session.messageCountHint) + if (typeof hinted === 'number') return hinted + return 0 + }, [sessionMessageCounts]) + + const orderSessionsForExport = useCallback((source: SessionRow[]): SessionRow[] => { + return source + .filter((session) => session.hasSession && isContentScopeSession(session)) + .map((session) => ({ + session, + count: resolveSessionExistingMessageCount(session) + })) + .filter((item) => item.count > 0) + .sort((a, b) => { + const kindDiff = exportKindPriority[a.session.kind] - exportKindPriority[b.session.kind] + if (kindDiff !== 0) return kindDiff + if (a.count !== b.count) return b.count - a.count + const tsA = a.session.sortTimestamp || a.session.lastTimestamp || 0 + const tsB = b.session.sortTimestamp || b.session.lastTimestamp || 0 + if (tsA !== tsB) return tsB - tsA + return (a.session.displayName || a.session.username) + .localeCompare(b.session.displayName || b.session.username, 'zh-Hans-CN') + }) + .map((item) => item.session) + }, [resolveSessionExistingMessageCount]) + const openBatchExport = () => { - const selectable = new Set(sessions.filter(session => session.hasSession).map(session => session.username)) - const ids = Array.from(selectedSessions).filter(id => selectable.has(id)) - if (ids.length === 0) return - const nameMap = new Map(sessions.map(session => [session.username, session.displayName || session.username])) - const names = ids.map(id => nameMap.get(id) || id) + const selectedSet = new Set(selectedSessions) + const selectedRows = sessions.filter((session) => selectedSet.has(session.username)) + const orderedRows = orderSessionsForExport(selectedRows) + if (orderedRows.length === 0) { + window.alert('所选会话暂无可导出的消息(总消息数为 0)') + return + } + const ids = orderedRows.map((session) => session.username) + const names = orderedRows.map((session) => session.displayName || session.username) openExportDialog({ scope: 'multi', @@ -2704,13 +2556,13 @@ function ExportPage() { } const openContentExport = (contentType: ContentType) => { - const ids = sessions - .filter(session => session.hasSession && isContentScopeSession(session)) - .map(session => session.username) - - const names = sessions - .filter(session => session.hasSession && isContentScopeSession(session)) - .map(session => session.displayName || session.username) + const orderedRows = orderSessionsForExport(sessions) + if (orderedRows.length === 0) { + window.alert('当前会话列表暂无可导出的消息(总消息数为 0)') + return + } + const ids = orderedRows.map((session) => session.username) + const names = orderedRows.map((session) => session.displayName || session.username) openExportDialog({ scope: 'content', @@ -2752,17 +2604,6 @@ function ExportPage() { return set }, [tasks]) - const pausedSessionIds = useMemo(() => { - const set = new Set() - for (const task of tasks) { - if (task.status !== 'paused') continue - for (const id of task.payload.sessionIds) { - set.add(id) - } - } - return set - }, [tasks]) - const inProgressSessionIds = useMemo(() => { const set = new Set() for (const task of tasks) { @@ -3567,7 +3408,6 @@ function ExportPage() { const isRunning = runningSessionIds.has(session.username) const isQueued = queuedSessionIds.has(session.username) - const isPaused = pausedSessionIds.has(session.username) const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) return ( @@ -3580,8 +3420,8 @@ function ExportPage() { 详情 {recent && {recent}} @@ -3668,7 +3508,6 @@ function ExportPage() { const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length - const taskPausedCount = tasks.filter(task => task.status === 'paused').length const showInitialSkeleton = isLoading && sessions.length === 0 const chooseExportFolder = useCallback(async () => { const result = await window.electronAPI.dialog.openFile({ @@ -3722,7 +3561,6 @@ function ExportPage() {
进行中 {taskRunningCount} 排队 {taskQueuedCount} - 暂停 {taskPausedCount} 总计 {tasks.length}
- )} - {task.status === 'paused' && ( - - )} - {(task.status === 'running' || task.status === 'queued' || task.status === 'paused') && ( - - )} @@ -4253,7 +4062,6 @@ function ExportPage() { const canExport = Boolean(matchedSession?.hasSession) const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username) - const isPaused = canExport && pausedSessionIds.has(contact.username) const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) @@ -4300,8 +4108,8 @@ function ExportPage() { 详情 {recent && {recent}} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 0e45244..8957d40 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -692,12 +692,10 @@ export interface ElectronAPI { estimatedSeconds: number sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> }> - exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions, taskId?: string) => Promise<{ + exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ success: boolean successCount?: number failCount?: number - paused?: boolean - stopped?: boolean pendingSessionIds?: string[] successSessionIds?: string[] failedSessionIds?: string[] @@ -712,8 +710,6 @@ export interface ElectronAPI { successCount?: number error?: string }> - pauseTask: (taskId: string) => Promise<{ success: boolean; error?: string }> - stopTask: (taskId: string) => Promise<{ success: boolean; error?: string }> onProgress: (callback: (payload: ExportProgress) => void) => () => void } whisper: { @@ -767,8 +763,7 @@ export interface ElectronAPI { exportVideos?: boolean startTime?: number endTime?: number - taskId?: string - }) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> + }) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>