Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions apps/desktop/electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
FleetRunIdSchema,
IPC,
JobIdSchema,
OpenSeriesRequestSchema,
OpenWithRequestSchema,
PreviewRequestSchema,
PathSchema,
Expand Down Expand Up @@ -44,7 +45,7 @@ import {
} from './services/fleet.js'
import { browserFor, dropSession, sessionFor } from './services/sessions.js'
import { store } from './services/store.js'
import { handlersFor, openWith } from './services/open-with.js'
import { advanceSeries, handlersFor, openSeries, openWith, stopSeries } from './services/open-with.js'
import { cancelPreview, cancelTransfer, previewTransfer, saveProfile, startTransfer } from './services/transfers.js'

/**
Expand Down Expand Up @@ -304,13 +305,26 @@ export function registerIpc(): void {
// Local only. A pane pointed at a server has no path this machine can open,
// and the renderer disables the item there rather than sending a remote one.

handle(IPC.fsHandlers, z.object({ path: PathSchema }), async ({ path }) => handlersFor(resolveLocalPath(path)))
handle(IPC.fsHandlers, z.object({ paths: z.array(PathSchema).min(1).max(500) }), async ({ paths }) =>
handlersFor(paths.map(resolveLocalPath)),
)

handle(IPC.fsOpenWith, OpenWithRequestSchema, async ({ paths, handlerId }) => {
await openWith(paths.map(resolveLocalPath), handlerId)
return true
})

handle(IPC.fsOpenWith, OpenWithRequestSchema, async ({ path, handlerId }) => {
await openWith(resolveLocalPath(path), handlerId)
handle(IPC.fsOpenSeries, OpenSeriesRequestSchema, async ({ seriesId, paths, handlerId }, event) => {
// Not awaited: the run outlives the call by design, and reports over
// eventOpenSeries as it goes.
void openSeries(seriesId, paths.map(resolveLocalPath), handlerId, event.sender)
return true
})

handle(IPC.fsOpenSeriesAdvance, z.object({ seriesId: JobIdSchema }), async ({ seriesId }) => advanceSeries(seriesId))

handle(IPC.fsOpenSeriesStop, z.object({ seriesId: JobIdSchema }), async ({ seriesId }) => stopSeries(seriesId))

// --- transfers -----------------------------------------------------------

handle(IPC.transfersPreview, PreviewRequestSchema, async (request, event) => previewTransfer(request, event.sender))
Expand Down
131 changes: 131 additions & 0 deletions apps/desktop/electron/main/services/open-series.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'

vi.mock('electron', () => ({ shell: { openPath: async () => '' } }))

const applications = mkdtempSync(join(tmpdir(), 'diskpush-apps-'))
process.env.XDG_DATA_HOME = join(applications, 'home')
process.env.XDG_DATA_DIRS = applications
const appsDir = join(applications, 'applications')
mkdirSync(appsDir, { recursive: true })

/**
* A fake "player": a script that ignores its argument and takes `seconds`.
*
* It has to be a script rather than `Exec=sleep 1`, because gio appends the
* file to the Exec line and `sleep 1 /tmp/a.mkv` dies instantly with "invalid
* time interval" -- which the code then correctly reads as a hand-off, so the
* naive fixture tests the opposite of what it looks like it tests.
*/
function fakeApp(name: string, seconds: number) {
const script = join(applications, `${name}.sh`)
writeFileSync(script, `#!/bin/sh\nsleep ${seconds}\n`, { mode: 0o755 })
writeFileSync(
join(appsDir, name),
`[Desktop Entry]\nType=Application\nName=${name}\nExec=${script}\nTerminal=false\n`,
)
}

// Comfortably above HANDOFF_MS, so it reads as somebody using the file.
fakeApp('slow.desktop', 2)
fakeApp('instant.desktop', 0)

const { openSeries, advanceSeries, stopSeries } = await import('./open-with.js')

type Event = { type: string; index?: number; total?: number; handedOff?: boolean; opened?: number; stopped?: boolean }

function collector() {
const events: Event[] = []
return {
events,
sender: {
isDestroyed: () => false,
send: (_channel: string, payload: { event: Event }) => events.push(payload.event),
} as never,
}
}

const until = async (condition: () => boolean, ms = 8000) => {
const end = Date.now() + ms
while (Date.now() < end) {
if (condition()) return
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error('condition never held')
}

describe('openSeries', () => {
/*
* The mechanism this rests on, measured rather than assumed: `gio launch`
* returns in ~17ms, but the application it starts inherits the stdio pipes,
* so the PIPES close when the application ends (~3018ms for a 3s app).
* Waiting on the pipes is what makes "opens the next when you finish" work
* without reimplementing the desktop entry's Exec syntax.
*/
it('waits for one application to finish before opening the next', async () => {
const { events, sender } = collector()
const files = ['/tmp/a.mkv', '/tmp/b.mkv']

const started = Date.now()
await openSeries('s-1', files, 'slow.desktop', sender)
const elapsed = Date.now() - started

const opening = events.filter((e) => e.type === 'opening')
expect(opening).toHaveLength(2)
expect(events.at(-1)).toMatchObject({ type: 'done', opened: 2, stopped: false })
// Two two-second applications in sequence cannot finish in under three.
expect(elapsed).toBeGreaterThan(3000)
// And it did not auto-advance: each one was genuinely waited for.
expect(events.filter((e) => e.type === 'finished-one').every((e) => e.handedOff === false)).toBe(true)
}, 20000)

/*
* A single-instance application hands the file to the copy already running
* and exits at once. Advancing on that would dump the whole list into it,
* which is the exact thing this mode exists to prevent, so it stops and
* waits to be told instead.
*/
it('stops and waits when the application hands off instead of finishing', async () => {
const { events, sender } = collector()
const run = openSeries('s-2', ['/tmp/a.mkv', '/tmp/b.mkv'], 'instant.desktop', sender)

await until(() => events.some((e) => e.type === 'finished-one'))
const first = events.find((e) => e.type === 'finished-one')!
expect(first.handedOff).toBe(true)

// It is parked: the second file has not been opened on its own.
await new Promise((resolve) => setTimeout(resolve, 300))
expect(events.filter((e) => e.type === 'opening')).toHaveLength(1)

// ...until it is advanced by hand.
expect(advanceSeries('s-2')).toBe(true)
await run
expect(events.filter((e) => e.type === 'opening')).toHaveLength(2)
expect(events.at(-1)).toMatchObject({ type: 'done', opened: 2 })
}, 20000)

it('stops the rest of the list on request', async () => {
const { events, sender } = collector()
const run = openSeries('s-3', ['/tmp/a.mkv', '/tmp/b.mkv', '/tmp/c.mkv'], 'instant.desktop', sender)

await until(() => events.some((e) => e.type === 'finished-one'))
expect(stopSeries('s-3')).toBe(true)
await run

expect(events.at(-1)).toMatchObject({ type: 'done', stopped: true })
expect(events.filter((e) => e.type === 'opening')).toHaveLength(1)
}, 20000)

it('reports an application that is no longer installed', async () => {
const { events, sender } = collector()
await openSeries('s-4', ['/tmp/a.mkv'], 'gone.desktop', sender)
expect(events).toEqual([{ type: 'error', message: 'That application is no longer installed.' }])
})

it('advancing an unknown or unparked series does nothing', () => {
expect(advanceSeries('nope')).toBe(false)
expect(stopSeries('nope')).toBe(false)
})
})
Loading
Loading