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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ npm run dist:win # Windows installer and portable exe, in release/
npm run dist:mac # macOS dmg and zip, in release/ (run on a Mac)
```

For a signed, notarized Mac package and physical-microscope verification, see [the macOS release guide](docs/macos-release.md).

If the app says the camera is busy, another program has it. Close the Camera app, Teams, or a browser tab using the camera; Electroscope keeps retrying and connects as soon as it is free.

## Develop
Expand Down
Binary file added docs/images/macos-camera-permission-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/macos-camera-permission-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/macos-camera-unavailable-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/macos-camera-unavailable-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions docs/macos-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# macOS release

The Mac app uses the same viewer and MCP implementation as Windows. Build on an Apple Silicon Mac with Node 24 and the UnlikeOtherAI Developer ID Application certificate (team `23B7GTLBN7`). Camera permission is requested by macOS when the app first opens a camera.

Install dependencies with `npm ci`, then run `npm run typecheck`, `npm test`, `npm run build`, and `node scripts/e2e.mjs --fake`.

Unlock the existing signing keychain and set `CSC_KEYCHAIN` to its path if it is outside the default keychain search list. Load the protected notarization environment into the shell; it must define `APP_STORE_CONNECT_API_KEY_PATH`, `APP_STORE_CONNECT_API_KEY_ID`, and `APP_STORE_CONNECT_API_ISSUER_ID`. Keep credentials outside the repository and disable shell tracing. From a clean committed worktree, run:

```sh
bash scripts/release-macos.sh
```

The script builds an arm64 app, verifies the UnlikeOtherAI signature, submits it to Apple, staples the accepted ticket, checks Gatekeeper, and writes a ZIP, source revision, byte count, and SHA-256 under `release/`. It does not upload or publish the package.

Test the signed app with the physical microscope, using its camera-list label:

```sh
ELECTROSCOPE_EXE="$PWD/release/mac-arm64/Electroscope.app/Contents/MacOS/Electroscope" \
ELECTROSCOPE_CAMERA='GENERAL - UVC' node scripts/e2e.mjs
```

Allow camera access in the macOS prompt. The suite selects the named camera and checks the live viewer, all MCP tools, drawing with Command-Z, image pixels, and saving full-resolution photos. It writes screenshots and captures to `test-output/real/`. Synthetic-camera checks are separate from physical-camera verification.

Copy the verified app to `/Applications/Electroscope.app`, then repeat the hardware suite with `ELECTROSCOPE_EXE` pointing inside the installed bundle. Launch the installed app normally and select the microscope; the choice persists across launches. Keep release artifacts and test evidence outside disposable worktrees before cleanup.
17 changes: 15 additions & 2 deletions scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// node scripts/e2e.mjs real camera
// node scripts/e2e.mjs --fake Chromium's synthetic camera (CI)
// ELECTROSCOPE_EXE=<path> tests a packaged build instead of the source tree.
// ELECTROSCOPE_CAMERA=<label> selects a particular physical camera before testing.
import { _electron as electron } from 'playwright-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
Expand Down Expand Up @@ -53,6 +54,15 @@ const page = await app.firstWindow();
page.on('console', (m) => m.type() === 'error' && console.log(' [renderer error]', m.text()));
await page.setViewportSize({ width: 1360, height: 860 }).catch(() => undefined);

if (process.env.ELECTROSCOPE_CAMERA) {
const camera = process.env.ELECTROSCOPE_CAMERA;
await page.locator('#camera-list button').filter({ hasText: camera }).click();
await page.waitForFunction((label) => {
return document.getElementById('hud-cam')?.textContent.includes(label)
&& document.getElementById('video')?.videoWidth > 0;
}, camera, { timeout: 30000 });
}

await step('window shows a live camera', async () => {
await page.waitForFunction(() => document.getElementById('video')?.videoWidth > 0, null, { timeout: 30000 });
const info = await page.evaluate(() => ({
Expand Down Expand Up @@ -232,7 +242,10 @@ await step('marks follow a zoomed region capture', async () => {
const p = await pixel(marked, 0.5, 0.3);
const q = await pixel(clean, 0.5, 0.3);
console.log(` inside-ring pixel marked=${p} clean=${q}`);
assert.ok(p[0] > q[0] + 8 && p[2] > q[2] + 8, `fill tint expected inside the ring, got ${p} vs ${q}`);
// Magenta raises red/blue relative to green. Compare that colour shift rather than demanding
// an exact increase in each channel across two different live frames (sensor noise/JPEG).
const magenta = ([r, g, b]) => r + b - 2 * g;
assert.ok(magenta(p) > magenta(q) + 30, `fill tint expected inside the ring, got ${p} vs ${q}`);
});

await step('clear_annotations removes some, then all', async () => {
Expand Down Expand Up @@ -283,7 +296,7 @@ await step('the human can draw every mark type with a chosen colour, and the age
assert.equal(await page.locator('#tb-clear-all').isDisabled(), false);

// Ctrl+Z removes the last mark; the agent can restyle a human mark by id.
await page.keyboard.press('Control+z');
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+z' : 'Control+z');
assert.equal(json(await call('get_status')).annotations.length, 4);
const restyled = json(await call('annotate', { annotations: [{ id: marks[0].id, type: 'circle', x: marks[0].x, y: marks[0].y, radius: marks[0].radius, color: 'red' }] }));
assert.equal(restyled.annotations.find((m) => m.id === marks[0].id).color, 'red');
Expand Down
54 changes: 54 additions & 0 deletions scripts/release-macos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set +x
set -euo pipefail

cd "$(dirname "$0")/.."
[[ "$(uname -s)" == Darwin ]] || { echo 'Run this script on macOS.' >&2; exit 1; }
[[ -z "$(git status --porcelain)" ]] || { echo 'Commit source changes before releasing.' >&2; exit 1; }
revision="$(git rev-parse HEAD)"

identity='UnlikeOtherAI s.r.o. (23B7GTLBN7)'
: "${APP_STORE_CONNECT_API_KEY_PATH:?Set the notarization API key path.}"
: "${APP_STORE_CONNECT_API_KEY_ID:?Set the notarization API key ID.}"
: "${APP_STORE_CONNECT_API_ISSUER_ID:?Set the notarization API issuer ID.}"
[[ -f "$APP_STORE_CONNECT_API_KEY_PATH" ]] || { echo 'Notarization key is unavailable.' >&2; exit 1; }

# The caller unlocks its signing keychain; no passwords enter this script or repository.
export CSC_NAME="$identity"
export CSC_IDENTITY_AUTO_DISCOVERY=true
npm run build
npx electron-builder --mac dir --arm64 --config.mac.notarize=false

app='release/mac-arm64/Electroscope.app'
codesign --verify --strict --deep "$app"
signature="$(codesign -dv --verbose=4 "$app" 2>&1)"
grep -Fq "Authority=Developer ID Application: $identity" <<< "$signature"
grep -Fq 'TeamIdentifier=23B7GTLBN7' <<< "$signature"

[[ "$(git rev-parse HEAD)" == "$revision" && -z "$(git status --porcelain)" ]] || {
echo 'Source changed during the build; rebuild from a clean revision.' >&2; exit 1;
}
version="$(node -p "JSON.parse(require('fs').readFileSync('package.json')).version")"
out="release/Electroscope-$version-mac-arm64-${revision:0:7}"
[[ ! -e "$out.zip" ]] || { echo 'This release already exists.' >&2; exit 1; }
ditto -c -k --keepParent "$app" "$out-submission.zip"
xcrun notarytool submit "$out-submission.zip" \
--key "$APP_STORE_CONNECT_API_KEY_PATH" \
--key-id "$APP_STORE_CONNECT_API_KEY_ID" \
--issuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
--wait --output-format json > "$out-notary.json"
node --input-type=module - "$out-notary.json" <<'JS'
import { readFileSync } from 'node:fs';
const result = JSON.parse(readFileSync(process.argv[2], 'utf8'));
if (result.status !== 'Accepted') throw new Error(`Notarization ${result.status}: ${result.id}`);
console.log(`Notarization accepted: ${result.id}`);
JS
xcrun stapler staple "$app"
xcrun stapler validate "$app"
codesign --verify --strict --deep "$app"
spctl --assess --type execute --verbose=4 "$app"
ditto -c -k --keepParent "$app" "$out.zip"
printf 'Source: %s\n' "$revision" > "$out-manifest.txt"
stat -f 'Bytes: %z' "$out.zip" >> "$out-manifest.txt"
shasum -a 256 "$out.zip" >> "$out-manifest.txt"
cat "$out-manifest.txt"
29 changes: 20 additions & 9 deletions src/renderer/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,18 +167,23 @@ export class CameraSession extends EventTarget {
await new Promise((r) => setTimeout(r, 700));
}
}
this.activeId = id;
this.video.srcObject = this.stream;
this.readTrack();
this.changed();
// Some UVC drivers return a stream but never deliver a first frame. In that case play()
// also stays pending forever; keep camera switching and automatic recovery available.
void this.video.play().catch(() => undefined);
await waitForFrame(this.video);
} catch (e) {
this.closeStream();
this.activeId = null;
this.busy = (e as DOMException).name === 'NotReadableError';
this.error = describeError(e);
this.changed();
throw new Error(this.error);
}
this.activeId = id;
this.busy = false;
this.video.srcObject = this.stream;
await this.video.play().catch(() => undefined);
await waitForFrame(this.video);
this.readTrack();
this.stream.getVideoTracks()[0]?.addEventListener('ended', () => {
this.activeId = null;
Expand Down Expand Up @@ -414,25 +419,31 @@ export class CameraSession extends EventTarget {

function waitForFrame(video: HTMLVideoElement): Promise<void> {
if (video.readyState >= 2 && video.videoWidth > 0) return Promise.resolve();
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const done = () => {
clearTimeout(timer);
video.removeEventListener('loadeddata', done);
resolve();
if (video.readyState >= 2 && video.videoWidth > 0) resolve();
else reject(new DOMException('The camera did not deliver a video frame. Retrying automatically.', 'NotReadableError'));
};
const timer = setTimeout(done, 5000);
video.addEventListener('loadeddata', done);
setTimeout(done, 5000);
});
}

export function describeError(e: unknown): string {
export function describeError(
e: unknown,
platform = typeof window !== 'undefined' ? window.electroscope?.platform : undefined,
): string {
const err = e as DOMException;
switch (err?.name) {
case 'NotAllowedError':
if (platform === 'darwin') return 'Camera access is blocked. Open System Settings > Privacy & Security > Camera and allow Electroscope.';
return 'Camera access is blocked. On Windows, open Settings > Privacy & security > Camera and allow desktop apps to use your camera.';
case 'NotFoundError':
return 'No camera found. Plug in your microscope and it will appear here.';
case 'NotReadableError':
return 'Another app is using this camera (often the Windows Camera app, Teams or a browser tab). Close it and Electroscope will connect automatically.';
return 'The camera is unavailable. Close any other app using it; Electroscope will retry automatically.';
case 'OverconstrainedError':
return 'The camera does not support that resolution.';
default:
Expand Down
51 changes: 51 additions & 0 deletions test/camera.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, expect, it, vi } from 'vitest';
import { CameraSession } from '../src/renderer/camera.js';

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('releases a camera that never produces frames, then retries without blocking camera switching', async () => {
vi.useFakeTimers();
const stop = vi.fn();
const track = Object.assign(new EventTarget(), {
stop,
getSettings: () => ({ width: 1280, height: 720 }),
getCapabilities: () => ({}),
});
const stream = { getTracks: () => [track], getVideoTracks: () => [track] };
const mediaDevices = Object.assign(new EventTarget(), {
getUserMedia: vi.fn().mockResolvedValue(stream),
});
vi.stubGlobal('navigator', { mediaDevices });
vi.stubGlobal('window', globalThis);
const video = Object.assign(new EventTarget(), {
readyState: 0,
videoWidth: 0,
videoHeight: 0,
srcObject: null,
// Reproduce the driver failure: play never settles either.
play: () => new Promise<void>(() => {}),
requestVideoFrameCallback: vi.fn(),
});
const session = new CameraSession(video as unknown as HTMLVideoElement);
const opening = expect(session.open('microscope')).rejects.toThrow(/camera/i);
await vi.advanceTimersByTimeAsync(5000);
await opening;
expect(stop).toHaveBeenCalledOnce();
expect(session.activeId).toBeNull();
expect(session.busy).toBe(true);
expect(video.srcObject).toBeNull();

// The next opening gets a usable frame; even a pending play() must not block it.
video.readyState = 2;
video.videoWidth = 1280;
video.videoHeight = 720;
await session.retryIfBusy();
expect(session.activeId).toBe('microscope');
expect(session.width).toBe(1280);
expect(session.busy).toBe(false);
expect(session.error).toBeNull();
session.dispose();
});
Loading