diff --git a/electron/media-upload-automation.js b/electron/media-upload-automation.js index 171133f0..9b4daff4 100644 --- a/electron/media-upload-automation.js +++ b/electron/media-upload-automation.js @@ -29,7 +29,7 @@ export function isDirectMediaUrl(url) { /** * Run automated upload for a provider. * @param {Object} options - * @param {string} options.provider - Provider id (catbox, imgur) + * @param {string} options.provider - Provider id (catbox, imgur, imgbb) * @param {string} options.filePath - Absolute path to the file to upload * @returns {Promise<{ url: string; provider: string }>} * @throws {Error} On missing recipe, blocked indicators, timeout, or invalid URL @@ -129,6 +129,14 @@ export async function automateUploadMedia(options) { nodeId: fileInputNodeId, files: [filePath], }); + await new Promise((r) => setTimeout(r, 500)); + + if (recipe.prepareSubmitJs) { + await sendCommand('Runtime.evaluate', { + expression: recipe.prepareSubmitJs, + returnByValue: true, + }); + } let submitNodeId = null; for (const sel of recipe.submitSelectorCandidates) { @@ -173,6 +181,27 @@ export async function automateUploadMedia(options) { (function() { const selectors = ${JSON.stringify(selectorCandidates)}; const attr = ${JSON.stringify(attr)}; + function normalizeUrl(value) { + if (!value) return ''; + let url = String(value).trim(); + if (url.startsWith('//')) url = 'https:' + url; + return url; + } + function hasMediaExtension(url) { + return /\\.(?:jpe?g|png|gif|webp|bmp|avif|mp4|webm|mov|avi|mkv|gifv)(?:[?#].*)?$/i.test(url); + } + function pickDirectMediaUrl(value) { + const text = String(value || ''); + const candidates = text.match(/https?:\\/\\/[^\\s"'<>\\[\\]]+/g) || []; + for (const candidate of candidates) { + const normalized = normalizeUrl(candidate); + if (hasMediaExtension(normalized)) return normalized; + } + const normalized = normalizeUrl(text); + if (normalized.startsWith('http') && hasMediaExtension(normalized)) return normalized; + if (normalized.startsWith('http')) return normalized; + return ''; + } for (const sel of selectors) { try { const el = document.querySelector(sel); @@ -185,7 +214,8 @@ export async function automateUploadMedia(options) { } else { url = (el.getAttribute(attr) || el[attr] || '').trim(); } - if (url && url.startsWith('http')) return url; + const directUrl = pickDirectMediaUrl(url); + if (directUrl) return directUrl; } catch (e) {} } return null; diff --git a/electron/media-upload-automation.test.js b/electron/media-upload-automation.test.js index 496e659e..6ce7aad0 100644 --- a/electron/media-upload-automation.test.js +++ b/electron/media-upload-automation.test.js @@ -155,6 +155,28 @@ describe('media-upload-automation', () => { expect(fakeWindow.destroy).toHaveBeenCalledOnce(); }); + it('uploads to ImgBB through the hidden BrowserWindow recipe', async () => { + const recipe = MEDIA_UPLOAD_RECIPES.imgbb; + const fakeWindow = createFakeBrowserWindow({ + getNodeId: (selector) => { + if (selector === recipe.fileInputSelectorCandidates[0]) return 10; + if (selector === recipe.submitSelectorCandidates[0]) return 20; + return 0; + }, + runtimeValues: [true, 'https://i.ibb.co/example/uploaded.png'], + }); + electronState.createWindow = () => fakeWindow; + + const result = await automateUploadMedia({ provider: 'imgbb', filePath: '/tmp/upload.png' }); + + expect(result).toEqual({ url: 'https://i.ibb.co/example/uploaded.png', provider: 'imgbb' }); + expect(fakeWindow.loadURL).toHaveBeenCalledWith(recipe.uploadUrl); + expect(fakeWindow.webContents.debugger.sendCommand).toHaveBeenCalledWith('DOM.setFileInputFiles', { + nodeId: 10, + files: ['/tmp/upload.png'], + }); + }); + it('fails fast when a blocked indicator is present before upload begins', async () => { const recipe = MEDIA_UPLOAD_RECIPES.imgur; const fakeWindow = createFakeBrowserWindow({ @@ -242,4 +264,9 @@ describe('media-upload-automation + recipes integration', () => { expect(recipe.successExtractor.selectorCandidates.length).toBeGreaterThanOrEqual(1); } }); + + it('imgbb success extractor targets direct-media domain', () => { + const imgbbSelectors = MEDIA_UPLOAD_RECIPES.imgbb.successExtractor.selectorCandidates; + expect(imgbbSelectors.some((selector) => selector.includes('i.ibb.co') || selector.includes('html-embed-medium'))).toBe(true); + }); }); diff --git a/electron/media-upload-recipes.js b/electron/media-upload-recipes.js index f15f52be..cd416271 100644 --- a/electron/media-upload-recipes.js +++ b/electron/media-upload-recipes.js @@ -4,12 +4,14 @@ * Selectors are candidate-based: try each until one matches. Fail fast on blocked indicators. * * Android vs Electron differences (documented to prevent drift): - * - Android (MediaUploadRecipes.java): imgur only; no catbox (uses different flow). - * Uses DataTransfer JS injection. - * - Electron: catbox/imgur. Uses CDP DOM.setFileInputFiles + submit button click. + * - Android (MediaUploadRecipes.java): app uploads use native catbox, plus WebView + * automation for imgbb. The imgur runner is retained for diagnostics/tests only. + * Real Uri attempts use the WebView file chooser callback; fixtures/fallback use + * DataTransfer. + * - Electron: catbox/imgur/imgbb. Uses CDP DOM.setFileInputFiles + submit button click. * Catbox: Electron-only; keep behavior unchanged. - * - Blocked/success selectors: reconciled with Android for imgur. - * - Timeouts: imgur 45s; catbox 30s (Electron-only). + * - Blocked/success selectors: reconciled with Android for imgbb and imgur. + * - Timeouts: imgbb 60s; imgur 45s; catbox 30s (Electron-only). * * @typedef {Object} ProviderRecipe * @property {string} uploadUrl - Full URL of the provider's upload page @@ -18,6 +20,7 @@ * @property {Object} successExtractor - How to extract the result URL from the page * @property {readonly string[]} successExtractor.selectorCandidates - Selectors for element containing result URL * @property {'href'|'src'|'value'|'text'} successExtractor.attribute - Attribute or 'text' for textContent + * @property {string=} prepareSubmitJs - Optional JS to normalize provider controls before clicking submit * @property {readonly string[]} blockedIndicators - Selectors that indicate captcha/login/challenge (fail immediately if present) * @property {number} timeoutMs - Max time to wait for upload completion */ @@ -48,6 +51,26 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({ blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']), timeoutMs: 45_000, }), + imgbb: Object.freeze({ + uploadUrl: 'https://imgbb.com/', + fileInputSelectorCandidates: Object.freeze(['input[type="file"]', '#anywhere-upload-input', 'input[data-action="anywhere-upload-input"]']), + submitSelectorCandidates: Object.freeze(['button[data-action="upload"]', 'button.btn.green', 'button[type="submit"]', '[data-action="upload"]']), + successExtractor: Object.freeze({ + selectorCandidates: Object.freeze([ + 'input[name="html-embed-medium"]', + 'textarea[name="html-embed-medium"]', + '#uploaded-embed-code-1', + 'input[value*="i.ibb.co"]', + 'textarea', + 'img[src*="i.ibb.co"]', + ]), + attribute: 'value', + }), + prepareSubmitJs: + "(function(){var select=document.querySelector('#upload-expiration,select[name=\"upload-expiration\"]');if(select){select.value='';select.dispatchEvent(new Event('change',{bubbles:true}));}return true;})()", + blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']), + timeoutMs: 60_000, + }), }); /** diff --git a/electron/media-upload-recipes.test.js b/electron/media-upload-recipes.test.js index 2cfe0fc1..f71e05c6 100644 --- a/electron/media-upload-recipes.test.js +++ b/electron/media-upload-recipes.test.js @@ -9,6 +9,7 @@ describe('media-upload-recipes', () => { it('exports MEDIA_UPLOAD_RECIPES with expected providers', () => { expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('catbox'); expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgur'); + expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgbb'); }); it('validateRecipes passes for current recipes', () => { @@ -31,6 +32,12 @@ describe('media-upload-recipes', () => { expect(MEDIA_UPLOAD_RECIPES.imgur.timeoutMs).toBe(45_000); }); + it('imgbb targets full image embed codes and has Android parity timeout', () => { + expect(MEDIA_UPLOAD_RECIPES.imgbb.timeoutMs).toBe(60_000); + expect(MEDIA_UPLOAD_RECIPES.imgbb.uploadUrl).toBe('https://imgbb.com/'); + expect(MEDIA_UPLOAD_RECIPES.imgbb.successExtractor.selectorCandidates).toContain('input[name="html-embed-medium"]'); + }); + it('selector fallback order: generic file input before provider-specific', () => { for (const [, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) { const first = recipe.fileInputSelectorCandidates[0]; diff --git a/src/lib/media-hosting/__tests__/direct-url.test.ts b/src/lib/media-hosting/__tests__/direct-url.test.ts index eb922fa0..b7b48e78 100644 --- a/src/lib/media-hosting/__tests__/direct-url.test.ts +++ b/src/lib/media-hosting/__tests__/direct-url.test.ts @@ -5,6 +5,7 @@ describe('direct-url', () => { describe('isDirectMediaUrl', () => { it('returns true for image extensions', () => { expect(isDirectMediaUrl('https://example.com/photo.jpg')).toBe(true); + expect(isDirectMediaUrl('https://i.ibb.co/example/photo.jpg')).toBe(true); expect(isDirectMediaUrl('https://example.com/photo.jpeg')).toBe(true); expect(isDirectMediaUrl('https://example.com/photo.png')).toBe(true); expect(isDirectMediaUrl('https://example.com/photo.gif')).toBe(true); diff --git a/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts b/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts index f751ebbc..4518edf9 100644 --- a/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts +++ b/src/lib/media-hosting/__tests__/upload-orchestrator.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { orchestrateElectronUpload } from '../upload-orchestrator'; import { uploadToCatbox } from '../../utils/catbox-utils'; +import type { ProviderId } from '../types'; vi.mock('../../utils/catbox-utils', () => ({ uploadToCatbox: vi.fn(), @@ -11,7 +12,7 @@ function createElectronApiMock() { 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 })), + automateUploadMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/abc.png', provider: options.provider })), getPathForFile: vi.fn((): string | null => '/tmp/image.png'), }; } @@ -48,6 +49,21 @@ describe('orchestrateElectronUpload', () => { }); }); + it('routes ImgBB through Electron automation', async () => { + const electronApi = createElectronApiMock(); + electronApi.automateUploadMedia = vi.fn(async () => ({ url: 'https://i.ibb.co/example/image.png', provider: 'imgbb' as const })); + window.electronApi = electronApi; + + const file = new File(['x'], 'x.png', { type: 'image/png' }); + const url = await orchestrateElectronUpload(file, ['imgbb']); + + expect(url).toBe('https://i.ibb.co/example/image.png'); + expect(electronApi.automateUploadMedia).toHaveBeenCalledWith({ + provider: 'imgbb', + 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); diff --git a/src/lib/media-hosting/providers.ts b/src/lib/media-hosting/providers.ts index fdd8db96..daaeedac 100644 --- a/src/lib/media-hosting/providers.ts +++ b/src/lib/media-hosting/providers.ts @@ -4,6 +4,8 @@ interface ProviderDefinition { id: ProviderId; label: string; homepageUrl: string; + /** Image URLs used to detect whether this provider is reachable from the user's network. */ + availabilityProbeUrls: readonly string[]; /** Runtimes where automated upload is supported (non-web = no interactive fallback) */ supportedRuntimes: readonly MediaHostingRuntime[]; } @@ -14,12 +16,21 @@ export const MEDIA_HOSTING_PROVIDERS: readonly ProviderDefinition[] = [ id: 'catbox', label: 'Catbox', homepageUrl: 'https://catbox.moe', + availabilityProbeUrls: ['https://catbox.moe/pictures/logo.png', 'https://files.catbox.moe/8ten4y.png'], supportedRuntimes: ['web', 'electron', 'android'], }, { id: 'imgur', label: 'Imgur', homepageUrl: 'https://imgur.com', + availabilityProbeUrls: ['https://s.imgur.com/images/favicon-32x32.png', 'https://i.imgur.com/YpB7qfa.jpg'], + supportedRuntimes: ['electron'], + }, + { + id: 'imgbb', + label: 'ImgBB', + homepageUrl: 'https://imgbb.com', + availabilityProbeUrls: ['https://simgbb.com/images/logo.png', 'https://i.ibb.co/7Jsq00V5/spoiler.png'], supportedRuntimes: ['electron', 'android'], }, ] as const; diff --git a/src/lib/media-hosting/types.ts b/src/lib/media-hosting/types.ts index 1f41ed46..1338523e 100644 --- a/src/lib/media-hosting/types.ts +++ b/src/lib/media-hosting/types.ts @@ -1,5 +1,5 @@ /** Supported media hosting provider identifiers */ -export type ProviderId = 'catbox' | 'imgur'; +export type ProviderId = 'catbox' | 'imgur' | 'imgbb'; /** User-facing upload mode */ export type UploadMode = 'random' | 'preferred' | 'none'; diff --git a/src/lib/media-hosting/upload-orchestrator.ts b/src/lib/media-hosting/upload-orchestrator.ts index fb96bdeb..a142f774 100644 --- a/src/lib/media-hosting/upload-orchestrator.ts +++ b/src/lib/media-hosting/upload-orchestrator.ts @@ -58,11 +58,11 @@ function resolveElectronFilePath(file: File): string | null { /** * Uploads a file via a single provider. Catbox uses the web API; - * imgur uses Electron automation when available. + * imgur/imgbb use Electron automation when available. */ async function uploadViaProvider(provider: ProviderId, file: File): Promise { if (provider === 'catbox') return uploadToCatbox(file); - if (provider === 'imgur') { + if (provider === 'imgur' || provider === 'imgbb') { const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia; if (fn) { const filePath = resolveElectronFilePath(file); diff --git a/src/stores/__tests__/use-media-hosting-store.test.ts b/src/stores/__tests__/use-media-hosting-store.test.ts index b0519ef8..154d0efc 100644 --- a/src/stores/__tests__/use-media-hosting-store.test.ts +++ b/src/stores/__tests__/use-media-hosting-store.test.ts @@ -19,14 +19,23 @@ describe('useMediaHostingStore', () => { it('exports MEDIA_HOSTING_PROVIDERS with ids, labels, homepage URLs, runtime metadata', () => { expect(MEDIA_HOSTING_PROVIDERS).toBeDefined(); - expect(MEDIA_HOSTING_PROVIDERS.length).toBe(2); + expect(MEDIA_HOSTING_PROVIDERS.length).toBe(3); const catbox = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'catbox'); expect(catbox).toEqual({ id: 'catbox', label: 'Catbox', homepageUrl: 'https://catbox.moe', + availabilityProbeUrls: ['https://catbox.moe/pictures/logo.png', 'https://files.catbox.moe/8ten4y.png'], supportedRuntimes: ['web', 'electron', 'android'], }); + const imgbb = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'imgbb'); + expect(imgbb).toEqual({ + id: 'imgbb', + label: 'ImgBB', + homepageUrl: 'https://imgbb.com', + availabilityProbeUrls: ['https://simgbb.com/images/logo.png', 'https://i.ibb.co/7Jsq00V5/spoiler.png'], + supportedRuntimes: ['electron', 'android'], + }); }); it('defaults uploadMode to random and preferredProvider to catbox', () => {