diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index e4a91013899..4f22b141a47 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -219,7 +219,7 @@ 1 src/renderer/src/components/tab-group/workspace-tab-close-commands.ts 1 src/renderer/src/components/task-page-jira-status-order.ts 2 src/renderer/src/components/task-page-linear-issue-model.tsx -10 src/renderer/src/components/task-page/TaskPage.tsx +9 src/renderer/src/components/task-page/TaskPage.tsx 2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx 1 src/renderer/src/components/task-page/linear/Filters.tsx 1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -254,7 +254,7 @@ 2 src/renderer/src/components/use-new-linear-issue-projects.ts 4 src/renderer/src/components/use-task-page-jira-creation-metadata.ts 4 src/renderer/src/components/use-task-page-jira-issue-creation.ts -2 src/renderer/src/components/use-task-page-jira-list-effects.ts +1 src/renderer/src/components/use-task-page-jira-list-effects.ts 4 src/renderer/src/components/use-task-page-linear-board.ts 6 src/renderer/src/components/use-task-page-linear-creation-state.ts 4 src/renderer/src/components/use-task-page-linear-issue-creation.ts diff --git a/config/scripts/mobile-web-app-render.test.mjs b/config/scripts/mobile-web-app-render.test.mjs index 3f105a7dfed..b9d59de4689 100644 --- a/config/scripts/mobile-web-app-render.test.mjs +++ b/config/scripts/mobile-web-app-render.test.mjs @@ -330,10 +330,10 @@ describe('the shell policy this page is tested under', () => { it('admits data: and https: for images and for nothing else', () => { expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([ - "img-src 'self' data: https:" + "img-src 'self' data: blob: https:" ]) expect(cspHeader.split('; ').filter((entry) => entry.includes('https:'))).toEqual([ - "img-src 'self' data: https:" + "img-src 'self' data: blob: https:" ]) // `http:` is not a substring of `https:`, so this still refuses a cleartext source. expect(cspHeader).not.toContain('http:') diff --git a/config/scripts/mobile-web-app-source-control-render.test.mjs b/config/scripts/mobile-web-app-source-control-render.test.mjs index acecfce594d..94432b58fa3 100644 --- a/config/scripts/mobile-web-app-source-control-render.test.mjs +++ b/config/scripts/mobile-web-app-source-control-render.test.mjs @@ -243,13 +243,13 @@ describeRender( it('serves the shipped image policy and neither route violates it', async () => { // The policy the shells send, read from the Kotlin source rather than restated, so this // cannot pass against a header the app does not use. - expect(cspHeader).toContain("img-src 'self' data: https:") + expect(cspHeader).toContain("img-src 'self' data: blob: https:") const swift = await readFile( join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'), 'utf8' ) // The other shell says the same thing, which no served header can show. - expect(swift).toContain(`"img-src 'self' data: https:"`) + expect(swift).toContain(`"img-src 'self' data: blob: https:"`) for (const [route, text] of [ [HUB_ROUTE, 'Source Control'], diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt index 844e7505219..f8b952028ea 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt @@ -36,7 +36,7 @@ internal val MOBILE_WEB_SHELL_CSP = listOf( // // `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable and // replaceable in flight by anything on the path. - "img-src 'self' data: https:", + "img-src 'self' data: blob: https:", "font-src 'none'", // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the // page cannot already read, and the bootstrap page reads ./manifest.json through it. This is the diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt index 41fa3826e16..2bcf5720755 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellCspTest.kt @@ -15,7 +15,7 @@ class MobileWebShellCspTest { assertTrue(directives.contains("style-src 'self' 'unsafe-inline'")) // A file preview is a `data:` URI; `https:` is the favicon, project icon and avatar the // page already renders, and the sealed preview frame. See MobileWebShellCsp. - assertTrue(directives.contains("img-src 'self' data: https:")) + assertTrue(directives.contains("img-src 'self' data: blob: https:")) // The bootstrap page reads ./manifest.json from its own origin, which is one read-only // directory behind the manifest map, so 'self' reaches nothing it cannot already read. assertTrue(directives.contains("connect-src 'self'")) @@ -42,17 +42,17 @@ class MobileWebShellCspTest { // Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a // directive that grew one would fail here instead of passing a blanket absence check. assertEquals( - listOf("img-src 'self' data: https:"), + listOf("img-src 'self' data: blob: https:"), directives.filter { it.contains("data:") } ) assertEquals( - listOf("media-src blob:"), + listOf("img-src 'self' data: blob: https:", "media-src blob:"), directives.filter { it.contains("blob:") } ) // Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so // this still refuses a cleartext source anywhere in the header. assertEquals( - listOf("img-src 'self' data: https:"), + listOf("img-src 'self' data: blob: https:"), directives.filter { it.contains("https:") } ) assertFalse(MOBILE_WEB_SHELL_CSP.contains("http:")) diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift index 59768104001..f362890518e 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift @@ -33,7 +33,7 @@ enum MobileWebShellCsp { // // `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable // and replaceable in flight by anything on the path. - "img-src 'self' data: https:", + "img-src 'self' data: blob: https:", "font-src 'none'", // The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the // page cannot already read, and the bootstrap page reads ./manifest.json through it. This is diff --git a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift index f6a2502cf73..81afd6fdfc6 100644 --- a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift +++ b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift @@ -211,7 +211,7 @@ import Foundation precondition(directives.contains("style-src 'self' 'unsafe-inline'")) // A file preview is a `data:` URI; `https:` is the favicon, project icon and avatar the // page already renders, and the sealed preview frame. See MobileWebShellCsp. - precondition(directives.contains("img-src 'self' data: https:")) + precondition(directives.contains("img-src 'self' data: blob: https:")) precondition(directives.contains("connect-src 'self'")) precondition(directives.contains("worker-src 'none'")) precondition(directives.contains("frame-src 'none'")) @@ -224,11 +224,11 @@ import Foundation precondition(!header.contains("unsafe-eval")) // Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a // directive that grew one would fail here instead of passing a blanket absence check. - precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: https:"]) - precondition(!header.contains("blob:")) + precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: blob: https:"]) + precondition(directives.filter { $0.contains("blob:") } == ["img-src 'self' data: blob: https:", "media-src blob:"]) // Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so // this still refuses a cleartext source anywhere in the header. - precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: https:"]) + precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: blob: https:"]) precondition(!header.contains("http:")) precondition(!header.contains("\r") && !header.contains("\n")) } diff --git a/src/main/ipc/jira-project-metadata-registration.test.ts b/src/main/ipc/jira-project-metadata-registration.test.ts new file mode 100644 index 00000000000..28a5ab3058b --- /dev/null +++ b/src/main/ipc/jira-project-metadata-registration.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + listProjects: vi.fn(), + listIssueTypes: vi.fn(), + listCreateFields: vi.fn(), + listPriorities: vi.fn() +})) +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) +vi.mock('./preflight', () => ({ _resetPreflightCache: vi.fn() })) +vi.mock('../jira/client', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(), + selectSite: vi.fn(), + testConnection: vi.fn() +})) +vi.mock('../jira/issues', () => ({ + addIssueComment: vi.fn(), + createIssue: vi.fn(), + getIssue: vi.fn(), + getIssueSummary: vi.fn(), + getIssueComments: vi.fn(), + getProjectStatusOrder: vi.fn(), + listCreateFields: mocks.listCreateFields, + listIssueTypes: mocks.listIssueTypes, + listIssues: vi.fn(), + listPriorities: mocks.listPriorities, + listProjects: mocks.listProjects, + listTransitions: vi.fn(), + searchIssues: vi.fn(), + updateIssue: vi.fn(), + listAssignableUsers: vi.fn(), + listAssignableUsersForProject: vi.fn(), + searchUsers: vi.fn() +})) +import { registerJiraHandlers } from './jira' + +function handler(channel: string) { + const callback = mocks.handle.mock.calls.find(([name]) => name === channel)?.[1] + if (typeof callback !== 'function') { + throw new Error(`Missing handler: ${channel}`) + } + return callback +} + +beforeEach(() => { + vi.clearAllMocks() + registerJiraHandlers() +}) + +describe('Jira project metadata IPC registration', () => { + it('registers each metadata channel once through the main Jira registration', () => { + for (const channel of [ + 'jira:listProjects', + 'jira:listIssueTypes', + 'jira:listCreateFields', + 'jira:listPriorities' + ]) { + expect(mocks.handle.mock.calls.filter(([name]) => name === channel)).toHaveLength(1) + } + }) + + it('retains all-sites selection and site normalization', async () => { + await handler('jira:listProjects')(undefined, { siteId: ' all ' }) + expect(mocks.listProjects).toHaveBeenLastCalledWith('all') + await handler('jira:listProjects')(undefined, { siteId: ' ' }) + expect(mocks.listProjects).toHaveBeenLastCalledWith(undefined) + await handler('jira:listPriorities')(undefined, { siteId: ' site-1 ' }) + expect(mocks.listPriorities).toHaveBeenCalledWith('site-1') + }) + + it('preserves invalid project and issue type sentinels', async () => { + await expect( + handler('jira:listIssueTypes')(undefined, { projectIdOrKey: ' ' }) + ).resolves.toEqual([]) + await expect( + handler('jira:listCreateFields')(undefined, { projectIdOrKey: 'ABC', issueTypeId: 1 }) + ).resolves.toEqual([]) + expect(mocks.listIssueTypes).not.toHaveBeenCalled() + expect(mocks.listCreateFields).not.toHaveBeenCalled() + }) + + it('forwards trimmed project and issue type identifiers to the same site', async () => { + await handler('jira:listIssueTypes')(undefined, { + projectIdOrKey: ' ABC ', + siteId: ' site-1 ' + }) + expect(mocks.listIssueTypes).toHaveBeenCalledWith('ABC', 'site-1') + await handler('jira:listCreateFields')(undefined, { + projectIdOrKey: ' ABC ', + issueTypeId: ' 10001 ', + siteId: ' site-1 ' + }) + expect(mocks.listCreateFields).toHaveBeenCalledWith('ABC', '10001', 'site-1') + }) +}) diff --git a/src/main/ipc/jira-project-metadata.ts b/src/main/ipc/jira-project-metadata.ts new file mode 100644 index 00000000000..cdee93072ec --- /dev/null +++ b/src/main/ipc/jira-project-metadata.ts @@ -0,0 +1,42 @@ +import { ipcMain } from 'electron' +import { listCreateFields, listIssueTypes, listPriorities, listProjects } from '../jira/issues' +import type { JiraSiteSelection } from '../../shared/jira-types' + +export function registerJiraProjectMetadataHandlers( + normalizeSiteId: (value: unknown) => string | undefined +): void { + ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => { + return listProjects(normalizeSiteId(args?.siteId)) + }) + + ipcMain.handle( + 'jira:listIssueTypes', + async (_event, args: { projectIdOrKey: string; siteId?: string }) => { + if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { + return [] + } + return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId)) + } + ) + + ipcMain.handle( + 'jira:listCreateFields', + async (_event, args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => { + if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { + return [] + } + if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) { + return [] + } + return listCreateFields( + args.projectIdOrKey.trim(), + args.issueTypeId.trim(), + normalizeSiteId(args.siteId) + ) + } + ) + + ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => { + return listPriorities(normalizeSiteId(args?.siteId)) + }) +} diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index 3560388364e..6aa42a40d64 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -1,9 +1,11 @@ import { JiraIssueListFiltersParam } from '../../shared/rpc-contract/jira-quick-filter-params' import { ipcMain } from 'electron' +import type { JiraMediaFormat } from '../../shared/jira-attachment-image' import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client' import { _resetPreflightCache } from './preflight' import { JiraCancellableRequests } from './jira-cancellable-requests' import { registerJiraUserSearchHandlers } from './jira-user-search' +import { registerJiraProjectMetadataHandlers } from './jira-project-metadata' import { addIssueComment, createIssue, @@ -11,11 +13,7 @@ import { getIssueSummary, getIssueComments, getProjectStatusOrder, - listCreateFields, - listIssueTypes, listIssues, - listPriorities, - listProjects, listTransitions, searchIssues, updateIssue @@ -187,12 +185,20 @@ export function registerJiraHandlers(): void { } ) - ipcMain.handle('jira:getIssue', async (_event, args: { key: string; siteId?: string }) => { - if (typeof args?.key !== 'string' || !args.key.trim()) { - return null + ipcMain.handle( + 'jira:getIssue', + async (_event, args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return null + } + return getIssue( + args.key.trim(), + normalizeSiteId(args.siteId), + false, + args.mediaFormat === 'reference' ? 'reference' : undefined + ) } - return getIssue(args.key.trim(), normalizeSiteId(args.siteId)) - }) + ) ipcMain.handle( 'jira:lookupIssueSummary', @@ -266,47 +272,22 @@ export function registerJiraHandlers(): void { } ) - ipcMain.handle('jira:issueComments', async (_event, args: { key: string; siteId?: string }) => { - if (typeof args?.key !== 'string' || !args.key.trim()) { - return [] - } - return getIssueComments(args.key.trim(), normalizeSiteId(args.siteId)) - }) - - ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => { - return listProjects(normalizeSiteSelection(args?.siteId)) - }) - ipcMain.handle( - 'jira:listIssueTypes', - async (_event, args: { projectIdOrKey: string; siteId?: string }) => { - if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { + 'jira:issueComments', + async (_event, args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { return [] } - return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId)) - } - ) - - ipcMain.handle( - 'jira:listCreateFields', - async (_event, args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => { - if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { - return [] - } - if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) { - return [] - } - return listCreateFields( - args.projectIdOrKey.trim(), - args.issueTypeId.trim(), - normalizeSiteId(args.siteId) + return getIssueComments( + args.key.trim(), + normalizeSiteId(args.siteId), + false, + args.mediaFormat === 'reference' ? 'reference' : undefined ) } ) - ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => { - return listPriorities(normalizeSiteId(args?.siteId)) - }) + registerJiraProjectMetadataHandlers(normalizeSiteId) registerJiraUserSearchHandlers() diff --git a/src/main/jira/attachment-content-url.ts b/src/main/jira/attachment-content-url.ts new file mode 100644 index 00000000000..102f8cf8b89 --- /dev/null +++ b/src/main/jira/attachment-content-url.ts @@ -0,0 +1,27 @@ +import type { JiraSite } from '../../shared/jira-types' +import { apiBasePath } from './authenticated-request' +import { isJiraGatewayBaseUrl } from './gateway-base-url' +import type { AttachmentMeta } from './attachment-meta' + +export function attachmentContentUrl(site: JiraSite, meta: AttachmentMeta): URL | null { + if (!meta.contentUrl && site.authType === 'server') { + return null + } + const contentUrl = meta.contentUrl ? new URL(meta.contentUrl, `${site.siteUrl}/`) : null + const scopedSiteAttachment = + contentUrl?.origin === new URL(site.siteUrl).origin && + site.authType !== 'server' && + isJiraGatewayBaseUrl(site.apiBaseUrl) && + !contentUrl.pathname.startsWith('/rest/') + const url = + contentUrl && !scopedSiteAttachment + ? contentUrl + : new URL( + `${apiBasePath(site)}/attachment/content/${encodeURIComponent(meta.id)}`, + site.siteUrl + ) + if (/\/rest\/api\/(?:2|3)\/attachment\/content\/[^/]+$/i.test(url.pathname)) { + url.searchParams.set('redirect', 'false') + } + return url +} diff --git a/src/main/jira/attachment-discovery.ts b/src/main/jira/attachment-discovery.ts index 945acbac247..e16dd73e899 100644 --- a/src/main/jira/attachment-discovery.ts +++ b/src/main/jira/attachment-discovery.ts @@ -31,6 +31,8 @@ export function selectPreferredAttachmentIds(args: { renderedHtmlIds: string[] attachmentField: unknown mediaAttrs: readonly JiraAdfMediaAttrs[] + maxBytes?: number + maxImages?: number }): { preferredIds: string[]; fallbackRan: boolean; needCount: number } { const needing = args.mediaAttrs.filter((attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url))) const needCount = needing.length @@ -44,10 +46,10 @@ export function selectPreferredAttachmentIds(args: { let fallbackRan = false if (needCount > preferredIds.length) { - const metas = parseImageAttachmentMetas(args.attachmentField) + const metas = parseImageAttachmentMetas(args.attachmentField, args.maxBytes) // Why: multiple Jira screenshots often share image.png — assign next unused meta per node. for (const node of needing) { - if (preferredIds.length >= MAX_IMAGES) { + if (preferredIds.length >= (args.maxImages ?? MAX_IMAGES)) { break } const alt = (node.alt ?? '').trim() @@ -68,7 +70,7 @@ export function selectPreferredAttachmentIds(args: { } return { - preferredIds: preferredIds.slice(0, MAX_IMAGES), + preferredIds: preferredIds.slice(0, args.maxImages ?? MAX_IMAGES), fallbackRan, needCount } diff --git a/src/main/jira/attachment-image-bytes.ts b/src/main/jira/attachment-image-bytes.ts new file mode 100644 index 00000000000..7ff51044c46 --- /dev/null +++ b/src/main/jira/attachment-image-bytes.ts @@ -0,0 +1,92 @@ +import { jiraRequestBinary, type JiraClientForSite } from './authenticated-request' +import type { AttachmentMeta } from './attachment-meta' +import { attachmentContentUrl } from './attachment-content-url' +import { + loadAttachmentBytesWithCache, + type AttachmentImageBytes, + attachmentImagesEpoch, + getCachedAttachmentBytes +} from './attachment-image-cache' +import { + JIRA_IMAGE_MAX_BYTES, + JIRA_IMAGE_MAX_PENDING_READS, + isJiraImageMimeType +} from '../../shared/jira-attachment-image' +import { withJiraDeadline } from './jira-read-failure' +import { PrioritySemaphore } from '../../shared/priority-semaphore' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' + +const downloads = new PrioritySemaphore(3) +const interests = new Map() + +export async function readAttachmentImageBytes( + client: JiraClientForSite, + meta: AttachmentMeta, + signal?: AbortSignal +): Promise { + if (signal?.aborted) { + throw signal.reason ?? new Error('Jira image request cancelled') + } + const cached = getCachedAttachmentBytes(client.site.id, meta.id) + if (cached) { + return cached + } + const key = `${client.site.id}::${attachmentImagesEpoch(client.site.id)}::${meta.id}` + let interest = interests.get(key) + if (!interest || interest.controller.signal.aborted) { + if (interests.size >= JIRA_IMAGE_MAX_PENDING_READS) { + throw new Error('Jira image queue is full.') + } + interest = { controller: new AbortController(), readers: 0 } + interests.set(key, interest) + } + const owned = interest + owned.readers += 1 + try { + return await waitForPromiseWithSignal( + loadAttachmentBytesWithCache({ + siteId: client.site.id, + attachmentId: meta.id, + signal: owned.controller.signal, + load: async () => { + const release = await downloads.acquire(0, owned.controller.signal) + try { + const url = attachmentContentUrl(client.site, meta) + if (!url) { + return null + } + const binary = await withJiraDeadline(owned.controller.signal, 30_000, (deadline) => + waitForPromiseWithSignal( + jiraRequestBinary(client, url.toString(), { + maxBytes: JIRA_IMAGE_MAX_BYTES, + signal: deadline + }), + deadline + ) + ) + const reported = binary.contentType.split(';')[0]?.trim().toLowerCase() + const mimeType = + !reported || reported === 'application/octet-stream' + ? meta.mimeType.toLowerCase() + : reported + if (!isJiraImageMimeType(mimeType) || binary.data.byteLength === 0) { + return null + } + return { data: new Uint8Array(binary.data), mimeType } + } finally { + release() + } + } + }), + signal + ) + } finally { + owned.readers -= 1 + if (owned.readers === 0) { + owned.controller.abort() + if (interests.get(key) === owned) { + interests.delete(key) + } + } + } +} diff --git a/src/main/jira/attachment-image-cache.ts b/src/main/jira/attachment-image-cache.ts index f1a8a0c3154..f520901ef97 100644 --- a/src/main/jira/attachment-image-cache.ts +++ b/src/main/jira/attachment-image-cache.ts @@ -1,6 +1,4 @@ -// Why: description and comments fetch attachments independently; comments also -// refetch after every post. Cache finished data URLs in main so the second path -// does not re-download or re-base64 the same attachment bytes. +// Description, comments, and lazy image reads share one bounded cache. import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' @@ -8,14 +6,20 @@ const CACHE_TTL_MS = 30 * 60_000 const MAX_CACHE_ENTRIES = 96 const MAX_CACHE_BYTES = 24 * 1024 * 1024 +export type AttachmentImageBytes = { data: Uint8Array; mimeType: string } +type ImageValue = string | AttachmentImageBytes + type CacheEntry = { - dataUrl: string + image: ImageValue byteSize: number storedAt: number } const cache = new Map() -const inFlight = new Map>() +const inFlight = new Map< + string, + { promise: Promise; signal?: AbortSignal; generation: symbol } +>() let expiryTimer: ReturnType | null = null let scheduledExpiryAt = Infinity // Why: mid-flight downloads must not repopulate cache after disconnect/clearToken. @@ -34,7 +38,7 @@ function nextEpoch(): number { return epochTicker } -function currentEpoch(siteId: string): number { +export function attachmentImagesEpoch(siteId: string): number { return Math.max(globalEpoch, siteEpoch.get(siteId) ?? 0) } @@ -101,7 +105,7 @@ function evictUntilWithinBounds(): void { } } -export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string): string | null { +function getCachedImage(siteId: string, attachmentId: string): ImageValue | null { pruneExpired() const key = cacheKey(siteId, attachmentId) const entry = cache.get(key) @@ -115,19 +119,19 @@ export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string) // Why: delete-then-set refreshes insertion order for LRU-style eviction. cache.delete(key) cache.set(key, entry) - return entry.dataUrl + return entry.image } -export function setCachedAttachmentDataUrl(args: { +function setCachedImage(args: { siteId: string attachmentId: string - dataUrl: string + image: ImageValue byteSize: number }): void { pruneExpired() const key = cacheKey(args.siteId, args.attachmentId) const entry: CacheEntry = { - dataUrl: args.dataUrl, + image: args.image, byteSize: args.byteSize, storedAt: Date.now() } @@ -138,54 +142,127 @@ export function setCachedAttachmentDataUrl(args: { } /** - * Singleflight loader: concurrent cold misses for the same attachment share one - * download. Failed loads are not cached so a later retry can succeed. + * Singleflight loader: concurrent cold misses share one download within their + * cancellation domain. Failed loads are not cached so a later retry can succeed. */ -export async function loadAttachmentDataUrlWithCache(args: { +async function loadImageWithCache(args: { siteId: string attachmentId: string - load: () => Promise<{ dataUrl: string; byteSize: number } | null> -}): Promise { - const cached = getCachedAttachmentDataUrl(args.siteId, args.attachmentId) + load: () => Promise<{ image: ImageValue; byteSize: number } | null> + signal?: AbortSignal +}): Promise { + const cached = getCachedImage(args.siteId, args.attachmentId) if (cached) { return cached } - const key = cacheKey(args.siteId, args.attachmentId) + // Legacy waiters must not inherit a cancellable reader's download. + const key = `${cacheKey(args.siteId, args.attachmentId)}::${args.signal ? 'cancellable' : 'legacy'}` const existing = inFlight.get(key) - if (existing) { - return existing + if (existing && !existing.signal?.aborted) { + return existing.promise } - const epochAtStart = currentEpoch(args.siteId) - const promise = (async (): Promise => { + const epochAtStart = attachmentImagesEpoch(args.siteId) + const generation = Symbol() + const promise = (async (): Promise => { try { const loaded = await args.load() if (!loaded) { return null } // Why: return bytes to the waiter but skip cache if site was cleared mid-flight. - if (currentEpoch(args.siteId) === epochAtStart) { - setCachedAttachmentDataUrl({ + if (!args.signal?.aborted && attachmentImagesEpoch(args.siteId) === epochAtStart) { + setCachedImage({ siteId: args.siteId, attachmentId: args.attachmentId, - dataUrl: loaded.dataUrl, + image: loaded.image, byteSize: loaded.byteSize }) } - return loaded.dataUrl + return loaded.image } finally { // A cleared generation no longer owns the current download's singleflight slot. - if (currentEpoch(args.siteId) === epochAtStart) { + if (inFlight.get(key)?.generation === generation) { inFlight.delete(key) } } })() - inFlight.set(key, promise) + inFlight.set(key, { promise, signal: args.signal, generation }) return promise } +function imageDataUrl(image: ImageValue): string { + return typeof image === 'string' + ? image + : `data:${image.mimeType};base64,${Buffer.from(image.data).toString('base64')}` +} + +function imageBytes(image: ImageValue): AttachmentImageBytes | null { + if (typeof image !== 'string') { + return image + } + const match = /^data:(image\/[^;]+);base64,(.*)$/s.exec(image) + if (!match?.[1] || match[2] === undefined) { + return null + } + return { data: Uint8Array.from(Buffer.from(match[2], 'base64')), mimeType: match[1] } +} + +export function getCachedAttachmentBytes( + siteId: string, + attachmentId: string +): AttachmentImageBytes | null { + const image = getCachedImage(siteId, attachmentId) + return image === null ? null : imageBytes(image) +} + +export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string): string | null { + const image = getCachedImage(siteId, attachmentId) + return image === null ? null : imageDataUrl(image) +} + +export function setCachedAttachmentDataUrl(args: { + siteId: string + attachmentId: string + dataUrl: string + byteSize: number +}): void { + setCachedImage({ ...args, image: args.dataUrl }) +} + +export async function loadAttachmentDataUrlWithCache(args: { + siteId: string + attachmentId: string + load: () => Promise<{ dataUrl: string; byteSize: number } | null> +}): Promise { + const image = await loadImageWithCache({ + ...args, + load: async () => { + const loaded = await args.load() + return loaded && { image: loaded.dataUrl, byteSize: loaded.byteSize } + } + }) + return image === null ? null : imageDataUrl(image) +} + +export async function loadAttachmentBytesWithCache(args: { + siteId: string + attachmentId: string + load: () => Promise + signal?: AbortSignal +}): Promise { + const image = await loadImageWithCache({ + ...args, + load: async () => { + const loaded = await args.load() + return loaded && { image: loaded, byteSize: loaded.data.byteLength } + } + }) + return image === null ? null : imageBytes(image) +} + export function clearAttachmentImagesForSite(siteId?: string): void { if (siteId == null || siteId === '') { cache.clear() diff --git a/src/main/jira/attachment-image-grants.ts b/src/main/jira/attachment-image-grants.ts new file mode 100644 index 00000000000..80959907f4e --- /dev/null +++ b/src/main/jira/attachment-image-grants.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'node:crypto' +import { JIRA_IMAGE_SCHEME } from '../../shared/jira-attachment-image' +import { attachmentImagesEpoch } from './attachment-image-cache' +import type { JiraClientForSite } from './authenticated-request' +import type { AttachmentMeta } from './attachment-meta' + +const GRANT_TTL_MS = 30 * 60_000 +const MAX_GRANTS = 2048 +type ImageGrant = { siteId: string; meta: AttachmentMeta; epoch: number; expiresAt: number } +const grants = new Map() +const references = new Map() + +function revokeExpired(): void { + for (const [reference, grant] of grants) { + if (grant.expiresAt <= Date.now() || grant.epoch !== attachmentImagesEpoch(grant.siteId)) { + grants.delete(reference) + references.delete(`${grant.siteId}::${grant.meta.id}`) + } + } +} + +export function registerAttachmentImageGrant( + client: JiraClientForSite, + meta: AttachmentMeta +): string { + revokeExpired() + const identity = `${client.site.id}::${meta.id}` + const existing = references.get(identity) + if (existing) { + const grant = grants.get(existing) + if (grant) { + grant.meta = meta + grant.expiresAt = Date.now() + GRANT_TTL_MS + return `${JIRA_IMAGE_SCHEME}://attachment/${existing}` + } + } + while (grants.size >= MAX_GRANTS) { + const oldest = grants.keys().next().value + if (!oldest) { + break + } + const grant = grants.get(oldest) + grants.delete(oldest) + if (grant) { + references.delete(`${grant.siteId}::${grant.meta.id}`) + } + } + const reference = randomUUID() + grants.set(reference, { + siteId: client.site.id, + meta, + epoch: attachmentImagesEpoch(client.site.id), + expiresAt: Date.now() + GRANT_TTL_MS + }) + references.set(identity, reference) + return `${JIRA_IMAGE_SCHEME}://attachment/${reference}` +} + +export function attachmentImageGrant(reference: string): ImageGrant | null { + revokeExpired() + return grants.get(reference) ?? null +} + +export function isAttachmentImageGrantCurrent(reference: string, grant: ImageGrant): boolean { + return attachmentImageGrant(reference) === grant +} + +export function resetAttachmentImageGrantsForTest(): void { + grants.clear() + references.clear() +} diff --git a/src/main/jira/attachment-image-protocol.ts b/src/main/jira/attachment-image-protocol.ts new file mode 100644 index 00000000000..df5c6ebcad1 --- /dev/null +++ b/src/main/jira/attachment-image-protocol.ts @@ -0,0 +1,38 @@ +import { protocol } from 'electron' +import { JIRA_IMAGE_SCHEME, jiraImageReference } from '../../shared/jira-attachment-image' +import { readGrantedAttachmentImage } from './attachment-image-read' + +export const JIRA_IMAGE_CUSTOM_SCHEME: Electron.CustomScheme = { + scheme: JIRA_IMAGE_SCHEME, + privileges: { standard: true, secure: true, stream: true } +} + +export async function handleJiraImageRequest(request: Request): Promise { + const reference = jiraImageReference(request.url) + if (!reference) { + return new Response(null, { status: 404 }) + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response(null, { status: 405 }) + } + try { + const image = await readGrantedAttachmentImage(reference, request.signal) + if (!image) { + return new Response(null, { status: 404 }) + } + return new Response(request.method === 'HEAD' ? null : image.data, { + headers: { + 'Content-Type': image.mimeType, + 'Content-Length': String(image.data.byteLength), + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff' + } + }) + } catch { + return new Response(null, { status: 404 }) + } +} + +export function installJiraImageProtocolHandler(): void { + protocol.handle(JIRA_IMAGE_SCHEME, handleJiraImageRequest) +} diff --git a/src/main/jira/attachment-image-read.test.ts b/src/main/jira/attachment-image-read.test.ts new file mode 100644 index 00000000000..2c86798413b --- /dev/null +++ b/src/main/jira/attachment-image-read.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './authenticated-request' +import { JIRA_IMAGE_MAX_PENDING_READS } from '../../shared/jira-attachment-image' +import { readAttachmentImageBytes } from './attachment-image-bytes' +import { + registerAttachmentImageGrant, + attachmentImageGrant, + resetAttachmentImageGrantsForTest +} from './attachment-image-grants' +import { readGrantedAttachmentImage } from './attachment-image-read' +import { handleJiraImageRequest } from './attachment-image-protocol' +import { clearAttachmentImagesForSite, _resetAttachmentImageCache } from './attachment-image-cache' + +const { binary, clients } = vi.hoisted(() => ({ binary: vi.fn(), clients: vi.fn() })) +vi.mock('./authenticated-request', () => ({ + apiBasePath: () => '/rest/api/3', + jiraRequestBinary: binary +})) +vi.mock('./client', () => ({ getClients: clients })) +vi.mock('electron', () => ({ protocol: { handle: vi.fn() } })) +const client: JiraClientForSite = { + site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' }, + authorization: 'private-token' +} +const meta = { id: '1', filename: 'shot.png', mimeType: 'image/png', size: 3 } +const bytes = Uint8Array.from([1, 2, 3]) +function grant(): string { + return registerAttachmentImageGrant(client, meta).split('/').at(-1) ?? '' +} + +beforeEach(() => { + _resetAttachmentImageCache() + resetAttachmentImageGrantsForTest() + binary.mockReset().mockResolvedValue({ data: bytes.buffer, contentType: 'image/png' }) + clients.mockReset().mockReturnValue([client]) +}) +afterEach(() => { + _resetAttachmentImageCache() + vi.useRealTimers() +}) + +describe('authenticated Jira image reads', () => { + it('uses only a live opaque grant, never a caller URL or stored credential', async () => { + const reference = grant() + expect(attachmentImageGrant(reference)).not.toHaveProperty('authorization') + expect(await readGrantedAttachmentImage('unknown')).toBeNull() + expect(binary).not.toHaveBeenCalled() + expect(await readGrantedAttachmentImage(reference)).toEqual({ + data: bytes, + mimeType: 'image/png' + }) + expect(binary).toHaveBeenCalledWith( + client, + 'https://jira.test/rest/api/3/attachment/content/1?redirect=false', + expect.objectContaining({ maxBytes: 8 * 1024 * 1024, signal: expect.any(AbortSignal) }) + ) + clients.mockReturnValue([]) + expect(await readGrantedAttachmentImage(reference)).toBeNull() + }) + + it('discards a download if the site disconnects before it finishes', async () => { + let finish: ((value: { data: ArrayBuffer; contentType: string }) => void) | undefined + binary.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const reading = readGrantedAttachmentImage(grant()) + await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1)) + clearAttachmentImagesForSite('site') + finish?.({ data: bytes.buffer, contentType: 'image/png' }) + expect(await reading).toBeNull() + }) + + it('shares a download and aborts only after the last reader leaves', async () => { + let downloadSignal: AbortSignal | undefined + binary.mockImplementation((_client, _url, options: { signal: AbortSignal }) => { + downloadSignal = options.signal + return new Promise((_resolve, reject) => + options.signal.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + ) + }) + const first = new AbortController() + const second = new AbortController() + const a = readAttachmentImageBytes(client, meta, first.signal).catch((error) => error) + const b = readAttachmentImageBytes(client, meta, second.signal).catch((error) => error) + await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1)) + first.abort() + await a + expect(downloadSignal?.aborted).toBe(false) + second.abort() + await b + expect(downloadSignal?.aborted).toBe(true) + binary.mockResolvedValue({ data: bytes.buffer, contentType: 'image/png' }) + expect(await readAttachmentImageBytes(client, meta)).toEqual({ + data: bytes, + mimeType: 'image/png' + }) + }) + + it('allows three concurrent downloads across different issues, with a bounded queue', async () => { + const controller = new AbortController() + binary.mockImplementation( + (_client, _url, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => + options.signal.addEventListener('abort', () => reject(new Error('cancelled')), { + once: true + }) + ) + ) + const reads = Array.from({ length: JIRA_IMAGE_MAX_PENDING_READS }, (_, id) => + readAttachmentImageBytes(client, { ...meta, id: String(id) }, controller.signal).catch( + (error) => error + ) + ) + await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(3)) + await expect(readAttachmentImageBytes(client, { ...meta, id: 'overflow' })).rejects.toThrow( + 'queue is full' + ) + controller.abort() + await Promise.all(reads) + expect(binary).toHaveBeenCalledTimes(3) + }) + + it('loads the combined 48 description and comment references without rejecting a reader', async () => { + const releases: (() => void)[] = [] + binary.mockImplementation( + () => + new Promise((resolve) => { + releases.push(() => resolve({ data: bytes.buffer, contentType: 'image/png' })) + }) + ) + const reads = Array.from({ length: 48 }, (_, id) => + readAttachmentImageBytes(client, { ...meta, id: String(id) }) + ) + await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(3)) + for (let batch = 0; batch < 16; batch += 1) { + await vi.waitFor(() => expect(releases).toHaveLength(3)) + releases.splice(0).forEach((release) => release()) + } + expect((await Promise.all(reads)).filter(Boolean)).toHaveLength(48) + expect(binary).toHaveBeenCalledTimes(48) + }) + + it('uses the REST attachment fallback for scoped site links while preserving Server paths', async () => { + const scoped = { + ...client, + site: { + ...client.site, + apiBaseUrl: 'https://api.atlassian.com/ex/jira/tenant', + authType: 'cloud' as const + } + } + await readAttachmentImageBytes(scoped, { ...meta, contentUrl: '/secure/attachment/1/shot.png' }) + expect(binary).toHaveBeenCalledWith( + scoped, + 'https://jira.test/rest/api/3/attachment/content/1?redirect=false', + expect.anything() + ) + }) + + it('expires grants and renews them when the issue is read again', () => { + vi.useFakeTimers() + const reference = grant() + vi.advanceTimersByTime(29 * 60_000) + expect(grant()).toBe(reference) + vi.advanceTimersByTime(29 * 60_000) + expect(attachmentImageGrant(reference)).not.toBeNull() + vi.advanceTimersByTime(2 * 60_000) + expect(attachmentImageGrant(reference)).toBeNull() + }) + + it('preserves Server content paths and rejects SVG responses', async () => { + const server = { ...client, site: { ...client.site, authType: 'server' as const } } + expect(await readAttachmentImageBytes(server, meta)).toBeNull() + binary.mockResolvedValue({ data: bytes.buffer, contentType: 'image/svg+xml' }) + expect( + await readAttachmentImageBytes(server, { + ...meta, + contentUrl: '/secure/attachment/1/shot.png' + }) + ).toBeNull() + expect(binary).toHaveBeenCalledWith( + server, + 'https://jira.test/secure/attachment/1/shot.png', + expect.any(Object) + ) + }) +}) + +describe('local Jira image protocol', () => { + it('serves bounded image bytes and headers without exposing authorization', async () => { + const url = `orca-jira-image://attachment/${grant()}` + const response = await handleJiraImageRequest(new Request(url)) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('image/png') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('authorization')).toBeNull() + expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes) + const head = await handleJiraImageRequest(new Request(url, { method: 'HEAD' })) + expect(head.headers.get('content-length')).toBe('3') + expect(await head.text()).toBe('') + }) + it('refuses unknown, malformed, revoked and non-GET image requests', async () => { + const url = `orca-jira-image://attachment/${grant()}` + expect((await handleJiraImageRequest(new Request(`${url}?url=https://evil.test`))).status).toBe( + 404 + ) + expect((await handleJiraImageRequest(new Request(url, { method: 'POST' }))).status).toBe(405) + clearAttachmentImagesForSite('site') + expect((await handleJiraImageRequest(new Request(url))).status).toBe(404) + expect(binary).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/jira/attachment-image-read.ts b/src/main/jira/attachment-image-read.ts new file mode 100644 index 00000000000..914b09648ce --- /dev/null +++ b/src/main/jira/attachment-image-read.ts @@ -0,0 +1,23 @@ +import { getClients } from './client' +import { attachmentImageGrant, isAttachmentImageGrantCurrent } from './attachment-image-grants' +import { readAttachmentImageBytes } from './attachment-image-bytes' +import type { AttachmentImageBytes } from './attachment-image-cache' + +export async function readGrantedAttachmentImage( + reference: string, + signal?: AbortSignal +): Promise { + const grant = attachmentImageGrant(reference) + if (!grant) { + return null + } + const client = getClients(grant.siteId).find((entry) => entry.site.id === grant.siteId) + if (!client) { + return null + } + const image = await readAttachmentImageBytes(client, grant.meta, signal) + if (!isAttachmentImageGrantCurrent(reference, grant) || signal?.aborted) { + return null + } + return image +} diff --git a/src/main/jira/attachment-image-references.test.ts b/src/main/jira/attachment-image-references.test.ts new file mode 100644 index 00000000000..ed063f549d4 --- /dev/null +++ b/src/main/jira/attachment-image-references.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { JIRA_IMAGE_MAX_BYTES } from '../../shared/jira-attachment-image' +import { loadIssueImageAttachments, createMediaMarkdownResolver } from './attachment-images' +import { attachmentImageGrant, resetAttachmentImageGrantsForTest } from './attachment-image-grants' +import { clearAttachmentImagesForSite, _resetAttachmentImageCache } from './attachment-image-cache' +import type { JiraClientForSite } from './authenticated-request' + +vi.mock('./authenticated-request', () => ({ + apiBasePath: () => '/rest/api/3', + jiraRequestBinary: vi.fn(), + JiraApiError: class extends Error {} +})) +const client: JiraClientForSite = { + site: { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: '', + accountId: '', + displayName: '' + }, + authorization: 'secret' +} +const metadata = [ + { + id: '1', + filename: 'shot.png', + mimeType: 'image/png', + size: 3 * 1024 * 1024, + content: 'https://example.atlassian.net/secure/attachment/1/shot.png' + } +] + +describe('lazy Jira image references', () => { + beforeEach(() => { + _resetAttachmentImageCache() + resetAttachmentImageGrantsForTest() + vi.clearAllMocks() + }) + it('keeps image bytes out of Markdown without downloading the attachment', async () => { + const { jiraRequestBinary } = await import('./authenticated-request') + const images = await loadIssueImageAttachments(client, metadata, ['1'], 'reference') + const markdown = createMediaMarkdownResolver(images, ['1'])({ id: 'media-id', alt: 'shot.png' }) + expect(markdown).toMatch(/^!\[shot.png\]\(orca-jira-image:\/\/attachment\/[0-9a-f-]+\)$/) + expect(markdown).not.toContain('data:') + expect(jiraRequestBinary).not.toHaveBeenCalled() + }) + it('keeps grants isolated by site and revokes them on disconnect', async () => { + const images = await loadIssueImageAttachments(client, metadata, ['1'], 'reference') + const reference = images[0]?.dataUrl.split('/').at(-1) ?? '' + expect(attachmentImageGrant(reference)?.siteId).toBe('site-1') + clearAttachmentImagesForSite('other-site') + expect(attachmentImageGrant(reference)).not.toBeNull() + clearAttachmentImagesForSite('site-1') + expect(attachmentImageGrant(reference)).toBeNull() + }) + it('retains per-image limits while removing the legacy aggregate-byte cap', async () => { + const entries = [1, 2, 3].map((id) => ({ ...metadata[0], id: String(id) })) + expect( + await loadIssueImageAttachments(client, entries, ['1', '2', '3'], 'reference') + ).toHaveLength(3) + const oversized = [{ ...metadata[0], size: JIRA_IMAGE_MAX_BYTES + 1 }] + expect(await loadIssueImageAttachments(client, oversized, ['1'], 'reference')).toEqual([]) + }) +}) + +it('discovers larger images by filename when rendered HTML has no attachment IDs', async () => { + const { collectIssueMediaRequest } = await import('./jira-issue-media') + const raw = { + key: 'JIRA-1', + fields: { + attachment: metadata, + description: { + type: 'doc', + content: [ + { + type: 'mediaSingle', + content: [{ type: 'media', attrs: { id: 'media-id', alt: 'shot.png' } }] + } + ] + } + } + } + expect(collectIssueMediaRequest(raw)?.preferredIds).toEqual([]) + expect(collectIssueMediaRequest(raw, 'reference')?.preferredIds).toEqual(['1']) +}) + +it('keeps the old per-image cap when an old client reuses a newer byte-cache entry', async () => { + const { loadAttachmentBytesWithCache } = await import('./attachment-image-cache') + _resetAttachmentImageCache() + await loadAttachmentBytesWithCache({ + siteId: client.site.id, + attachmentId: '1', + load: async () => ({ data: new Uint8Array(3 * 1024 * 1024), mimeType: 'image/png' }) + }) + expect(await loadIssueImageAttachments(client, [{ ...metadata[0], size: 0 }], ['1'])).toEqual([]) +}) diff --git a/src/main/jira/attachment-images.ts b/src/main/jira/attachment-images.ts index bc0f8f6f523..61b8a1cca6b 100644 --- a/src/main/jira/attachment-images.ts +++ b/src/main/jira/attachment-images.ts @@ -1,5 +1,6 @@ import type { JiraClientForSite } from './authenticated-request' -import { JiraApiError, apiBasePath, jiraRequestBinary } from './authenticated-request' +import { attachmentContentUrl } from './attachment-content-url' +import { JiraApiError, jiraRequestBinary } from './authenticated-request' import type { JiraAdfMediaAttrs, JiraAdfMediaResolver } from './adf-markdown' import { escapeMarkdownAlt, unresolvedMediaPlaceholder } from './adf-markdown' import { escapeMarkdownLinkDestination } from './adf-media-destination' @@ -13,6 +14,12 @@ import { type AttachmentMeta } from './attachment-meta' import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + JIRA_IMAGE_MAX_BYTES, + JIRA_IMAGE_MAX_REFERENCES, + type JiraMediaFormat +} from '../../shared/jira-attachment-image' +import { registerAttachmentImageGrant } from './attachment-image-grants' const DOWNLOAD_CONCURRENCY = 3 @@ -38,16 +45,9 @@ async function downloadImageAttachment( attachmentId: meta.id, load: async () => { try { - const contentUrl = meta.contentUrl - ? new URL(meta.contentUrl, `${client.site.siteUrl}/`) - : // Why: Cloud fallback uses apiBasePath so Server sites that lack contentUrl - // still hit /rest/api/2 if ever called; Server still requires content metadata. - new URL( - `${apiBasePath(client.site)}/attachment/content/${encodeURIComponent(meta.id)}`, - client.site.siteUrl - ) - if (/\/rest\/api\/(?:2|3)\/attachment\/content\/[^/]+$/i.test(contentUrl.pathname)) { - contentUrl.searchParams.set('redirect', 'false') + const contentUrl = attachmentContentUrl(client.site, meta) + if (!contentUrl) { + return null } const binary = await jiraRequestBinary(client, contentUrl.toString()) if (binary.data.byteLength === 0 || binary.data.byteLength > MAX_IMAGE_BYTES) { @@ -83,6 +83,9 @@ async function downloadImageAttachment( // Approximate byte size from base64 payload when served from cache. const base64Part = dataUrl.includes(',') ? dataUrl.slice(dataUrl.indexOf(',') + 1) : '' const byteSize = Math.floor((base64Part.length * 3) / 4) + if (byteSize === 0 || byteSize > MAX_IMAGE_BYTES) { + return null + } return { id: meta.id, @@ -96,9 +99,13 @@ async function downloadImageAttachment( export async function loadIssueImageAttachments( client: JiraClientForSite, attachmentField: unknown, - preferredIds: string[] = [] + preferredIds: string[] = [], + mediaFormat?: JiraMediaFormat ): Promise { - const metas = parseImageAttachmentMetas(attachmentField) + const metas = parseImageAttachmentMetas( + attachmentField, + mediaFormat ? JIRA_IMAGE_MAX_BYTES : MAX_IMAGE_BYTES + ) if (metas.length === 0 || preferredIds.length === 0) { return [] } @@ -115,6 +122,16 @@ export async function loadIssueImageAttachments( } } + if (mediaFormat === 'reference') { + return ordered.slice(0, JIRA_IMAGE_MAX_REFERENCES).map((meta) => ({ + id: meta.id, + filename: meta.filename, + mimeType: meta.mimeType, + byteSize: meta.size, + dataUrl: registerAttachmentImageGrant(client, meta) + })) + } + // Why: pre-select by declared size so concurrent downloads do not fetch bodies // that will be dropped by the total budget after completion. const toDownload: AttachmentMeta[] = [] @@ -149,7 +166,7 @@ export async function loadIssueImageAttachments( } export type MediaResolutionStats = { - /** Attachment-needing media nodes that successfully resolved to a data: image. */ + /** Attachment-needing media nodes that resolved to an image destination. */ attachmentResolvedCount: number } @@ -202,7 +219,7 @@ export function createMediaMarkdownResolver( if (attrs.id) { const cached = resolvedByMediaId.get(attrs.id) if (cached) { - if (stats && !cached.startsWith('*[') && cached.includes('data:')) { + if (stats && cached.startsWith('![')) { stats.attachmentResolvedCount += 1 } return cached diff --git a/src/main/jira/attachment-legacy-cancellation.test.ts b/src/main/jira/attachment-legacy-cancellation.test.ts new file mode 100644 index 00000000000..6247e2c18fc --- /dev/null +++ b/src/main/jira/attachment-legacy-cancellation.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { readAttachmentImageBytes } from './attachment-image-bytes' +import { loadIssueImageAttachments, type JiraImageAttachment } from './attachment-images' +import * as cache from './attachment-image-cache' +import type { JiraClientForSite } from './authenticated-request' + +const { binary } = vi.hoisted(() => ({ binary: vi.fn() })) +vi.mock('./authenticated-request', () => ({ + apiBasePath: () => '/rest/api/3', + jiraRequestBinary: binary, + JiraApiError: class extends Error {} +})) + +const client: JiraClientForSite = { + site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' }, + authorization: 'fixture-token' +} +const meta = { id: '1', filename: 'shot.png', mimeType: 'image/png', size: 3 } +const bytes = Uint8Array.from([1, 2, 3]) + +beforeEach(() => { + binary.mockReset() +}) + +afterEach(() => { + cache._resetAttachmentImageCache() + vi.restoreAllMocks() +}) + +it.each(['cancellable-first', 'legacy-first'] as const)( + 'retains a legacy inline image after new reader cancellation (%s)', + async (order) => { + const finish: (() => void)[] = [] + binary.mockImplementation( + (_client, _url, options?: { signal?: AbortSignal }) => + new Promise((resolve, reject) => { + finish.push(() => resolve({ data: bytes.buffer, contentType: 'image/png' })) + options?.signal?.addEventListener( + 'abort', + () => reject(new Error('new reader cancelled')), + { + once: true + } + ) + }) + ) + const legacyLoading = vi.spyOn(cache, 'loadAttachmentDataUrlWithCache') + const controller = new AbortController() + const readLegacy = (): Promise<{ images: JiraImageAttachment[] } | { error: unknown }> => + loadIssueImageAttachments(client, [meta], ['1']).then( + (images) => ({ images }), + (error) => ({ error }) + ) + const readNew = () => + readAttachmentImageBytes(client, meta, controller.signal).catch(() => null) + const first = order === 'cancellable-first' ? readNew() : readLegacy() + await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1)) + const second = order === 'cancellable-first' ? readLegacy() : readNew() + const newReading = order === 'cancellable-first' ? first : second + const legacyReading = order === 'cancellable-first' ? second : first + await vi.waitFor(() => expect(legacyLoading).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(2)) + controller.abort() + await newReading + finish.forEach((resolve) => resolve()) + expect(await legacyReading).toMatchObject({ + images: [{ id: '1', dataUrl: 'data:image/png;base64,AQID' }] + }) + expect(await readAttachmentImageBytes(client, meta)).toEqual({ + data: bytes, + mimeType: 'image/png' + }) + expect(binary).toHaveBeenCalledTimes(2) + } +) diff --git a/src/main/jira/attachment-meta.ts b/src/main/jira/attachment-meta.ts index e7c67e7a2ec..8c6f1f68bf2 100644 --- a/src/main/jira/attachment-meta.ts +++ b/src/main/jira/attachment-meta.ts @@ -26,7 +26,10 @@ export function isImageMimeType(mimeType: string): boolean { ) } -export function parseImageAttachmentMetas(attachmentField: unknown): AttachmentMeta[] { +export function parseImageAttachmentMetas( + attachmentField: unknown, + maxBytes = MAX_IMAGE_BYTES +): AttachmentMeta[] { if (!Array.isArray(attachmentField)) { return [] } @@ -40,7 +43,7 @@ export function parseImageAttachmentMetas(attachmentField: unknown): AttachmentM if (!id || !isImageMimeType(mimeType)) { continue } - if (size > MAX_IMAGE_BYTES) { + if (size > maxBytes) { continue } const contentUrl = asString(record.content) diff --git a/src/main/jira/authenticated-request-image-limit.test.ts b/src/main/jira/authenticated-request-image-limit.test.ts new file mode 100644 index 00000000000..e6e39fcde9a --- /dev/null +++ b/src/main/jira/authenticated-request-image-limit.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { jiraRequestBinary, type JiraClientForSite } from './authenticated-request' + +const { fetchImage } = vi.hoisted(() => ({ fetchImage: vi.fn() })) +vi.mock('../network/http-client', () => ({ + getMainHttpClient: () => ({ fetch: fetchImage, proxySession: () => null }) +})) +vi.mock('../network/proxy-settings', () => ({ + ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../observability/tracer', () => ({ + withSpan: ( + _name: string, + run: (span: { setAttribute: () => void; addEvent: () => void }) => Promise + ) => run({ setAttribute: () => {}, addEvent: () => {} }) +})) +const client: JiraClientForSite = { + site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' }, + authorization: 'secret' +} +beforeEach(() => fetchImage.mockReset()) + +describe('bounded Jira attachment downloads', () => { + it('rejects oversized declared bodies before consuming any bytes', async () => { + const cancel = vi.fn() + const response = new Response(new ReadableStream({ cancel }), { + headers: { 'content-length': '9' } + }) + const arrayBuffer = vi.spyOn(response, 'arrayBuffer') + fetchImage.mockResolvedValue(response) + await expect(jiraRequestBinary(client, '/image', { maxBytes: 8 })).rejects.toThrow('byte limit') + expect(cancel).toHaveBeenCalledOnce() + expect(arrayBuffer).not.toHaveBeenCalled() + }) + it('caps chunked bodies even when Content-Length is missing', async () => { + let part = 0 + const cancel = vi.fn() + const response = new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(Uint8Array.from([++part, 0, 0])) + }, + cancel + }) + ) + fetchImage.mockResolvedValue(response) + await expect(jiraRequestBinary(client, '/image', { maxBytes: 8 })).rejects.toThrow('byte limit') + expect(cancel).toHaveBeenCalledOnce() + expect(part).toBeLessThanOrEqual(4) + }) + it('returns bounded bytes and forwards caller cancellation to the HTTP client', async () => { + const controller = new AbortController() + fetchImage.mockResolvedValue( + new Response(Uint8Array.from([1, 2, 3]), { headers: { 'content-type': 'image/png' } }) + ) + expect( + await jiraRequestBinary(client, '/image', { maxBytes: 8, signal: controller.signal }) + ).toEqual({ data: Uint8Array.from([1, 2, 3]).buffer, contentType: 'image/png' }) + expect(fetchImage).toHaveBeenCalledWith( + 'https://jira.test/image', + expect.objectContaining({ signal: controller.signal }) + ) + }) +}) diff --git a/src/main/jira/authenticated-request.ts b/src/main/jira/authenticated-request.ts index 91790aa0d07..ffb91e7fd7b 100644 --- a/src/main/jira/authenticated-request.ts +++ b/src/main/jira/authenticated-request.ts @@ -6,6 +6,7 @@ import { isJiraGatewayBaseUrl } from './gateway-base-url' import { JiraApiError, toJiraApiError } from './jira-api-error' export { JiraApiError } from './jira-api-error' +import { readFetchResponseBytesWithinLimit } from '../../shared/fetch-response-body' // Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser // User-Agent, failing them with "XSRF check failed" even under API-token auth. @@ -177,7 +178,8 @@ function resolveBinaryRequestUrl(site: JiraSite, pathOrUrl: string): URL { export async function jiraRequestBinary( client: JiraClientForSite, - pathOrUrl: string + pathOrUrl: string, + options: { maxBytes?: number; signal?: AbortSignal } = {} ): Promise<{ data: ArrayBuffer; contentType: string }> { const requestUrl = resolveBinaryRequestUrl(client.site, pathOrUrl) const headers = new Headers() @@ -188,14 +190,19 @@ export async function jiraRequestBinary( headers.set('Authorization', client.authorization) const response = await jiraFetch(requestUrl.toString(), { headers, + signal: options.signal, ...(isJiraGatewayBaseUrl(apiBaseUrl(client.site)) ? { redirect: 'error' as const } : {}) }) if (!response.ok) { throw await toJiraApiError(response, requestUrl.toString()) } const contentType = response.headers.get('content-type') || 'application/octet-stream' + const bytes = await readFetchResponseBytesWithinLimit( + response, + options.maxBytes ?? 2 * 1024 * 1024 + ) return { - data: await response.arrayBuffer(), + data: Uint8Array.from(bytes).buffer, contentType } } diff --git a/src/main/jira/client-scoped-token.test.ts b/src/main/jira/client-scoped-token.test.ts index 36d1c1ec61d..1694a94769d 100644 --- a/src/main/jira/client-scoped-token.test.ts +++ b/src/main/jira/client-scoped-token.test.ts @@ -98,16 +98,19 @@ describe('scoped Jira Cloud tokens over HTTP', () => { dataUrl: 'data:image/png;base64,AQ==', byteSize: 1 }) + const epoch = cache.attachmentImagesEpoch(client.site.id) authorized = false await expect( jira.connect({ ...CREDENTIAL, apiToken: 'rejected-token' }) ).resolves.toMatchObject({ ok: false }) expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).not.toBeNull() + expect(cache.attachmentImagesEpoch(client.site.id)).toBe(epoch) authorized = true await expect( jira.connect({ ...CREDENTIAL, apiToken: 'replacement-token' }) ).resolves.toMatchObject({ ok: true }) expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).toBeNull() + expect(cache.attachmentImagesEpoch(client.site.id)).toBeGreaterThan(epoch) expect(jira.getClients()[0]?.authorization).toBe( `Basic ${Buffer.from('ada@example.com:replacement-token').toString('base64')}` ) diff --git a/src/main/jira/jira-issue-comments.ts b/src/main/jira/jira-issue-comments.ts index d2c93a35be2..88ffa0b9836 100644 --- a/src/main/jira/jira-issue-comments.ts +++ b/src/main/jira/jira-issue-comments.ts @@ -1,5 +1,10 @@ import { requireStrictJiraSite } from './jira-read-failure' import type { JiraComment } from '../../shared/jira-types' +import { + JIRA_IMAGE_MAX_BYTES, + JIRA_IMAGE_MAX_REFERENCES, + type JiraMediaFormat +} from '../../shared/jira-attachment-image' import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' import { clearToken, getClients, isAuthError } from './client' @@ -39,7 +44,8 @@ function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraCom async function collectCommentMediaRequest( client: JiraClientForSite, key: string, - comments: JiraRecord[] + comments: JiraRecord[], + mediaFormat?: JiraMediaFormat ): Promise { const htmlIds: string[] = [] const seen = new Set() @@ -80,7 +86,8 @@ async function collectCommentMediaRequest( const selection = selectPreferredAttachmentIds({ renderedHtmlIds: htmlIds, attachmentField, - mediaAttrs + mediaAttrs, + ...(mediaFormat ? { maxBytes: JIRA_IMAGE_MAX_BYTES, maxImages: JIRA_IMAGE_MAX_REFERENCES } : {}) }) if (selection.needCount === 0 && selection.preferredIds.length === 0) { return undefined @@ -97,7 +104,8 @@ async function collectCommentMediaRequest( export async function getIssueComments( key: string, siteId?: string | null, - strict = false + strict = false, + mediaFormat?: JiraMediaFormat ): Promise { const entries = getClients(siteId) requireStrictJiraSite(entries, siteId, strict) @@ -121,7 +129,7 @@ export async function getIssueComments( }) return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` }) - mediaRequest = await collectCommentMediaRequest(entry, key, comments) + mediaRequest = await collectCommentMediaRequest(entry, key, comments, mediaFormat) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) @@ -140,7 +148,9 @@ export async function getIssueComments( } try { - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const prepared = mediaRequest + ? await prepareMediaResolver(entry, mediaRequest, mediaFormat) + : undefined const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) if (prepared) { flushMediaResolutionWarn(entry, prepared) diff --git a/src/main/jira/jira-issue-media.ts b/src/main/jira/jira-issue-media.ts index 743717a320f..8ffd6ae4827 100644 --- a/src/main/jira/jira-issue-media.ts +++ b/src/main/jira/jira-issue-media.ts @@ -11,6 +11,11 @@ import { } from './attachment-images' import type { JiraClientForSite } from './authenticated-request' import { asRecord, asString, type JiraRecord } from './jira-record-pages' +import { + JIRA_IMAGE_MAX_BYTES, + JIRA_IMAGE_MAX_REFERENCES, + type JiraMediaFormat +} from '../../shared/jira-attachment-image' export type MediaRequest = { attachmentField: unknown @@ -21,7 +26,10 @@ export type MediaRequest = { } /** Pooled: HTML/ADF selection only — no binary downloads. */ -export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { +export function collectIssueMediaRequest( + raw: JiraRecord, + mediaFormat?: JiraMediaFormat +): MediaRequest | undefined { const fields = asRecord(raw.fields) const renderedFields = asRecord(raw.renderedFields) const htmlIds = extractAttachmentContentIdsFromHtml( @@ -31,7 +39,8 @@ export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefi const selection = selectPreferredAttachmentIds({ renderedHtmlIds: htmlIds, attachmentField: fields.attachment, - mediaAttrs + mediaAttrs, + ...(mediaFormat ? { maxBytes: JIRA_IMAGE_MAX_BYTES, maxImages: JIRA_IMAGE_MAX_REFERENCES } : {}) }) if (selection.needCount === 0 && selection.preferredIds.length === 0) { return undefined @@ -54,7 +63,8 @@ export type PreparedMedia = { /** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ export async function prepareMediaResolver( client: JiraClientForSite, - request: MediaRequest + request: MediaRequest, + mediaFormat?: JiraMediaFormat ): Promise { if (request.preferredIds.length === 0) { warnIfMediaResolutionIncomplete({ @@ -70,7 +80,8 @@ export async function prepareMediaResolver( const images = await loadIssueImageAttachments( client, request.attachmentField, - request.preferredIds + request.preferredIds, + mediaFormat ) if (images.length === 0) { warnIfMediaResolutionIncomplete({ diff --git a/src/main/jira/jira-issue-read.ts b/src/main/jira/jira-issue-read.ts index bc8f184a4b1..add2ddee30c 100644 --- a/src/main/jira/jira-issue-read.ts +++ b/src/main/jira/jira-issue-read.ts @@ -1,4 +1,5 @@ import type { JiraIssue, JiraSiteSelection } from '../../shared/jira-types' +import type { JiraMediaFormat } from '../../shared/jira-attachment-image' import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' import { acquire, release } from './request-queue' import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' @@ -30,7 +31,8 @@ const ISSUE_SUMMARY_TIMEOUT_MS = 30_000 export async function getIssue( key: string, siteId?: JiraSiteSelection | null, - strict = false + strict = false, + mediaFormat?: JiraMediaFormat ): Promise { const entries = getClients(siteId) requireStrictJiraSite(entries, siteId, strict) @@ -52,7 +54,7 @@ export async function getIssue( `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` ) // Why: keep only JSON under the pool; binary downloads fan out after release. - mediaRequest = collectIssueMediaRequest(issue) + mediaRequest = collectIssueMediaRequest(issue, mediaFormat) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) @@ -77,7 +79,9 @@ export async function getIssue( if (!issue) { continue } - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const prepared = mediaRequest + ? await prepareMediaResolver(entry, mediaRequest, mediaFormat) + : undefined const mapped = mapJiraIssue(entry.site, issue, prepared?.options, agileFields) if (prepared) { flushMediaResolutionWarn(entry, prepared) diff --git a/src/main/runtime/rpc/methods/jira-attachment-image.test.ts b/src/main/runtime/rpc/methods/jira-attachment-image.test.ts new file mode 100644 index 00000000000..ea9236de4ed --- /dev/null +++ b/src/main/runtime/rpc/methods/jira-attachment-image.test.ts @@ -0,0 +1,100 @@ +import '../unused-default-rpc-methods.test-fixture' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { JIRA_ATTACHMENT_IMAGE_METHODS } from './jira-attachment-image' +import { + registerAttachmentImageGrant, + resetAttachmentImageGrantsForTest +} from '../../../jira/attachment-image-grants' +import { + clearAttachmentImagesForSite, + _resetAttachmentImageCache +} from '../../../jira/attachment-image-cache' +import { decodeJiraImageFrame } from '../../../../shared/jira-image-stream-frame' + +const { read } = vi.hoisted(() => ({ read: vi.fn() })) +vi.mock('../../../jira/attachment-image-read', () => ({ readGrantedAttachmentImage: read })) +const transferId = '12345678-1234-4234-8234-123456789012' +const runtime = { getRuntimeId: () => 'runtime' } +const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The image handler never calls runtime; this fixture implements the dispatcher's metadata read. + runtime: runtime as unknown as OrcaRuntimeService, + methods: JIRA_ATTACHMENT_IMAGE_METHODS +}) +function request() { + const url = registerAttachmentImageGrant( + { + site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' }, + authorization: 'secret' + }, + { id: '1', filename: 'image.png', mimeType: 'image/png', size: 1 } + ) + return { + id: 'image', + authToken: 'token', + method: 'jira.attachmentImage', + params: { reference: url.split('/').at(-1), transferId } + } +} +beforeEach(() => { + _resetAttachmentImageCache() + resetAttachmentImageGrantsForTest() + read.mockReset().mockResolvedValue({ data: new Uint8Array(130_000), mimeType: 'image/png' }) +}) + +describe('Jira image RPC carrier', () => { + it('sends metadata, bounded addressed binary chunks and an end marker', async () => { + const replies: string[] = [] + const frames: Uint8Array[] = [] + await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply), { + sendBinary: (bytes) => { + frames.push(bytes) + } + }) + expect(replies[0]).toContain('"byteLength":130000') + expect(replies.at(-1)).toContain('"type":"end"') + expect(frames).toHaveLength(2) + expect(decodeJiraImageFrame(frames[0] ?? new Uint8Array(), transferId)?.bytes.byteLength).toBe( + 64 * 1024 + ) + expect(decodeJiraImageFrame(frames[1] ?? new Uint8Array(), transferId)?.sequence).toBe(1) + expect(replies.join('')).not.toContain('secret') + }) + it.each(['cancel', 'revoke', 'refuse'] as const)( + 'stops sending chunks after %s', + async (action) => { + const replies: string[] = [] + const controller = new AbortController() + const binary = vi.fn(() => { + if (action === 'cancel') { + controller.abort() + } + if (action === 'revoke') { + clearAttachmentImagesForSite('site') + } + return action !== 'refuse' + }) + await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply), { + sendBinary: binary, + signal: controller.signal + }) + expect(binary).toHaveBeenCalledOnce() + expect(replies.join('')).not.toContain('"type":"end"') + expect(replies.at(-1)).toContain('"ok":false') + } + ) + it('requires a binary transport and rejects unknown grant references', async () => { + const replies: string[] = [] + await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply)) + expect(replies.join('')).toContain('binary subscription transport') + expect(read).not.toHaveBeenCalled() + const unknown = request() + unknown.params.reference = '00000000-0000-0000-0000-000000000000' + await dispatcher.dispatchStreaming(unknown, (reply) => replies.push(reply), { + sendBinary: vi.fn() + }) + expect(replies.join('')).toContain('unavailable') + expect(read).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/jira-attachment-image.ts b/src/main/runtime/rpc/methods/jira-attachment-image.ts new file mode 100644 index 00000000000..f8e23c22287 --- /dev/null +++ b/src/main/runtime/rpc/methods/jira-attachment-image.ts @@ -0,0 +1,52 @@ +import { encodeJiraImageFrame } from '../../../../shared/jira-image-stream-frame' +import { JIRA_IMAGE_CHUNK_BYTES } from '../../../../shared/jira-attachment-image' +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' +import { throwIfSignalAborted } from '../../../../shared/abort-signal-reason' +import { + attachmentImageGrant, + isAttachmentImageGrantCurrent +} from '../../../jira/attachment-image-grants' +import { readGrantedAttachmentImage } from '../../../jira/attachment-image-read' +import { AttachmentImage } from '../../../../shared/rpc-contract/jira-params' +import { defineStreamingMethod } from '../core' + +export const JIRA_ATTACHMENT_IMAGE_METHODS = [ + defineStreamingMethod({ + name: 'jira.attachmentImage', + permission: 'workspace', + params: AttachmentImage, + handler: async ({ reference, transferId }, { sendBinary, signal }, emit) => { + if (!sendBinary) { + throw new Error('Jira images require a binary subscription transport.') + } + const grant = attachmentImageGrant(reference) + const image = grant && (await readGrantedAttachmentImage(reference, signal)) + if (!image || !grant) { + throw new Error('Jira image is unavailable. Reload the issue to try again.') + } + emit({ type: 'image', mimeType: image.mimeType, byteLength: image.data.byteLength }) + for (let offset = 0; offset < image.data.byteLength; offset += JIRA_IMAGE_CHUNK_BYTES) { + throwIfSignalAborted(signal) + if (!isAttachmentImageGrantCurrent(reference, grant)) { + throw new Error('Jira image access was revoked.') + } + const accepted = sendBinary( + encodeJiraImageFrame( + transferId, + offset / JIRA_IMAGE_CHUNK_BYTES, + image.data.subarray(offset, offset + JIRA_IMAGE_CHUNK_BYTES) + ) + ) + if (accepted === false || accepted === 'backlogged') { + throw new Error('Jira image transport closed.') + } + await yieldToEventLoop() + } + throwIfSignalAborted(signal) + if (!isAttachmentImageGrantCurrent(reference, grant)) { + throw new Error('Jira image access was revoked.') + } + emit({ type: 'end' }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index 9bd21dbffaf..1d1e22243f2 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -116,7 +116,7 @@ describe('jira RPC methods', () => { undefined, undefined ) - expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined) + expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined, undefined) expect(runtime.jiraLookupIssueSummary).toHaveBeenCalledWith( 'ABC-3', 'site-1', @@ -142,7 +142,7 @@ describe('jira RPC methods', () => { 'site-1' ) expect(runtime.jiraAddIssueComment).toHaveBeenCalledWith('ABC-3', 'Looks good', 'site-1') - expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1', undefined) + expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1', undefined, undefined) }) it('passes verified read requests and atomic label operations through RPC', async () => { @@ -158,10 +158,20 @@ describe('jira RPC methods', () => { methods: JIRA_METHODS }) await dispatcher.dispatch( - makeRequest('jira.getIssue', { key: 'ABC-1', siteId: 'site-1', strict: true }) + makeRequest('jira.getIssue', { + key: 'ABC-1', + siteId: 'site-1', + strict: true, + mediaFormat: 'reference' + }) ) await dispatcher.dispatch( - makeRequest('jira.issueComments', { key: 'ABC-1', siteId: 'site-1', strict: true }) + makeRequest('jira.issueComments', { + key: 'ABC-1', + siteId: 'site-1', + strict: true, + mediaFormat: 'reference' + }) ) await dispatcher.dispatch( makeRequest('jira.updateIssue', { @@ -170,8 +180,8 @@ describe('jira RPC methods', () => { updates: { labelOperations: { add: ['bug'] } } }) ) - expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-1', 'site-1', true) - expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-1', 'site-1', true) + expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-1', 'site-1', true, 'reference') + expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-1', 'site-1', true, 'reference') expect(runtime.jiraUpdateIssue).toHaveBeenCalledWith( 'ABC-1', { labelOperations: { add: ['bug'] } }, diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 51ffffb0306..aa7f1cf23af 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -3,6 +3,7 @@ import { JIRA_PAYLOAD_MAX_CHARS } from '../../../../shared/jira-payload-stream' import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' +import { JIRA_ATTACHMENT_IMAGE_METHODS } from './jira-attachment-image' import { AssignableUsers, GenerateIssueSummary, @@ -51,6 +52,7 @@ function generationOwner(context: Pick) } export const JIRA_METHODS = [ + ...JIRA_ATTACHMENT_IMAGE_METHODS, defineMethod({ name: 'jira.generateIssueSummary', permission: 'workspace', @@ -132,7 +134,7 @@ export const JIRA_METHODS = [ permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => - runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict) + runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict, params.mediaFormat) }), defineMethod({ name: 'jira.lookupIssueSummary', @@ -151,7 +153,12 @@ export const JIRA_METHODS = [ params: IssueKey, handler: async (params, { runtime }, emit) => { emitJiraPayload( - await runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict), + await runtime.jiraGetIssue( + params.key.trim(), + params.siteId, + params.strict, + params.mediaFormat + ), emit ) } @@ -190,7 +197,7 @@ export const JIRA_METHODS = [ permission: 'workspace', params: IssueKey, handler: async (params, { runtime }) => - runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict) + runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict, params.mediaFormat) }), defineStreamingMethod({ name: 'jira.issueCommentsStream', @@ -198,7 +205,12 @@ export const JIRA_METHODS = [ params: IssueKey, handler: async (params, { runtime }, emit) => { emitJiraPayload( - await runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict), + await runtime.jiraIssueComments( + params.key.trim(), + params.siteId, + params.strict, + params.mediaFormat + ), emit ) } diff --git a/src/main/runtime/runtime-jira-commands.ts b/src/main/runtime/runtime-jira-commands.ts index c30ba5c34a9..14a6da516db 100644 --- a/src/main/runtime/runtime-jira-commands.ts +++ b/src/main/runtime/runtime-jira-commands.ts @@ -15,6 +15,7 @@ import type { JiraIssueUpdate, JiraSiteSelection } from '../../shared/jira-types' +import type { JiraMediaFormat } from '../../shared/jira-attachment-image' import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client' import { addIssueComment, @@ -103,8 +104,13 @@ export class RuntimeJiraCommands { return createIssue(args) } - jiraGetIssue(key: string, siteId?: string, strict = false): ReturnType { - return getIssue(key, siteId, strict) + jiraGetIssue( + key: string, + siteId?: string, + strict = false, + mediaFormat?: JiraMediaFormat + ): ReturnType { + return getIssue(key, siteId, strict, mediaFormat) } jiraLookupIssueSummary( @@ -134,9 +140,10 @@ export class RuntimeJiraCommands { jiraIssueComments( key: string, siteId?: string, - strict = false + strict = false, + mediaFormat?: JiraMediaFormat ): ReturnType { - return getIssueComments(key, siteId, strict) + return getIssueComments(key, siteId, strict, mediaFormat) } jiraListProjects(siteId?: JiraSiteSelection, strict = false): ReturnType { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index 4f4b9d4ad15..b175513b672 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -43,6 +43,7 @@ export const PAIRED_DESKTOP_METHODS = [ 'accounts.subscribe', 'accounts.unsubscribe', // Jira credentials and issue mutations remain desktop-only. + 'jira.attachmentImage', 'jira.getIssue', 'jira.issueComments', 'jira.listIssues', diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 00b886db297..da4e81ed753 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -86,6 +86,7 @@ import { initOpenCodeUsagePath } from '../opencode-usage/store' import { initMuseUsagePath } from '../muse-usage/store' import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol' import { MEDIA_PREVIEW_CUSTOM_SCHEME } from '../media/media-preview-protocol' +import { JIRA_IMAGE_CUSTOM_SCHEME } from '../jira/attachment-image-protocol' import { startCrashpadCapture } from '../crash-reporting/crashpad-capture' import { CrashReportStore } from '../crash-reporting/crash-report-store' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' @@ -207,8 +208,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b // Why before any spawn: `signalProcessTree` is shared with the CLI and relay, so // it can only reach the main-process guard and breadcrumb store once this is registered. installMainProcessTreeKillGate() - const isDev = is.dev - configureDevUserDataPath(isDev) + configureDevUserDataPath(is.dev) configureOrcaUserDataPathEnv() // Why these four lines are one step (#16761): the two above decide where userData lives, and // everything below may resolve a path. Installing the accessor any later leaves a window where an @@ -253,8 +253,8 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) // Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. // Why dev locks too: two processes on one profile corrupt its stores (PR #1326 / #1312); parallel `pnpm dev` needs ORCA_DEV_USER_DATA_PATH per copy. - const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) - const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + const bypass = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode: state.isServeMode }) + const skip = shouldSkipSingleInstanceLock({ isDev: is.dev, isServeMode: state.isServeMode }) if (bypass) { // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. logSingleInstanceLockBypass() @@ -271,7 +271,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. // In dev it is the line `pnpm dev` prints before exiting. logSingleInstanceLockFailure({ - isDevDesktop: isDev && !state.isServeMode, + isDevDesktop: is.dev && !state.isServeMode, userDataPath: app.getPath('userData') }) // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). @@ -325,7 +325,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b setSpeechServiceFactories(electronSpeechServiceFactories) setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. - const shouldCoupleToDevParent = isDev && !state.isServeMode + const shouldCoupleToDevParent = is.dev && !state.isServeMode installDevParentDisconnectQuit(shouldCoupleToDevParent) installDevParentWatchdog(shouldCoupleToDevParent) installDevParentSignalQuit(shouldCoupleToDevParent) @@ -360,7 +360,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b initMuseUsagePath() // Why: Electron freezes the privileged scheme table at ready, so the doc-preview // scheme must be declared here or its webview loses fetch/secure-origin privileges. - registerDocPreviewSchemePrivileges([MEDIA_PREVIEW_CUSTOM_SCHEME]) + registerDocPreviewSchemePrivileges([MEDIA_PREVIEW_CUSTOM_SCHEME, JIRA_IMAGE_CUSTOM_SCHEME]) // Why: must precede app.whenReady() so Crashpad is installed before the // first renderer spawns; a CHECK before this point is still exit-code-only. startCrashpadCapture() diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 09201cc2537..b01069aa3a3 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -33,6 +33,7 @@ import { } from '../browser/browser-session-proxy' import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol' import { installMediaPreviewProtocolHandler } from '../media/media-preview-protocol' +import { installJiraImageProtocolHandler } from '../jira/attachment-image-protocol' import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' import { browserSessionRegistry } from '../browser/browser-session-registry' @@ -259,6 +260,7 @@ export async function initializeReadyFoundation(): Promise { setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. installMediaPreviewProtocolHandler() + installJiraImageProtocolHandler() installDocPreviewProtocolHandler() registerDocPreviewGrantHandlers() // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts index 15129404915..2faa3ab1c55 100644 --- a/src/preload/api/jira-api.ts +++ b/src/preload/api/jira-api.ts @@ -1,3 +1,4 @@ +import type { JiraMediaFormat } from '../../shared/jira-attachment-image' import type { JiraIssueSummaryGenerationRequest, JiraIssueSummaryGenerationCancellation, @@ -49,7 +50,11 @@ export type JiraApi = { siteId?: JiraSiteSelection filters?: JiraIssueListFilters }) => Promise - getIssue: (args: { key: string; siteId?: string }) => Promise + getIssue: (args: { + key: string + siteId?: string + mediaFormat?: JiraMediaFormat + }) => Promise lookupIssueSummary: (args: { key: string siteId: string @@ -73,7 +78,11 @@ export type JiraApi = { body: string siteId?: string }) => Promise<{ ok: true; id: string } | { ok: false; error: string }> - issueComments: (args: { key: string; siteId?: string }) => Promise + issueComments: (args: { + key: string + siteId?: string + mediaFormat?: JiraMediaFormat + }) => Promise listProjects: (args?: { siteId?: JiraSiteSelection }) => Promise listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => Promise listCreateFields: (args: { diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts index f90b863dcdc..8045e252abf 100644 --- a/src/preload/api/jira-bridge.ts +++ b/src/preload/api/jira-bridge.ts @@ -1,3 +1,4 @@ +import type { JiraMediaFormat } from '../../shared/jira-attachment-image' import type { JiraIssueSummaryGenerationRequest, JiraIssueSummaryGenerationCancellation @@ -37,7 +38,8 @@ export const jiraApi = { siteId?: string }) => ipcRenderer.invoke('jira:listIssues', args), - getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args), + getIssue: (args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => + ipcRenderer.invoke('jira:getIssue', args), lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) => ipcRenderer.invoke('jira:lookupIssueSummary', args), @@ -74,7 +76,7 @@ export const jiraApi = { }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => ipcRenderer.invoke('jira:addIssueComment', args), - issueComments: (args: { key: string; siteId?: string }) => + issueComments: (args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => ipcRenderer.invoke('jira:issueComments', args), listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args), diff --git a/src/renderer/index.html b/src/renderer/index.html index a481d0e1443..390af65c6f8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,6 +2,10 @@ + Orca diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index ee158707631..980f29cf186 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -385,6 +385,7 @@ export default function JiraIssueWorkspace({ ({ + acquire: vi.fn(), + release: vi.fn(), + listeners: new Set<(environmentId: string | null) => void>() +})) +vi.mock('@/runtime/runtime-jira-image-cache', () => ({ + acquireJiraImageUrl: acquire, + hasCachedJiraImage: () => false, + onJiraImageCacheInvalidated: (listener: (environmentId: string | null) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } +})) +const target = { kind: 'environment' as const, environmentId: 'first-host' } +beforeEach(() => { + acquire.mockReset() + release.mockReset() + listeners.clear() +}) +afterEach(cleanup) + +it('removes the previous image immediately when its mounted owner or reference changes', async () => { + acquire + .mockResolvedValueOnce({ url: 'blob:first', release }) + .mockImplementationOnce(() => new Promise(() => {})) + const view = render( + + ) + await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:first')) + view.rerender( + + ) + expect(screen.queryByRole('img')).toBeNull() + expect(release).toHaveBeenCalledOnce() +}) + +it('lets a new reference load after the previous reference failed', async () => { + acquire + .mockRejectedValueOnce(new Error('unavailable')) + .mockResolvedValueOnce({ url: 'blob:second', release }) + const view = render( + + ) + await waitFor(() => expect(acquire).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 0)) + view.rerender( + + ) + await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:second')) +}) + +it('reacquires the same reference after disconnect and reconnect invalidations', async () => { + acquire + .mockResolvedValueOnce({ url: 'blob:first', release }) + .mockRejectedValueOnce(new Error('disconnected')) + .mockResolvedValueOnce({ url: 'blob:reconnected', release }) + render() + await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:first')) + await act(async () => listeners.forEach((listener) => listener('first-host'))) + expect(screen.queryByRole('img')).toBeNull() + await waitFor(() => expect(acquire).toHaveBeenCalledTimes(2)) + await act(async () => listeners.forEach((listener) => listener('other-host'))) + expect(acquire).toHaveBeenCalledTimes(2) + await act(async () => listeners.forEach((listener) => listener('first-host'))) + await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:reconnected')) + expect(acquire).toHaveBeenCalledTimes(3) + expect(release).toHaveBeenCalledOnce() +}) + +it('fences a pending rejection synchronously when its cache is invalidated', async () => { + let requestSignal: AbortSignal | undefined + let rejectFirst: ((reason: Error) => void) | undefined + acquire + .mockImplementationOnce((_target, _reference, signal: AbortSignal) => { + requestSignal = signal + return new Promise((_resolve, reject) => { + rejectFirst = reject + }) + }) + .mockResolvedValueOnce({ url: 'blob:retried', release }) + render() + await waitFor(() => expect(acquire).toHaveBeenCalledOnce()) + await act(async () => { + listeners.forEach((listener) => listener('first-host')) + expect(requestSignal?.aborted).toBe(true) + rejectFirst?.(new Error('Cache invalidated')) + await Promise.resolve() + }) + await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:retried')) + expect(acquire).toHaveBeenCalledTimes(2) +}) diff --git a/src/renderer/src/components/jira-attachment-image.tsx b/src/renderer/src/components/jira-attachment-image.tsx new file mode 100644 index 00000000000..6a5357ea8f1 --- /dev/null +++ b/src/renderer/src/components/jira-attachment-image.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { + acquireJiraImageUrl, + hasCachedJiraImage, + onJiraImageCacheInvalidated +} from '@/runtime/runtime-jira-image-cache' +import { jiraImageProtocolUrl } from '@/runtime/runtime-jira-image-client' +import { translate } from '@/i18n/i18n' +import { ExpandableMarkdownImage } from './sidebar/MarkdownImageLightbox' + +type JiraAttachmentImageProps = { + target: RuntimeClientTarget + reference: string + alt?: string + compact: boolean +} + +export function JiraAttachmentImage(props: JiraAttachmentImageProps): React.JSX.Element { + const owner = props.target.kind === 'environment' ? props.target.environmentId : 'local' + return +} + +function ResolvedJiraAttachmentImage({ + target, + reference, + alt, + compact +}: JiraAttachmentImageProps): React.JSX.Element { + const [resolved, setResolved] = useState(null) + const [failed, setFailed] = useState(false) + const [generation, setGeneration] = useState(0) + const activeRequest = useRef(null) + const localProtocol = target.kind === 'local' && !hasCachedJiraImage(target, reference) + const src = localProtocol ? jiraImageProtocolUrl(reference) : resolved + useEffect( + () => + onJiraImageCacheInvalidated((environmentId) => { + if ( + environmentId === null || + (target.kind === 'environment' && environmentId === target.environmentId) + ) { + activeRequest.current?.abort() + setResolved(null) + setFailed(false) + setGeneration((value) => value + 1) + } + }), + [target] + ) + useEffect(() => { + if (localProtocol) { + return + } + const controller = new AbortController() + activeRequest.current = controller + let release: (() => void) | undefined + void acquireJiraImageUrl(target, reference, controller.signal) + .then((image) => { + if (controller.signal.aborted) { + image.release() + return + } + release = image.release + setResolved(image.url) + }) + .catch(() => { + if (!controller.signal.aborted) { + setFailed(true) + } + }) + return () => { + controller.abort() + if (activeRequest.current === controller) { + activeRequest.current = null + } + release?.() + } + }, [generation, localProtocol, reference, target]) + if (!src || failed) { + return ( + + {alt || translate('auto.components.sidebar.MarkdownImageLightbox.image', 'Image')} + + ) + } + return ( + setFailed(true)} + alt={alt} + triggerClassName={compact ? 'my-1' : undefined} + className={ + compact + ? 'max-h-32 max-w-full rounded-sm object-contain outline outline-1 outline-border/70' + : 'max-h-96 max-w-full rounded-md object-contain outline outline-1 outline-border/70' + } + /> + ) +} diff --git a/src/renderer/src/components/jira-issue-markdown.tsx b/src/renderer/src/components/jira-issue-markdown.tsx new file mode 100644 index 00000000000..0d36059abe3 --- /dev/null +++ b/src/renderer/src/components/jira-issue-markdown.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useMemo } from 'react' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { jiraImageReference } from '../../../shared/jira-attachment-image' +import CommentMarkdown from './sidebar/CommentMarkdown' +import { selectCommentMarkdownComponents } from './sidebar/comment-markdown-element-renderers' +import { JiraAttachmentImage } from './jira-attachment-image' + +const JiraMarkdownContext = createContext<{ target: RuntimeClientTarget; compact: boolean }>({ + target: { kind: 'local' }, + compact: false +}) + +function JiraMarkdownImage(props: React.ComponentProps<'img'>): React.JSX.Element | null { + const { target, compact } = useContext(JiraMarkdownContext) + const reference = jiraImageReference(props.src) + if (reference) { + return ( + + ) + } + const BaseImage = selectCommentMarkdownComponents({ + variant: compact ? 'compact' : 'document', + expandImages: true + }).img + return BaseImage ? : null +} + +const imageExtension = { + remarkPlugins: [], + sanitizeAttributes: {}, + components: { img: JiraMarkdownImage } +} + +export function JiraIssueMarkdown({ + target, + content, + compact = false, + className +}: { + target: RuntimeClientTarget + content: string + compact?: boolean + className?: string +}): React.JSX.Element { + const environmentId = target.kind === 'environment' ? target.environmentId : null + const value = useMemo<{ target: RuntimeClientTarget; compact: boolean }>( + () => ({ + target: environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, + compact + }), + [environmentId, compact] + ) + return ( + + + + ) +} diff --git a/src/renderer/src/components/jira-issue-workspace-content.tsx b/src/renderer/src/components/jira-issue-workspace-content.tsx index 11be7282114..084ad3c7384 100644 --- a/src/renderer/src/components/jira-issue-workspace-content.tsx +++ b/src/renderer/src/components/jira-issue-workspace-content.tsx @@ -1,6 +1,7 @@ import type { LucideIcon } from 'lucide-react' import { ArrowRight, LoaderCircle, RefreshCw, Save, Send } from 'lucide-react' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { JiraIssueMarkdown } from './jira-issue-markdown' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { JiraIcon } from '@/components/icons/JiraIcon' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -17,6 +18,7 @@ export type JiraIssueWorkspaceAction = { export function JiraIssueWorkspaceContent({ displayed, + imageTarget, titleDraft, setTitleDraft, labelsDraft, @@ -32,6 +34,7 @@ export function JiraIssueWorkspaceContent({ actionItems }: { displayed: JiraIssue + imageTarget: RuntimeClientTarget titleDraft: string setTitleDraft: (value: string) => void labelsDraft: string @@ -119,9 +122,9 @@ export function JiraIssueWorkspaceContent({ {displayed.description?.trim() ? ( - ) : ( @@ -196,9 +199,10 @@ export function JiraIssueWorkspaceContent({
{/* Why: Jira comment screenshots need the same preview affordance without changing compact comment typography. */} -
diff --git a/src/renderer/src/components/jira-issue-workspace-image-owner.test.tsx b/src/renderer/src/components/jira-issue-workspace-image-owner.test.tsx new file mode 100644 index 00000000000..58fa6cdf613 --- /dev/null +++ b/src/renderer/src/components/jira-issue-workspace-image-owner.test.tsx @@ -0,0 +1,291 @@ +// @vitest-environment happy-dom + +import { act, type ComponentProps, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraComment, JiraIssue, JiraTransition } from '../../../shared/jira-types' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { AppState } from '../store/types' +import type { + JiraIssueCommentComposer, + JiraIssueWorkspaceContent +} from './jira-issue-workspace-content' +import type { JiraIssueMetadataBar } from './jira-issue-workspace-chrome' +import JiraIssueWorkspace from './JiraIssueWorkspace' + +type PendingRead = { + target: RuntimeClientTarget + key: string + resolve: (value: T) => void + reject: (error: Error) => void +} +type ComposerProps = ComponentProps +type ContentProps = ComponentProps +type MetadataProps = ComponentProps +type JiraStoreBoundary = Pick & { + settings: Pick, 'activeRuntimeEnvironmentId'> +} + +const boundary = vi.hoisted( + (): { + issues: PendingRead[] + comments: PendingRead[] + transitions: PendingRead[] + composer: ComposerProps | null + settings: JiraStoreBoundary['settings'] + content: ContentProps | null + metadata: MetadataProps | null + } => ({ + issues: [], + comments: [], + transitions: [], + composer: null, + settings: { activeRuntimeEnvironmentId: 'environment-1' }, + content: null, + metadata: null + }) +) + +vi.mock('@/store', () => { + return { + useAppStore: (select: (state: JiraStoreBoundary) => unknown) => + select({ + settings: boundary.settings, + patchJiraIssue: () => {} + }) + } +}) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/runtime/runtime-jira-client', () => { + function request( + queue: PendingRead[], + target: RuntimeClientTarget, + key: string + ): Promise { + return new Promise((resolve, reject) => queue.push({ target, key, resolve, reject })) + } + return { + jiraGetIssueForTarget: (target: RuntimeClientTarget, key: string) => + request(boundary.issues, target, key), + jiraIssueCommentsForTarget: (target: RuntimeClientTarget, key: string) => + request(boundary.comments, target, key), + jiraListTransitionsForTarget: (target: RuntimeClientTarget, key: string) => + request(boundary.transitions, target, key), + jiraListPrioritiesForTarget: async () => [], + jiraListAssignableUsersForTarget: async () => [], + jiraUpdateIssueForTarget: async () => ({ ok: true }), + jiraAddIssueCommentForTarget: async () => ({ ok: true, id: 'added' }) + } +}) +vi.mock('@/components/ui/sheet', () => { + const Content = ({ children }: { children: ReactNode }) =>
{children}
+ return { + // Keep the owner mounted, including its closed content, so stale state is observable. + Sheet: ({ open, children }: { open: boolean; children: ReactNode }) => ( +
{children}
+ ), + SheetContent: Content, + SheetTitle: Content, + SheetDescription: Content + } +}) +vi.mock('./jira-issue-workspace-chrome', () => ({ + JiraIssueWorkspaceHeader: ({ issueLoading }: { issueLoading: boolean }) => ( +
{String(issueLoading)}
+ ), + JiraIssueMetadataBar: (props: MetadataProps) => ( +
{ + boundary.metadata = node ? props : null + }} + > + {props.transitions.map((transition) => transition.id).join(',')} +
+ ) +})) +vi.mock('./jira-issue-workspace-content', () => ({ + JiraIssueWorkspaceContent: (props: ComponentProps) => ( +
{ + boundary.content = node ? props : null + }} + > + {props.displayed.title} + {props.displayed.labels.join(',')} + {props.displayed.description} + {JSON.stringify(props.imageTarget)} + {props.comments.map((comment) => comment.id).join(',')} + {String(props.commentsLoading)} + {props.commentsError} + +
+ ), + JiraIssueCommentComposer: (props: ComposerProps) => ( +
{ + boundary.composer = node ? props : null + }} + /> + ) +})) + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'runtime:environment-1' +} +let root: Root | null = null +let container: HTMLDivElement + +function issue(key = 'JIR-1', title = key): JiraIssue { + return { + id: key, + key, + title, + siteId: 'site-1', + description: '', + url: `https://jira.invalid/browse/${key}`, + project: { id: 'project', key: 'PRJ', name: 'Project' }, + issueType: { id: 'task', name: 'Task' }, + status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'New' }, + labels: [], + createdAt: '2026-10-02', + updatedAt: '2026-10-02' + } +} + +function comment(id: string): JiraComment { + return { id, body: id, createdAt: '2026-10-02' } +} + +async function render( + selected: JiraIssue | null, + context: TaskSourceContext | null = sourceContext +): Promise { + if (!root) { + throw new Error('Missing React root') + } + const mountedRoot = root + await act(async () => { + mountedRoot.render( + {}} + onClose={() => {}} + /> + ) + }) +} + +function take(queue: PendingRead[]): PendingRead { + const pending = queue.shift() + if (!pending) { + throw new Error('Missing pending Jira read') + } + return pending +} + +function text(id: string): string | null | undefined { + return container.querySelector(`[data-testid="${id}"]`)?.textContent +} + +beforeEach(() => { + boundary.settings = { activeRuntimeEnvironmentId: 'environment-1' } + boundary.issues.length = 0 + boundary.comments.length = 0 + boundary.transitions.length = 0 + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + const mountedRoot = root + if (mountedRoot) { + await act(async () => mountedRoot.unmount()) + } + root = null + await act(async () => { + for (const pending of [...boundary.issues, ...boundary.comments, ...boundary.transitions]) { + pending.reject(new Error('Test cleanup')) + } + }) + boundary.issues.length = 0 + boundary.comments.length = 0 + boundary.transitions.length = 0 + boundary.composer = null + boundary.content = null + boundary.metadata = null + document.body.replaceChildren() +}) + +describe('Jira issue workspace image ownership', () => { + it('keeps explicit ownership without duplicate reads when the default host changes', async () => { + const selected = issue() + const owner: RuntimeClientTarget = { kind: 'environment', environmentId: 'environment-1' } + await render(selected) + boundary.settings = { activeRuntimeEnvironmentId: 'environment-2' } + await render(selected) + for (const reads of [boundary.issues, boundary.comments, boundary.transitions]) { + expect(reads).toHaveLength(1) + expect(reads[0]?.target).toEqual(owner) + } + expect(text('image-owner')).toBe(JSON.stringify(owner)) + }) + + it('fences late detail, comments and transitions when the same issue switches owners', async () => { + const selected = issue() + await render(selected) + const oldIssue = take(boundary.issues) + const oldComments = take(boundary.comments) + const oldTransitions = take(boundary.transitions) + await render(selected, { ...sourceContext, hostId: 'runtime:environment-2' }) + const nextIssue = take(boundary.issues) + const nextComments = take(boundary.comments) + const nextTransitions = take(boundary.transitions) + expect(nextIssue.target).toEqual({ kind: 'environment', environmentId: 'environment-2' }) + expect(nextComments.target).toEqual(nextIssue.target) + expect(nextTransitions.target).toEqual(nextIssue.target) + expect(text('image-owner')).toBe(JSON.stringify(nextIssue.target)) + await act(async () => { + oldIssue.resolve(issue('JIR-1', 'Old owner')) + oldComments.resolve([comment('old')]) + oldTransitions.resolve([{ id: 'old', name: 'Old', to: selected.status }]) + }) + expect(text('title')).toBe('JIR-1') + expect(text('comments')).toBe('') + expect(text('transitions')).toBe('') + await act(async () => { + nextIssue.resolve(issue('JIR-1', 'New owner')) + nextComments.resolve([comment('new')]) + nextTransitions.resolve([{ id: 'new', name: 'New', to: selected.status }]) + }) + expect(text('title')).toBe('New owner') + expect(text('comments')).toBe('new') + expect(text('transitions')).toBe('new') + }) + + it('uses one account owner for row-less detail, comments, transitions and images', async () => { + await render(issue(), null) + const detail = take(boundary.issues) + const comments = take(boundary.comments) + const transitions = take(boundary.transitions) + expect(detail.target).toEqual({ kind: 'environment', environmentId: 'environment-1' }) + expect(comments.target).toBe(detail.target) + expect(transitions.target).toBe(detail.target) + expect(text('image-owner')).toBe(JSON.stringify(detail.target)) + await act(async () => { + detail.resolve(null) + comments.resolve([]) + transitions.resolve([]) + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index fcc8388e7a3..ec48712abd7 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -195,7 +195,7 @@ const commentMarkdownSanitizeSchema = { // Why: native chat opts into file URI links after sanitize; the URL // transform below still strips them for all other markdown surfaces. href: [...(defaultSchema.protocols?.href ?? []), 'file'], - src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob'] + src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob', 'orca-jira-image'] } } diff --git a/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx b/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx index 969e9d135e5..24f9e27beb9 100644 --- a/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx +++ b/src/renderer/src/components/sidebar/MarkdownImageLightbox.tsx @@ -16,6 +16,7 @@ type ExpandableMarkdownImageProps = { alt?: string className?: string triggerClassName?: string + onError?: () => void } /** @@ -26,7 +27,8 @@ export function ExpandableMarkdownImage({ src, alt, className, - triggerClassName + triggerClassName, + onError }: ExpandableMarkdownImageProps): React.JSX.Element { const [open, setOpen] = React.useState(false) const label = @@ -51,7 +53,12 @@ export function ExpandableMarkdownImage({ 'Expand image' )} > - {alt + {alt { useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot) remoteRuntimeTerminalColorPush.observeStatusSnapshot(snapshot) + observeJiraImageHostStatus(snapshot) } let stopped = false unsubs.push(statusApi.onStatusChanged(apply), () => { diff --git a/src/renderer/src/runtime/runtime-jira-client.test.ts b/src/renderer/src/runtime/runtime-jira-client.test.ts index d12f42d0c92..b8754e6539e 100644 --- a/src/renderer/src/runtime/runtime-jira-client.test.ts +++ b/src/renderer/src/runtime/runtime-jira-client.test.ts @@ -16,6 +16,8 @@ import { jiraSearchUsers, jiraListIssues } from './runtime-jira-client' +import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image' +import { clearJiraImageCache } from './runtime-jira-image-cache' import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture' import { @@ -75,6 +77,7 @@ beforeEach(() => { }) afterEach(() => { + clearJiraImageCache() vi.unstubAllGlobals() }) describe('current sprint runtime compatibility', () => { @@ -451,7 +454,15 @@ describe('runtime Jira client search bounds', () => { ) }) - it('streams image-bearing issue and comment payloads from remote runtimes', async () => { + it('streams image-bearing issue and comment payloads from older remote runtimes', async () => { + const status = createCompatibleRuntimeStatusResponse() + if (!status.ok) { + throw new Error('Expected successful status') + } + status.result.capabilities = status.result.capabilities?.filter( + (capability) => capability !== JIRA_IMAGE_CAPABILITY + ) + runtimeCall.mockResolvedValue(status) runtimeSubscribe.mockImplementation( async (args: RuntimeSubscribeArgs, callbacks: RuntimeSubscribeCallbacks) => { const payload = @@ -501,7 +512,7 @@ describe('runtime Jira client search bounds', () => { }, expect.anything() ) - expect(runtimeCall).not.toHaveBeenCalled() + expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'status.get' })) }) }) diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index a1049595f32..02c1ae8eefb 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -23,6 +23,7 @@ import type { import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { searchLocalJiraIssues } from './local-jira-search-cancellation' import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client' +import { clearJiraImageCache } from './runtime-jira-image-cache' import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target' import { parseJiraConnectionStatus } from './runtime-jira-connection-status' @@ -85,6 +86,7 @@ export async function jiraDisconnect( settings: RuntimeJiraSettings, siteId?: string | null ): Promise { + clearJiraImageCache() const target = getJiraRuntimeTarget(settings) if (target.kind === 'environment') { await callRuntimeRpc<{ ok: true }>(target, 'jira.disconnect', siteId ? { siteId } : undefined, { diff --git a/src/renderer/src/runtime/runtime-jira-detail-client.test.ts b/src/renderer/src/runtime/runtime-jira-detail-client.test.ts index 26f4736f730..d7256a8c873 100644 --- a/src/renderer/src/runtime/runtime-jira-detail-client.test.ts +++ b/src/renderer/src/runtime/runtime-jira-detail-client.test.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { jiraAddIssueCommentForTarget, @@ -54,8 +55,16 @@ describe('captured Jira detail owners', () => { await jiraListPrioritiesForTarget(owner, 'site-1') await jiraListAssignableUsersForTarget(owner, 'ENG-1', 'Ada', 'site-1') - expect(localMethods.getIssue).toHaveBeenCalledWith({ key: 'ENG-1', siteId: 'site-1' }) - expect(localMethods.issueComments).toHaveBeenCalledWith({ key: 'ENG-1', siteId: 'site-1' }) + expect(localMethods.getIssue).toHaveBeenCalledWith({ + key: 'ENG-1', + siteId: 'site-1', + mediaFormat: 'reference' + }) + expect(localMethods.issueComments).toHaveBeenCalledWith({ + key: 'ENG-1', + siteId: 'site-1', + mediaFormat: 'reference' + }) expect(localMethods.updateIssue).toHaveBeenCalledWith({ key: 'ENG-1', siteId: 'site-1', @@ -128,9 +137,16 @@ describe('captured Jira detail owners', () => { error: { code: 'method_not_found', message: 'Old host' } }) ) + const status = createCompatibleRuntimeStatusResponse() + if (!status.ok) { + throw new Error('Expected successful status') + } + status.result.capabilities = status.result.capabilities?.filter( + (capability) => capability !== JIRA_IMAGE_CAPABILITY + ) runtimeCall.mockImplementation(async (args: { method: string }) => args.method === 'status.get' - ? createCompatibleRuntimeStatusResponse() + ? status : { id: 'rpc-1', ok: true, diff --git a/src/renderer/src/runtime/runtime-jira-detail-client.ts b/src/renderer/src/runtime/runtime-jira-detail-client.ts index 9d4659bad84..4ac5b089296 100644 --- a/src/renderer/src/runtime/runtime-jira-detail-client.ts +++ b/src/renderer/src/runtime/runtime-jira-detail-client.ts @@ -1,3 +1,5 @@ +import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image' +import { migrateLegacyJiraImages } from './runtime-jira-image-cache' import type { JiraComment, JiraIssue, @@ -7,7 +9,11 @@ import type { JiraTransition } from '../../../shared/jira-types' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' -import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client' +import { + assertRuntimeEnvironmentCapability, + callRuntimeRpc, + runtimeEnvironmentSupportsCapability +} from './runtime-rpc-client' import { JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { RuntimeRpcCallError } from './runtime-rpc-result' import { readRuntimeJiraPayload } from './runtime-jira-payload-stream' @@ -37,10 +43,30 @@ export async function jiraGetIssueForTarget( key: string, siteId?: string | null ): Promise { - const args = { key, siteId: siteId ?? undefined } - return target.kind === 'environment' - ? readRemoteJiraPayload(target, 'jira.getIssueStream', 'jira.getIssue', args) - : window.api.jira.getIssue(args) + const references = + target.kind === 'local' || + (await runtimeEnvironmentSupportsCapability(target.environmentId, JIRA_IMAGE_CAPABILITY)) + const args = { + key, + siteId: siteId ?? undefined, + ...(references ? { mediaFormat: 'reference' as const } : {}) + } + const issue = + target.kind === 'environment' + ? references + ? await callRuntimeRpc(target, 'jira.getIssue', args, { + timeoutMs: 30_000 + }) + : await readRemoteJiraPayload( + target, + 'jira.getIssueStream', + 'jira.getIssue', + args + ) + : await window.api.jira.getIssue(args) + return issue + ? { ...issue, description: migrateLegacyJiraImages(issue.description ?? '', target) } + : null } export async function jiraUpdateIssueForTarget( @@ -84,15 +110,31 @@ export async function jiraIssueCommentsForTarget( key: string, siteId?: string | null ): Promise { - const args = { key, siteId: siteId ?? undefined } - return target.kind === 'environment' - ? readRemoteJiraPayload( - target, - 'jira.issueCommentsStream', - 'jira.issueComments', - args - ) - : window.api.jira.issueComments(args) + const references = + target.kind === 'local' || + (await runtimeEnvironmentSupportsCapability(target.environmentId, JIRA_IMAGE_CAPABILITY)) + const args = { + key, + siteId: siteId ?? undefined, + ...(references ? { mediaFormat: 'reference' as const } : {}) + } + const comments = + target.kind === 'environment' + ? references + ? await callRuntimeRpc(target, 'jira.issueComments', args, { + timeoutMs: 30_000 + }) + : await readRemoteJiraPayload( + target, + 'jira.issueCommentsStream', + 'jira.issueComments', + args + ) + : await window.api.jira.issueComments(args) + return comments.map((comment) => ({ + ...comment, + body: migrateLegacyJiraImages(comment.body, target) + })) } export async function jiraListPrioritiesForTarget( diff --git a/src/renderer/src/runtime/runtime-jira-detail-images.test.ts b/src/renderer/src/runtime/runtime-jira-detail-images.test.ts new file mode 100644 index 00000000000..ff3cd1f2a68 --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-detail-images.test.ts @@ -0,0 +1,226 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { jiraGetIssueForTarget, jiraIssueCommentsForTarget } from './runtime-jira-detail-client' +import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image' +import { clearJiraImageCache, hasCachedJiraImage } from './runtime-jira-image-cache' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' +import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture' + +type SubscribeArgs = Parameters[0] +type SubscribeCallbacks = Parameters[1] +const target = { kind: 'environment' as const, environmentId: 'issue-owner' } +const image = '![shot](orca-jira-image://attachment/12345678-1234-1234-1234-123456789012)' +const localIssue = vi.fn() +const localComments = vi.fn() +const runtimeCall = vi.fn() +const runtimeSubscribe = vi.fn() +const close = vi.fn() + +function statusWithoutImages() { + const response = createCompatibleRuntimeStatusResponse() + if (!response.ok) { + throw new Error('Expected a successful compatibility fixture') + } + response.result.capabilities = response.result.capabilities?.filter( + (capability) => capability !== JIRA_IMAGE_CAPABILITY + ) + return response +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + localIssue.mockReset() + localComments.mockReset() + runtimeCall.mockReset() + runtimeSubscribe.mockReset() + close.mockReset() + vi.stubGlobal('window', { + api: { + jira: { getIssue: localIssue, issueComments: localComments }, + runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } + } + }) +}) + +afterEach(() => { + clearJiraImageCache() + vi.unstubAllGlobals() +}) + +describe('captured Jira detail owners', () => { + it('requests image references through local issue and comment IPC', async () => { + localIssue.mockResolvedValue({ key: 'JIR-1', description: image }) + localComments.mockResolvedValue([{ id: 'comment', body: image }]) + await expect(jiraGetIssueForTarget({ kind: 'local' }, 'JIR-1', 'site')).resolves.toMatchObject({ + description: image + }) + await expect(jiraIssueCommentsForTarget({ kind: 'local' }, 'JIR-1', 'site')).resolves.toEqual([ + { id: 'comment', body: image } + ]) + const args = { key: 'JIR-1', siteId: 'site', mediaFormat: 'reference' } + expect(localIssue).toHaveBeenCalledWith(args) + expect(localComments).toHaveBeenCalledWith(args) + expect(runtimeCall).not.toHaveBeenCalled() + expect(runtimeSubscribe).not.toHaveBeenCalled() + }) + + it('checks the captured remote owner capability and reads short image references there', async () => { + runtimeCall.mockImplementation(async (args: { method: string }) => + args.method === 'status.get' + ? createCompatibleRuntimeStatusResponse() + : { + id: 'detail', + ok: true, + result: + args.method === 'jira.getIssue' + ? { key: 'JIR-1', description: image } + : [{ id: 'comment', body: image }], + _meta: { runtimeId: 'remote-runtime' } + } + ) + await expect(jiraGetIssueForTarget(target, 'JIR-1', 'site')).resolves.toMatchObject({ + description: image + }) + await expect(jiraIssueCommentsForTarget(target, 'JIR-1', 'site')).resolves.toEqual([ + { id: 'comment', body: image } + ]) + for (const method of ['status.get', 'jira.getIssue', 'jira.issueComments']) { + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'issue-owner', + method + }) + ) + } + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'jira.getIssue', + params: { key: 'JIR-1', siteId: 'site', mediaFormat: 'reference' } + }) + ) + expect(localIssue).not.toHaveBeenCalled() + expect(localComments).not.toHaveBeenCalled() + expect(runtimeSubscribe).not.toHaveBeenCalled() + }) + + it('keeps old-host streams and migrated image bytes under their captured owner', async () => { + runtimeCall.mockResolvedValue(statusWithoutImages()) + runtimeSubscribe.mockImplementation( + async (args: SubscribeArgs, callbacks: SubscribeCallbacks) => { + const body = '![shot](data:image/png;base64,AQ==)' + const payload = + args.method === 'jira.getIssueStream' + ? { key: 'JIR-1', description: body } + : [{ id: 'comment', body }] + for (const result of [ + { type: 'chunk', content: JSON.stringify(payload) }, + { type: 'end' } + ]) { + callbacks.onResponse({ + id: 'detail', + ok: true, + result, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return { unsubscribe: close, sendBinary: vi.fn() } + } + ) + const issue = await jiraGetIssueForTarget(target, 'JIR-1', 'site') + const comments = await jiraIssueCommentsForTarget(target, 'JIR-1', 'site') + for (const body of [issue?.description, comments[0]?.body]) { + const reference = body?.match(/orca-jira-image:\/\/attachment\/([^)]+)/)?.[1] + if (!reference) { + throw new Error('Expected a migrated image reference') + } + expect(hasCachedJiraImage(target, reference)).toBe(true) + expect( + hasCachedJiraImage({ kind: 'environment', environmentId: 'other-owner' }, reference) + ).toBe(false) + } + for (const method of ['jira.getIssueStream', 'jira.issueCommentsStream']) { + expect(runtimeSubscribe).toHaveBeenCalledWith( + { + selector: 'issue-owner', + method, + params: { key: 'JIR-1', siteId: 'site' }, + timeoutMs: 60_000 + }, + expect.anything() + ) + } + await Promise.resolve() + expect(close).toHaveBeenCalledTimes(2) + }) + + it('falls back to the original owner RPC only when an old host lacks the stream method', async () => { + runtimeCall.mockImplementation(async (args: { method: string }) => + args.method === 'status.get' + ? statusWithoutImages() + : { + id: 'detail', + ok: true, + result: args.method === 'jira.getIssue' ? null : [], + _meta: { runtimeId: 'remote-runtime' } + } + ) + runtimeSubscribe.mockImplementation( + async (_args: SubscribeArgs, callbacks: SubscribeCallbacks) => { + callbacks.onResponse({ + id: 'detail', + ok: false, + error: { + code: 'method_not_found', + message: 'Unknown method' + } + }) + return { unsubscribe: close, sendBinary: vi.fn() } + } + ) + await expect(jiraGetIssueForTarget(target, 'JIR-1')).resolves.toBeNull() + await expect(jiraIssueCommentsForTarget(target, 'JIR-1')).resolves.toEqual([]) + for (const method of ['jira.getIssue', 'jira.issueComments']) { + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'issue-owner', + method, + params: { key: 'JIR-1', siteId: undefined }, + timeoutMs: 30_000 + }) + ) + } + expect(localIssue).not.toHaveBeenCalled() + expect(localComments).not.toHaveBeenCalled() + }) + + it('preserves stream errors instead of retrying failed reads through another path', async () => { + runtimeCall.mockResolvedValue(statusWithoutImages()) + runtimeSubscribe.mockImplementation( + async (_args: SubscribeArgs, callbacks: SubscribeCallbacks) => { + callbacks.onResponse({ + id: 'detail', + ok: false, + error: { + code: 'forbidden', + message: 'Access denied' + } + }) + return { unsubscribe: close, sendBinary: vi.fn() } + } + ) + await expect(jiraGetIssueForTarget(target, 'JIR-1')).rejects.toMatchObject({ + code: 'forbidden' + }) + await expect(jiraIssueCommentsForTarget(target, 'JIR-1')).rejects.toMatchObject({ + code: 'forbidden' + }) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'jira.getIssue' }) + ) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'jira.issueComments' }) + ) + expect(localIssue).not.toHaveBeenCalled() + expect(localComments).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-jira-image-cache.test.ts b/src/renderer/src/runtime/runtime-jira-image-cache.test.ts new file mode 100644 index 00000000000..7e3207c107a --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-image-cache.test.ts @@ -0,0 +1,255 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acquireJiraImageUrl, + migrateLegacyJiraImages, + hasCachedJiraImage, + clearJiraImageCache, + observeJiraImageHostStatus, + onJiraImageCacheInvalidated +} from './runtime-jira-image-cache' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { jiraImageReference } from '../../../shared/jira-attachment-image' + +const { download } = vi.hoisted(() => ({ download: vi.fn() })) +vi.mock('./runtime-jira-image-client', () => ({ readRuntimeJiraImage: download })) +const target = { kind: 'environment' as const, environmentId: 'host' } +const createUrl = vi.fn() +const revoke = vi.fn() +const originalUrl = URL +beforeEach(() => { + vi.stubGlobal( + 'URL', + class extends originalUrl { + static createObjectURL = createUrl + static revokeObjectURL = revoke + } + ) + clearJiraImageCache() + download.mockReset().mockResolvedValue(new Blob([Uint8Array.of(1)], { type: 'image/png' })) + createUrl.mockReset().mockImplementation(() => `blob:${createUrl.mock.calls.length}`) + revoke.mockReset() + replaceRuntimeEnvironmentRevisions([]) + replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 1 }]) +}) +afterEach(() => { + clearJiraImageCache() + vi.useRealTimers() + vi.unstubAllGlobals() +}) +function acquire(reference = 'image') { + return acquireJiraImageUrl(target, reference, new AbortController().signal) +} + +describe('bounded Jira Blob image cache', () => { + it('shares concurrent downloads, retains active URLs and revokes on clear', async () => { + const [a, b] = await Promise.all([acquire(), acquire()]) + expect(download).toHaveBeenCalledOnce() + expect(a.url).toBe(b.url) + a.release() + expect(revoke).not.toHaveBeenCalled() + b.release() + clearJiraImageCache() + expect(revoke).toHaveBeenCalledWith(a.url) + }) + it('aborts the shared transfer only after both readers cancel', async () => { + let requestSignal: AbortSignal | undefined + download.mockImplementation((_target, _reference, signal: AbortSignal) => { + requestSignal = signal + return new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }) + ) + }) + const a = new AbortController() + const b = new AbortController() + const first = acquireJiraImageUrl(target, 'same', a.signal).catch((error) => error) + const second = acquireJiraImageUrl(target, 'same', b.signal).catch((error) => error) + await vi.waitFor(() => expect(download).toHaveBeenCalledOnce()) + a.abort() + await first + expect(requestSignal?.aborted).toBe(false) + b.abort() + await second + expect(requestSignal?.aborted).toBe(true) + }) + it('admits no more than 24 MiB of active image URLs', async () => { + download.mockResolvedValue(new Blob([new Uint8Array(8 * 1024 * 1024)], { type: 'image/png' })) + const held = await Promise.all([acquire('1'), acquire('2'), acquire('3')]) + await expect(acquire('4')).rejects.toThrow('cache is full') + expect(createUrl).toHaveBeenCalledTimes(3) + held[0]?.release() + const next = await acquire('4') + expect(revoke).toHaveBeenCalledOnce() + next.release() + held.forEach((image) => image.release()) + }) + it('expires unused URLs and fences a same-ID host replacement', async () => { + vi.useFakeTimers() + const image = await acquire() + image.release() + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(hasCachedJiraImage(target, 'image')).toBe(false) + expect(revoke).toHaveBeenCalledWith(image.url) + const replacement = await acquire() + replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 2 }], { + retired: ['host'], + sameHost: [] + }) + expect(hasCachedJiraImage(target, 'image')).toBe(false) + expect(revoke).toHaveBeenCalledWith(replacement.url) + replacement.release() + }) + it('clears a host cache when the connection is lost or revoked', async () => { + const image = await acquire() + observeJiraImageHostStatus({ + environmentId: 'host', + pairingRevision: 1, + sequence: 2, + checkedAt: 0, + status: null, + verification: 'unavailable', + transport: 'disconnected' + }) + expect(hasCachedJiraImage(target, 'image')).toBe(false) + expect(revoke).toHaveBeenCalledWith(image.url) + image.release() + }) + it('allows the combined description and comment queue while keeping three active downloads', async () => { + const releases: (() => void)[] = [] + download.mockImplementation( + () => + new Promise((resolve) => { + releases.push(() => resolve(new Blob([Uint8Array.of(1)], { type: 'image/png' }))) + }) + ) + const reads = Array.from({ length: 48 }, (_, id) => acquire(String(id))) + await vi.waitFor(() => expect(download).toHaveBeenCalledTimes(3)) + for (let batch = 0; batch < 16; batch += 1) { + await vi.waitFor(() => expect(releases).toHaveLength(3)) + releases.splice(0).forEach((release) => release()) + } + const images = await Promise.all(reads) + expect(images).toHaveLength(48) + expect(download).toHaveBeenCalledTimes(48) + images.forEach((image) => image.release()) + }) + + it('notifies mounted readers once when a disconnected host is verified again', () => { + const listener = vi.fn() + const stop = onJiraImageCacheInvalidated(listener) + const status = { + environmentId: 'host', + pairingRevision: 1, + sequence: 2, + checkedAt: 0, + status: null, + verification: 'unavailable' as const, + transport: 'disconnected' as const + } + observeJiraImageHostStatus(status) + expect(listener).toHaveBeenCalledTimes(1) + observeJiraImageHostStatus({ ...status, sequence: 3, transport: 'connecting' }) + expect(listener).toHaveBeenCalledTimes(1) + observeJiraImageHostStatus({ + ...status, + sequence: 4, + verification: 'verified', + transport: 'ready' + }) + expect(listener).toHaveBeenCalledTimes(2) + observeJiraImageHostStatus({ + ...status, + sequence: 4, + verification: 'verified', + transport: 'ready' + }) + expect(listener).toHaveBeenCalledTimes(2) + stop() + }) + + it('does not let stale host snapshots consume the reconnect retry', () => { + const listener = vi.fn() + const stop = onJiraImageCacheInvalidated(listener) + const status = { + environmentId: 'host', + pairingRevision: 1, + sequence: 3, + checkedAt: 0, + status: null, + verification: 'unavailable' as const, + transport: 'disconnected' as const + } + observeJiraImageHostStatus(status) + observeJiraImageHostStatus({ + ...status, + sequence: 2, + verification: 'verified', + transport: 'ready' + }) + observeJiraImageHostStatus({ + ...status, + pairingRevision: 0, + sequence: 10, + verification: 'verified', + transport: 'ready' + }) + expect(listener).toHaveBeenCalledTimes(1) + observeJiraImageHostStatus({ + ...status, + sequence: 4, + verification: 'verified', + transport: 'ready' + }) + expect(listener).toHaveBeenCalledTimes(2) + stop() + }) + + it('forgets the old reconnect marker when a host is replaced', () => { + observeJiraImageHostStatus({ + environmentId: 'host', + pairingRevision: 1, + sequence: 3, + checkedAt: 0, + status: null, + verification: 'unavailable', + transport: 'disconnected' + }) + replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 2 }], { + retired: ['host'], + sameHost: [] + }) + const listener = vi.fn() + const stop = onJiraImageCacheInvalidated(listener) + observeJiraImageHostStatus({ + environmentId: 'host', + pairingRevision: 2, + sequence: 1, + checkedAt: 0, + status: null, + verification: 'verified', + transport: 'ready' + }) + expect(listener).not.toHaveBeenCalled() + stop() + }) + + it('converts old-host image data into short references before retaining Markdown', async () => { + const body = migrateLegacyJiraImages( + '![shot](data:image/png;base64,AQID) and literal data:image/png;base64,AQID', + target + ) + expect(body).toMatch( + /^!\[shot\]\(orca-jira-image:\/\/attachment\/[0-9a-f-]+\) and literal data:/ + ) + const url = body.match(/orca-jira-image:\/\/attachment\/[0-9a-f-]+/)?.[0] + const reference = jiraImageReference(url) + if (!reference) { + throw new Error('Expected migrated image reference') + } + const image = await acquire(reference) + expect(image.url).toMatch(/^blob:/) + expect(download).not.toHaveBeenCalled() + image.release() + expect(migrateLegacyJiraImages('![bad](data:image/png;base64,A===)', target)).toBe('![bad]()') + }) +}) diff --git a/src/renderer/src/runtime/runtime-jira-image-cache.ts b/src/renderer/src/runtime/runtime-jira-image-cache.ts new file mode 100644 index 00000000000..62621126902 --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-image-cache.ts @@ -0,0 +1,264 @@ +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import { + JIRA_IMAGE_MAX_BYTES, + JIRA_IMAGE_MAX_REFERENCES, + JIRA_IMAGE_MAX_PENDING_READS +} from '../../../shared/jira-attachment-image' +import { PrioritySemaphore } from '../../../shared/priority-semaphore' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import { readRuntimeJiraImage } from './runtime-jira-image-client' +import { + getRuntimeEnvironmentRevision, + onRuntimeEnvironmentsRetired, + onRuntimeEnvironmentRevisionsChanged +} from './runtime-environment-revision' +import { createBrowserUuid } from '@/lib/browser-uuid' + +const MAX_CACHE_BYTES = 24 * 1024 * 1024 +const MAX_CACHE_ENTRIES = 96 +const CACHE_TTL_MS = 30 * 60_000 +const downloads = new PrioritySemaphore(3) +type ImageEntry = { url: string; bytes: number; users: number; expiresAt: number } +const entries = new Map() +const pending = new Map< + string, + { promise: Promise; controller: AbortController; users: number } +>() +const invalidationListeners = new Set<(environmentId: string | null) => void>() +const disconnectedHosts = new Set() +const lastHostStatusSequences = new Map() +let expiryTimer: ReturnType | undefined + +function imageOwner(target: RuntimeClientTarget): string { + return target.kind === 'environment' ? target.environmentId : 'local' +} + +function imageKey(target: RuntimeClientTarget, reference: string): string { + const revision = + target.kind === 'environment' ? getRuntimeEnvironmentRevision(target.environmentId) : 0 + return `${imageOwner(target)}::${revision ?? 0}::${reference}` +} + +function removeEntry(key: string, entry: ImageEntry): void { + URL.revokeObjectURL(entry.url) + entries.delete(key) +} + +function scheduleExpiry(): void { + clearTimeout(expiryTimer) + const expiresAt = Math.min( + ...[...entries.values()].filter((entry) => entry.users === 0).map((entry) => entry.expiresAt) + ) + expiryTimer = Number.isFinite(expiresAt) + ? setTimeout( + () => { + evictUnused() + scheduleExpiry() + }, + Math.max(1, expiresAt - Date.now()) + ) + : undefined +} + +function evictUnused(requiredBytes = 0): boolean { + for (const [key, entry] of entries) { + if (entry.users === 0 && entry.expiresAt <= Date.now()) { + removeEntry(key, entry) + } + } + let bytes = [...entries.values()].reduce((total, entry) => total + entry.bytes, 0) + for (const [key, entry] of entries) { + if (bytes + requiredBytes <= MAX_CACHE_BYTES && entries.size < MAX_CACHE_ENTRIES) { + break + } + if (entry.users > 0) { + continue + } + removeEntry(key, entry) + bytes -= entry.bytes + } + return bytes + requiredBytes <= MAX_CACHE_BYTES && entries.size < MAX_CACHE_ENTRIES +} + +function saveImage(key: string, blob: Blob): boolean { + if (!evictUnused(blob.size)) { + return false + } + entries.set(key, { + url: URL.createObjectURL(blob), + bytes: blob.size, + users: 0, + expiresAt: Date.now() + CACHE_TTL_MS + }) + scheduleExpiry() + return true +} + +export async function acquireJiraImageUrl( + target: RuntimeClientTarget, + reference: string, + signal: AbortSignal +): Promise<{ url: string; release: () => void }> { + if (signal.aborted) { + throw signal.reason + } + evictUnused() + const key = imageKey(target, reference) + let entry = entries.get(key) + if (!entry) { + let loading = pending.get(key) + if (!loading || loading.controller.signal.aborted) { + if (pending.size >= JIRA_IMAGE_MAX_PENDING_READS) { + throw new Error('Jira image queue is full.') + } + const controller = new AbortController() + loading = { + controller, + users: 0, + promise: (async () => { + const release = await downloads.acquire(0, controller.signal) + try { + const blob = await readRuntimeJiraImage(target, reference, controller.signal) + if (controller.signal.aborted || key !== imageKey(target, reference)) { + return + } + if (!saveImage(key, blob)) { + throw new Error('Jira image cache is full.') + } + } finally { + release() + } + })().finally(() => { + if (pending.get(key)?.controller === controller) { + pending.delete(key) + } + }) + } + pending.set(key, loading) + } + const owned = loading + owned.users += 1 + try { + await waitForPromiseWithSignal(owned.promise, signal) + } finally { + owned.users -= 1 + if (owned.users === 0 && pending.get(key) === owned) { + owned.controller.abort() + } + } + entry = entries.get(key) + } + if (!entry || signal.aborted) { + throw new Error('Jira image is unavailable.') + } + entries.delete(key) + entries.set(key, entry) + entry.users += 1 + scheduleExpiry() + const owned = entry + let released = false + return { + url: owned.url, + release: () => { + if (released) { + return + } + released = true + owned.users -= 1 + evictUnused() + scheduleExpiry() + } + } +} + +export function migrateLegacyJiraImages(content: string, target: RuntimeClientTarget): string { + let admitted = 0 + return content.replace( + /(!\[(?:\\.|[^\\\]])*\]\()data:(image\/(?:png|jpeg|gif|webp|bmp|avif|tiff|x-icon));base64,([A-Za-z0-9+/=]+)(\))/g, + (_url, prefix: string, mimeType: string, encoded: string, suffix: string) => { + if ( + ++admitted > JIRA_IMAGE_MAX_REFERENCES || + encoded.length > Math.ceil(JIRA_IMAGE_MAX_BYTES / 3) * 4 + ) { + return `${prefix}${suffix}` + } + try { + const bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0)) + if (bytes.byteLength === 0 || bytes.byteLength > JIRA_IMAGE_MAX_BYTES) { + return `${prefix}${suffix}` + } + const reference = createBrowserUuid() + if (!saveImage(imageKey(target, reference), new Blob([bytes], { type: mimeType }))) { + return `${prefix}${suffix}` + } + return `${prefix}orca-jira-image://attachment/${reference}${suffix}` + } catch { + return `${prefix}${suffix}` + } + } + ) +} + +export function hasCachedJiraImage(target: RuntimeClientTarget, reference: string): boolean { + evictUnused() + return entries.has(imageKey(target, reference)) +} + +export function onJiraImageCacheInvalidated( + listener: (environmentId: string | null) => void +): () => void { + invalidationListeners.add(listener) + return () => invalidationListeners.delete(listener) +} + +export function clearJiraImageCache(environmentId?: string): void { + const prefix = environmentId ? `${environmentId}::` : null + for (const [key, entry] of entries) { + if (!prefix || key.startsWith(prefix)) { + removeEntry(key, entry) + } + } + for (const [key, download] of pending) { + if (!prefix || key.startsWith(prefix)) { + download.controller.abort() + pending.delete(key) + } + } + scheduleExpiry() + for (const listener of invalidationListeners) { + listener(environmentId ?? null) + } +} + +export function observeJiraImageHostStatus(snapshot: RuntimeHostStatusSnapshot): void { + if ( + getRuntimeEnvironmentRevision(snapshot.environmentId) !== snapshot.pairingRevision || + (lastHostStatusSequences.get(snapshot.environmentId) ?? -1) >= snapshot.sequence + ) { + return + } + lastHostStatusSequences.set(snapshot.environmentId, snapshot.sequence) + if (snapshot.retired || snapshot.verification === 'blocked') { + disconnectedHosts.delete(snapshot.environmentId) + clearJiraImageCache(snapshot.environmentId) + } else if (snapshot.transport === 'disconnected') { + disconnectedHosts.add(snapshot.environmentId) + clearJiraImageCache(snapshot.environmentId) + } else if ( + snapshot.transport === 'ready' && + snapshot.verification === 'verified' && + disconnectedHosts.delete(snapshot.environmentId) + ) { + clearJiraImageCache(snapshot.environmentId) + } +} + +function forgetJiraImageHost(environmentId: string): void { + disconnectedHosts.delete(environmentId) + lastHostStatusSequences.delete(environmentId) + clearJiraImageCache(environmentId) +} + +onRuntimeEnvironmentsRetired((ids) => ids.forEach(forgetJiraImageHost)) +onRuntimeEnvironmentRevisionsChanged((ids) => ids.forEach(forgetJiraImageHost)) diff --git a/src/renderer/src/runtime/runtime-jira-image-client.test.ts b/src/renderer/src/runtime/runtime-jira-image-client.test.ts new file mode 100644 index 00000000000..cc90b1d8cd5 --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-image-client.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readRuntimeJiraImage } from './runtime-jira-image-client' +import { encodeJiraImageFrame } from '../../../shared/jira-image-stream-frame' + +type Callbacks = Parameters[1] +const subscribe = vi.fn() +const streams: { callbacks: Callbacks; transferId: string; close: ReturnType }[] = [] +const target = { kind: 'environment' as const, environmentId: 'remote' } +const reference = '12345678-1234-4234-8234-123456789012' +function stream(index = 0) { + const value = streams[index] + if (!value) { + throw new Error('Missing image stream') + } + return value +} +function emit(result: unknown, index = 0): void { + stream(index).callbacks.onResponse({ + id: 'image', + ok: true, + result, + _meta: { runtimeId: 'remote' } + }) +} +function send(bytes: Uint8Array, sequence = 0, index = 0): void { + stream(index).callbacks.onBinary?.( + encodeJiraImageFrame(stream(index).transferId, sequence, bytes) + ) +} +function read() { + return readRuntimeJiraImage(target, reference, new AbortController().signal) +} + +beforeEach(() => { + streams.length = 0 + subscribe + .mockReset() + .mockImplementation(async (args: { params: unknown }, callbacks: Callbacks) => { + if ( + !args.params || + typeof args.params !== 'object' || + !('transferId' in args.params) || + typeof args.params.transferId !== 'string' + ) { + throw new Error('Missing transfer identity') + } + const close = vi.fn() + streams.push({ callbacks, transferId: args.params.transferId, close }) + return { unsubscribe: close, sendBinary: vi.fn() } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } }) +}) +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +describe('remote Jira binary images', () => { + it('assembles only its own ordered image frames and closes the subscription', async () => { + const image = read() + emit({ type: 'image', mimeType: 'image/png', byteLength: 3 }) + stream().callbacks.onBinary?.(Uint8Array.from([0x74, 1, 0, 0])) + stream().callbacks.onBinary?.( + encodeJiraImageFrame('87654321-4321-4321-8321-210987654321', 0, Uint8Array.of(99)) + ) + send(Uint8Array.from([1, 2])) + send(Uint8Array.of(3), 1) + emit({ type: 'end' }) + const blob = await image + expect(blob.type).toBe('image/png') + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(Uint8Array.from([1, 2, 3])) + await Promise.resolve() + expect(stream().close).toHaveBeenCalledOnce() + }) + + it.each([ + { mimeType: 'image/svg+xml', byteLength: 1 }, + { mimeType: 'image/png', byteLength: 8 * 1024 * 1024 + 1 }, + { mimeType: 'image/png', byteLength: -1 } + ])('rejects invalid or oversized metadata before retaining bytes: %o', async (header) => { + const image = read().catch((error) => error) + emit({ type: 'image', ...header }) + expect(await image).toBeInstanceOf(Error) + expect(stream().close).toHaveBeenCalledOnce() + }) + + it.each(['before metadata', 'out of order', 'oversized', 'incomplete'] as const)( + 'rejects %s image chunks', + async (mode) => { + const image = read().catch((error) => error) + if (mode !== 'before metadata') { + emit({ type: 'image', mimeType: 'image/png', byteLength: 1 }) + } + if (mode === 'incomplete') { + emit({ type: 'end' }) + } else { + send( + mode === 'oversized' ? Uint8Array.from([1, 2]) : Uint8Array.of(1), + mode === 'out of order' ? 1 : 0 + ) + } + expect(await image).toBeInstanceOf(Error) + expect(stream().close).toHaveBeenCalledOnce() + } + ) + + it('separates simultaneous image transfers on a shared binary carrier', async () => { + const a = read() + const b = read() + emit({ type: 'image', mimeType: 'image/png', byteLength: 1 }, 0) + emit({ type: 'image', mimeType: 'image/png', byteLength: 1 }, 1) + for (const [index, byte] of [ + [0, 1], + [1, 2] + ]) { + const frame = encodeJiraImageFrame(stream(index).transferId, 0, Uint8Array.of(byte)) + for (const opened of streams) { + opened.callbacks.onBinary?.(frame) + } + } + emit({ type: 'end' }, 0) + emit({ type: 'end' }, 1) + expect(new Uint8Array(await (await a).arrayBuffer())).toEqual(Uint8Array.of(1)) + expect(new Uint8Array(await (await b).arrayBuffer())).toEqual(Uint8Array.of(2)) + }) + + it('cancels before the subscribe handle is returned', async () => { + let finish: ((value: { unsubscribe: () => void; sendBinary: () => void }) => void) | undefined + const close = vi.fn() + subscribe.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const controller = new AbortController() + const image = readRuntimeJiraImage(target, reference, controller.signal).catch((error) => error) + controller.abort() + expect(await image).toBeInstanceOf(Error) + finish?.({ unsubscribe: close, sendBinary: vi.fn() }) + await Promise.resolve() + expect(close).toHaveBeenCalledOnce() + }) + + it('ends stalled image downloads after the transfer deadline', async () => { + vi.useFakeTimers() + const image = read().catch((error) => error) + await vi.advanceTimersByTimeAsync(60_000) + expect(await image).toMatchObject({ message: 'Jira image transfer timed out.' }) + expect(stream().close).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/runtime/runtime-jira-image-client.ts b/src/renderer/src/runtime/runtime-jira-image-client.ts new file mode 100644 index 00000000000..d5bee3fe6fc --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-image-client.ts @@ -0,0 +1,155 @@ +import { + JIRA_IMAGE_MAX_BYTES, + jiraImageReference, + isJiraImageMimeType +} from '../../../shared/jira-attachment-image' +import { decodeJiraImageFrame } from '../../../shared/jira-image-stream-frame' +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { subscribeRuntimeEnvironment } from './runtime-environment-pairing-refresh' +import { RuntimeRpcCallError } from './runtime-rpc-result' + +export async function readRuntimeJiraImage( + target: RuntimeClientTarget, + reference: string, + signal: AbortSignal +): Promise { + if (target.kind !== 'environment') { + throw new Error('Local Jira images use the image protocol.') + } + const transferId = createBrowserUuid() + let sequence = 0 + const revision = getRuntimeEnvironmentRevision(target.environmentId) + let close: (() => void) | undefined + let closeWhenReady = false + const chunks: Uint8Array[] = [] + let received = 0 + let mimeType = '' + let expected = -1 + return new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout(() => fail(new Error('Jira image transfer timed out.')), 60_000) + const cleanup = (): void => { + clearTimeout(timeout) + signal.removeEventListener('abort', abort) + if (close) { + close() + } else { + closeWhenReady = true + } + } + const fail = (error: unknown): void => { + if (settled) { + return + } + settled = true + chunks.length = 0 + cleanup() + reject(error) + } + const abort = (): void => fail(signal.reason ?? new Error('Jira image request cancelled.')) + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) { + abort() + return + } + void subscribeRuntimeEnvironment( + { + selector: target.environmentId, + method: 'jira.attachmentImage', + params: { reference, transferId }, + timeoutMs: 60_000, + expectedEnvironmentPairingRevision: revision + }, + { + onResponse: (response) => { + if (settled) { + return + } + if (!response.ok) { + fail(new RuntimeRpcCallError(response)) + return + } + const value = response.result + if (!value || typeof value !== 'object' || !('type' in value)) { + fail(new Error('Invalid Jira image response.')) + return + } + if ( + value.type === 'image' && + 'mimeType' in value && + 'byteLength' in value && + typeof value.mimeType === 'string' && + isJiraImageMimeType(value.mimeType) && + typeof value.byteLength === 'number' && + Number.isSafeInteger(value.byteLength) && + value.byteLength > 0 && + value.byteLength <= JIRA_IMAGE_MAX_BYTES && + expected === -1 + ) { + mimeType = value.mimeType + expected = value.byteLength + return + } + if (value.type === 'end' && received === expected && expected > 0) { + settled = true + const blob = new Blob(chunks, { type: mimeType }) + chunks.length = 0 + cleanup() + resolve(blob) + return + } + fail(new Error('Jira image was invalid or incomplete.')) + }, + onBinary: (raw) => { + if (settled) { + return + } + let frame + try { + frame = decodeJiraImageFrame(raw, transferId) + } catch (error) { + fail(error) + return + } + if (!frame) { + return + } + if (frame.sequence !== sequence++) { + fail(new Error('Jira image chunks arrived out of order.')) + return + } + const chunk = frame.bytes + if (expected < 0 || chunk.byteLength === 0) { + fail(new Error('Jira image bytes arrived before metadata.')) + return + } + received += chunk.byteLength + if (received > JIRA_IMAGE_MAX_BYTES || (expected >= 0 && received > expected)) { + fail(new Error('Jira image exceeded its transfer limit.')) + return + } + chunks.push(Uint8Array.from(chunk)) + }, + onError: fail, + onClose: () => fail(new Error('Jira image transfer closed before completion.')) + } + ) + .then((handle) => { + close = handle.unsubscribe + if (closeWhenReady) { + close() + } + }) + .catch(fail) + }) +} + +export function jiraImageProtocolUrl(reference: string): string { + const url = `orca-jira-image://attachment/${reference}` + if (!jiraImageReference(url)) { + throw new Error('Invalid Jira image reference.') + } + return url +} diff --git a/src/shared/jira-attachment-image.ts b/src/shared/jira-attachment-image.ts new file mode 100644 index 00000000000..a11b81df7b2 --- /dev/null +++ b/src/shared/jira-attachment-image.ts @@ -0,0 +1,24 @@ +export const JIRA_IMAGE_SCHEME = 'orca-jira-image' +export const JIRA_IMAGE_CAPABILITY = 'jira.attachment-images.v1' as const +export const JIRA_IMAGE_MAX_BYTES = 8 * 1024 * 1024 +export const JIRA_IMAGE_MAX_REFERENCES = 24 +export const JIRA_IMAGE_MAX_PENDING_READS = 96 +export const JIRA_IMAGE_CHUNK_BYTES = 64 * 1024 +export type JiraMediaFormat = 'reference' + +export function jiraImageReference(url: string | undefined): string | null { + if (!url) { + return null + } + const match = + /^orca-jira-image:\/\/attachment\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec( + url + ) + return match?.[1] ?? null +} + +export function isJiraImageMimeType(value: string): boolean { + return /^image\/(?:png|apng|jpeg|jpg|gif|webp|bmp|avif|tiff|x-icon|vnd\.microsoft\.icon)$/.test( + value + ) +} diff --git a/src/shared/jira-detail-capabilities.ts b/src/shared/jira-detail-capabilities.ts index c24a5a2e2bb..b64d6dfaa9e 100644 --- a/src/shared/jira-detail-capabilities.ts +++ b/src/shared/jira-detail-capabilities.ts @@ -1,6 +1,14 @@ +import { JIRA_IMAGE_CAPABILITY } from './jira-attachment-image' + export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const export const JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY = 'jira.transition-fields.v1' as const export const JIRA_CURRENT_SPRINT_RUNTIME_CAPABILITY = 'jira.current-sprint.v1' as const export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE = 'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.' + +export const JIRA_DETAIL_RUNTIME_CAPABILITIES = [ + JIRA_USER_FIELDS_RUNTIME_CAPABILITY, + JIRA_IMAGE_CAPABILITY, + JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/jira-image-stream-frame.ts b/src/shared/jira-image-stream-frame.ts new file mode 100644 index 00000000000..df6d1b69edb --- /dev/null +++ b/src/shared/jira-image-stream-frame.ts @@ -0,0 +1,43 @@ +import { JIRA_IMAGE_CHUNK_BYTES } from './jira-attachment-image' + +const IMAGE_KIND = 0x6a +const IMAGE_VERSION = 1 +const HEADER_BYTES = 42 +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +export function encodeJiraImageFrame( + transferId: string, + sequence: number, + bytes: Uint8Array +): Uint8Array { + const frame = new Uint8Array(HEADER_BYTES + bytes.byteLength) + frame[0] = IMAGE_KIND + frame[1] = IMAGE_VERSION + new DataView(frame.buffer).setUint32(2, sequence, true) + frame.set(encoder.encode(transferId), 6) + frame.set(bytes, HEADER_BYTES) + return frame +} + +export function decodeJiraImageFrame( + bytes: Uint8Array, + transferId: string +): { sequence: number; bytes: Uint8Array } | null { + if ( + bytes.byteLength < HEADER_BYTES || + bytes[0] !== IMAGE_KIND || + bytes[1] !== IMAGE_VERSION || + decoder.decode(bytes.subarray(6, HEADER_BYTES)) !== transferId + ) { + return null + } + const payload = bytes.subarray(HEADER_BYTES) + if (payload.byteLength === 0 || payload.byteLength > JIRA_IMAGE_CHUNK_BYTES) { + throw new Error('Invalid Jira image chunk size.') + } + return { + sequence: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(2, true), + bytes: payload + } +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index a27c5719605..7a2ff4538ee 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -385,8 +385,7 @@ export const RUNTIME_CAPABILITIES = [ REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, - jiraDetailCapabilities.JIRA_USER_FIELDS_RUNTIME_CAPABILITY, - jiraDetailCapabilities.JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY, + ...jiraDetailCapabilities.JIRA_DETAIL_RUNTIME_CAPABILITIES, JIRA_SAVED_FILTER_RUNTIME_CAPABILITY, JIRA_STATUS_FILTER_RUNTIME_CAPABILITY, AI_VAULT_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/jira-params.ts b/src/shared/rpc-contract/jira-params.ts index 71fdc79ff30..4bae29213bf 100644 --- a/src/shared/rpc-contract/jira-params.ts +++ b/src/shared/rpc-contract/jira-params.ts @@ -48,7 +48,13 @@ export const ListIssues = z export const IssueKey = z.object({ key: requiredString('Issue key is required'), siteId: OptionalString, - strict: z.boolean().optional() + strict: z.boolean().optional(), + mediaFormat: z.literal('reference').optional() +}) + +export const AttachmentImage = z.object({ + reference: z.string().uuid(), + transferId: z.string().uuid() }) export const CreateIssue = z.object({ diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 69d35eee193..1e417f291ab 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -316,6 +316,7 @@ import { } from './hosted-review-params' import { AssignableUsers, + AttachmentImage, CancelGenerateIssueSummary, Connect, CreateIssue as CreateIssueOfJiraParams, @@ -984,6 +985,7 @@ export const RPC_PARAMS_BY_METHOD = { 'hostedReview.forBranch': HostedReviewForBranch, 'hostedReview.getCreationEligibility': HostedReviewCreationEligibility, 'jira.addIssueComment': IssueCommentOfJiraParams, + 'jira.attachmentImage': AttachmentImage, 'jira.cancelGenerateIssueSummary': CancelGenerateIssueSummary, 'jira.connect': Connect, 'jira.createIssue': CreateIssueOfJiraParams,