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
57 changes: 55 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -534,11 +534,14 @@ jobs:
name: e2e-build-out
path: out/
# The packaged orcad slot the T6-11 launcher runs: its own node-pty under the pinned Node.
- name: Build this runner's orcad slot
# The template is what `orca serve`'s default selection materializes that slot from.
- name: Build this runner's orcad slot and template
run: |
slot="$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad-prebuilds
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad-prebuilds --require-slots "$slot"
pnpm build:orcad
node config/scripts/build-orcad-template.mjs --targets "$slot"
- name: Switch serve hosts on one profile
env:
SKIP_BUILD: '1'
Expand Down Expand Up @@ -641,6 +644,56 @@ jobs:
retention-days: 7
if-no-files-found: ignore

# D7 on Windows: both hosts share <userData>\daemon and so one daemon pipe. Built here rather than
# from the Linux e2e build because the slot, template and addons are Windows-native.
orcad-serve-mode-switch-windows:
name: orca serve Electron/orcad mode switch on Windows (D7)
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts')
runs-on: windows-2022
timeout-minutes: 45
env:
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- uses: ./.github/actions/prepare-orcad-prebuilds
with:
resolve-windows-cache: 'true'
restore-windows-cache: 'true'
- name: Build the e2e app, CLI, orcad slot and template
shell: bash
run: |
pnpm run build:relay
pnpm exec electron-vite build --mode e2e
pnpm run build:cli
pnpm build:orcad
node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64
node config/scripts/build-orcad-template.mjs --targets win32-x64
- name: Switch serve hosts on one profile
env:
SKIP_BUILD: '1'
ORCA_E2E_ORCAD_SERVE: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_E2E_PRESERVE_PROFILE_LOGS_DIR: ${{ github.workspace }}\test-results\profile-logs
# A launch that dies before its window prints this trail into Playwright's call log.
ORCA_STARTUP_DIAGNOSTICS: '1'
ELECTRON_ENABLE_LOGGING: '1'
ELECTRON_ENABLE_STACK_DUMPING: '1'
run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/orcad-serve-mode-switch.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
name: orcad-serve-mode-switch-windows-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore

ssh-localhost:
name: localhost SSH terminal and hooks
needs: [build, prepare-native-cache]
Expand Down
1 change: 1 addition & 0 deletions config/scripts/release-cut-token-permissions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const EXPECTED_MATRIX = {
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
'.github/workflows/e2e.yml#orcad-auto-convert-docker': { contents: 'read' },
'.github/workflows/e2e.yml#orcad-serve-mode-switch': { contents: 'read' },
'.github/workflows/e2e.yml#orcad-serve-mode-switch-windows': { contents: 'read' },
'.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' },
Expand Down
18 changes: 18 additions & 0 deletions docs/reference/orcad-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ the daemon shares the service cgroup and a combined-unit stop ends live terminal
it is populated from `/proc/self/cgroup`, so it reports the isolation the daemon actually has
rather than what the launcher intended.

## `orca serve` on this machine

`orca serve` runs on the local orcad slot by default. The CLI asks the app's
`out/main/orcad/orcad-local-serve-selection-entry.js` (run as plain Node on the app's executable)
which host to use. Any reason orcad cannot serve falls back to Electron serve with one
`[serve] using Electron serve: <reason>` line on stderr. Those reasons are: no slot for this host,
no template in the install, the pinned Node could not be fetched, or a failed native preflight.

- `ORCA_SERVE_RUNTIME=electron` keeps Electron serve and skips the question. `orcad` (or unset) is
the default, and any other value falls back with a reason.
- Packaged macOS stays on Electron: only Electron serve, supervised by the CLI, can take a remote
app update there, and orcad has no updater. Recipe-JSON serve has no handoff and uses orcad.
- Windows defaults to Electron serve for now; `ORCA_SERVE_RUNTIME=orcad` opts in. Both hosts share
`<userData>\daemon` and so one daemon pipe. The `orcad-serve-mode-switch-windows` e2e job gates D7
on Windows.

The slot and its pinned Node live under the desktop's `<userData>/orcad-artifacts`.

## Bind policy

`--bind <literal-ip>`, **default `127.0.0.1`**.
Expand Down
2 changes: 2 additions & 0 deletions docs/site/content/docs/remote-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ orca-ide serve --port 6768 --pairing-address 100.64.1.20

Use only one host mode at a time. If the Orca desktop app is already sharing that computer, do not start a second `orca serve` process for the same setup.

`orca serve` runs on Orca's own Node server (orcad) by default. If orcad can't serve on this computer, it uses the desktop app's server instead and prints one line saying why. On Windows it uses the desktop app's server unless you set `ORCA_SERVE_RUNTIME=orcad`, and on a packaged macOS app it currently always does, so paired clients can still update it. To always use the desktop app's server, set `ORCA_SERVE_RUNTIME=electron`.

### Mobile from a headless server

For the Orca mobile app, request a mobile-scoped QR code and link:
Expand Down
93 changes: 93 additions & 0 deletions src/cli/runtime/launch-serve-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { spawnMock, resolveLocalServeRuntimeMock, serveWithOrcadMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
resolveLocalServeRuntimeMock: vi.fn(),
serveWithOrcadMock: vi.fn()
}))

vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn() }))
vi.mock('./serve-orcad-launch', () => ({
resolveLocalServeRuntime: resolveLocalServeRuntimeMock,
serveWithOrcad: serveWithOrcadMock
}))

import { serveOrcaApp } from './launch'

class FakeChildProcess extends EventEmitter {
stdout = new EventEmitter()
kill = vi.fn()
unref = vi.fn()
pid = 4101
}

/** Electron serve that exits cleanly once spawned, whenever selection gets to spawning it. */
function electronChild(): void {
spawnMock.mockImplementation(() => {
const child = new FakeChildProcess()
setTimeout(() => child.emit('exit', 0, null), 0)
return child
})
}

describe('orca serve host selection', () => {
let stderr: string[]

beforeEach(() => {
spawnMock.mockReset()
resolveLocalServeRuntimeMock.mockReset()
serveWithOrcadMock.mockReset()
process.env.ORCA_APP_EXECUTABLE = '/opt/orca/orca-ide'
stderr = []
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
stderr.push(String(chunk))
return true
})
})

afterEach(() => {
vi.restoreAllMocks()
delete process.env.ORCA_APP_EXECUTABLE
delete process.env.ORCA_SERVE_RUNTIME
})

it('serves on orcad by default', async () => {
const selection = { kind: 'orcad', runtime: '/node', entry: '/slot/orcad.js', version: '1' }
resolveLocalServeRuntimeMock.mockResolvedValue(selection)
serveWithOrcadMock.mockResolvedValue(0)

await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
expect(serveWithOrcadMock).toHaveBeenCalledWith(
selection,
{ json: true },
expect.any(String),
expect.any(Function)
)
expect(spawnMock).not.toHaveBeenCalled()
expect(stderr.join('')).toContain('[serve] running on orcad 1')
})

it('falls back to Electron and prints why when orcad cannot serve', async () => {
resolveLocalServeRuntimeMock.mockResolvedValue({ kind: 'electron', reason: 'no template' })
electronChild()

await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
expect(spawnMock).toHaveBeenCalledWith(
'/opt/orca/orca-ide',
expect.arrayContaining(['--serve', '--serve-json']),
expect.any(Object)
)
expect(stderr.join('')).toContain('[serve] using Electron serve: no template')
})

it('keeps Electron without asking orcad when ORCA_SERVE_RUNTIME=electron', async () => {
process.env.ORCA_SERVE_RUNTIME = 'electron'
electronChild()

await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
expect(resolveLocalServeRuntimeMock).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledOnce()
expect(stderr.join('')).not.toContain('[serve]')
})
})
3 changes: 3 additions & 0 deletions src/cli/runtime/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,13 @@ describe('serveOrcaApp', () => {
spawnMock.mockReset()
spawnSyncMock.mockReset()
process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
// These cover Electron serve itself; the orcad default is in launch-serve-runtime.test.ts.
process.env.ORCA_SERVE_RUNTIME = 'electron'
})

afterEach(() => {
vi.restoreAllMocks()
delete process.env.ORCA_SERVE_RUNTIME
delete process.env.ORCA_APP_EXECUTABLE
delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT
delete process.env.ORCA_USER_DATA_PATH
Expand Down
6 changes: 3 additions & 3 deletions src/cli/runtime/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
superviseForegroundServe
} from './serve-update-supervisor'
import { RuntimeClientError } from './types'
import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
import { SERVE_RUNTIME_ELECTRON, SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch'
import { waitForRecipeJson } from './serve-recipe-json'

Expand Down Expand Up @@ -86,8 +86,8 @@ export type ServeOrcaAppArgs = {

export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
// Why synchronous unless opted in: the default path must spawn Electron exactly as before.
if (process.env[SERVE_RUNTIME_ENV] !== 'orcad') {
// Why synchronous on the opt-out: it must spawn Electron exactly as before, without asking.
if (process.env[SERVE_RUNTIME_ENV] === SERVE_RUNTIME_ELECTRON) {
return serveWithElectron(executable, args)
}
if (args.recipeJson && !args.projectRoot) {
Expand Down
7 changes: 5 additions & 2 deletions src/main/orcad/orcad-daemon-session-client-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ export async function buildDaemonSessionClient(outfile: string): Promise<void> {
adapter.onData(event => { if (event.id === sessionId) output += event.data })
const deadline = setTimeout(() => { console.error('timed out; output: ' + output); process.exit(98) }, 20_000)
;(async () => {
const win32 = process.platform === 'win32'
const spawned = await adapter.spawn(op === 'create'
? { sessionId, cols: 80, rows: 24, cwd, shellOverride: '/bin/sh' }
? { sessionId, cols: 80, rows: 24, cwd, shellOverride: win32 ? 'cmd.exe' : '/bin/sh' }
: { sessionId, cols: 80, rows: 24 })
adapter.write(spawned.id, "printf 'ORCA_SERVE_%s\\\\n' " + marker + "\\r")
// Both shells echo what is typed, so the typed form must not already read as the marker.
const typed = win32 ? 'echo ORCA_^SERVE_' + marker : "printf 'ORCA_SERVE_%s\\\\n' " + marker
adapter.write(spawned.id, typed + "\\r")
while (!output.includes('ORCA_SERVE_' + marker)) await new Promise(r => setTimeout(r, 50))
clearTimeout(deadline)
await adapter.disconnectOnly()
Expand Down
31 changes: 23 additions & 8 deletions src/main/orcad/orcad-local-serve-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function input(overrides: Partial<ServeRuntimeSelectionInput> = {}): ServeRuntim
const cachedNode = join(root, 'cached-node')
writeFileSync(cachedNode, '#!/bin/sh\n')
return {
env: { [SERVE_RUNTIME_ENV]: 'orcad' },
env: {},
platform: 'linux',
userDataPath: root,
templateDirs: [join(root, 'missing-template'), template],
Expand All @@ -56,11 +56,21 @@ function input(overrides: Partial<ServeRuntimeSelectionInput> = {}): ServeRuntim
}

describe('orca serve runtime selection', () => {
it('stays on Electron, silently, unless orcad is asked for', async () => {
expect(await selectServeRuntime(input({ env: {} }))).toEqual({
kind: 'electron',
reason: null
})
it('stays on Electron, silently, when Electron is asked for', async () => {
const options = input({ env: { [SERVE_RUNTIME_ENV]: 'electron' } })
expect(await selectServeRuntime(options)).toEqual({ kind: 'electron', reason: null })
expect(options.materializeSlot).not.toHaveBeenCalled()
})

it('serves on orcad by default and when orcad is asked for by name', async () => {
for (const env of [{}, { [SERVE_RUNTIME_ENV]: 'orcad' }]) {
expect(await selectServeRuntime(input({ env }))).toMatchObject({ kind: 'orcad' })
}
})

it('serves on orcad on Windows only when orcad is asked for by name', async () => {
const options = input({ env: { [SERVE_RUNTIME_ENV]: 'orcad' }, platform: 'win32' })
expect(await selectServeRuntime(options)).toMatchObject({ kind: 'orcad' })
})

it('runs the local slot on its pinned Node, linked into userData beside it', async () => {
Expand All @@ -83,8 +93,13 @@ describe('orca serve runtime selection', () => {
})

it.each([
['Windows', { platform: 'win32' as const }, 'local orcad on Windows is not enabled'],
['packaged macOS', { usesMacUpdateHandoff: true }, 'no macOS app-update handoff'],
[
'an unknown runtime name',
{ env: { [SERVE_RUNTIME_ENV]: 'bun' } },
'ORCA_SERVE_RUNTIME=bun is neither orcad nor electron'
],
['Windows by default', { platform: 'win32' as const }, 'not the default on Windows yet'],
['packaged macOS', { usesMacUpdateHandoff: true }, 'so paired clients can still update it'],
['an unsupported host', { hostTarget: () => 'linux-riscv64-glibc' }, 'no orcad build exists'],
['an install without the template', { templateDirs: [] }, 'carries no orcad template'],
[
Expand Down
20 changes: 15 additions & 5 deletions src/main/orcad/orcad-local-serve-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* App-side on purpose: the CLI runs it through `orcad-local-serve-selection-entry.ts`, so the
* CLI bundle never carries the materializers.
*
* Opt-in while orcad serve is new: `ORCA_SERVE_RUNTIME=orcad`. Anything that stops orcad from
* orcad by default; `ORCA_SERVE_RUNTIME=electron` opts out. Anything that stops orcad from
* serving this machine (no slot for the target, no pinned Node, a native module it cannot load,
* a host or packaging path it does not cover yet) falls back to Electron with one stderr line
* saying why. Everything this writes stays inside the desktop's userData (design D7): the slot
Expand All @@ -19,6 +19,7 @@ import {
parseOrcadNativePreflightReport
} from '../../shared/orcad-native-preflight-report'
import {
SERVE_RUNTIME_ELECTRON,
SERVE_RUNTIME_ENV,
type ServeRuntimeSelection
} from '../../shared/orcad-local-serve-selection'
Expand Down Expand Up @@ -53,15 +54,24 @@ function electron(reason: string): ServeRuntimeSelection {
export async function selectServeRuntime(
input: ServeRuntimeSelectionInput
): Promise<ServeRuntimeSelection> {
if (input.env[SERVE_RUNTIME_ENV] !== 'orcad') {
const requested = input.env[SERVE_RUNTIME_ENV]
if (requested === SERVE_RUNTIME_ELECTRON) {
return { kind: 'electron', reason: null }
}
if (input.platform === 'win32') {
return electron('local orcad on Windows is not enabled in this build')
if (requested && requested !== 'orcad') {
return electron(`${SERVE_RUNTIME_ENV}=${requested} is neither orcad nor electron`)
}
// Why: Windows stays on Electron by default until orcad serve is flipped on there separately.
if (!requested && input.platform === 'win32') {
return electron(
'local orcad serve is not the default on Windows yet; set ORCA_SERVE_RUNTIME=orcad to opt in'
)
}
// Why: only packaged macOS serve can take a remote app update, through Electron's updater
// and this CLI's supervisor; orcad has no updater, so switching would drop that.
if (input.usesMacUpdateHandoff) {
return electron(
'orcad has no macOS app-update handoff yet; packaged macOS serve stays on Electron'
'packaged macOS serve stays on Electron so paired clients can still update it (orcad has no app updater)'
)
}
const target = (input.hostTarget ?? (() => nativeSlotName(detectNativeHostAbi())))()
Expand Down
Loading
Loading