markpdfdown 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { app, ipcMain, dialog, protocol, nativeImage, BrowserWindow, shell } from "electron";
1
+ import { app, ipcMain, dialog, shell, safeStorage, protocol, nativeImage, BrowserWindow } from "electron";
2
2
  import path from "path";
3
3
  import fs, { promises } from "fs";
4
4
  import isDev from "electron-is-dev";
@@ -1318,23 +1318,23 @@ class LLMClientFactory {
1318
1318
  static async createClient(type, apiKey, baseUrl) {
1319
1319
  switch (type) {
1320
1320
  case "openai": {
1321
- const OpenAIModule = await import("./OpenAIClient-gyy2nFkw.js");
1321
+ const OpenAIModule = await import("./OpenAIClient-DsHXIb_l.js");
1322
1322
  return new OpenAIModule.OpenAIClient(apiKey, baseUrl || "");
1323
1323
  }
1324
1324
  case "openai-responses": {
1325
- const OpenAIResponsesModule = await import("./OpenAIResponsesClient-DETYz2nL.js");
1325
+ const OpenAIResponsesModule = await import("./OpenAIResponsesClient-DgM49Ri3.js");
1326
1326
  return new OpenAIResponsesModule.OpenAIResponsesClient(apiKey, baseUrl || "");
1327
1327
  }
1328
1328
  case "gemini": {
1329
- const GeminiModule = await import("./GeminiClient-CrtYbwaF.js");
1329
+ const GeminiModule = await import("./GeminiClient-DBJawhLe.js");
1330
1330
  return new GeminiModule.GeminiClient(apiKey, baseUrl || "");
1331
1331
  }
1332
1332
  case "anthropic": {
1333
- const AnthropicModule = await import("./AnthropicClient-CTbHYiqm.js");
1333
+ const AnthropicModule = await import("./AnthropicClient-Dm_xth4j.js");
1334
1334
  return new AnthropicModule.AnthropicClient(apiKey, baseUrl || "");
1335
1335
  }
1336
1336
  case "ollama": {
1337
- const OllamaModule = await import("./OllamaClient-DKJsnvIt.js");
1337
+ const OllamaModule = await import("./OllamaClient-CJY8PExo.js");
1338
1338
  return new OllamaModule.OllamaClient(apiKey, baseUrl || "");
1339
1339
  }
1340
1340
  default:
@@ -3506,19 +3506,27 @@ function registerFileHandlers() {
3506
3506
  }
3507
3507
  }
3508
3508
  );
3509
- ipcMain.handle(IPC_CHANNELS.FILE.SELECT_DIALOG, async () => {
3509
+ ipcMain.handle(IPC_CHANNELS.FILE.SELECT_DIALOG, async (_, allowOffice) => {
3510
3510
  try {
3511
+ const pdfAndImageExtensions = ["pdf", "jpg", "jpeg", "png", "bmp", "gif"];
3512
+ const officeExtensions = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"];
3513
+ const filters = allowOffice ? [
3514
+ {
3515
+ name: "Supported Files",
3516
+ extensions: [...pdfAndImageExtensions, ...officeExtensions]
3517
+ },
3518
+ { name: "PDF and Images", extensions: pdfAndImageExtensions },
3519
+ { name: "Office Documents", extensions: officeExtensions },
3520
+ { name: "All Files", extensions: ["*"] }
3521
+ ] : [
3522
+ { name: "PDF and Images", extensions: pdfAndImageExtensions },
3523
+ { name: "PDF Documents", extensions: ["pdf"] },
3524
+ { name: "Images", extensions: ["jpg", "jpeg", "png", "bmp", "gif"] },
3525
+ { name: "All Files", extensions: ["*"] }
3526
+ ];
3511
3527
  const result = await dialog.showOpenDialog({
3512
3528
  properties: ["openFile", "multiSelections"],
3513
- filters: [
3514
- {
3515
- name: "PDF and Images",
3516
- extensions: ["pdf", "jpg", "jpeg", "png", "bmp", "gif"]
3517
- },
3518
- { name: "PDF Documents", extensions: ["pdf"] },
3519
- { name: "Images", extensions: ["jpg", "jpeg", "png", "bmp", "gif"] },
3520
- { name: "All Files", extensions: ["*"] }
3521
- ]
3529
+ filters
3522
3530
  });
3523
3531
  return {
3524
3532
  success: true,
@@ -3723,6 +3731,7 @@ function registerAppHandlers() {
3723
3731
  return getAppVersion();
3724
3732
  });
3725
3733
  }
3734
+ const API_BASE_URL = process.env.API_BASE_URL || "https://markdown.fit";
3726
3735
  class WindowManager {
3727
3736
  static instance;
3728
3737
  mainWindow = null;
@@ -3757,186 +3766,1734 @@ class WindowManager {
3757
3766
  }
3758
3767
  }
3759
3768
  const windowManager = WindowManager.getInstance();
3760
- const { autoUpdater } = pkg$1;
3761
- class UpdateService {
3769
+ const REFRESH_TOKEN_DIR = "auth";
3770
+ const REFRESH_TOKEN_FILE = "refresh_token.enc";
3771
+ const TOKEN_REFRESH_MARGIN_MS = 60 * 1e3;
3772
+ const DEVICE_POLL_INTERVAL_MS = 5e3;
3773
+ const INIT_RETRY_DELAY_MS = 30 * 1e3;
3774
+ const MAX_AUTO_REFRESH_RETRIES = 3;
3775
+ class AuthTokenInvalidError extends Error {
3776
+ constructor(message) {
3777
+ super(message);
3778
+ this.name = "AuthTokenInvalidError";
3779
+ }
3780
+ }
3781
+ function buildUserAgent() {
3782
+ const appVersion = app.getVersion();
3783
+ const electronVersion = process.versions.electron;
3784
+ const chromeVersion = process.versions.chrome;
3785
+ const nodeVersion = process.versions.node;
3786
+ const platform = `${process.platform}; ${process.arch}`;
3787
+ return `MarkPDFdown/${appVersion} Electron/${electronVersion} Chrome/${chromeVersion} Node/${nodeVersion} (${platform})`;
3788
+ }
3789
+ class AuthManager {
3762
3790
  static instance;
3763
- initialized = false;
3764
- isChecking = false;
3791
+ accessToken = null;
3792
+ accessTokenExpiresAt = 0;
3793
+ refreshToken = null;
3794
+ userProfile = null;
3795
+ deviceFlowStatus = "idle";
3796
+ userCode = null;
3797
+ verificationUrl = null;
3798
+ error = null;
3799
+ isLoading = false;
3800
+ pollTimer = null;
3801
+ refreshTimer = null;
3802
+ initRetryTimer = null;
3803
+ autoRefreshRetryCount = 0;
3804
+ deviceCode = null;
3805
+ pollExpiresAt = 0;
3806
+ userAgent = "";
3807
+ refreshInFlight = null;
3765
3808
  constructor() {
3766
3809
  }
3767
3810
  static getInstance() {
3768
- if (!UpdateService.instance) {
3769
- UpdateService.instance = new UpdateService();
3811
+ if (!AuthManager.instance) {
3812
+ AuthManager.instance = new AuthManager();
3770
3813
  }
3771
- return UpdateService.instance;
3772
- }
3773
- ensureInitialized() {
3774
- if (this.initialized) return;
3775
- if (!app.isPackaged) return;
3776
- this.initialized = true;
3777
- autoUpdater.autoDownload = true;
3778
- autoUpdater.allowPrerelease = false;
3779
- autoUpdater.autoInstallOnAppQuit = true;
3780
- this.registerListeners();
3781
- }
3782
- registerListeners() {
3783
- autoUpdater.on("checking-for-update", () => {
3784
- console.log("[UpdateService] Checking for updates...");
3785
- this.sendStatus({ status: UpdateStatus.CHECKING });
3786
- });
3787
- autoUpdater.on("update-available", (info) => {
3788
- console.log("[UpdateService] Update available:", info.version);
3789
- this.sendStatus({ status: UpdateStatus.AVAILABLE, version: info.version });
3790
- });
3791
- autoUpdater.on("update-not-available", (info) => {
3792
- console.log("[UpdateService] No update available. Current version:", info.version);
3793
- this.sendStatus({ status: UpdateStatus.NOT_AVAILABLE, version: info.version });
3794
- });
3795
- autoUpdater.on("download-progress", (progress) => {
3796
- console.log(`[UpdateService] Download progress: ${progress.percent.toFixed(1)}%`);
3797
- this.sendStatus({ status: UpdateStatus.DOWNLOADING, progress: progress.percent });
3798
- });
3799
- autoUpdater.on("update-downloaded", (info) => {
3800
- console.log("[UpdateService] Update downloaded:", info.version);
3801
- this.sendStatus({ status: UpdateStatus.DOWNLOADED, version: info.version });
3802
- });
3803
- autoUpdater.on("error", (error) => {
3804
- const errorMessage = error instanceof Error ? error.message : String(error);
3805
- const safeMessage = errorMessage.length > 200 ? `${errorMessage.slice(0, 200)}...` : errorMessage;
3806
- const errorDetails = {
3807
- message: safeMessage,
3808
- name: error instanceof Error ? error.name : void 0,
3809
- stack: error instanceof Error ? error.stack : void 0,
3810
- code: error.code,
3811
- statusCode: error.statusCode
3812
- };
3813
- console.error("[UpdateService] Error:", errorDetails);
3814
- this.sendStatus({ status: UpdateStatus.ERROR, error: safeMessage });
3815
- });
3814
+ return AuthManager.instance;
3816
3815
  }
3817
- sendStatus(data) {
3818
- windowManager.sendToRenderer(IPC_CHANNELS.EVENTS.UPDATER_STATUS, data);
3816
+ /**
3817
+ * Initialize on app startup: restore session from persisted refresh token
3818
+ */
3819
+ async initialize() {
3820
+ console.log("[AuthManager] Initializing...");
3821
+ this.userAgent = buildUserAgent();
3822
+ console.log(`[AuthManager] User-Agent: ${this.userAgent}`);
3823
+ this.isLoading = true;
3824
+ this.broadcastState();
3825
+ try {
3826
+ const storedRefreshToken = this.loadRefreshToken();
3827
+ if (!storedRefreshToken) {
3828
+ console.log("[AuthManager] No stored refresh token, starting fresh");
3829
+ this.isLoading = false;
3830
+ this.broadcastState();
3831
+ return;
3832
+ }
3833
+ this.refreshToken = storedRefreshToken;
3834
+ await this.refreshAccessToken();
3835
+ await this.fetchUserProfile();
3836
+ console.log("[AuthManager] Session restored successfully");
3837
+ } catch (err) {
3838
+ console.warn("[AuthManager] Failed to restore session:", err);
3839
+ if (err instanceof AuthTokenInvalidError) {
3840
+ this.clearTokens();
3841
+ } else {
3842
+ this.accessToken = null;
3843
+ this.accessTokenExpiresAt = 0;
3844
+ this.userProfile = null;
3845
+ this.scheduleInitRetry();
3846
+ }
3847
+ }
3848
+ this.isLoading = false;
3849
+ this.broadcastState();
3819
3850
  }
3820
- async checkForUpdates() {
3821
- this.ensureInitialized();
3822
- if (!this.initialized) return;
3823
- if (this.isChecking) return;
3824
- this.isChecking = true;
3851
+ /**
3852
+ * Start the device authorization login flow
3853
+ */
3854
+ async startDeviceLogin() {
3855
+ if (this.deviceFlowStatus === "polling" || this.deviceFlowStatus === "pending_browser") {
3856
+ return { success: false, error: "Login already in progress" };
3857
+ }
3858
+ this.error = null;
3859
+ this.deviceFlowStatus = "pending_browser";
3860
+ this.broadcastState();
3825
3861
  try {
3826
- await autoUpdater.checkForUpdates();
3827
- } finally {
3828
- this.isChecking = false;
3862
+ const res = await fetch(`${API_BASE_URL}/api/v1/auth/device/code`, {
3863
+ method: "POST",
3864
+ headers: this.getDefaultHeaders({ "Content-Type": "application/json" })
3865
+ });
3866
+ if (!res.ok) {
3867
+ throw new Error(`Device code request failed: ${res.status}`);
3868
+ }
3869
+ const responseJson = await res.json();
3870
+ if (!responseJson.success || !responseJson.data) {
3871
+ throw new Error(`Device code request failed: invalid response`);
3872
+ }
3873
+ const data = responseJson.data;
3874
+ this.deviceCode = data.device_code;
3875
+ this.userCode = data.user_code;
3876
+ this.verificationUrl = data.verification_url;
3877
+ this.pollExpiresAt = Date.now() + data.expires_in * 1e3;
3878
+ try {
3879
+ await shell.openExternal(data.verification_url);
3880
+ } catch (browserErr) {
3881
+ console.error("[AuthManager] Failed to open browser:", browserErr);
3882
+ this.deviceFlowStatus = "error";
3883
+ this.error = "Failed to open browser for authorization";
3884
+ this.broadcastState();
3885
+ return { success: false, error: this.error };
3886
+ }
3887
+ this.deviceFlowStatus = "polling";
3888
+ this.broadcastState();
3889
+ this.startPolling(data.interval || DEVICE_POLL_INTERVAL_MS / 1e3);
3890
+ return { success: true };
3891
+ } catch (err) {
3892
+ console.error("[AuthManager] Device login failed:", err);
3893
+ this.deviceFlowStatus = "error";
3894
+ this.error = err instanceof Error ? err.message : String(err);
3895
+ this.broadcastState();
3896
+ return { success: false, error: this.error };
3829
3897
  }
3830
3898
  }
3831
- quitAndInstall() {
3832
- this.ensureInitialized();
3833
- autoUpdater.quitAndInstall();
3899
+ /**
3900
+ * Cancel an in-progress login flow
3901
+ */
3902
+ cancelLogin() {
3903
+ this.stopPolling();
3904
+ this.deviceCode = null;
3905
+ this.userCode = null;
3906
+ this.verificationUrl = null;
3907
+ this.deviceFlowStatus = "idle";
3908
+ this.error = null;
3909
+ this.broadcastState();
3834
3910
  }
3835
- }
3836
- const updateService = UpdateService.getInstance();
3837
- function registerUpdaterHandlers() {
3838
- ipcMain.handle(IPC_CHANNELS.UPDATER.CHECK_FOR_UPDATES, async () => {
3839
- if (!app.isPackaged) {
3840
- console.log("[Updater] Skipping update check in development mode");
3841
- windowManager.sendToRenderer(IPC_CHANNELS.EVENTS.UPDATER_STATUS, {
3842
- status: UpdateStatus.NOT_AVAILABLE
3843
- });
3911
+ /**
3912
+ * Check device token status immediately (for OAuth callback)
3913
+ * Call this when receiving protocol URL callback to speed up token acquisition
3914
+ */
3915
+ async checkDeviceTokenStatus() {
3916
+ if (!this.deviceCode || this.deviceFlowStatus !== "polling") {
3844
3917
  return;
3845
3918
  }
3846
- await updateService.checkForUpdates();
3847
- });
3848
- ipcMain.handle(IPC_CHANNELS.UPDATER.QUIT_AND_INSTALL, () => {
3849
- updateService.quitAndInstall();
3850
- });
3851
- }
3852
- function registerAllHandlers() {
3853
- registerProviderHandlers();
3854
- registerModelHandlers();
3855
- registerTaskHandlers();
3856
- registerTaskDetailHandlers();
3857
- registerFileHandlers();
3858
- registerCompletionHandlers();
3859
- registerAppHandlers();
3860
- registerUpdaterHandlers();
3861
- console.log("[IPC] All handlers registered successfully");
3862
- }
3863
- function registerIpcHandlers() {
3864
- registerAllHandlers();
3865
- }
3866
- class EventBridge {
3867
- isInitialized = false;
3868
- initialize() {
3869
- if (this.isInitialized) return;
3870
- eventBus.onTaskEvent("task:*", this.handleTaskEvent.bind(this));
3871
- eventBus.onTaskDetailEvent("taskDetail:*", this.handleTaskDetailEvent.bind(this));
3872
- this.isInitialized = true;
3873
- console.log("[EventBridge] Initialized");
3919
+ console.log("[AuthManager] Checking device token status immediately...");
3920
+ try {
3921
+ const res = await fetch(
3922
+ `${API_BASE_URL}/api/v1/auth/device/token?device_code=${encodeURIComponent(this.deviceCode)}`,
3923
+ { headers: this.getDefaultHeaders() }
3924
+ );
3925
+ if (res.status === 200) {
3926
+ const responseJson = await res.json();
3927
+ if (!responseJson.success || !responseJson.data) {
3928
+ throw new Error("Token check failed: invalid response");
3929
+ }
3930
+ this.handleTokenResponse(responseJson.data);
3931
+ this.stopPolling();
3932
+ this.deviceFlowStatus = "idle";
3933
+ this.userCode = null;
3934
+ this.verificationUrl = null;
3935
+ this.deviceCode = null;
3936
+ await this.fetchUserProfile();
3937
+ this.broadcastState();
3938
+ console.log("[AuthManager] Token obtained via immediate check");
3939
+ return;
3940
+ }
3941
+ if (res.status === 428) {
3942
+ console.log("[AuthManager] Still waiting, polling will continue...");
3943
+ return;
3944
+ }
3945
+ const body = await res.text();
3946
+ console.warn("[AuthManager] Token check error:", res.status, body);
3947
+ } catch (err) {
3948
+ console.warn("[AuthManager] Token check failed:", err);
3949
+ }
3874
3950
  }
3875
- handleTaskEvent(data) {
3876
- const { type, taskId, task, timestamp } = data;
3877
- windowManager.sendToRenderer("task:event", {
3878
- type,
3879
- taskId,
3880
- task,
3881
- timestamp
3882
- });
3951
+ /**
3952
+ * Log out: call API, clear local tokens
3953
+ */
3954
+ async logout() {
3955
+ if (this.accessToken) {
3956
+ try {
3957
+ await this.fetchWithAuth(`${API_BASE_URL}/api/v1/auth/logout`, { method: "POST" });
3958
+ } catch (err) {
3959
+ console.warn("[AuthManager] Logout API call failed:", err);
3960
+ }
3961
+ }
3962
+ this.clearTokens();
3963
+ this.broadcastState();
3883
3964
  }
3884
- handleTaskDetailEvent(data) {
3885
- const { type, taskId, pageId, page, status, timestamp } = data;
3886
- windowManager.sendToRenderer("taskDetail:event", {
3887
- type,
3888
- taskId,
3889
- pageId,
3890
- page,
3891
- status,
3892
- timestamp
3893
- });
3965
+ /**
3966
+ * Get a valid access token, refreshing if needed
3967
+ */
3968
+ async getAccessToken() {
3969
+ if (!this.refreshToken) {
3970
+ return null;
3971
+ }
3972
+ if (this.accessToken && Date.now() < this.accessTokenExpiresAt - TOKEN_REFRESH_MARGIN_MS) {
3973
+ return this.accessToken;
3974
+ }
3975
+ try {
3976
+ await this.refreshAccessToken();
3977
+ return this.accessToken;
3978
+ } catch (err) {
3979
+ if (err instanceof AuthTokenInvalidError) {
3980
+ this.clearTokens();
3981
+ this.broadcastState();
3982
+ }
3983
+ return null;
3984
+ }
3894
3985
  }
3895
- cleanup() {
3896
- eventBus.removeAllListeners();
3897
- this.isInitialized = false;
3986
+ /**
3987
+ * Get current auth state snapshot
3988
+ */
3989
+ getAuthState() {
3990
+ return {
3991
+ isAuthenticated: !!this.accessToken && !!this.userProfile,
3992
+ isLoading: this.isLoading,
3993
+ user: this.userProfile,
3994
+ deviceFlowStatus: this.deviceFlowStatus,
3995
+ userCode: this.userCode,
3996
+ verificationUrl: this.verificationUrl,
3997
+ error: this.error
3998
+ };
3898
3999
  }
3899
- }
3900
- const eventBridge = new EventBridge();
3901
- if (!app.isPackaged) {
3902
- app.setName("MarkPDFdown");
3903
- const userDataPath = app.getPath("userData");
3904
- if (userDataPath.endsWith("Electron")) {
3905
- const newPath = userDataPath.replace(/Electron$/, "MarkPDFdown");
3906
- app.setPath("userData", newPath);
4000
+ /**
4001
+ * Get cached user profile
4002
+ */
4003
+ getUserProfile() {
4004
+ return this.userProfile;
3907
4005
  }
3908
- }
3909
- function getIconPath() {
3910
- let iconName;
3911
- if (app.isPackaged) {
3912
- iconName = process.platform === "darwin" ? "icons/mac/icon.icns" : process.platform === "win32" ? "icons/win/icon.ico" : "icons/png/512x512.png";
3913
- } else {
3914
- iconName = process.platform === "darwin" ? "icons/mac/png/512x512.png" : "icons/png/512x512.png";
4006
+ /**
4007
+ * Make an authenticated API request. Automatically retries once on 401 by refreshing the token.
4008
+ * @param url - Request URL
4009
+ * @param options - Fetch RequestInit options
4010
+ * @param meta - Additional options: timeoutMs (0 = no timeout, default auto-detected from body type)
4011
+ */
4012
+ async fetchWithAuth(url, options = {}, meta) {
4013
+ const token = await this.getAccessToken();
4014
+ if (!token) {
4015
+ throw new Error("Authentication required");
4016
+ }
4017
+ const isFormData = options.body instanceof FormData;
4018
+ const timeoutMs = meta?.timeoutMs !== void 0 ? meta.timeoutMs : isFormData ? 120 * 1e3 : 8e3;
4019
+ const callerSignal = options.signal;
4020
+ const buildSignal = () => {
4021
+ const signals = [];
4022
+ let tid = null;
4023
+ if (callerSignal) signals.push(callerSignal);
4024
+ if (timeoutMs > 0) {
4025
+ const tc = new AbortController();
4026
+ tid = setTimeout(() => tc.abort(), timeoutMs);
4027
+ signals.push(tc.signal);
4028
+ }
4029
+ if (signals.length === 0) return { signal: void 0, timeoutId: null };
4030
+ if (signals.length === 1) return { signal: signals[0], timeoutId: tid };
4031
+ if (typeof AbortSignal.any === "function") {
4032
+ return { signal: AbortSignal.any(signals), timeoutId: tid };
4033
+ }
4034
+ const fc = new AbortController();
4035
+ for (const s of signals) {
4036
+ if (s.aborted) {
4037
+ fc.abort(s.reason);
4038
+ break;
4039
+ }
4040
+ s.addEventListener("abort", () => fc.abort(s.reason), { once: true });
4041
+ }
4042
+ return { signal: fc.signal, timeoutId: tid };
4043
+ };
4044
+ const { signal, timeoutId } = buildSignal();
4045
+ try {
4046
+ const res = await fetch(url, {
4047
+ ...options,
4048
+ signal,
4049
+ headers: {
4050
+ ...this.getDefaultHeaders(),
4051
+ Authorization: `Bearer ${token}`,
4052
+ ...options.headers
4053
+ }
4054
+ });
4055
+ if (res.status !== 401) {
4056
+ return res;
4057
+ }
4058
+ if (!this.refreshToken) {
4059
+ this.clearTokens();
4060
+ this.broadcastState();
4061
+ throw new Error("Authentication required");
4062
+ }
4063
+ try {
4064
+ await this.refreshAccessToken();
4065
+ } catch (err) {
4066
+ if (err instanceof AuthTokenInvalidError) {
4067
+ this.clearTokens();
4068
+ this.broadcastState();
4069
+ }
4070
+ throw new Error("Authentication required");
4071
+ }
4072
+ const { signal: retrySignal, timeoutId: retryTimeoutId } = buildSignal();
4073
+ try {
4074
+ return await fetch(url, {
4075
+ ...options,
4076
+ signal: retrySignal,
4077
+ headers: {
4078
+ ...this.getDefaultHeaders(),
4079
+ Authorization: `Bearer ${this.accessToken}`,
4080
+ ...options.headers
4081
+ }
4082
+ });
4083
+ } finally {
4084
+ if (retryTimeoutId) clearTimeout(retryTimeoutId);
4085
+ }
4086
+ } catch (error) {
4087
+ if (error?.name === "AbortError" && callerSignal?.aborted) {
4088
+ throw error;
4089
+ }
4090
+ if (error?.name === "AbortError") {
4091
+ throw new Error("Request timeout");
4092
+ }
4093
+ throw error;
4094
+ } finally {
4095
+ if (timeoutId) clearTimeout(timeoutId);
4096
+ }
3915
4097
  }
3916
- if (process.env.ELECTRON_RENDERER_URL) {
3917
- return path.join(process.cwd(), "public", iconName);
4098
+ // ─── Private Methods ─────────────────────────────────────────────
4099
+ getDefaultHeaders(extra = {}) {
4100
+ return {
4101
+ "User-Agent": this.userAgent,
4102
+ ...extra
4103
+ };
3918
4104
  }
3919
- if (app.isPackaged) {
3920
- return path.join(process.resourcesPath, iconName);
4105
+ startPolling(intervalSeconds) {
4106
+ const intervalMs = Math.max(intervalSeconds * 1e3, DEVICE_POLL_INTERVAL_MS);
4107
+ const poll = async () => {
4108
+ if (Date.now() > this.pollExpiresAt) {
4109
+ this.deviceFlowStatus = "expired";
4110
+ this.error = "Device code expired";
4111
+ this.stopPolling();
4112
+ this.broadcastState();
4113
+ return;
4114
+ }
4115
+ try {
4116
+ const res = await fetch(
4117
+ `${API_BASE_URL}/api/v1/auth/device/token?device_code=${encodeURIComponent(this.deviceCode)}`,
4118
+ { headers: this.getDefaultHeaders() }
4119
+ );
4120
+ if (res.status === 200) {
4121
+ const responseJson = await res.json();
4122
+ if (!responseJson.success || !responseJson.data) {
4123
+ throw new Error("Token polling failed: invalid response");
4124
+ }
4125
+ this.handleTokenResponse(responseJson.data);
4126
+ this.stopPolling();
4127
+ this.deviceFlowStatus = "idle";
4128
+ this.userCode = null;
4129
+ this.verificationUrl = null;
4130
+ this.deviceCode = null;
4131
+ await this.fetchUserProfile();
4132
+ this.broadcastState();
4133
+ return;
4134
+ }
4135
+ if (res.status === 428) {
4136
+ this.pollTimer = setTimeout(poll, intervalMs);
4137
+ return;
4138
+ }
4139
+ const body = await res.text();
4140
+ throw new Error(`Token polling failed: ${res.status} ${body}`);
4141
+ } catch (err) {
4142
+ if (this.deviceFlowStatus === "polling") {
4143
+ console.warn("[AuthManager] Poll error, retrying:", err);
4144
+ this.pollTimer = setTimeout(poll, intervalMs);
4145
+ }
4146
+ }
4147
+ };
4148
+ this.pollTimer = setTimeout(poll, intervalMs);
3921
4149
  }
3922
- const appPath = app.getAppPath();
3923
- const possiblePaths = [
3924
- path.join(appPath, "..", "..", "public", iconName),
3925
- // dist/main -> 根目录
3926
- path.join(appPath, "..", "public", iconName),
3927
- // dist -> 根目录
3928
- path.join(appPath, "public", iconName),
3929
- // 当前目录
3930
- path.join(process.cwd(), "public", iconName)
3931
- // 工作目录
3932
- ];
3933
- for (const iconPath of possiblePaths) {
3934
- if (fs.existsSync(iconPath)) {
3935
- return iconPath;
4150
+ stopPolling() {
4151
+ if (this.pollTimer) {
4152
+ clearTimeout(this.pollTimer);
4153
+ this.pollTimer = null;
4154
+ }
4155
+ }
4156
+ handleTokenResponse(data) {
4157
+ this.accessToken = data.access_token;
4158
+ this.accessTokenExpiresAt = Date.now() + data.expires_in * 1e3;
4159
+ if (data.refresh_token) {
4160
+ this.refreshToken = data.refresh_token;
4161
+ this.persistRefreshToken(data.refresh_token);
4162
+ } else {
4163
+ console.warn("[AuthManager] No refresh_token in response, skipping persistence");
4164
+ }
4165
+ this.scheduleTokenRefresh(data.expires_in);
4166
+ }
4167
+ scheduleTokenRefresh(expiresInSeconds) {
4168
+ if (this.refreshTimer) {
4169
+ clearTimeout(this.refreshTimer);
4170
+ }
4171
+ if (!this.refreshToken) {
4172
+ console.log("[AuthManager] No refresh token, skipping auto-refresh schedule");
4173
+ return;
4174
+ }
4175
+ const refreshInMs = Math.max(expiresInSeconds * 1e3 - TOKEN_REFRESH_MARGIN_MS, 0);
4176
+ this.refreshTimer = setTimeout(async () => {
4177
+ try {
4178
+ await this.refreshAccessToken();
4179
+ } catch (err) {
4180
+ console.error("[AuthManager] Auto-refresh failed:", err);
4181
+ if (err instanceof AuthTokenInvalidError) {
4182
+ this.clearTokens();
4183
+ this.broadcastState();
4184
+ } else {
4185
+ this.autoRefreshRetryCount++;
4186
+ if (this.autoRefreshRetryCount <= MAX_AUTO_REFRESH_RETRIES) {
4187
+ const retryDelayMs = Math.min(3e4 * Math.pow(2, this.autoRefreshRetryCount - 1), 5 * 60 * 1e3);
4188
+ console.log(`[AuthManager] Scheduling auto-refresh retry ${this.autoRefreshRetryCount}/${MAX_AUTO_REFRESH_RETRIES} in ${retryDelayMs / 1e3}s`);
4189
+ this.refreshTimer = setTimeout(async () => {
4190
+ try {
4191
+ await this.refreshAccessToken();
4192
+ } catch (retryErr) {
4193
+ console.error("[AuthManager] Auto-refresh retry failed:", retryErr);
4194
+ if (retryErr instanceof AuthTokenInvalidError) {
4195
+ this.clearTokens();
4196
+ this.broadcastState();
4197
+ } else if (this.autoRefreshRetryCount < MAX_AUTO_REFRESH_RETRIES) {
4198
+ this.scheduleTokenRefresh(retryDelayMs / 1e3);
4199
+ } else {
4200
+ console.error("[AuthManager] Max auto-refresh retries reached, keeping refresh token for next manual attempt");
4201
+ }
4202
+ }
4203
+ }, retryDelayMs);
4204
+ } else {
4205
+ console.error("[AuthManager] Max auto-refresh retries reached, keeping refresh token for next manual attempt");
4206
+ }
4207
+ }
4208
+ }
4209
+ }, refreshInMs);
4210
+ }
4211
+ async refreshAccessToken() {
4212
+ if (this.refreshInFlight) {
4213
+ return this.refreshInFlight;
4214
+ }
4215
+ this.refreshInFlight = this.doRefreshAccessToken();
4216
+ try {
4217
+ await this.refreshInFlight;
4218
+ } finally {
4219
+ this.refreshInFlight = null;
4220
+ }
4221
+ }
4222
+ async doRefreshAccessToken() {
4223
+ if (!this.refreshToken) {
4224
+ throw new AuthTokenInvalidError("No refresh token available");
4225
+ }
4226
+ let res;
4227
+ try {
4228
+ res = await fetch(`${API_BASE_URL}/api/v1/auth/token/refresh`, {
4229
+ method: "POST",
4230
+ headers: this.getDefaultHeaders({ "Content-Type": "application/json" }),
4231
+ body: JSON.stringify({ refresh_token: this.refreshToken })
4232
+ });
4233
+ } catch (err) {
4234
+ throw new Error(`Token refresh network error: ${err instanceof Error ? err.message : String(err)}`);
4235
+ }
4236
+ if (!res.ok) {
4237
+ if (res.status === 401 || res.status === 403) {
4238
+ throw new AuthTokenInvalidError(`Token refresh rejected: ${res.status}`);
4239
+ }
4240
+ throw new Error(`Token refresh server error: ${res.status}`);
4241
+ }
4242
+ const responseJson = await res.json();
4243
+ if (!responseJson.success || !responseJson.data) {
4244
+ throw new Error("Token refresh failed: invalid response");
4245
+ }
4246
+ this.handleTokenResponse(responseJson.data);
4247
+ this.autoRefreshRetryCount = 0;
4248
+ }
4249
+ async fetchUserProfile() {
4250
+ if (!this.accessToken) return;
4251
+ const res = await this.fetchWithAuth(`${API_BASE_URL}/api/v1/user/profile`);
4252
+ if (!res.ok) {
4253
+ throw new Error(`Fetch user profile failed: ${res.status}`);
4254
+ }
4255
+ const responseJson = await res.json();
4256
+ if (!responseJson.success || !responseJson.data) {
4257
+ throw new Error("Fetch user profile failed: invalid response");
4258
+ }
4259
+ this.userProfile = responseJson.data;
4260
+ }
4261
+ persistRefreshToken(token) {
4262
+ try {
4263
+ if (!safeStorage.isEncryptionAvailable()) {
4264
+ console.warn("[AuthManager] Encryption not available, refresh token will only be kept in memory (not persisted to disk)");
4265
+ return;
4266
+ }
4267
+ const dir = path.join(app.getPath("userData"), REFRESH_TOKEN_DIR);
4268
+ if (!fs.existsSync(dir)) {
4269
+ fs.mkdirSync(dir, { recursive: true });
4270
+ }
4271
+ const filePath = path.join(dir, REFRESH_TOKEN_FILE);
4272
+ const encrypted = safeStorage.encryptString(token);
4273
+ fs.writeFileSync(filePath, encrypted);
4274
+ } catch (err) {
4275
+ console.warn("[AuthManager] Failed to persist refresh token:", err);
4276
+ }
4277
+ }
4278
+ loadRefreshToken() {
4279
+ try {
4280
+ if (!safeStorage.isEncryptionAvailable()) {
4281
+ console.warn("[AuthManager] Encryption not available, cannot load persisted refresh token");
4282
+ return null;
4283
+ }
4284
+ const filePath = path.join(app.getPath("userData"), REFRESH_TOKEN_DIR, REFRESH_TOKEN_FILE);
4285
+ if (!fs.existsSync(filePath)) {
4286
+ return null;
4287
+ }
4288
+ const data = fs.readFileSync(filePath);
4289
+ return safeStorage.decryptString(data);
4290
+ } catch (err) {
4291
+ console.warn("[AuthManager] Failed to load refresh token:", err);
4292
+ return null;
4293
+ }
4294
+ }
4295
+ deleteRefreshToken() {
4296
+ try {
4297
+ const filePath = path.join(app.getPath("userData"), REFRESH_TOKEN_DIR, REFRESH_TOKEN_FILE);
4298
+ if (fs.existsSync(filePath)) {
4299
+ fs.unlinkSync(filePath);
4300
+ }
4301
+ } catch (err) {
4302
+ console.warn("[AuthManager] Failed to delete refresh token:", err);
4303
+ }
4304
+ }
4305
+ /**
4306
+ * Schedule a retry of initialization after a transient failure.
4307
+ * The refresh token is still stored on disk, so we just need to try refreshing again.
4308
+ */
4309
+ scheduleInitRetry() {
4310
+ if (this.initRetryTimer) {
4311
+ clearTimeout(this.initRetryTimer);
4312
+ }
4313
+ console.log(`[AuthManager] Scheduling init retry in ${INIT_RETRY_DELAY_MS / 1e3}s`);
4314
+ this.initRetryTimer = setTimeout(async () => {
4315
+ this.initRetryTimer = null;
4316
+ if (this.accessToken || !this.refreshToken) {
4317
+ return;
4318
+ }
4319
+ console.log("[AuthManager] Retrying session restoration...");
4320
+ try {
4321
+ await this.refreshAccessToken();
4322
+ await this.fetchUserProfile();
4323
+ console.log("[AuthManager] Session restored on retry");
4324
+ this.broadcastState();
4325
+ } catch (err) {
4326
+ console.warn("[AuthManager] Init retry failed:", err);
4327
+ if (err instanceof AuthTokenInvalidError) {
4328
+ this.clearTokens();
4329
+ this.broadcastState();
4330
+ }
4331
+ }
4332
+ }, INIT_RETRY_DELAY_MS);
4333
+ }
4334
+ clearTokens() {
4335
+ this.accessToken = null;
4336
+ this.accessTokenExpiresAt = 0;
4337
+ this.refreshToken = null;
4338
+ this.userProfile = null;
4339
+ this.error = null;
4340
+ this.deviceFlowStatus = "idle";
4341
+ this.userCode = null;
4342
+ this.verificationUrl = null;
4343
+ this.deviceCode = null;
4344
+ this.autoRefreshRetryCount = 0;
4345
+ this.stopPolling();
4346
+ if (this.refreshTimer) {
4347
+ clearTimeout(this.refreshTimer);
4348
+ this.refreshTimer = null;
4349
+ }
4350
+ if (this.initRetryTimer) {
4351
+ clearTimeout(this.initRetryTimer);
4352
+ this.initRetryTimer = null;
4353
+ }
4354
+ this.deleteRefreshToken();
4355
+ }
4356
+ broadcastState() {
4357
+ windowManager.sendToRenderer("auth:stateChanged", this.getAuthState());
4358
+ }
4359
+ }
4360
+ const authManager = AuthManager.getInstance();
4361
+ class CloudService {
4362
+ static instance;
4363
+ constructor() {
4364
+ }
4365
+ static getInstance() {
4366
+ if (!CloudService.instance) {
4367
+ CloudService.instance = new CloudService();
4368
+ }
4369
+ return CloudService.instance;
4370
+ }
4371
+ /**
4372
+ * Convert a file using the cloud API
4373
+ * @param fileData - File data with either path (local file) or content (ArrayBuffer)
4374
+ * @returns Task creation response with task_id and events_url
4375
+ */
4376
+ async convert(fileData) {
4377
+ try {
4378
+ const token = await authManager.getAccessToken();
4379
+ if (!token) {
4380
+ return { success: false, error: "Authentication required" };
4381
+ }
4382
+ const model = fileData.model || "lite";
4383
+ console.log("[CloudService] Starting cloud conversion for:", fileData.name, "model:", model);
4384
+ const formData = new FormData();
4385
+ let fileBuffer;
4386
+ if (fileData.content) {
4387
+ fileBuffer = fileData.content;
4388
+ } else if (fileData.path) {
4389
+ const buffer = await fs$1.readFile(fileData.path);
4390
+ fileBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
4391
+ } else {
4392
+ return { success: false, error: "No file content or path provided" };
4393
+ }
4394
+ const blob = new Blob([fileBuffer]);
4395
+ formData.append("file", blob, fileData.name);
4396
+ formData.append("model", model);
4397
+ formData.append("language", "auto");
4398
+ if (fileData.page_range) {
4399
+ formData.append("page_range", fileData.page_range);
4400
+ }
4401
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/convert`, {
4402
+ method: "POST",
4403
+ body: formData
4404
+ // Note: Do NOT set Content-Type manually - let the browser/fetch set it with proper boundary
4405
+ });
4406
+ if (!res.ok) {
4407
+ const errorBody = await res.json().catch(() => null);
4408
+ const errorMessage = errorBody?.error?.message || `Upload failed: ${res.status}`;
4409
+ console.error("[CloudService] Convert API error:", errorMessage);
4410
+ return { success: false, error: errorMessage };
4411
+ }
4412
+ const responseJson = await res.json();
4413
+ if (!responseJson.success || !responseJson.data) {
4414
+ return { success: false, error: "Invalid response from server" };
4415
+ }
4416
+ console.log("[CloudService] Task created:", responseJson.data.task_id);
4417
+ return { success: true, data: responseJson.data };
4418
+ } catch (error) {
4419
+ console.error("[CloudService] convert error:", error);
4420
+ return {
4421
+ success: false,
4422
+ error: error instanceof Error ? error.message : String(error)
4423
+ };
4424
+ }
4425
+ }
4426
+ /**
4427
+ * Get tasks from the cloud API
4428
+ */
4429
+ async getTasks(page = 1, pageSize = 10) {
4430
+ try {
4431
+ const params = new URLSearchParams({
4432
+ page: String(page),
4433
+ page_size: String(pageSize)
4434
+ });
4435
+ const res = await authManager.fetchWithAuth(
4436
+ `${API_BASE_URL}/api/v1/tasks?${params.toString()}`
4437
+ );
4438
+ if (!res.ok) {
4439
+ const errorBody = await res.json().catch(() => null);
4440
+ return {
4441
+ success: false,
4442
+ error: errorBody?.error?.message || `Failed to fetch tasks: ${res.status}`
4443
+ };
4444
+ }
4445
+ const responseJson = await res.json();
4446
+ if (!responseJson.success) {
4447
+ return { success: false, error: responseJson.error?.message || "Invalid tasks response" };
4448
+ }
4449
+ return {
4450
+ success: true,
4451
+ data: responseJson.data,
4452
+ pagination: responseJson.pagination
4453
+ };
4454
+ } catch (error) {
4455
+ console.error("[CloudService] getTasks error:", error);
4456
+ return {
4457
+ success: false,
4458
+ error: error instanceof Error ? error.message : String(error)
4459
+ };
4460
+ }
4461
+ }
4462
+ /**
4463
+ * Get a single task by ID
4464
+ */
4465
+ async getTaskById(id) {
4466
+ try {
4467
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}`);
4468
+ if (!res.ok) {
4469
+ const errorBody = await res.json().catch(() => null);
4470
+ return {
4471
+ success: false,
4472
+ error: errorBody?.error?.message || `Failed to fetch task: ${res.status}`
4473
+ };
4474
+ }
4475
+ const responseJson = await res.json();
4476
+ if (!responseJson.success || !responseJson.data) {
4477
+ return { success: false, error: "Invalid task response" };
4478
+ }
4479
+ return { success: true, data: responseJson.data };
4480
+ } catch (error) {
4481
+ console.error("[CloudService] getTaskById error:", error);
4482
+ return {
4483
+ success: false,
4484
+ error: error instanceof Error ? error.message : String(error)
4485
+ };
4486
+ }
4487
+ }
4488
+ /**
4489
+ * Get pages for a task
4490
+ */
4491
+ async getTaskPages(id, page = 1, pageSize = 50) {
4492
+ try {
4493
+ const params = new URLSearchParams({
4494
+ page: String(page),
4495
+ page_size: String(pageSize)
4496
+ });
4497
+ const res = await authManager.fetchWithAuth(
4498
+ `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/pages?${params.toString()}`
4499
+ );
4500
+ if (!res.ok) {
4501
+ const errorBody = await res.json().catch(() => null);
4502
+ return {
4503
+ success: false,
4504
+ error: errorBody?.error?.message || `Failed to fetch task pages: ${res.status}`
4505
+ };
4506
+ }
4507
+ const responseJson = await res.json();
4508
+ if (!responseJson.success) {
4509
+ return { success: false, error: responseJson.error?.message || "Invalid pages response" };
4510
+ }
4511
+ return {
4512
+ success: true,
4513
+ data: responseJson.data,
4514
+ pagination: responseJson.pagination
4515
+ };
4516
+ } catch (error) {
4517
+ console.error("[CloudService] getTaskPages error:", error);
4518
+ return {
4519
+ success: false,
4520
+ error: error instanceof Error ? error.message : String(error)
4521
+ };
4522
+ }
4523
+ }
4524
+ /**
4525
+ * Cancel a task
4526
+ */
4527
+ async cancelTask(id) {
4528
+ try {
4529
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/cancel`, {
4530
+ method: "POST"
4531
+ });
4532
+ if (!res.ok) {
4533
+ const errorBody = await res.json().catch(() => null);
4534
+ return {
4535
+ success: false,
4536
+ error: errorBody?.error?.message || `Failed to cancel task: ${res.status}`
4537
+ };
4538
+ }
4539
+ const responseJson = await res.json();
4540
+ if (!responseJson.success || !responseJson.data) {
4541
+ return { success: false, error: "Invalid cancel response" };
4542
+ }
4543
+ return { success: true, data: responseJson.data };
4544
+ } catch (error) {
4545
+ console.error("[CloudService] cancelTask error:", error);
4546
+ return {
4547
+ success: false,
4548
+ error: error instanceof Error ? error.message : String(error)
4549
+ };
4550
+ }
4551
+ }
4552
+ /**
4553
+ * Retry an entire task (creates a new task)
4554
+ */
4555
+ async retryTask(id) {
4556
+ try {
4557
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/retry`, {
4558
+ method: "POST"
4559
+ });
4560
+ if (!res.ok) {
4561
+ const errorBody = await res.json().catch(() => null);
4562
+ return {
4563
+ success: false,
4564
+ error: errorBody?.error?.message || `Failed to retry task: ${res.status}`
4565
+ };
4566
+ }
4567
+ const responseJson = await res.json();
4568
+ if (!responseJson.success || !responseJson.data) {
4569
+ return { success: false, error: "Invalid retry response" };
4570
+ }
4571
+ return { success: true, data: responseJson.data };
4572
+ } catch (error) {
4573
+ console.error("[CloudService] retryTask error:", error);
4574
+ return {
4575
+ success: false,
4576
+ error: error instanceof Error ? error.message : String(error)
4577
+ };
4578
+ }
4579
+ }
4580
+ /**
4581
+ * Retry a single page
4582
+ */
4583
+ async retryPage(taskId, pageNumber) {
4584
+ try {
4585
+ const res = await authManager.fetchWithAuth(
4586
+ `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(taskId)}/pages/${encodeURIComponent(String(pageNumber))}/retry`,
4587
+ { method: "POST" }
4588
+ );
4589
+ if (!res.ok) {
4590
+ const errorBody = await res.json().catch(() => null);
4591
+ return {
4592
+ success: false,
4593
+ error: errorBody?.error?.message || `Failed to retry page: ${res.status}`
4594
+ };
4595
+ }
4596
+ const responseJson = await res.json();
4597
+ if (!responseJson.success || !responseJson.data) {
4598
+ return { success: false, error: "Invalid page retry response" };
4599
+ }
4600
+ return { success: true, data: responseJson.data };
4601
+ } catch (error) {
4602
+ console.error("[CloudService] retryPage error:", error);
4603
+ return {
4604
+ success: false,
4605
+ error: error instanceof Error ? error.message : String(error)
4606
+ };
4607
+ }
4608
+ }
4609
+ /**
4610
+ * Get task conversion result (merged markdown)
4611
+ */
4612
+ async getTaskResult(id) {
4613
+ try {
4614
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/result`, {}, { timeoutMs: 0 });
4615
+ if (!res.ok) {
4616
+ const errorBody = await res.json().catch(() => null);
4617
+ return {
4618
+ success: false,
4619
+ error: errorBody?.error?.message || `Failed to fetch result: ${res.status}`
4620
+ };
4621
+ }
4622
+ const responseJson = await res.json();
4623
+ if (!responseJson.success || !responseJson.data) {
4624
+ return { success: false, error: "Invalid result response" };
4625
+ }
4626
+ return { success: true, data: responseJson.data };
4627
+ } catch (error) {
4628
+ console.error("[CloudService] getTaskResult error:", error);
4629
+ return {
4630
+ success: false,
4631
+ error: error instanceof Error ? error.message : String(error)
4632
+ };
4633
+ }
4634
+ }
4635
+ /**
4636
+ * Download PDF file for a task
4637
+ */
4638
+ async downloadPdf(id) {
4639
+ try {
4640
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/pdf`, {}, { timeoutMs: 0 });
4641
+ if (!res.ok) {
4642
+ const errorBody = await res.json().catch(() => null);
4643
+ return {
4644
+ success: false,
4645
+ error: errorBody?.error?.message || `Failed to download PDF: ${res.status}`
4646
+ };
4647
+ }
4648
+ const contentDisposition = res.headers.get("Content-Disposition") || "";
4649
+ const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
4650
+ const rawName = match ? match[1] : `task-${id}.pdf`;
4651
+ const fileName = path.basename(rawName).replace(/[\u0000-\u001f<>:"|?*]/g, "_") || `task-${id}.pdf`;
4652
+ const buffer = await res.arrayBuffer();
4653
+ return { success: true, data: { buffer, fileName } };
4654
+ } catch (error) {
4655
+ console.error("[CloudService] downloadPdf error:", error);
4656
+ return {
4657
+ success: false,
4658
+ error: error instanceof Error ? error.message : String(error)
4659
+ };
4660
+ }
4661
+ }
4662
+ /**
4663
+ * Get page image via proxy (for relative API paths that need auth)
4664
+ */
4665
+ async getPageImage(taskId, pageNumber) {
4666
+ try {
4667
+ const res = await authManager.fetchWithAuth(
4668
+ `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(taskId)}/pages/${encodeURIComponent(String(pageNumber))}/image`,
4669
+ {},
4670
+ { timeoutMs: 0 }
4671
+ );
4672
+ if (!res.ok) {
4673
+ return {
4674
+ success: false,
4675
+ error: `Failed to fetch page image: ${res.status}`
4676
+ };
4677
+ }
4678
+ const contentType = res.headers.get("Content-Type") || "image/png";
4679
+ const buffer = await res.arrayBuffer();
4680
+ const base64 = Buffer.from(buffer).toString("base64");
4681
+ const dataUrl = `data:${contentType};base64,${base64}`;
4682
+ return { success: true, data: { dataUrl } };
4683
+ } catch (error) {
4684
+ console.error("[CloudService] getPageImage error:", error);
4685
+ return {
4686
+ success: false,
4687
+ error: error instanceof Error ? error.message : String(error)
4688
+ };
4689
+ }
4690
+ }
4691
+ /**
4692
+ * Get credits info from the cloud API
4693
+ */
4694
+ async getCredits() {
4695
+ try {
4696
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/credits`);
4697
+ if (!res.ok) {
4698
+ const errorBody = await res.json().catch(() => null);
4699
+ return {
4700
+ success: false,
4701
+ error: errorBody?.error?.message || `Failed to fetch credits: ${res.status}`
4702
+ };
4703
+ }
4704
+ const responseJson = await res.json();
4705
+ if (!responseJson.success || !responseJson.data) {
4706
+ return { success: false, error: "Invalid credits response" };
4707
+ }
4708
+ return { success: true, data: responseJson.data };
4709
+ } catch (error) {
4710
+ console.error("[CloudService] getCredits error:", error);
4711
+ return {
4712
+ success: false,
4713
+ error: error instanceof Error ? error.message : String(error)
4714
+ };
4715
+ }
4716
+ }
4717
+ /**
4718
+ * Get credit history (transactions) from the cloud API
4719
+ */
4720
+ async getCreditHistory(page = 1, pageSize = 20, type) {
4721
+ try {
4722
+ const params = new URLSearchParams({
4723
+ page: String(page),
4724
+ page_size: String(pageSize)
4725
+ });
4726
+ if (type) {
4727
+ params.set("type", type);
4728
+ }
4729
+ const res = await authManager.fetchWithAuth(
4730
+ `${API_BASE_URL}/api/v1/credits/transactions?${params.toString()}`
4731
+ );
4732
+ if (!res.ok) {
4733
+ const errorBody = await res.json().catch(() => null);
4734
+ return {
4735
+ success: false,
4736
+ error: errorBody?.error?.message || `Failed to fetch credit history: ${res.status}`
4737
+ };
4738
+ }
4739
+ const responseJson = await res.json();
4740
+ if (!responseJson.success) {
4741
+ return { success: false, error: responseJson.error?.message || "Invalid credit history response" };
4742
+ }
4743
+ return {
4744
+ success: true,
4745
+ data: responseJson.data,
4746
+ pagination: responseJson.pagination
4747
+ };
4748
+ } catch (error) {
4749
+ console.error("[CloudService] getCreditHistory error:", error);
4750
+ return {
4751
+ success: false,
4752
+ error: error instanceof Error ? error.message : String(error)
4753
+ };
4754
+ }
4755
+ }
4756
+ /**
4757
+ * Delete a cloud task (only terminal states can be deleted)
4758
+ * Terminal states: FAILED=0, COMPLETED=6, CANCELLED=7, PARTIAL_FAILED=8
4759
+ */
4760
+ async deleteTask(id) {
4761
+ try {
4762
+ const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}`, {
4763
+ method: "DELETE"
4764
+ });
4765
+ if (!res.ok) {
4766
+ const errorBody = await res.json().catch(() => null);
4767
+ return {
4768
+ success: false,
4769
+ error: errorBody?.error?.message || `Failed to delete task: ${res.status}`
4770
+ };
4771
+ }
4772
+ const responseJson = await res.json();
4773
+ if (!responseJson.success || !responseJson.data) {
4774
+ return { success: false, error: "Invalid delete response" };
4775
+ }
4776
+ return { success: true, data: responseJson.data };
4777
+ } catch (error) {
4778
+ console.error("[CloudService] deleteTask error:", error);
4779
+ return {
4780
+ success: false,
4781
+ error: error instanceof Error ? error.message : String(error)
4782
+ };
4783
+ }
4784
+ }
4785
+ }
4786
+ const cloudService = CloudService.getInstance();
4787
+ const HEARTBEAT_TIMEOUT_MS = 9e4;
4788
+ const INITIAL_RECONNECT_DELAY_MS = 1e3;
4789
+ const MAX_RECONNECT_DELAY_MS = 3e4;
4790
+ const FORWARDABLE_EVENTS = /* @__PURE__ */ new Set([
4791
+ "pdf_ready",
4792
+ "page_started",
4793
+ "page_completed",
4794
+ "page_failed",
4795
+ "page_retry_started",
4796
+ "completed",
4797
+ "error",
4798
+ "cancelled"
4799
+ ]);
4800
+ class CloudSSEManager {
4801
+ static instance;
4802
+ abortController = null;
4803
+ lastEventId = "0";
4804
+ reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
4805
+ reconnectTimer = null;
4806
+ heartbeatTimer = null;
4807
+ connected = false;
4808
+ constructor() {
4809
+ }
4810
+ static getInstance() {
4811
+ if (!CloudSSEManager.instance) {
4812
+ CloudSSEManager.instance = new CloudSSEManager();
4813
+ }
4814
+ return CloudSSEManager.instance;
4815
+ }
4816
+ /**
4817
+ * Connect to the global SSE endpoint.
4818
+ * Safe to call multiple times — tears down any existing connection first.
4819
+ * Preserves lastEventId so reconnection can resume from where it left off.
4820
+ */
4821
+ async connect() {
4822
+ if (this.connected) {
4823
+ console.log("[CloudSSE] Already connected, skipping");
4824
+ return;
4825
+ }
4826
+ this.connected = true;
4827
+ const token = await authManager.getAccessToken();
4828
+ if (!token) {
4829
+ console.log("[CloudSSE] No auth token, skipping connect");
4830
+ this.connected = false;
4831
+ return;
4832
+ }
4833
+ if (!this.connected) {
4834
+ console.log("[CloudSSE] Disconnected while obtaining token, aborting");
4835
+ return;
4836
+ }
4837
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
4838
+ try {
4839
+ await this.startStream();
4840
+ } catch (error) {
4841
+ console.error("[CloudSSE] startStream failed during connect:", error);
4842
+ this.connected = false;
4843
+ }
4844
+ }
4845
+ /**
4846
+ * Disconnect from SSE but preserve lastEventId for resumption.
4847
+ * Use this for temporary disconnections (e.g., component unmount, re-render).
4848
+ */
4849
+ disconnect() {
4850
+ console.log("[CloudSSE] Disconnecting (preserving lastEventId for resumption)");
4851
+ this.connected = false;
4852
+ this.cleanup();
4853
+ }
4854
+ /**
4855
+ * Fully disconnect and reset all state including lastEventId.
4856
+ * Use this only on explicit logout — the next connect() will start fresh.
4857
+ */
4858
+ resetAndDisconnect() {
4859
+ console.log("[CloudSSE] Full reset and disconnect");
4860
+ this.connected = false;
4861
+ this.lastEventId = "0";
4862
+ this.cleanup();
4863
+ }
4864
+ cleanup() {
4865
+ if (this.abortController) {
4866
+ this.abortController.abort();
4867
+ this.abortController = null;
4868
+ }
4869
+ if (this.reconnectTimer) {
4870
+ clearTimeout(this.reconnectTimer);
4871
+ this.reconnectTimer = null;
4872
+ }
4873
+ if (this.heartbeatTimer) {
4874
+ clearTimeout(this.heartbeatTimer);
4875
+ this.heartbeatTimer = null;
4876
+ }
4877
+ }
4878
+ resetHeartbeatTimer() {
4879
+ if (this.heartbeatTimer) {
4880
+ clearTimeout(this.heartbeatTimer);
4881
+ }
4882
+ this.heartbeatTimer = setTimeout(() => {
4883
+ console.warn("[CloudSSE] Heartbeat timeout, reconnecting...");
4884
+ this.reconnect();
4885
+ }, HEARTBEAT_TIMEOUT_MS);
4886
+ }
4887
+ async reconnect() {
4888
+ if (!this.connected) return;
4889
+ if (this.abortController) {
4890
+ this.abortController.abort();
4891
+ this.abortController = null;
4892
+ }
4893
+ if (this.heartbeatTimer) {
4894
+ clearTimeout(this.heartbeatTimer);
4895
+ this.heartbeatTimer = null;
4896
+ }
4897
+ if (this.reconnectTimer) {
4898
+ clearTimeout(this.reconnectTimer);
4899
+ this.reconnectTimer = null;
4900
+ }
4901
+ console.log(`[CloudSSE] Reconnecting in ${this.reconnectDelay}ms (lastEventId=${this.lastEventId})...`);
4902
+ this.reconnectTimer = setTimeout(async () => {
4903
+ this.reconnectTimer = null;
4904
+ if (!this.connected) return;
4905
+ await this.startStream();
4906
+ }, this.reconnectDelay);
4907
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
4908
+ }
4909
+ async startStream() {
4910
+ if (this.abortController) {
4911
+ this.abortController.abort();
4912
+ }
4913
+ const url = `${API_BASE_URL}/api/v1/tasks/events`;
4914
+ this.abortController = new AbortController();
4915
+ const headers = {
4916
+ Accept: "text/event-stream",
4917
+ "Cache-Control": "no-cache",
4918
+ Connection: "keep-alive"
4919
+ };
4920
+ if (this.lastEventId !== "0") {
4921
+ headers["Last-Event-ID"] = this.lastEventId;
4922
+ }
4923
+ try {
4924
+ if (isDev) {
4925
+ console.log(`[CloudSSE] Connecting to ${url} (Last-Event-ID=${this.lastEventId})`);
4926
+ }
4927
+ const res = await authManager.fetchWithAuth(url, {
4928
+ headers,
4929
+ signal: this.abortController.signal
4930
+ }, { timeoutMs: 0 });
4931
+ if (isDev) {
4932
+ console.log(`[CloudSSE] Response status: ${res.status}, content-type: ${res.headers.get("content-type")}`);
4933
+ }
4934
+ if (!res.ok) {
4935
+ console.error(`[CloudSSE] HTTP error: ${res.status}`);
4936
+ this.reconnect();
4937
+ return;
4938
+ }
4939
+ const contentType = res.headers.get("content-type") || "";
4940
+ if (!contentType.includes("text/event-stream")) {
4941
+ console.error(`[CloudSSE] Unexpected content-type: ${contentType}, expected text/event-stream`);
4942
+ this.reconnect();
4943
+ return;
4944
+ }
4945
+ if (!res.body) {
4946
+ console.error("[CloudSSE] No response body");
4947
+ this.reconnect();
4948
+ return;
4949
+ }
4950
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
4951
+ this.resetHeartbeatTimer();
4952
+ console.log("[CloudSSE] Connected, reading stream...");
4953
+ await this.readStream(res.body);
4954
+ } catch (error) {
4955
+ if (error.name === "AbortError") {
4956
+ console.log("[CloudSSE] Stream aborted");
4957
+ return;
4958
+ }
4959
+ console.error("[CloudSSE] Stream error:", error?.message || error);
4960
+ this.reconnect();
4961
+ }
4962
+ }
4963
+ async readStream(body) {
4964
+ const reader = body.getReader();
4965
+ const decoder = new TextDecoder();
4966
+ let buffer = "";
4967
+ let chunkCount = 0;
4968
+ let aborted = false;
4969
+ try {
4970
+ while (true) {
4971
+ const { done, value } = await reader.read();
4972
+ if (done) {
4973
+ console.log(`[CloudSSE] Stream ended after ${chunkCount} chunks`);
4974
+ break;
4975
+ }
4976
+ chunkCount++;
4977
+ const chunk = decoder.decode(value, { stream: true });
4978
+ if (isDev) {
4979
+ console.log(`[CloudSSE] Chunk #${chunkCount} (${value.byteLength} bytes)`);
4980
+ }
4981
+ buffer += chunk;
4982
+ buffer = buffer.replace(/\r\n/g, "\n");
4983
+ const messages = buffer.split("\n\n");
4984
+ buffer = messages.pop() || "";
4985
+ for (const msg of messages) {
4986
+ if (msg.trim()) {
4987
+ this.parseSSEMessage(msg);
4988
+ }
4989
+ }
4990
+ }
4991
+ } catch (error) {
4992
+ if (error.name === "AbortError") {
4993
+ aborted = true;
4994
+ } else {
4995
+ console.error("[CloudSSE] Read error:", error?.message || error);
4996
+ }
4997
+ } finally {
4998
+ reader.releaseLock();
4999
+ }
5000
+ if (!aborted && this.connected) {
5001
+ this.reconnect();
5002
+ }
5003
+ }
5004
+ parseSSEMessage(raw) {
5005
+ let eventType = "";
5006
+ let data = "";
5007
+ let id = "";
5008
+ for (const line of raw.split("\n")) {
5009
+ if (line.startsWith("event:")) {
5010
+ eventType = line.slice(6).trim();
5011
+ } else if (line.startsWith("data:")) {
5012
+ if (data.length > 0) data += "\n";
5013
+ data += line.slice(5).trim();
5014
+ } else if (line.startsWith("id:")) {
5015
+ id = line.slice(3).trim();
5016
+ }
5017
+ }
5018
+ if (id) {
5019
+ this.lastEventId = id;
5020
+ }
5021
+ if (!eventType || !data) return;
5022
+ this.resetHeartbeatTimer();
5023
+ if (isDev && eventType !== "heartbeat") {
5024
+ console.log(`[CloudSSE] Event: type=${eventType}, id=${id || "none"}, data=${data.substring(0, 200)}`);
5025
+ }
5026
+ if (eventType === "connected" || eventType === "heartbeat") {
5027
+ return;
5028
+ }
5029
+ try {
5030
+ const parsedData = JSON.parse(data);
5031
+ if (!FORWARDABLE_EVENTS.has(eventType)) {
5032
+ console.warn(`[CloudSSE] Unknown event type: ${eventType}, skipping`);
5033
+ return;
5034
+ }
5035
+ const event = {
5036
+ type: eventType,
5037
+ data: parsedData
5038
+ };
5039
+ if (isDev) {
5040
+ console.log(`[CloudSSE] Forwarding to renderer: type=${eventType}, task_id=${parsedData.task_id || "none"}`);
5041
+ }
5042
+ windowManager.sendToRenderer("cloud:taskEvent", event);
5043
+ } catch (error) {
5044
+ console.error("[CloudSSE] Failed to parse event data:", error, data);
5045
+ }
5046
+ }
5047
+ }
5048
+ const cloudSSEManager = CloudSSEManager.getInstance();
5049
+ const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024;
5050
+ function registerCloudHandlers() {
5051
+ ipcMain.handle("cloud:convert", async (_, fileData) => {
5052
+ try {
5053
+ if (!fileData.path && !fileData.content) {
5054
+ return { success: false, error: "No file content or path provided" };
5055
+ }
5056
+ if (fileData.content && fileData.content.byteLength > MAX_UPLOAD_SIZE_BYTES) {
5057
+ return { success: false, error: `File too large (max ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB)` };
5058
+ }
5059
+ if (fileData.path) {
5060
+ try {
5061
+ const stat = await fs.promises.stat(fileData.path);
5062
+ if (stat.size > MAX_UPLOAD_SIZE_BYTES) {
5063
+ return { success: false, error: `File too large (max ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB)` };
5064
+ }
5065
+ } catch {
5066
+ return { success: false, error: "File not found or not accessible" };
5067
+ }
5068
+ }
5069
+ const result = await cloudService.convert(fileData);
5070
+ return result;
5071
+ } catch (error) {
5072
+ console.error("[IPC] cloud:convert error:", error);
5073
+ return {
5074
+ success: false,
5075
+ error: error instanceof Error ? error.message : String(error)
5076
+ };
5077
+ }
5078
+ });
5079
+ ipcMain.handle("cloud:getTasks", async (_, params) => {
5080
+ try {
5081
+ return await cloudService.getTasks(params.page, params.pageSize);
5082
+ } catch (error) {
5083
+ console.error("[IPC] cloud:getTasks error:", error);
5084
+ return {
5085
+ success: false,
5086
+ error: error instanceof Error ? error.message : String(error)
5087
+ };
5088
+ }
5089
+ });
5090
+ ipcMain.handle("cloud:getTaskById", async (_, id) => {
5091
+ try {
5092
+ return await cloudService.getTaskById(id);
5093
+ } catch (error) {
5094
+ console.error("[IPC] cloud:getTaskById error:", error);
5095
+ return {
5096
+ success: false,
5097
+ error: error instanceof Error ? error.message : String(error)
5098
+ };
5099
+ }
5100
+ });
5101
+ ipcMain.handle("cloud:getTaskPages", async (_, params) => {
5102
+ try {
5103
+ return await cloudService.getTaskPages(params.taskId, params.page, params.pageSize);
5104
+ } catch (error) {
5105
+ console.error("[IPC] cloud:getTaskPages error:", error);
5106
+ return {
5107
+ success: false,
5108
+ error: error instanceof Error ? error.message : String(error)
5109
+ };
5110
+ }
5111
+ });
5112
+ ipcMain.handle("cloud:cancelTask", async (_, id) => {
5113
+ try {
5114
+ return await cloudService.cancelTask(id);
5115
+ } catch (error) {
5116
+ console.error("[IPC] cloud:cancelTask error:", error);
5117
+ return {
5118
+ success: false,
5119
+ error: error instanceof Error ? error.message : String(error)
5120
+ };
5121
+ }
5122
+ });
5123
+ ipcMain.handle("cloud:retryTask", async (_, id) => {
5124
+ try {
5125
+ return await cloudService.retryTask(id);
5126
+ } catch (error) {
5127
+ console.error("[IPC] cloud:retryTask error:", error);
5128
+ return {
5129
+ success: false,
5130
+ error: error instanceof Error ? error.message : String(error)
5131
+ };
5132
+ }
5133
+ });
5134
+ ipcMain.handle("cloud:deleteTask", async (_, id) => {
5135
+ try {
5136
+ return await cloudService.deleteTask(id);
5137
+ } catch (error) {
5138
+ console.error("[IPC] cloud:deleteTask error:", error);
5139
+ return {
5140
+ success: false,
5141
+ error: error instanceof Error ? error.message : String(error)
5142
+ };
5143
+ }
5144
+ });
5145
+ ipcMain.handle("cloud:retryPage", async (_, params) => {
5146
+ try {
5147
+ return await cloudService.retryPage(params.taskId, params.pageNumber);
5148
+ } catch (error) {
5149
+ console.error("[IPC] cloud:retryPage error:", error);
5150
+ return {
5151
+ success: false,
5152
+ error: error instanceof Error ? error.message : String(error)
5153
+ };
5154
+ }
5155
+ });
5156
+ ipcMain.handle("cloud:getTaskResult", async (_, id) => {
5157
+ try {
5158
+ return await cloudService.getTaskResult(id);
5159
+ } catch (error) {
5160
+ console.error("[IPC] cloud:getTaskResult error:", error);
5161
+ return {
5162
+ success: false,
5163
+ error: error instanceof Error ? error.message : String(error)
5164
+ };
5165
+ }
5166
+ });
5167
+ ipcMain.handle("cloud:downloadPdf", async (_, id) => {
5168
+ try {
5169
+ const result = await cloudService.downloadPdf(id);
5170
+ if (!result.success || !result.data) {
5171
+ return { success: false, error: result.error || "Download failed" };
5172
+ }
5173
+ const { buffer, fileName } = result.data;
5174
+ const downloadsPath = app.getPath("downloads");
5175
+ const saveResult = await dialog.showSaveDialog({
5176
+ defaultPath: path.join(downloadsPath, fileName),
5177
+ filters: [{ name: "PDF", extensions: ["pdf"] }]
5178
+ });
5179
+ if (saveResult.canceled || !saveResult.filePath) {
5180
+ return { success: false, error: "Cancelled" };
5181
+ }
5182
+ fs.writeFileSync(saveResult.filePath, Buffer.from(buffer));
5183
+ return { success: true, data: { filePath: saveResult.filePath } };
5184
+ } catch (error) {
5185
+ console.error("[IPC] cloud:downloadPdf error:", error);
5186
+ return {
5187
+ success: false,
5188
+ error: error instanceof Error ? error.message : String(error)
5189
+ };
5190
+ }
5191
+ });
5192
+ ipcMain.handle("cloud:getPageImage", async (_, params) => {
5193
+ try {
5194
+ return await cloudService.getPageImage(params.taskId, params.pageNumber);
5195
+ } catch (error) {
5196
+ console.error("[IPC] cloud:getPageImage error:", error);
5197
+ return {
5198
+ success: false,
5199
+ error: error instanceof Error ? error.message : String(error)
5200
+ };
5201
+ }
5202
+ });
5203
+ ipcMain.handle("cloud:getCredits", async () => {
5204
+ try {
5205
+ return await cloudService.getCredits();
5206
+ } catch (error) {
5207
+ console.error("[IPC] cloud:getCredits error:", error);
5208
+ return {
5209
+ success: false,
5210
+ error: error instanceof Error ? error.message : String(error)
5211
+ };
5212
+ }
5213
+ });
5214
+ ipcMain.handle("cloud:getCreditHistory", async (_, params) => {
5215
+ try {
5216
+ return await cloudService.getCreditHistory(params.page, params.pageSize, params.type);
5217
+ } catch (error) {
5218
+ console.error("[IPC] cloud:getCreditHistory error:", error);
5219
+ return {
5220
+ success: false,
5221
+ error: error instanceof Error ? error.message : String(error)
5222
+ };
5223
+ }
5224
+ });
5225
+ ipcMain.handle("cloud:sseConnect", async () => {
5226
+ try {
5227
+ await cloudSSEManager.connect();
5228
+ return { success: true };
5229
+ } catch (error) {
5230
+ console.error("[IPC] cloud:sseConnect error:", error);
5231
+ return {
5232
+ success: false,
5233
+ error: error instanceof Error ? error.message : String(error)
5234
+ };
5235
+ }
5236
+ });
5237
+ ipcMain.handle("cloud:sseDisconnect", async () => {
5238
+ try {
5239
+ cloudSSEManager.disconnect();
5240
+ return { success: true };
5241
+ } catch (error) {
5242
+ console.error("[IPC] cloud:sseDisconnect error:", error);
5243
+ return {
5244
+ success: false,
5245
+ error: error instanceof Error ? error.message : String(error)
5246
+ };
5247
+ }
5248
+ });
5249
+ ipcMain.handle("cloud:sseResetAndDisconnect", async () => {
5250
+ try {
5251
+ cloudSSEManager.resetAndDisconnect();
5252
+ return { success: true };
5253
+ } catch (error) {
5254
+ console.error("[IPC] cloud:sseResetAndDisconnect error:", error);
5255
+ return {
5256
+ success: false,
5257
+ error: error instanceof Error ? error.message : String(error)
5258
+ };
5259
+ }
5260
+ });
5261
+ console.log("[IPC] Cloud handlers registered");
5262
+ }
5263
+ function registerAuthHandlers() {
5264
+ ipcMain.handle("auth:login", async () => {
5265
+ try {
5266
+ const result = await authManager.startDeviceLogin();
5267
+ return result;
5268
+ } catch (error) {
5269
+ console.error("[IPC] auth:login error:", error);
5270
+ return {
5271
+ success: false,
5272
+ error: error instanceof Error ? error.message : String(error)
5273
+ };
5274
+ }
5275
+ });
5276
+ ipcMain.handle("auth:cancelLogin", async () => {
5277
+ try {
5278
+ authManager.cancelLogin();
5279
+ return { success: true };
5280
+ } catch (error) {
5281
+ console.error("[IPC] auth:cancelLogin error:", error);
5282
+ return {
5283
+ success: false,
5284
+ error: error instanceof Error ? error.message : String(error)
5285
+ };
5286
+ }
5287
+ });
5288
+ ipcMain.handle("auth:logout", async () => {
5289
+ try {
5290
+ await authManager.logout();
5291
+ return { success: true };
5292
+ } catch (error) {
5293
+ console.error("[IPC] auth:logout error:", error);
5294
+ return {
5295
+ success: false,
5296
+ error: error instanceof Error ? error.message : String(error)
5297
+ };
5298
+ }
5299
+ });
5300
+ ipcMain.handle("auth:getAuthState", async () => {
5301
+ try {
5302
+ const state = authManager.getAuthState();
5303
+ return { success: true, data: state };
5304
+ } catch (error) {
5305
+ console.error("[IPC] auth:getAuthState error:", error);
5306
+ return {
5307
+ success: false,
5308
+ error: error instanceof Error ? error.message : String(error)
5309
+ };
5310
+ }
5311
+ });
5312
+ console.log("[IPC] Auth handlers registered");
5313
+ }
5314
+ const { autoUpdater } = pkg$1;
5315
+ class UpdateService {
5316
+ static instance;
5317
+ initialized = false;
5318
+ isChecking = false;
5319
+ constructor() {
5320
+ }
5321
+ static getInstance() {
5322
+ if (!UpdateService.instance) {
5323
+ UpdateService.instance = new UpdateService();
5324
+ }
5325
+ return UpdateService.instance;
5326
+ }
5327
+ ensureInitialized() {
5328
+ if (this.initialized) return;
5329
+ if (!app.isPackaged) return;
5330
+ this.initialized = true;
5331
+ autoUpdater.autoDownload = true;
5332
+ autoUpdater.allowPrerelease = false;
5333
+ autoUpdater.autoInstallOnAppQuit = true;
5334
+ this.registerListeners();
5335
+ }
5336
+ registerListeners() {
5337
+ autoUpdater.on("checking-for-update", () => {
5338
+ console.log("[UpdateService] Checking for updates...");
5339
+ this.sendStatus({ status: UpdateStatus.CHECKING });
5340
+ });
5341
+ autoUpdater.on("update-available", (info) => {
5342
+ console.log("[UpdateService] Update available:", info.version);
5343
+ this.sendStatus({ status: UpdateStatus.AVAILABLE, version: info.version });
5344
+ });
5345
+ autoUpdater.on("update-not-available", (info) => {
5346
+ console.log("[UpdateService] No update available. Current version:", info.version);
5347
+ this.sendStatus({ status: UpdateStatus.NOT_AVAILABLE, version: info.version });
5348
+ });
5349
+ autoUpdater.on("download-progress", (progress) => {
5350
+ console.log(`[UpdateService] Download progress: ${progress.percent.toFixed(1)}%`);
5351
+ this.sendStatus({ status: UpdateStatus.DOWNLOADING, progress: progress.percent });
5352
+ });
5353
+ autoUpdater.on("update-downloaded", (info) => {
5354
+ console.log("[UpdateService] Update downloaded:", info.version);
5355
+ this.sendStatus({ status: UpdateStatus.DOWNLOADED, version: info.version });
5356
+ });
5357
+ autoUpdater.on("error", (error) => {
5358
+ const errorMessage = error instanceof Error ? error.message : String(error);
5359
+ const safeMessage = errorMessage.length > 200 ? `${errorMessage.slice(0, 200)}...` : errorMessage;
5360
+ const errorDetails = {
5361
+ message: safeMessage,
5362
+ name: error instanceof Error ? error.name : void 0,
5363
+ stack: error instanceof Error ? error.stack : void 0,
5364
+ code: error.code,
5365
+ statusCode: error.statusCode
5366
+ };
5367
+ console.error("[UpdateService] Error:", errorDetails);
5368
+ this.sendStatus({ status: UpdateStatus.ERROR, error: safeMessage });
5369
+ });
5370
+ }
5371
+ sendStatus(data) {
5372
+ windowManager.sendToRenderer(IPC_CHANNELS.EVENTS.UPDATER_STATUS, data);
5373
+ }
5374
+ async checkForUpdates() {
5375
+ this.ensureInitialized();
5376
+ if (!this.initialized) return;
5377
+ if (this.isChecking) return;
5378
+ this.isChecking = true;
5379
+ try {
5380
+ await autoUpdater.checkForUpdates();
5381
+ } finally {
5382
+ this.isChecking = false;
5383
+ }
5384
+ }
5385
+ quitAndInstall() {
5386
+ this.ensureInitialized();
5387
+ autoUpdater.quitAndInstall();
5388
+ }
5389
+ }
5390
+ const updateService = UpdateService.getInstance();
5391
+ function registerUpdaterHandlers() {
5392
+ ipcMain.handle(IPC_CHANNELS.UPDATER.CHECK_FOR_UPDATES, async () => {
5393
+ if (!app.isPackaged) {
5394
+ console.log("[Updater] Skipping update check in development mode");
5395
+ windowManager.sendToRenderer(IPC_CHANNELS.EVENTS.UPDATER_STATUS, {
5396
+ status: UpdateStatus.NOT_AVAILABLE
5397
+ });
5398
+ return;
5399
+ }
5400
+ await updateService.checkForUpdates();
5401
+ });
5402
+ ipcMain.handle(IPC_CHANNELS.UPDATER.QUIT_AND_INSTALL, () => {
5403
+ updateService.quitAndInstall();
5404
+ });
5405
+ }
5406
+ function registerAllHandlers() {
5407
+ registerProviderHandlers();
5408
+ registerModelHandlers();
5409
+ registerTaskHandlers();
5410
+ registerTaskDetailHandlers();
5411
+ registerFileHandlers();
5412
+ registerCompletionHandlers();
5413
+ registerAuthHandlers();
5414
+ registerCloudHandlers();
5415
+ registerAppHandlers();
5416
+ registerUpdaterHandlers();
5417
+ console.log("[IPC] All handlers registered successfully");
5418
+ }
5419
+ function registerIpcHandlers() {
5420
+ registerAllHandlers();
5421
+ }
5422
+ class EventBridge {
5423
+ isInitialized = false;
5424
+ initialize() {
5425
+ if (this.isInitialized) return;
5426
+ eventBus.onTaskEvent("task:*", this.handleTaskEvent.bind(this));
5427
+ eventBus.onTaskDetailEvent("taskDetail:*", this.handleTaskDetailEvent.bind(this));
5428
+ this.isInitialized = true;
5429
+ console.log("[EventBridge] Initialized");
5430
+ }
5431
+ handleTaskEvent(data) {
5432
+ const { type, taskId, task, timestamp } = data;
5433
+ windowManager.sendToRenderer("task:event", {
5434
+ type,
5435
+ taskId,
5436
+ task,
5437
+ timestamp
5438
+ });
5439
+ }
5440
+ handleTaskDetailEvent(data) {
5441
+ const { type, taskId, pageId, page, status, timestamp } = data;
5442
+ windowManager.sendToRenderer("taskDetail:event", {
5443
+ type,
5444
+ taskId,
5445
+ pageId,
5446
+ page,
5447
+ status,
5448
+ timestamp
5449
+ });
5450
+ }
5451
+ cleanup() {
5452
+ eventBus.removeAllListeners();
5453
+ this.isInitialized = false;
5454
+ }
5455
+ }
5456
+ const eventBridge = new EventBridge();
5457
+ if (!app.isPackaged) {
5458
+ app.setName("MarkPDFdown");
5459
+ const userDataPath = app.getPath("userData");
5460
+ if (userDataPath.endsWith("Electron")) {
5461
+ const newPath = userDataPath.replace(/Electron$/, "MarkPDFdown");
5462
+ app.setPath("userData", newPath);
5463
+ }
5464
+ }
5465
+ function getIconPath() {
5466
+ let iconName;
5467
+ if (app.isPackaged) {
5468
+ iconName = process.platform === "darwin" ? "icons/mac/icon.icns" : process.platform === "win32" ? "icons/win/icon.ico" : "icons/png/512x512.png";
5469
+ } else {
5470
+ iconName = process.platform === "darwin" ? "icons/mac/png/512x512.png" : "icons/png/512x512.png";
5471
+ }
5472
+ if (process.env.ELECTRON_RENDERER_URL) {
5473
+ return path.join(process.cwd(), "public", iconName);
5474
+ }
5475
+ if (app.isPackaged) {
5476
+ return path.join(process.resourcesPath, iconName);
5477
+ }
5478
+ const appPath = app.getAppPath();
5479
+ const possiblePaths = [
5480
+ path.join(appPath, "..", "..", "public", iconName),
5481
+ // dist/main -> 根目录
5482
+ path.join(appPath, "..", "public", iconName),
5483
+ // dist -> 根目录
5484
+ path.join(appPath, "public", iconName),
5485
+ // 当前目录
5486
+ path.join(process.cwd(), "public", iconName)
5487
+ // 工作目录
5488
+ ];
5489
+ for (const iconPath of possiblePaths) {
5490
+ if (fs.existsSync(iconPath)) {
5491
+ return iconPath;
3936
5492
  }
3937
5493
  }
3938
5494
  return possiblePaths[0];
3939
5495
  }
5496
+ const PROTOCOL_NAME = "markpdfdown";
3940
5497
  protocol.registerSchemesAsPrivileged([
3941
5498
  {
3942
5499
  scheme: "local-file",
@@ -3948,6 +5505,65 @@ protocol.registerSchemesAsPrivileged([
3948
5505
  }
3949
5506
  }
3950
5507
  ]);
5508
+ if (process.defaultApp) {
5509
+ if (process.argv.length >= 2) {
5510
+ app.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [path.resolve(process.argv[1])]);
5511
+ }
5512
+ } else {
5513
+ app.setAsDefaultProtocolClient(PROTOCOL_NAME);
5514
+ }
5515
+ function handleProtocolUrl(url) {
5516
+ console.log("[Main] Received protocol URL");
5517
+ if (!url.startsWith(`${PROTOCOL_NAME}://`)) {
5518
+ console.warn("[Main] Ignoring URL with unexpected scheme");
5519
+ return;
5520
+ }
5521
+ try {
5522
+ const parsed = new URL(url);
5523
+ const host = parsed.host.toLowerCase();
5524
+ const pathname = parsed.pathname.replace(/\/+/g, "/").replace(/\/+$/, "");
5525
+ if (parsed.host.includes("%")) {
5526
+ console.warn("[Main] Ignoring protocol URL with encoded host");
5527
+ return;
5528
+ }
5529
+ const isAllowed = host === "auth" && pathname === "/callback" || host === "auth" && pathname === "";
5530
+ if (!isAllowed) {
5531
+ console.warn(`[Main] Ignoring protocol URL with unexpected path: ${host}${pathname}`);
5532
+ return;
5533
+ }
5534
+ } catch {
5535
+ console.warn("[Main] Ignoring malformed protocol URL");
5536
+ return;
5537
+ }
5538
+ if (mainWindow) {
5539
+ if (mainWindow.isMinimized()) {
5540
+ mainWindow.restore();
5541
+ }
5542
+ mainWindow.focus();
5543
+ }
5544
+ authManager.checkDeviceTokenStatus();
5545
+ }
5546
+ app.on("open-url", (event, url) => {
5547
+ event.preventDefault();
5548
+ handleProtocolUrl(url);
5549
+ });
5550
+ const gotTheLock = app.requestSingleInstanceLock();
5551
+ if (!gotTheLock) {
5552
+ app.quit();
5553
+ } else {
5554
+ app.on("second-instance", (_event, commandLine) => {
5555
+ if (mainWindow) {
5556
+ if (mainWindow.isMinimized()) {
5557
+ mainWindow.restore();
5558
+ }
5559
+ mainWindow.focus();
5560
+ }
5561
+ const url = commandLine.find((arg) => arg.startsWith(`${PROTOCOL_NAME}://`));
5562
+ if (url) {
5563
+ handleProtocolUrl(url);
5564
+ }
5565
+ });
5566
+ }
3951
5567
  let mainWindow;
3952
5568
  function registerLocalFileProtocol() {
3953
5569
  protocol.registerFileProtocol("local-file", (request, callback) => {
@@ -4096,6 +5712,10 @@ async function initializeBackgroundServices() {
4096
5712
  console.log("[Main] Initializing database in background...");
4097
5713
  await initDatabase();
4098
5714
  console.log(`[Main] Database initialized in ${Date.now() - startTime}ms`);
5715
+ console.log("[Main] Restoring auth session...");
5716
+ const authStartTime = Date.now();
5717
+ await authManager.initialize();
5718
+ console.log(`[Main] Auth session restored in ${Date.now() - authStartTime}ms`);
4099
5719
  console.log("[Main] Injecting preset providers...");
4100
5720
  const presetStartTime = Date.now();
4101
5721
  await presetProviderService.initialize();