Skip to content
← Back to Blog ·

Supporting Sony Cameras in eclipseClick

Two SDKs, One Bridge, and a Lot of COM Interop

Adding Sony camera support turned into a two-bridge problem: Sony's official SDK doesn't support their most popular cameras, and making the fallback work on .NET 8 with trimming required rethinking COM interop from scratch. Then a real-user crash exposed a third limitation hiding in the macOS libgphoto2 path.

SonyCamera SDKCOM InteropC++C#PTPWPDWindowsCMake

TL;DR

Sony's Camera Remote SDK (CrSDK) only supports a subset of their cameras. Older and budget models — ZV-E10, A7 III, A6400 — require a completely separate bridge using Windows Portable Devices (WPD) MTP extensions over PTP (Windows) or libgphoto2 (macOS). Getting the fallback bridge's COM interop working on .NET 8 with trimming required multiple rewrites. Sony testing exposed DeviceBusy retry gaps that affected every vendor. Later, a real user's crash log revealed that libgphoto2's Sony PC Remote driver terminates on back-to-back captures — fixed by guarding the burst-rate test path and surfacing a manual FPS entry instead. A follow-up supply-chain audit then discovered that every macOS tarball had been missing the CrSDK transport plugins entirely — SonyBridge had been silently falling back to gphoto2 on all macOS installs — plus four related pipeline bugs that slipped through because no check was looking for them.

Background

eclipseClick already supported Canon (via EDSDK) and Nikon (via the MAID SDK on Windows, libgphoto2 on macOS). The architecture uses a generic bridge protocol — each vendor runs as a child process communicating JSON-RPC 2.0 over stdio. Adding Sony was, in theory, just a matter of writing another bridge.

Sony provides the Camera Remote SDK (CrSDK) — a C++ SDK with a reasonably clean API, cross-platform support, and a free registration process. The catch is that it requires registration through Sony's developer portal and cannot be redistributed. That constraint shaped the entire CI pipeline.

What we didn't anticipate: a large fraction of Sony cameras — including some of their most popular mirrorless bodies — are not supported by CrSDK at all.

Challenge 1: A Proprietary SDK That Can't Ship in the Repo

Canon's EDSDK and Nikon's MAID SDK are both proprietary, but Sony's CrSDK has an additional constraint: it must not be distributed, even in a private repository. The SDK must be obtained directly from Sony after registration. This broke our standard CI approach of checking in prebuilt native dependencies.

The solution was to separate the Sony bridge into its own repository (sony-camera-bridge) and make the SDK optional at build time. The CMakeLists.txt detects whether the SDK is present and conditionally compiles the bridge:

if(EXISTS "${SONY_SDK_PATH}/include/CrDefines.h")
  message(STATUS "Sony CrSDK found at ${SONY_SDK_PATH}")
  target_compile_definitions(sony_bridge PRIVATE SONY_SDK_AVAILABLE)
  target_include_directories(sony_bridge PRIVATE "${SONY_SDK_PATH}/include")
else()
  message(WARNING "Sony CrSDK not found — building stub-only")
endif()

This lets the test suite build and run in any CI environment. A second CI workflow downloads the SDK from a private vendor repository (where we store it after registration) for builds that produce the actual shipping binary.

One compiler quirk worth noting: MSVC raised error C7744 on hex escape sequences in test strings. This is a strict-mode diagnostic triggered by multi-character hex sequences like \x8746 that MSVC interprets as ambiguous. The fix is to break the string or use a named constant rather than an inline literal.

Challenge 2: CrSDK Doesn't Support Older Models

After shipping the CrSDK bridge, early beta testers immediately reported that their cameras weren't being detected. The affected models included the ZV-E10, A7 III, A6400, and several other popular bodies — all excluded from Sony's SDK compatibility list.

These cameras still support PTP (Picture Transfer Protocol) — the ISO standard for camera control over USB. On Windows, PTP access without a custom driver is available through the Windows Portable Devices (WPD) API, which exposes MTP extensions for PTP commands.

This became the sony-ptp-bridge: a new C# console app that uses WPD COM interop to issue raw PTP opcodes to Sony cameras and exposes the same JSON-RPC 2.0 interface as the CrSDK bridge. The eclipseClick integration layer uses switchBridge() at runtime to fall through from CrSDK to PTP if the camera isn't detected:

// SonyCameraService — runtime fallback
async connect(): Promise<boolean> {
  const primary = await super.connect();
  if (!primary) {
    // CrSDK didn't find the camera — try PTP fallback
    await this.switchBridge(ptpBridgeConfig);
    return await super.connect();
  }
  return true;
}

One early bug in the fallback: when SonyPtpBridge.exe was missing from the installation, the retry loop spun endlessly — calling connect() over and over with no circuit breaker. The fix was to check for the executable's presence before attempting the fallback, failing fast with a clear error message if it's not found.

Challenge 3: COM Interop on .NET 8 With Trimming

WPD is a COM API. Calling it from .NET requires COM interop — and this is where the sony-ptp-bridge consumed most of its development time.

The initial implementation used System.Runtime.InteropServices with manually-declared COM interface wrappers. This worked in debug builds. When published as a self-contained executable with trimming enabled — which is required to keep the binary size manageable — the COM marshalling infrastructure was trimmed away. The result was runtime crashes with no useful error messages, because the trimming happened silently at publish time.

The fix was to switch to the BuiltInComInteropSupport property in the project file, which preserves the COM interop infrastructure through trimming:

<!-- SonyPtpBridge.csproj -->
<PropertyGroup>
  <TargetFramework>net8.0-windows</TargetFramework>
  <PublishTrimmed>true</PublishTrimmed>
  <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
</PropertyGroup>

This one property preserves the full COM marshalling layer. The tradeoff is a slightly larger binary, but for a bridge executable that users download once, this is the right call.

The COM interop rewrite also required rewriting the corresponding tests. The original tests used Moq to mock the COM interface wrapper classes directly, which was tightly coupled to the implementation type. After switching to BuiltInComInteropSupport, the COM types are generated differently and the old mocks no longer compiled. We rewrote those tests to mock at the service boundary instead, which turned out to be a cleaner design regardless.

The final test count: 239 tests across 9 source files, with xUnit and FluentAssertions.

Challenge 4: DeviceBusy Errors During Bracket Sequences

Eclipse photography involves rapid bracket sequences — multiple exposures taken within a few seconds of totality. Both bridges needed to handle the case where a command arrives before the camera has finished processing the previous one.

Sony's CrSDK returns CrError_Adaptor_DeviceBusy (error code 0x8746) when this happens during a capture. The PTP protocol uses response code 0x2019 (Device Busy) for the same condition. Neither had retry logic initially.

For the CrSDK bridge, we wrapped the capture call in a retry loop:

// sony_controller.cpp
static CrError capture_with_retry(CrDeviceHandle handle, int max_attempts = 5) {
    for (int attempt = 0; attempt < max_attempts; ++attempt) {
        CrError err = SDK::SendCommand(handle, SDK::CrCommandId_Release, 0);
        if (err != CrError_Adaptor_DeviceBusy) return err;
        std::this_thread::sleep_for(std::chrono::milliseconds(200 * (attempt + 1)));
    }
    return CrError_Adaptor_DeviceBusy;
}

The same pattern — wrapping the operation in ExecuteWithBusyRetry — was applied to SetPropertyRaw in the PTP bridge. Property setting is just as likely to hit a busy camera as capture commands, especially when the bracket sequence changes ISO or shutter speed between shots.

While adding this retry logic, we audited the other vendors. Canon's bridge already had DeviceBusy retry built into the EDSDK layer. Nikon's MAID bridge did not — it was silently failing property sets under rapid fire. We added the same retry wrapper there too. Sony testing had exposed a latent cross-vendor bug.

Cross-vendor issues found during Sony testing

  • DSUSB trigger only asserted BIT_SHUTTER (0x01), not BIT_FOCUS (0x02). Some Nikon bodies and Sony bodies require both bits to trigger reliably.
  • Burst grouping threshold was 2000ms — aggressive enough to misgroup shots from the totality sequence into the same bracket set.
  • DSUSB bracket exposure wait was a fixed 100ms hold + 400ms delay, which is insufficient for exposures longer than a fraction of a second. Long-exposure bracket shots were being cut short.
  • Execution logs didn't identify which camera body produced each log entry, making multi-camera session debugging nearly impossible.

The Final Architecture

The two Sony bridges sit behind a single SonyCameraService that extends NativeSdkCameraService. The service tries CrSDK first; if the camera isn't detected within the connection timeout, it calls switchBridge() to swap to the PTP bridge at runtime without restarting the application.

Sony bridge selection (Windows)

SonyCameraService
├── Primary: SonyBridge.exe
│   └── CrSDK (C++)
│       Supported models only
│       backend: 'sony_remote'
└── Fallback: SonyPtpBridge.exe
    └── WPD MTP Extensions (C#)
        All PTP-capable models
        backend: 'sony_ptp'

Sony bridge selection (macOS)

SonyCameraService
├── Primary: SonyBridge (C++)
│   └── CrSDK (universal binary)
│       Supported models only
│       backend: 'sony_remote'
└── Fallback: GPhoto2Bridge
    └── libgphoto2 (C++)
        All PTP-capable models
        backend: 'gphoto2'

Both bridges speak the same JSON-RPC 2.0 protocol over stdio. The VendorBridgeClient layer handles process lifecycle, protocol framing, and reconnection — the camera service layer sees only the logical API.

The installer bundles both bridges: sony-bridge/ contains the CrSDK binary and its DLLs; sony-ptp-bridge/ contains the self-contained .NET 8 executable. Both are listed in electron-builder.yml under extraResources.

Cross-Platform Builds and macOS Universal Binaries

Sony's CrSDK ships platform-specific libraries. On Windows it's a set of DLLs; on macOS it's frameworks; on Linux it's shared objects. The CMake build uses conditional compilation to link the right set:

if(WIN32)
  target_link_libraries(sony_bridge "${SONY_SDK_PATH}/lib/x64/Cr_Core.lib")
elseif(APPLE)
  target_link_libraries(sony_bridge
    "-framework Cr_Core"
    "-framework CrAdapter")
else()
  target_link_libraries(sony_bridge
    "${SONY_SDK_PATH}/lib/linux/libCr_Core.so")
endif()

For macOS, CI produces both x64 and arm64 builds in separate steps. The Sony CrSDK frameworks ship as universal binaries, so the bridge itself just needs to be compiled for each architecture separately and then combined with lipo. This matches the pattern used for the Nikon gPhoto2 bridge on macOS.

One macOS-specific gotcha: the darwin-x64 prebuilds directory was missing from the initial integration. eclipseClick's main process tried to resolve the Sony bridge path for darwin-x64 and threw because the directory didn't exist. The fix was straightforward — add the missing directory and update the resolveSpawn() path resolution — but it only appeared during macOS CI, not on the developer's Apple Silicon machine where the arm64 path was present.

Challenge 5: libgphoto2 Crashes on Burst-Rate Measurement

A beta user reached out after the app terminated mid-session on macOS with three Sony cameras. Their log showed the crash during the burst-rate test — not during the actual eclipse capture. The burst-rate test in eclipseClick fires captureToCard() 10 times in a tight loop to measure continuous fps. For CrSDK and SonyPtpBridge that works fine. For the libgphoto2 fallback, it crashes.

The libgphoto2 Sony PC Remote driver cannot handle back-to-back gp_camera_capture calls. The first call succeeds; the second terminates the bridge process before any error handler can fire. This is not a DeviceBusy retry — it's a hard driver crash. Patching libgphoto2's Sony driver was ruled out immediately: it's a third-party open-source component, and a patch scoped to our specific call sequence would be unmaintainable.

The fix was to guard against reaching that code path in the first place. This required surfacing which bridge was actually active per slot — something that wasn't exposed before.

Per-slot backend signal

Until this point, the renderer only knew a slot's vendor (sony, canon, nikon). Whether that slot was running via CrSDK, WPD, or gphoto2 was internal to the main process. We added getBackend(): CameraBackend to the ICameraService interface, with SonyCameraService overriding it to return 'sony_remote', 'gphoto2', or 'sony_ptp' based on the active fallback. CameraSlotInfo now includes this backend field and it flows through to the renderer store.

Defense-in-depth guard

The IPC handler for camera:measureSlotFps now checks the condition before invoking measureFpsForService:

// ipc.ts — camera:measureSlotFps handler
if (captureMethod !== 'dsusb'
    && service.vendor === 'sony'
    && service.getBackend() === 'gphoto2') {
  throw new Error(
    'SDK burst test is disabled for Sony cameras running via ' +
    'gphoto2 fallback. Enter FPS manually in the Equipment page.'
  )
}

UI and workaround path

The Equipment page already had a manual FPS input used by DSUSB users. The script wizard already read it as its tier-1 source for burst rate — above both SDK and DSUSB measured values. For Sony+gphoto2 slots, we hide the SDK Test button and show "Auto burst test unavailable — enter FPS manually below." instead. The manual FPS input (renamed from the confusingly narrow "DSUSB:" label to "Manual FPS:") is the correct workaround: the user enters their camera's rated continuous fps, it persists across restarts, and the script wizard uses it to schedule burst sequences.

The guard doesn't affect the DSUSB burst measurement path. DSUSB uses a physical shutter trigger over USB HID — it never calls gp_camera_capture. Sony cameras with a multi-terminal port can use a DSUSB adapter the same way any other body can, bypassing the gphoto2 capture path entirely if a measured fps value is needed.

Follow-up: The CrSDK Payload That Wasn't

Several weeks after the sections above had shipped, we tightened the Sony supply chain: stop the local build script from downloading CrSDK over the network, make CI fail the job if any Sony prebuilt is missing, and verify the bundled runtime payload against an explicit manifest. The intent was narrow — guarantee the Camera Remote SDK is fully present on both x64 and arm64 DMGs. The side effect was discovering that every macOS DMG ever built had been missing half of it.

An explicit CrSDK manifest in the verify step

The macOS installer workflow's build-verification step previously wrapped its SonyBridge checks in if [ -f "$SONY" ]; then ... fi with no else branch. A missing binary was a no-op, not a failure. And nothing verified the runtime libraries alongside the binary at all. We replaced it with a fail-hard loop over every file SonyBridge actually dlopens:

libCr_Core.dylib
libmonitor_protocol.dylib
libmonitor_protocol_pf.dylib
CrAdapter/libCr_PTP_USB.dylib
CrAdapter/libCr_PTP_IP.dylib

The first three files passed. The last two — the USB and IP transport plugins — did not. Not intermittently; never. No sony-prebuilds-darwin-*.tar.gz published to the sony-camera-bridge dev release has ever contained a CrAdapter/ directory.

Why the Windows tarball was fine and the macOS tarball wasn't

CMakeLists.txt in sony-camera-bridge has two branches for copying the SDK runtime libs into the build output:

if(WIN32)
  add_custom_command(TARGET SonyBridge POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_directory ${SONY_LIB_DIR} $<TARGET_FILE_DIR:SonyBridge>
  )
elseif(UNIX)
  file(GLOB SONY_RUNTIME_LIBS "${SONY_LIB_DIR}/*.dylib" "${SONY_LIB_DIR}/*.so*")
  foreach(LIB ${SONY_RUNTIME_LIBS})
    add_custom_command(TARGET SonyBridge POST_BUILD
      COMMAND ${CMAKE_COMMAND} -E copy_if_different ${LIB} $<TARGET_FILE_DIR:SonyBridge>
    )
  endforeach()
endif()

Windows uses copy_directory, which is recursive. CrAdapter/ comes along for the ride; Windows installers have always shipped the transport plugins. macOS uses file(GLOB), which is not recursive. It picks up the top-level libCr_Core.dylib and the two monitor-protocol dylibs and ignores CrAdapter/ entirely. The release workflow then had this guard:

if [ -d "build/CrAdapter" ]; then
  cp -r build/CrAdapter staging/
fi

— which silently skipped on macOS because build/CrAdapter was never created. Every macOS tarball shipped without transport plugins. Every DMG built from those tarballs shipped a SonyBridge that could load the core SDK but couldn't actually open a USB or IP camera.

The runtime failure mode was quiet: CrSDK initialized, EnumCameraObjects returned zero devices, SonyBridge reported "no camera found," and the app fell through to its GPhoto2Bridge libgphoto2 fallback. Since gphoto2 handles most Sony PTP bodies correctly on macOS, users never saw an error — the fallback quietly covered for a primary that had never worked. The fix is two lines of cmake (mirror Windows' copy_directory on UNIX) plus upgrading the release workflow's silent skip to an exit 1 if the CrSDK payload is incomplete.

Four more findings from the same investigation

Once the manifest-verify step was in place, four latent bugs in the local-build and CI pipelines came out the same week.

  • The local build script didn't strip the dev's rpath. CI's install_name_tool -add_rpath @executable_path step existed and worked. The local scripts/build-mac-installer.js never ran the equivalent, so every DMG a developer built locally had their absolute home directory baked into SonyBridge's LC_RPATH. On the developer's own machine the binary still ran; on anyone else's — including the developer's own /Applications/ when they tested the install flow — dyld tried to resolve /Users/<dev>/dev/sony-camera-bridge/sdk/external/crsdk/libCr_Core.dylib and aborted with SIGABRT. Closing that bug also closed a small information leak — the developer's username had been ending up inside every locally-cut DMG.
  • GPhoto2Bridge was shipping unsigned from local builds. const entitlements = path.join(...) was declared inside the Nikon bridge's if (nikonAvailable) { ... } block, but the sibling GPhoto2Bridge staging block — also inside its own if (nikonAvailable) — referenced entitlements as if it were in scope. It isn't. A surrounding try/catch swallowed the ReferenceError as a warning, so GPhoto2Bridge shipped ad-hoc-signed but without hardened-runtime entitlements on every locally-built DMG. Hoisted to a module-scope ENTITLEMENTS constant shared by all three bridges.
  • CI could silently ship a DMG without Sony support. The old Sony prebuild download step used gh release download ... 2>/dev/null with an || exit 0 fallback. A missing release asset, expired token, or transient network error set sony_available=false and continued. Combined with the verify step's if [ -f "$SONY" ] guard, a DMG that lacked Sony bridge files altogether couldn't fail the build. The rewritten step lets the download failure propagate, and the new if [ ! -f "$SONY" ] branch exits non-zero.
  • The local script used to download CrSDK over the network. scripts/build-mac-installer.js had a tryDownloadSonyPrebuilts() fallback that curl'd sony-prebuilds-darwin-<arch>.tar.gz from the sony-camera-bridge release if no local cmake build existed. Convenient; also put proprietary CrSDK binaries on any contributor's machine that ran npm run pack:mac:arm64 without realizing the script would fetch them. Removed. Contributors who want to package the Mac DMG locally need their own cmake build of sony-camera-bridge, which requires their own vendor SDK access. The only path that leaves CrSDK on a contributor's disk is the one we explicitly authorize.

The common shape across all five findings: a path where a failure stopped propagating. || exit 0, if [ -f ... ] without an else, try/catch that downgraded a ReferenceError to a warning, a guard that assumed its prerequisite was always present, a network fallback nobody invoked deliberately. Each one was a line of code that turned a latent bug into a shipped bug, and finding any of them required adding the check that should have been there from day one.

What Each Bridge Supports

Bridge Technology Camera Coverage Platform Notes
SonyBridge CrSDK (C++) Alpha flagships, recent bodies Windows, macOS, Linux Full SDK burst test support
SonyPtpBridge.exe WPD MTP / PTP (C#) ZV-E10, A7 III, A6400, older bodies Windows only Full SDK burst test support
GPhoto2Bridge libgphoto2 (C++) Any PTP-capable Sony body macOS, Linux SDK burst test disabled — enter FPS manually

Models the CrSDK Natively Supports

The authoritative list lives in CrDefines.h of the Sony Camera Remote SDK. Version 2.01.00 (the release eclipseClick ships against) declares 31 models in the CrCameraDeviceModelList enum. If your camera appears below, SonyBridge (the CrSDK path) handles it with full remote control and burst testing. Anything not on this list routes through SonyPtpBridge.exe on Windows or GPhoto2Bridge on macOS/Linux.

Mirrorless / Alpha

  • ILCE-1, ILCE-1M2 (α1 II)
  • ILCE-7M4 (A7 IV), ILCE-7M5 (A7 V)
  • ILCE-7RM4, ILCE-7RM4A, ILCE-7RM5
  • ILCE-7SM3 (A7S III)
  • ILCE-7C, ILCE-7CM2 (A7C II), ILCE-7CR
  • ILCE-9M2, ILCE-9M3
  • ILCE-6700

Cinema Line (ILME)

  • ILME-FX2
  • ILME-FX3, ILME-FX3A
  • ILME-FX30
  • ILME-FX6
  • ILME-FR7

ZV (vlog)

  • ZV-E1
  • ZV-E10M2 (ZV-E10 II — not the original)

Cyber-shot (fixed-lens)

  • DSC-RX0M2 (RX0 II)
  • DSC-RX1RM3 (RX1R III)

Industrial / Broadcast

Enumerated by the SDK but not targeted at eclipse photographers — included for completeness.

  • ILX-LR1 · MPC-2610 · BRC-AM7 · PXW-Z200 · PXW-Z300 · PXW-Z380 · HXR-NX800

Conspicuously absent (and why the PTP fallback exists)

  • ZV-E10 (original, 2021) — only the ZV-E10 II made the cut
  • A7 III (ILCE-7M3, 2018) — the A7 IV and A7 V are supported, not its predecessor
  • A6400 (2019) and A6600 (2019) — only the A6700 is in the list
  • Anything from the 2019-and-older generation — A9 (original), A7R III, A7S II, RX100 line

These are precisely the bodies that drove us to build the Windows PTP bridge and keep libgphoto2 as the macOS/Linux backstop. If you own one of the "absent" cameras, you still get automated eclipse capture — just through a different transport.

Lessons Learned

  1. 1

    Read the SDK compatibility matrix first. Sony's developer portal lists supported camera models. Reading it before writing a line of code would have revealed the two-bridge problem from the start.

  2. 2

    COM interop and .NET trimming don't mix without explicit configuration. PublishTrimmed silently removes COM marshalling infrastructure. BuiltInComInteropSupport is not a default — you have to opt in.

  3. 3

    CI gaps surface on macOS. Missing darwin-x64 prebuilds only appear if CI actually runs on Intel macOS. Developer machines are Apple Silicon, so the bug was invisible locally.

  4. 4

    DeviceBusy is a cross-vendor problem. Every camera SDK has its own busy error code, and all of them need retry logic for rapid command sequences. Audit all vendors when you add retry to one.

  5. 5

    Test coverage prevents regression across the fallback chain. With 239 tests covering the PTP bridge in isolation, we caught the missing fallback circuit breaker before it reached users. Integration tests at the bridge level are worth the investment.

  6. 6

    Make the fallback fail fast. A missing executable in the fallback path should produce an immediate, clear error — not a retry loop. Users need to know which bridge is missing and why, not watch the app spin.

  7. 7

    Expose which backend is active, not just which vendor. Knowing a slot uses Sony isn't enough — you need to know whether it's using CrSDK, WPD, or gphoto2. Backend-specific driver limitations (like gphoto2's burst crash) can't be guarded against without a per-slot backend signal. Build that signal into the architecture from the start.

  8. 8

    Don't fix third-party driver bugs — guard against them. The libgphoto2 Sony PC Remote driver crashes on back-to-back captures. Patching it would be complex and fragile. The right response was to identify which code path triggers the crash and block it at the IPC boundary, surfacing a manual workaround for users instead.

  9. 9

    The verify step is load-bearing. If no test is checking for a specific file, that file can silently stop existing. The CrAdapter packaging gap was invisible for months because no test walked the full CrSDK runtime manifest. The new verify step does, and any future regression in any of the five required files fails the build immediately.

  10. 10

    A working fallback can mask a broken primary. On macOS the gphoto2 fallback covered for a SonyBridge that was missing its transport plugins from day one. If a fallback is good enough to hide the primary's failure, you don't learn whether the primary works. Worth running at least one CI job — or one manual test — with the fallback disabled to force the primary to stand on its own.

  11. 11

    Silent skips are the worst kind of skip. Every one of the five Sony pipeline findings had the same shape: a guard that turned a failure into a no-op (|| exit 0, if -f without else, try/catch downgrading a ReferenceError to a warning). Each one was one line of code that turned a latent bug into a shipped bug. When a step's failure mode matters, make it fail loudly.