diff --git a/android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java b/android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java index efbdaafa..e7db48ef 100644 --- a/android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java +++ b/android/app/src/main/java/plebchan/android/MediaUploadAutomationRunner.java @@ -49,6 +49,8 @@ public class MediaUploadAutomationRunner { private final Handler mainHandler; private final Runnable pollRunnable; private boolean finished; + private boolean fileChooserHandled; + private boolean fileInputTriggerAttempted; private long startTime; public MediaUploadAutomationRunner( @@ -86,6 +88,7 @@ public class MediaUploadAutomationRunner { WebView webView, ValueCallback filePathCallback, FileChooserParams fileChooserParams) { + fileChooserHandled = true; MediaUploadAutomationRunner.this.filePathCallback = filePathCallback; if (fileUri != null) { filePathCallback.onReceiveValue(new Uri[] {fileUri}); @@ -120,7 +123,21 @@ public class MediaUploadAutomationRunner { finish(new MediaUploadResult(false, null, "No trigger JS for " + provider)); return; } - webView.evaluateJavascript(js, value -> schedulePoll()); + fileInputTriggerAttempted = true; + webView.evaluateJavascript( + js, + value -> { + String normalized = value == null ? "" : value.replace("\"", "").trim(); + if ("true".equals(normalized)) { + schedulePoll(); + } else { + finish( + new MediaUploadResult( + false, + null, + "Could not find upload file input for provider " + provider)); + } + }); } private void schedulePoll() { @@ -136,6 +153,14 @@ public class MediaUploadAutomationRunner { finish(new MediaUploadResult(false, null, "Upload timeout")); return; } + if (fileInputTriggerAttempted + && !fileChooserHandled + && elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) { + finish( + new MediaUploadResult( + false, null, "Provider file chooser was not triggered for " + provider)); + return; + } String successJs = MediaUploadRecipes.getSuccessJs(provider); String blockedJs = MediaUploadRecipes.getBlockedJs(provider); diff --git a/android/app/src/main/java/plebchan/android/MediaUploadRecipes.java b/android/app/src/main/java/plebchan/android/MediaUploadRecipes.java index 01b56523..109bcad8 100644 --- a/android/app/src/main/java/plebchan/android/MediaUploadRecipes.java +++ b/android/app/src/main/java/plebchan/android/MediaUploadRecipes.java @@ -13,6 +13,8 @@ public final class MediaUploadRecipes { /** Max time to wait for upload completion (ms). */ public static final long UPLOAD_TIMEOUT_MS = 45_000; + /** Max time to wait for provider file input to be found/triggered (ms). */ + public static final long FILE_INPUT_TIMEOUT_MS = 8_000; /** Poll interval for success/blocked checks (ms). */ public static final long POLL_INTERVAL_MS = 500; @@ -61,7 +63,7 @@ public final class MediaUploadRecipes { if (i > 0) sb.append(","); sb.append("\"").append(escapeJs(selectors[i])).append("\""); } - sb.append("];for(var i=0;i ipcRenderer.invoke('copy-to-clipboard', text), getPlatform: () => ipcRenderer.invoke('get-platform'), automateUploadMedia: (options) => ipcRenderer.invoke('automate-upload-media', options), + getPathForFile: (file) => { + try { + return webUtils.getPathForFile(file); + } catch { + return null; + } + }, }); diff --git a/src/globals.d.ts b/src/globals.d.ts index c8f80d3d..2364956c 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -13,6 +13,7 @@ declare global { copyToClipboard: (text: string) => Promise<{ success: boolean; error?: string }>; getPlatform: () => Promise<{ platform: NodeJS.Platform; arch: string; version: string }>; automateUploadMedia: (options: { provider: ProviderId; filePath: string }) => Promise<{ url: string; provider: ProviderId }>; + getPathForFile?: (file: File) => string | null; }; } } diff --git a/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts b/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts new file mode 100644 index 00000000..9d015d6b --- /dev/null +++ b/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { orchestrateElectronUpload } from '../upload-orchestrator'; +import { uploadToCatbox } from '../../utils/catbox-utils'; + +vi.mock('../../utils/catbox-utils', () => ({ + uploadToCatbox: vi.fn(), +})); + +function createElectronApiMock() { + return { + isElectron: true, + copyToClipboard: vi.fn(async () => ({ success: true })), + getPlatform: vi.fn(async () => ({ platform: 'darwin' as NodeJS.Platform, arch: 'x64', version: 'v20.0.0' })), + automateUploadMedia: vi.fn(async () => ({ url: 'https://i.imgur.com/abc.png', provider: 'imgur' as const })), + getPathForFile: vi.fn((): string | null => '/tmp/image.png'), + }; +} + +describe('orchestrateElectronUpload', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.electronApi = undefined; + window.isElectron = false; + }); + + it('uploads via catbox provider directly', async () => { + vi.mocked(uploadToCatbox).mockResolvedValue('https://files.catbox.moe/a.png'); + const file = new File(['a'], 'a.png', { type: 'image/png' }); + + const url = await orchestrateElectronUpload(file, ['catbox']); + + expect(url).toBe('https://files.catbox.moe/a.png'); + expect(uploadToCatbox).toHaveBeenCalledWith(file); + }); + + it('uses electronApi.getPathForFile when File.path is unavailable', async () => { + const electronApi = createElectronApiMock(); + window.electronApi = electronApi; + + const file = new File(['x'], 'x.png', { type: 'image/png' }); + const url = await orchestrateElectronUpload(file, ['imgur']); + + expect(url).toBe('https://i.imgur.com/abc.png'); + expect(electronApi.getPathForFile).toHaveBeenCalledWith(file); + expect(electronApi.automateUploadMedia).toHaveBeenCalledWith({ + provider: 'imgur', + filePath: '/tmp/image.png', + }); + }); + + it('fails with provider attempt details if no file path can be resolved', async () => { + const electronApi = createElectronApiMock(); + electronApi.getPathForFile = vi.fn((): string | null => null); + window.electronApi = electronApi; + + const file = new File(['z'], 'z.png', { type: 'image/png' }); + + try { + await orchestrateElectronUpload(file, ['postimages']); + throw new Error('Expected orchestrateElectronUpload to throw'); + } catch (error) { + const typedError = error as Error & { + attempts?: Array<{ provider: string; error?: string }>; + }; + expect(typedError.message).toBe('All providers failed'); + expect(typedError.attempts?.[0]?.provider).toBe('postimages'); + expect(typedError.attempts?.[0]?.error).toContain('File path required for Electron automation'); + } + }); +}); diff --git a/src/lib/media-hosting/upload-orchestrator.ts b/src/lib/media-hosting/upload-orchestrator.ts index cae05db0..4caa762a 100644 --- a/src/lib/media-hosting/upload-orchestrator.ts +++ b/src/lib/media-hosting/upload-orchestrator.ts @@ -8,6 +8,23 @@ export interface OrchestratorAttempt { error?: string; } +function resolveElectronFilePath(file: File): string | null { + const fileWithPath = file as File & { path?: string }; + if (typeof fileWithPath.path === 'string' && fileWithPath.path.length > 0) { + return fileWithPath.path; + } + + const getPathForFile = window.electronApi?.getPathForFile; + if (typeof getPathForFile === 'function') { + const resolvedPath = getPathForFile(file); + if (typeof resolvedPath === 'string' && resolvedPath.length > 0) { + return resolvedPath; + } + } + + return null; +} + /** * Uploads a file via a single provider. Catbox uses the web API; * imgur/postimages use Electron automation when available. @@ -17,7 +34,7 @@ async function uploadViaProvider(provider: ProviderId, file: File): Promise