Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
430 lines
22 KiB
C++
430 lines
22 KiB
C++
#include "bridge.h"
|
|
|
|
#include <camera/camera.h>
|
|
#include <camera/device_discovery.h>
|
|
#include <stream/stream_delegate.h>
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <iomanip>
|
|
#include <locale>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <sstream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
using namespace ins_camera;
|
|
|
|
namespace {
|
|
constexpr size_t MAX_PACKET = 4 * 1024 * 1024;
|
|
constexpr size_t MAX_QUEUE = 8 * 1024 * 1024;
|
|
constexpr size_t MAX_RESULT = 60 * 1024;
|
|
|
|
std::string quote(const std::string& value) {
|
|
if (value.size() > 1024) throw std::runtime_error("oversized SDK string");
|
|
std::ostringstream out;
|
|
out << '"';
|
|
for (unsigned char c : value) {
|
|
if (c == '"' || c == '\\') out << '\\' << c;
|
|
else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(c);
|
|
else out << c;
|
|
}
|
|
return out.str() + '"';
|
|
}
|
|
|
|
template <typename T> bool contains(const std::vector<T>& values, T value) {
|
|
return std::find(values.begin(), values.end(), value) != values.end();
|
|
}
|
|
|
|
template <typename T> std::string numbers(const std::vector<T>& values) {
|
|
if (values.size() > 256) throw std::runtime_error("oversized capabilities");
|
|
std::string out = "[";
|
|
for (auto v : values) { if (out.size() > 1) out += ','; out += std::to_string(int(v)); }
|
|
return out + ']';
|
|
}
|
|
|
|
std::string strings(const std::vector<std::string>& values) {
|
|
if (values.size() > 256) throw std::runtime_error("oversized SDK list");
|
|
std::string out = "[";
|
|
for (const auto& v : values) { if (out.size() > 1) out += ','; out += quote(v); }
|
|
return out + ']';
|
|
}
|
|
|
|
std::string complete(const std::string& result) {
|
|
return "{\"state\":\"complete\",\"result\":" + result + '}';
|
|
}
|
|
const char* UNKNOWN = "{\"state\":\"unknown\",\"error\":\"camera_result_unconfirmed\"}";
|
|
const char* INVALID = "{\"state\":\"error\",\"error\":\"unsupported_camera_parameter\"}";
|
|
|
|
struct Packet { mc_x4_video info; std::vector<uint8_t> bytes; };
|
|
struct Frames final : StreamDelegate {
|
|
std::mutex mutex;
|
|
std::deque<Packet> queue;
|
|
size_t bytes = 0;
|
|
uint64_t generation = 0, sequence = 0;
|
|
std::atomic<int> codec{0};
|
|
std::atomic<bool> enabled{false};
|
|
|
|
void reset() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
queue.clear(); bytes = 0; ++generation;
|
|
}
|
|
void OnVideoData(const uint8_t* data, size_t size, int64_t timestamp,
|
|
uint8_t, int stream_index) override {
|
|
if (!enabled || !data || !size || size > MAX_PACKET || stream_index < 0 || stream_index > 1) return;
|
|
// SDK owns the callback bytes. Copy into a bounded queue; never block
|
|
// a vendor callback on a browser, disk, decoder or Python interpreter.
|
|
try {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (bytes + size > MAX_QUEUE || queue.size() >= 64) {
|
|
queue.clear(); bytes = 0; ++generation;
|
|
}
|
|
queue.push_back({{++sequence, generation, timestamp, stream_index,
|
|
codec.load(), uint32_t(size)}, {data, data + size}});
|
|
bytes += size;
|
|
} catch (...) {
|
|
// A memory allocation failure drops data; it must not unwind into
|
|
// the vendor's callback thread or terminate camera recording.
|
|
}
|
|
}
|
|
void OnAudioData(const uint8_t*, size_t, int64_t) override {}
|
|
void OnGyroData(const std::vector<GyroData>&) override {}
|
|
void OnExposureData(const ExposureData&) override {}
|
|
};
|
|
|
|
struct Notifications {
|
|
std::mutex mutex;
|
|
std::atomic<int> recording{-1};
|
|
std::atomic<uint64_t> revision{0};
|
|
std::atomic<int> stopped_reason{-1};
|
|
std::atomic<bool> battery_low{false}, storage_full{false}, temperature_high{false};
|
|
};
|
|
}
|
|
|
|
struct mc_x4 {
|
|
DeviceDiscovery discovery;
|
|
std::vector<DeviceDescriptor> descriptors;
|
|
std::shared_ptr<Camera> camera;
|
|
std::shared_ptr<Frames> frames = std::make_shared<Frames>();
|
|
std::shared_ptr<Notifications> notifications = std::make_shared<Notifications>();
|
|
std::string firmware, result;
|
|
bool opened = false;
|
|
int preview = 0;
|
|
// Only the isolated worker calls control methods; callbacks use separate
|
|
// shared state that remains alive until the vendor releases its callbacks.
|
|
~mc_x4() {
|
|
frames->enabled = false;
|
|
if (camera && opened) { try { camera->Close(); } catch (...) {} }
|
|
camera.reset();
|
|
try { discovery.FreeDeviceDescriptors(descriptors); } catch (...) {}
|
|
}
|
|
|
|
bool mode_supported(CameraFunctionMode mode) {
|
|
return contains(camera->GetSupportedVideoModes(), mode) || contains(camera->GetSupportedPhotoModes(), mode);
|
|
}
|
|
|
|
std::string status() {
|
|
BatteryStatus battery{};
|
|
StorageStatus storage{};
|
|
bool battery_ok = camera->GetBatteryStatus(battery);
|
|
bool storage_ok = camera->GetStorageState(storage);
|
|
// The SDK exposes a boolean capture poll, not a separate timeout code.
|
|
// Report its value only while independent status reads succeed; never
|
|
// replace a notification that arrived during the blocking poll.
|
|
auto revision = notifications->revision.load();
|
|
bool active = camera->CaptureCurrentStatus();
|
|
bool connected = camera->IsConnected();
|
|
{
|
|
std::lock_guard<std::mutex> lock(notifications->mutex);
|
|
if (revision == notifications->revision.load()) {
|
|
notifications->recording = connected && battery_ok && storage_ok ? (active ? 1 : 0) : -1;
|
|
}
|
|
}
|
|
std::string result = "{\"firmware\":" + quote(firmware) +
|
|
",\"connected\":" + (connected ? "true" : "false") +
|
|
",\"preview\":" + std::to_string(preview) +
|
|
",\"recording\":" + std::to_string(notifications->recording) +
|
|
",\"stopped_reason\":" + std::to_string(notifications->stopped_reason) +
|
|
",\"function_mode\":" + std::to_string(int(camera->GetCurrentFunctionMode())) +
|
|
",\"codec\":" + std::to_string(int(camera->GetVideoEncodeType())) +
|
|
",\"battery\":";
|
|
result += battery_ok ? "{\"level\":" + std::to_string(battery.battery_level) +
|
|
",\"scale\":" + std::to_string(battery.battery_scale) +
|
|
",\"power_type\":" + std::to_string(int(battery.power_type)) + "}" : "null";
|
|
result += ",\"storage\":";
|
|
result += storage_ok ? "{\"state\":" + std::to_string(int(storage.state)) +
|
|
",\"free_bytes\":" + std::to_string(storage.free_space) +
|
|
",\"total_bytes\":" + std::to_string(storage.total_space) + "}" : "null";
|
|
result += ",\"alerts\":{\"battery_low\":" + std::string(notifications->battery_low ? "true" : "false") +
|
|
",\"storage_full\":" + (notifications->storage_full ? "true" : "false") +
|
|
",\"temperature_high\":" + (notifications->temperature_high ? "true" : "false") + "}}";
|
|
return complete(result);
|
|
}
|
|
|
|
std::string settings(CameraFunctionMode mode) {
|
|
if (!mode_supported(mode)) return INVALID;
|
|
if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN;
|
|
auto capture = camera->GetCaptureSettings(mode);
|
|
auto exposure = camera->GetExposureSettings(mode);
|
|
if (!capture || !exposure || !std::isfinite(exposure->ShutterSpeed())) return UNKNOWN;
|
|
std::ostringstream out;
|
|
out.imbue(std::locale::classic());
|
|
out << "{\"mode\":" << int(mode)
|
|
<< ",\"photo_modes\":" << numbers(camera->GetSupportedPhotoModes())
|
|
<< ",\"video_modes\":" << numbers(camera->GetSupportedVideoModes())
|
|
<< ",\"photo_sizes\":" << numbers(camera->GetSupportedPhotoSizes(mode))
|
|
<< ",\"video_resolutions\":[";
|
|
auto resolutions = camera->GetSupportedVideoResolutions(mode);
|
|
if (resolutions.size() > 256) return UNKNOWN;
|
|
bool first = true;
|
|
for (auto resolution : resolutions) {
|
|
if (!first) out << ',';
|
|
first = false;
|
|
out << "{\"id\":" << int(resolution) << ",\"name\":" << quote(camera->GetVideoResolutionName(resolution)) << '}';
|
|
}
|
|
out << "],\"values\":{\"iso\":" << exposure->Iso()
|
|
<< ",\"shutter_seconds\":" << std::setprecision(17) << exposure->ShutterSpeed()
|
|
<< ",\"exposure_mode\":" << int(exposure->ExposureMode())
|
|
<< ",\"ev_twentieths\":" << exposure->EVBias()
|
|
<< ",\"white_balance\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_WhiteBalance)
|
|
<< ",\"video_resolution\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution)
|
|
<< ",\"photo_size\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_PhotoResolution)
|
|
<< "},\"attributes\":{";
|
|
auto names = camera->GetSupportedAttrNames(mode);
|
|
if (names.size() > 128) return UNKNOWN;
|
|
first = true;
|
|
for (const auto& name : names) {
|
|
if (!first) out << ',';
|
|
first = false;
|
|
out << quote(name) << ":{\"values\":" << strings(camera->GetSupportedAttrValues(mode, name))
|
|
<< ",\"depends_on\":" << strings(camera->GetAttrDependOn(mode, name)) << '}';
|
|
}
|
|
return complete(out.str() + "}}");
|
|
}
|
|
|
|
std::string apply(CameraFunctionMode mode, const std::string& key, double value) {
|
|
if (!mode_supported(mode) || !std::isfinite(value)) return INVALID;
|
|
status();
|
|
if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID;
|
|
// One parameter per operation: partial application of a multi-setting
|
|
// request must never be presented as an atomic success.
|
|
if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN;
|
|
bool acknowledged = false;
|
|
if (key == "function_mode") {
|
|
if (value == FUNCTION_MODE_NORMAL_VIDEO && contains(camera->GetSupportedVideoModes(), FUNCTION_MODE_NORMAL_VIDEO))
|
|
acknowledged = camera->SetVideoSubMode(VIDEO_NORMAL);
|
|
else if (value == FUNCTION_MODE_NORMAL_IMAGE && contains(camera->GetSupportedPhotoModes(), FUNCTION_MODE_NORMAL_IMAGE))
|
|
acknowledged = camera->SetPhotoSubMode(PHOTO_SINGLE);
|
|
else return INVALID;
|
|
if (!acknowledged || int(camera->GetCurrentFunctionMode()) != int(value)) return UNKNOWN;
|
|
return settings(static_cast<CameraFunctionMode>(int(value)));
|
|
} else if (key == "video_resolution" && value == std::floor(value) && value >= 0 && value <= 4096) {
|
|
auto resolution = static_cast<VideoResolution>(int(value));
|
|
if (!contains(camera->GetSupportedVideoResolutions(mode), resolution)) return INVALID;
|
|
RecordParams params{}; params.resolution = resolution;
|
|
acknowledged = camera->SetVideoCaptureParams(params, mode);
|
|
} else if (key == "photo_size" && value == std::floor(value) && value >= 0 && value <= 4096) {
|
|
auto size = static_cast<PhotoSize>(int(value));
|
|
if (!contains(camera->GetSupportedPhotoSizes(mode), size)) return INVALID;
|
|
acknowledged = camera->SetPhotoSize(mode, size);
|
|
} else {
|
|
// Exposure/white-balance ranges must come from this camera's
|
|
// capability table. No undocumented enum or guessed write.
|
|
const std::string attr = key == "iso" ? "exposure_iso" : key == "white_balance" ? "white_balance" : key == "exposure_mode" ? "exposure_program" : "";
|
|
if (attr.empty() || value != std::floor(value) || value < 0 || value > 65535) return INVALID;
|
|
std::string context;
|
|
for (const auto& dependency : camera->GetAttrDependOn(mode, attr)) {
|
|
// Resolve the current camera state, never a client-provided
|
|
// context that could widen a dependent capability branch.
|
|
auto current = camera->GetCaptureSettings(mode);
|
|
if (!current || dependency != "record_resolution") return INVALID;
|
|
if (!context.empty()) context += '|';
|
|
context += camera->GetVideoResolutionName(static_cast<VideoResolution>(current->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution)));
|
|
if (context.empty()) return INVALID;
|
|
}
|
|
auto values = camera->GetSupportedAttrValues(mode, attr, context);
|
|
bool admitted = false;
|
|
for (const auto& option : values) {
|
|
if (option == std::to_string(int(value)) || camera->GetAttrValueByName(attr, option) == int(value)) admitted = true;
|
|
}
|
|
if (!admitted) return INVALID;
|
|
if (key == "iso") {
|
|
auto settings = camera->GetExposureSettings(mode);
|
|
if (!settings || (settings->ExposureMode() != MANUAL && settings->ExposureMode() != ISO_PRIORITY)) return INVALID;
|
|
settings->SetIso(int(value));
|
|
acknowledged = camera->SetExposureSettings(mode, settings);
|
|
} else if (key == "exposure_mode") {
|
|
if (value > 5) return INVALID;
|
|
auto settings = camera->GetExposureSettings(mode);
|
|
if (!settings) return UNKNOWN;
|
|
settings->SetExposureMode(static_cast<PhotographyOptions_ExposureMode>(int(value)));
|
|
acknowledged = camera->SetExposureSettings(mode, settings);
|
|
} else {
|
|
auto settings = camera->GetCaptureSettings(mode);
|
|
if (!settings) return UNKNOWN;
|
|
settings->ResetSettingTypes();
|
|
// X4 capabilities and the pinned SDK example use Kelvin (0
|
|
// auto) via SetValue, not the legacy 0..5 WB enum.
|
|
settings->SetValue(CaptureSettings::CaptureSettings_WhiteBalance, int(value));
|
|
acknowledged = camera->SetCaptureSettings(mode, settings);
|
|
}
|
|
}
|
|
// Returning the actual refreshed values lets the caller distinguish an
|
|
// acknowledged request from a setting the camera declined to retain.
|
|
return acknowledged ? settings(mode) : UNKNOWN;
|
|
}
|
|
|
|
std::string call(int action, int mode_number, const std::string& key, double value) {
|
|
if (mode_number < 0 || mode_number > 255 || !std::isfinite(value)) return INVALID;
|
|
if (!camera->IsConnected()) return UNKNOWN;
|
|
auto mode = static_cast<CameraFunctionMode>(mode_number);
|
|
if (action == MC_X4_STATUS) return status();
|
|
if (action == MC_X4_SETTINGS_READ) return settings(mode);
|
|
if (action == MC_X4_SETTINGS_APPLY) return apply(mode, key, value);
|
|
if (action == MC_X4_PREVIEW_START) {
|
|
if (preview == 1) return complete("{\"ok\":true}");
|
|
if (preview == -1) return UNKNOWN;
|
|
LiveStreamParam params{};
|
|
params.enable_audio = false; params.enable_gyro = false; params.using_lrv = false;
|
|
params.video_resolution = RES_1920_960P30; params.lrv_video_resulution = RES_1920_960P30;
|
|
params.video_bitrate = 4000000;
|
|
frames->reset(); frames->codec = int(camera->GetVideoEncodeType()); frames->enabled = true;
|
|
preview = -1;
|
|
bool ok = camera->StartLiveStreaming(params);
|
|
frames->codec = int(camera->GetVideoEncodeType());
|
|
if (!ok) { frames->enabled = false; return UNKNOWN; }
|
|
preview = 1;
|
|
return complete("{\"ok\":true}");
|
|
}
|
|
if (action == MC_X4_PREVIEW_STOP) {
|
|
if (preview == 0) return complete("{\"ok\":true}");
|
|
preview = -1;
|
|
if (!camera->StopLiveStreaming()) return UNKNOWN;
|
|
preview = 0; frames->enabled = false; frames->reset();
|
|
return complete("{\"ok\":true}");
|
|
}
|
|
if (action == MC_X4_RECORD_START) {
|
|
if (camera->CaptureCurrentStatus()) {
|
|
notifications->recording = 1;
|
|
return complete("{\"recording\":true}");
|
|
}
|
|
if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_VIDEO) return INVALID;
|
|
uint64_t revision;
|
|
{
|
|
std::lock_guard<std::mutex> lock(notifications->mutex);
|
|
revision = notifications->revision; notifications->recording = -1;
|
|
}
|
|
if (!camera->StartRecording()) return UNKNOWN;
|
|
{
|
|
std::lock_guard<std::mutex> lock(notifications->mutex);
|
|
// A storage/temperature stop notification can precede the
|
|
// START acknowledgement. Never overwrite that newer fact.
|
|
if (notifications->revision == revision) notifications->recording = 1;
|
|
if (notifications->recording != 1) return UNKNOWN;
|
|
}
|
|
return complete("{\"recording\":true}");
|
|
}
|
|
if (action == MC_X4_RECORD_STOP) {
|
|
uint64_t revision;
|
|
{
|
|
std::lock_guard<std::mutex> lock(notifications->mutex);
|
|
revision = notifications->revision; notifications->recording = -1;
|
|
}
|
|
auto media = camera->StopRecording();
|
|
if (media.Empty()) return UNKNOWN;
|
|
{
|
|
std::lock_guard<std::mutex> lock(notifications->mutex);
|
|
if (notifications->revision == revision) notifications->recording = 0;
|
|
if (notifications->recording != 0) return UNKNOWN;
|
|
}
|
|
return complete("{\"recording\":false,\"files\":" + strings(media.OriginUrls()) + '}');
|
|
}
|
|
if (action == MC_X4_PHOTO) {
|
|
status();
|
|
if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID;
|
|
// Capture the operator-selected mode. Initialization never changes
|
|
// it or takes a photo as a side effect of verifying live frames.
|
|
if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_IMAGE) return INVALID;
|
|
auto media = camera->TakePhoto(RawCaptureType::PureShot, 15000);
|
|
return media.Empty() ? UNKNOWN : complete("{\"files\":" + strings(media.OriginUrls()) + '}');
|
|
}
|
|
if (action == MC_X4_FILES) {
|
|
if (value < 0 || value > 100000 || value != std::floor(value)) return INVALID;
|
|
auto files = camera->GetCameraFilesList();
|
|
size_t offset = size_t(value), stop = std::min(files.size(), offset + 32);
|
|
if (files.size() > 100000 || offset > files.size()) return INVALID;
|
|
std::vector<std::string> page(files.begin() + offset, files.begin() + stop);
|
|
return complete("{\"items\":" + strings(page) + ",\"total\":" + std::to_string(files.size()) +
|
|
",\"offset\":" + std::to_string(offset) + '}');
|
|
}
|
|
return INVALID;
|
|
}
|
|
};
|
|
|
|
extern "C" {
|
|
int mc_x4_abi(void) { return 1; }
|
|
mc_x4* mc_x4_open(const char* serial, const char* log_directory) {
|
|
try {
|
|
if (!serial || !*serial || std::strlen(serial) > 256 || !log_directory || !*log_directory) return nullptr;
|
|
auto handle = std::make_unique<mc_x4>();
|
|
SetLogPath(log_directory);
|
|
SetLogLevel(LogLevel::ERR);
|
|
handle->descriptors = handle->discovery.GetAvailableDevices();
|
|
// The service namespace must expose exactly one physical camera.
|
|
// Never take list[0] from a host-wide SDK enumeration.
|
|
if (handle->descriptors.size() != 1) return nullptr;
|
|
const auto& device = handle->descriptors.front();
|
|
if (device.camera_type != CameraType::Insta360X4 || device.info.connection_type != ConnectionType::USB || device.serial_number != serial) return nullptr;
|
|
handle->firmware = device.fw_version;
|
|
handle->camera = std::make_shared<Camera>(device.info);
|
|
handle->camera->SetServicePort(9099); // private network namespace per instance
|
|
handle->camera->SetTimeout(10000);
|
|
handle->opened = handle->camera->Open();
|
|
if (!handle->opened) return nullptr;
|
|
auto n = handle->notifications;
|
|
handle->camera->SetCaptureStateNotification([n](bool active) {
|
|
std::lock_guard<std::mutex> lock(n->mutex);
|
|
n->recording = active ? 1 : 0; ++n->revision;
|
|
});
|
|
handle->camera->SetCaptureStoppedNotification([n](const std::string&, int reason) {
|
|
std::lock_guard<std::mutex> lock(n->mutex);
|
|
n->recording = 0; n->stopped_reason = reason; ++n->revision;
|
|
});
|
|
handle->camera->SetBatteryLowNotification([n](int) { n->battery_low = true; });
|
|
handle->camera->SetStorageFullNotification([n]() { n->storage_full = true; });
|
|
handle->camera->SetTemperatureHighNotification([n]() { n->temperature_high = true; });
|
|
std::shared_ptr<StreamDelegate> delegate = handle->frames;
|
|
handle->camera->SetStreamDelegate(delegate);
|
|
return handle.release();
|
|
} catch (...) { return nullptr; }
|
|
}
|
|
const char* mc_x4_call(mc_x4* handle, int action, int mode, const char* key, double value) {
|
|
if (!handle) return UNKNOWN;
|
|
try {
|
|
if (key && std::strlen(key) > 64) return INVALID;
|
|
handle->result = handle->call(action, mode, key ? key : "", value);
|
|
if (handle->result.size() > MAX_RESULT) handle->result = UNKNOWN;
|
|
return handle->result.c_str();
|
|
} catch (...) { return UNKNOWN; }
|
|
}
|
|
int mc_x4_read_video(mc_x4* handle, mc_x4_video* info, uint8_t* buffer, size_t capacity) {
|
|
if (!handle || !info || !buffer) return -1;
|
|
try {
|
|
std::lock_guard<std::mutex> lock(handle->frames->mutex);
|
|
if (handle->frames->queue.empty()) return 0;
|
|
auto& packet = handle->frames->queue.front();
|
|
if (capacity < packet.bytes.size()) return -1;
|
|
*info = packet.info;
|
|
std::memcpy(buffer, packet.bytes.data(), packet.bytes.size());
|
|
handle->frames->bytes -= packet.bytes.size();
|
|
handle->frames->queue.pop_front();
|
|
return 1;
|
|
} catch (...) { return -1; }
|
|
}
|
|
void mc_x4_close(mc_x4* handle) { try { delete handle; } catch (...) {} }
|
|
}
|