diff --git a/.gitignore b/.gitignore index 0b96f60..04675b4 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ test-spatial_image test-continuity test-receiver_stream test-source_bridge + +test-atomic_file +test-json_protocol +test-receiver_output +test-service_lifecycle diff --git a/HyperDRM.manifest.json b/HyperDRM.manifest.json index c4480df..c7e58bb 100644 --- a/HyperDRM.manifest.json +++ b/HyperDRM.manifest.json @@ -1,15 +1,15 @@ { "id": "org.webosbrew.histogramhyperion", - "version": "0.12.0", + "version": "0.13.0", "type": "web", "title": "HyperDRM", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM", "rootRequired": true, - "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.12.0_arm.ipk", + "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.13.0_arm.ipk", "ipkHash": { - "sha256": "b4fcc609d763b8e1a72d84484a5d454627416dde0ef51ef65ab705173e3c0810" + "sha256": "a7ab184db8c948ceb4657f093c2cf10c13df3565ff9ed4d8482dcf2d59896a0a" }, - "ipkSize": 1204332 + "ipkSize": 1207634 } diff --git a/HyperDRM_0.12.0_arm.ipk b/HyperDRM_0.13.0_arm.ipk similarity index 88% rename from HyperDRM_0.12.0_arm.ipk rename to HyperDRM_0.13.0_arm.ipk index 4c7cbc0..8c21f83 100644 Binary files a/HyperDRM_0.12.0_arm.ipk and b/HyperDRM_0.13.0_arm.ipk differ diff --git a/README.md b/README.md index 53e5fe1..527b125 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Only the own service is modified. Optional Auto PicCap can configure its receive ## Installation -The current package is [HyperDRM_0.12.0_arm.ipk](HyperDRM_0.12.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow. +The current package is [HyperDRM_0.13.0_arm.ipk](HyperDRM_0.13.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow. Elevate the installed own service through Homebrew Channel: @@ -209,3 +209,27 @@ An existing tag or a branch other than main is rejected. Attach the exact verifi IPK to the matching Gitea release using an authorized session, then compare the downloaded attachment's size and SHA-256. Git access does not authorize web uploads. See [runtime architecture](docs/architecture.md) for module boundaries. + +### Receiver resilience and updates + +The main receiver socket stays nonblocking during connect, authentication, layout +loading, color output and acknowledgements. Only one command is in flight and the +newest unsent frame replaces older unsent frames. Timed-out or invalid replies +close the connection and use the existing retry policy. Start returns a Connecting +state; Stop cancels startup and sends a bounded asynchronous clear when needed. +LG capture initialization itself remains dependent on the vendor library. + +Receiver, profile and PicCap preferences share an atomic writer with private +permissions, interrupted/partial-write handling, file sync, rename and directory +sync. Failures before rename preserve the previous preference. A directory-sync +failure after rename is reported as committed with uncertain crash durability; +it is not represented as a rollback. + +The UI compares its generated version against the running service. Mismatches +block settings and startup actions while keeping Stop and status available. After +installing the matching package, Reload service explicitly restarts only HyperDRM +and briefly interrupts ambient lighting; it never runs automatically. + +`python release.py --sanitizers` additionally checks protocol, atomic-file, +receiver-output and simulated service-lifecycle tests with ASan/UBSan. These host +tests do not replace real LG capture and remote-control validation on a TV. diff --git a/VERSION b/VERSION index ac454c6..54d1a4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.0 +0.13.0 diff --git a/app/app.js b/app/app.js index 16353ea..4d6ee40 100644 --- a/app/app.js +++ b/app/app.js @@ -1,6 +1,26 @@ 'use strict'; var pending = []; var actionError = ''; +var versionCompatible = typeof HYPERDRM_APP_VERSION === 'undefined'; + +function checkServiceVersion(result) { + if (typeof HYPERDRM_APP_VERSION === 'undefined' || result.returnValue === false) return; + versionCompatible = result.version === HYPERDRM_APP_VERSION; + var warning = document.getElementById('versionWarning'); + warning.style.display = versionCompatible ? 'none' : 'block'; + document.getElementById('reloadService').style.display = versionCompatible ? 'none' : 'inline-block'; + warning.textContent = versionCompatible ? '' : 'App ' + HYPERDRM_APP_VERSION + ' / service ' + (result.version || 'unknown') + '. The installed service does not match this app. Finish the update, then select Reload service before changing settings. Reloading briefly stops ambient lighting.'; + ['start', 'autostart', 'handover', 'saveTarget', 'testConnection', 'calibrate', 'validateCalibration', 'saveProfile'].forEach(function(id) { + var button = document.getElementById(id); + if (!versionCompatible) { + button.versionBlocked = true; + button.disabled = true; + } else if (button.versionBlocked) { + button.versionBlocked = false; + button.disabled = id === 'handover' ? !result.piccapInstalled : id === 'testConnection' && result.connectionTest && ['Connecting', 'Sending request', 'Checking authentication', 'Reading LED layout'].indexOf(result.connectionTest.state) >= 0; + } + }); +} function showActionError(message) { actionError = message || ''; @@ -32,6 +52,13 @@ function showProfile() { } function call(method, done, data) { + if (!versionCompatible && ['start', 'autostart', 'handover', 'settings', 'testConnection', 'calibration', 'calibrationSession', 'calibrationDraft'].indexOf(method) >= 0 && !(method === 'calibrationSession' && data && data.enabled === false)) { + done({ + returnValue: false, + errorText: 'App and service versions do not match. Finish the update first.' + }); + return; + } if (['start', 'stop', 'autostart', 'handover', 'settings', 'testConnection', 'calibration'].indexOf(method) >= 0) clearActionError(); callUri('luna://org.webosbrew.histogramhyperion.service/' + method, function(result) { if (method !== 'status' && result.returnValue === false) showActionError(result.errorText || 'The action failed.'); @@ -117,10 +144,11 @@ function display(result) { toggle.textContent = 'Autostart: ' + (result.autostart ? 'On' : 'Off'); } document.getElementById('error').textContent = actionError || result.errorText || result.error || ''; - document.getElementById('status').textContent = result.returnValue === false ? 'Service unavailable' : (result.running ? 'Running' : 'Stopped') + ' · ' + (result.frames || 0) + ' acknowledged color updates'; + document.getElementById('status').textContent = result.returnValue === false ? 'Service unavailable' : (result.receiverConnecting ? 'Connecting to receiver' : result.running ? 'Running' : 'Stopped') + ' · ' + (result.frames || 0) + ' acknowledged color updates'; if (result.activeSource === 'PicCap') { document.getElementById('status').textContent = 'PicCap · ' + (result.running ? 'Running' : 'Switching capture source') + ' · ' + (result.piccapConnected ? 'Receiver connected' : 'Waiting for receiver'); document.getElementById('regions').textContent = ''; + checkServiceVersion(result); return; } if (result.piccapHandover) document.getElementById('status').textContent += ' · HyperDRM · ' + result.playbackProtection; @@ -143,7 +171,25 @@ function display(result) { e.title = 'Histogram ' + index; box.appendChild(e); }); + checkServiceVersion(result); } +document.getElementById('reloadService').onclick = function() { + var button = this; + button.disabled = true; + clearActionError(); + call('restartService', function(result) { + if (result.returnValue === false) { + button.disabled = false; + showActionError(result.errorText || 'Reinstall the current package, then restart its service.'); + return; + } + setTimeout(function() { + button.disabled = false; + call('status', display); + }, 1500); + }); +}; + document.getElementById('start').onclick = function() { call('start', function(r) { if (r.returnValue === false) display(r); @@ -347,6 +393,6 @@ document.getElementById('editProfile').onclick = function() { showProfile(); if (advancedProfile) document.getElementById('customProfileField').focus(); }; -setupRemoteNavigation(['overviewTab', 'receiverTab', 'calibrationTab', 'grantRoot', 'start', 'stop', 'refresh', 'autostart', 'handover', 'calibrationShortcut', 'receiver', 'hostField', 'portField', 'piccapPortField', 'tokenField', 'clearToken', 'saveTarget', 'testConnection', 'calibrate', 'validateCalibration', 'profile', 'saveProfile', 'editProfile', 'customProfileField', 'cancelCalibration'].map(function(id) { +setupRemoteNavigation(['overviewTab', 'receiverTab', 'calibrationTab', 'reloadService', 'grantRoot', 'start', 'stop', 'refresh', 'autostart', 'handover', 'calibrationShortcut', 'receiver', 'hostField', 'portField', 'piccapPortField', 'tokenField', 'clearToken', 'saveTarget', 'testConnection', 'calibrate', 'validateCalibration', 'profile', 'saveProfile', 'editProfile', 'customProfileField', 'cancelCalibration'].map(function(id) { return document.getElementById(id); }), document, window); diff --git a/app/appinfo.json b/app/appinfo.json index da045a5..6cae3d6 100644 --- a/app/appinfo.json +++ b/app/appinfo.json @@ -1,6 +1,6 @@ { "id": "org.webosbrew.histogramhyperion", - "version": "0.12.0", + "version": "0.13.0", "vendor": "Local development", "type": "web", "main": "index.html", diff --git a/app/index.html b/app/index.html index 904a1fc..049d5b8 100644 --- a/app/index.html +++ b/app/index.html @@ -21,6 +21,7 @@ nav{border-bottom:1px solid #456185;padding-bottom:10px;margin-bottom:18px}nav b

HyperDRM

Checking service …

+
@@ -55,4 +56,4 @@ nav{border-bottom:1px solid #456185;padding-bottom:10px;margin-bottom:18px}nav b
- + diff --git a/app/version.js b/app/version.js new file mode 100644 index 0000000..c99d394 --- /dev/null +++ b/app/version.js @@ -0,0 +1,2 @@ +'use strict'; +var HYPERDRM_APP_VERSION = '0.13.0'; diff --git a/app/wizard.js b/app/wizard.js index 01f029c..93da345 100644 --- a/app/wizard.js +++ b/app/wizard.js @@ -89,6 +89,10 @@ function wizardSample() { } function wizardStart(validation) { + if (typeof versionCompatible !== 'undefined' && !versionCompatible) { + document.getElementById('wizardStatus').textContent = 'Finish the app/service update before calibration.'; + return; + } if (wizard) return; if (typeof showPanel === 'function') showPanel('calibration', false); if (targetDirty || profileDirty) { diff --git a/docs/architecture.md b/docs/architecture.md index 985fd1c..7c9bdf2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,9 @@ Formatting must preserve include order (`SortIncludes: Never`). | service_calibration.h | Bounded calibration lease, proposals, profiles, model discovery | | service_status.h | Read-only status serialization | | capture_loop.h | Scheduler, histogram acquisition, missing-data recovery and output | +| receiver_output.h | Main asynchronous connection, transaction matching and frame coalescing | +| atomic_file.h | Durable preference replacement and explicit commit outcomes | +| service_context.h / runtime_context.h | Capture, receiver/layout and calibration session ownership | | receiver_stream.h | Independent, nonblocking receiver LED subscription | | source_bridge.h | Bounded color continuity during source transitions | | handover.h | Exclusive PicCap/histogram capture ownership | @@ -44,3 +47,25 @@ VERSION generates app/package metadata and native/version.h. release.py tests portable logic locally, builds the ARM service and verifies the exact package. Hardware capture and Magic Remote behavior still require a later TV check; a host test is not evidence of LG hardware behavior. + +The main receiver owns a ReceiverOutput context passed explicitly to begin, tick, +frame, stop and close. Its lifecycle is Idle -> Connecting -> Login (optional) -> +Layout -> Ready, with Failed on transport/protocol errors. Requests use a positive +32-bit `tan`, echoed by the receiver, to avoid acknowledging the wrong command. +Each tick has a 64 KiB send budget and a 64 KiB read budget; buffered input has an +eight-message budget and 2 MiB limit. An ACK can send the newest queued frame in +the same tick without resetting that byte budget. There is one pending command +and one replaceable frame, not an unbounded queue. Transaction deadlines are three +seconds. Shutdown and cancellation release all owned buffers and sockets. + +The source bridge receives CaptureContext explicitly; layout refresh receives +ReceiverLayoutContext explicitly. Capture data, continuity, recovery counters and +LG handles are grouped separately from receiver transport and calibration session +lease/model-query/proposal fields. LG/Luna symbols, persisted calibration and +handover ownership still have shared service scope; this refactor reduces globals +rather than claiming complete isolation. + +Transaction echo behavior is defined by the official implementations: +[Hyperion 2.0.16 JsonAPI](https://github.com/hyperion-project/hyperion.ng/blob/2.0.16/libsrc/api/JsonAPI.cpp) +and [HyperHDR HyperAPI](https://github.com/awawa-dev/HyperHDR/blob/master/sources/api/HyperAPI.cpp). +This code check does not replace a deployment test against the user's receiver. diff --git a/docs/publishing-review.md b/docs/publishing-review.md index 3c98eec..e878d17 100644 --- a/docs/publishing-review.md +++ b/docs/publishing-review.md @@ -157,3 +157,36 @@ checks the IPK's embedded metadata, executable architecture, size and SHA-256. Package timestamps are deterministic. No TV installation, application launch or service restart was performed for this version while the user was watching a film. Live LG capture and physical remote validation remain pending for this release. + +## Version 0.13.0 + +The main receiver connection uses nonblocking sockets for connect, login, layout, +color commands, matching transaction acknowledgements and clear. The synchronous +connector and send/receive loops were removed from production. Unsent frames +coalesce and per-tick byte/message budgets and transaction deadlines bound work. +Stop cancels an in-progress connection and clears pending output asynchronously. +LG capture initialization itself still depends on synchronous vendor APIs. + +Receiver, calibration and PicCap preferences share a private atomic writer with +unique temporary files, partial/EINTR write handling, file and directory sync, +rename, symlink rejection and explicit post-commit durability outcomes. Failure +before rename preserves the previous file; failure syncing the directory after +commit is logged as uncertain durability rather than incorrectly claiming rollback. + +Protocol tests cover fragmented, truncated, malformed, oversized and embedded-NUL +responses, invalid types, stale transaction IDs, send backpressure, deadlines and +disconnects. A deterministic 5000-input parser mutation test supplements the fixed +cases. ASan/UBSan checks include protocol, file replacement, output and a simulated +full service lifecycle. The latter verifies Start/Stop during connect and responsive +Status/Stop while an ACK is delayed, with a fake LG capture implementation. + +Capture, receiver/layout and calibration session state have explicit contexts; +bridge and layout-refresh functions receive their contexts directly. Shared LG/Luna +and persisted calibration/handover state remain scoped to the service. Generated +UI and native versions must match; settings/startup actions are blocked otherwise, +while Stop and status remain available. Reload service is an explicit user action, +never an automatic restart. + +All host checks and the ARM package verification pass. No TV connection, install, +launch or service restart was performed. Real LG capture, flash-filesystem behavior, +receiver interoperability and physical remote validation remain pending on the TV. diff --git a/docs/release-0.13.0.md b/docs/release-0.13.0.md new file mode 100644 index 0000000..fbb597a --- /dev/null +++ b/docs/release-0.13.0.md @@ -0,0 +1,12 @@ +# HyperDRM 0.13.0 + +- Keep receiver connect/auth/layout/color/clear operations nonblocking and coalesce unsent frames. +- Match transaction acknowledgements and bound work, buffers and deadlines. +- Share atomic preference persistence with interrupted-write handling and explicit commit outcomes. +- Add malformed-input, backpressure, disconnect, parser-mutation and simulated lifecycle tests. +- Group runtime state in capture, receiver/layout and calibration contexts. +- Detect app/service version mismatches and provide an explicit Reload service action. + +Validation: host JavaScript, native and Python tests pass, including ASan/UBSan +checks. The ARM IPK's embedded versions, architecture, size and SHA-256 match its +manifest. This version has not been installed or validated on the TV. diff --git a/native/atomic_file.h b/native/atomic_file.h new file mode 100644 index 0000000..099b825 --- /dev/null +++ b/native/atomic_file.h @@ -0,0 +1,91 @@ +#ifndef HYPERDRM_ATOMIC_FILE_H +#define HYPERDRM_ATOMIC_FILE_H +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef ATOMIC_WRITE +#define ATOMIC_WRITE write +#endif +#ifndef ATOMIC_SYNC +#define ATOMIC_SYNC fsync +#endif +#ifndef ATOMIC_RENAME +#define ATOMIC_RENAME renameat +#endif +typedef enum { SAVE_UNCHANGED, SAVE_DURABLE, SAVE_COMMITTED } SaveResult; +/* Failure before rename preserves the previous file. After rename, a directory + * sync failure cannot truthfully be called a rollback: report committed with + * uncertain durability, so memory and disk never deliberately diverge. */ +static inline SaveResult atomic_replace(const char *path, const void *data, size_t length) { + char parent[512], temporary[544]; + if (!path || !data || strlen(path) >= sizeof(parent)) + return SAVE_UNCHANGED; + strcpy(parent, path); + char *slash = strrchr(parent, '/'); + const char *name = slash ? strrchr(path, '/') + 1 : path; + if (!*name || !strcmp(name, ".") || !strcmp(name, "..")) + return SAVE_UNCHANGED; + if (slash) { + if (slash == parent) + slash[1] = 0; + else + *slash = 0; + } else + strcpy(parent, "."); + int dir = open(parent, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (dir < 0) + return SAVE_UNCHANGED; + struct stat existing; + int found = fstatat(dir, name, &existing, AT_SYMLINK_NOFOLLOW); + if ((found < 0 && errno != ENOENT) || (found == 0 && !S_ISREG(existing.st_mode)) || + ATOMIC_SYNC(dir) != 0) { + close(dir); + return SAVE_UNCHANGED; + } + if (snprintf(temporary, sizeof(temporary), "%s/.%s.tmp.XXXXXX", parent, name) >= + (int)sizeof(temporary)) { + close(dir); + return SAVE_UNCHANGED; + } + int fd = mkstemp(temporary); + if (fd < 0) { + close(dir); + return SAVE_UNCHANGED; + } + fcntl(fd, F_SETFD, FD_CLOEXEC); + const unsigned char *bytes = data; + size_t written = 0; + bool ok = true; + while (written < length) { + ssize_t n = ATOMIC_WRITE(fd, bytes + written, length - written); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) { + ok = false; + break; + } + written += (size_t)n; + } + if (ok) + ok = ATOMIC_SYNC(fd) == 0; + if (close(fd) != 0) + ok = false; + const char *tempname = strrchr(temporary, '/') + 1; + if (ok) + ok = ATOMIC_RENAME(dir, tempname, dir, name) == 0; + SaveResult result = SAVE_UNCHANGED; + if (ok) { + result = ATOMIC_SYNC(dir) == 0 ? SAVE_DURABLE : SAVE_COMMITTED; + if (result == SAVE_COMMITTED) + fprintf(stderr, "Preference committed; directory sync failed\n"); + } else + unlinkat(dir, tempname, 0); + close(dir); + return result; +} +#endif diff --git a/native/calibration.h b/native/calibration.h index 823c59f..63fe7b6 100644 --- a/native/calibration.h +++ b/native/calibration.h @@ -2,6 +2,7 @@ #define HYPERDRM_CALIBRATION_H #include #include "playback.h" +#include "atomic_file.h" #define CALIBRATION_PREF \ "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/" \ "calibration.json" @@ -52,7 +53,7 @@ static inline CalibrationKind calibration_effective(void) { return calibration_is_g4(tv_model) ? CAL_G4 : CAL_AVERAGE; } static inline bool calibration_parse(PlaybackJson *j, const char *text, Calibration *result) { - void *root = j->parse(text); + void *root = playback_parse_exact(j, text); if (!root) return false; Calibration next = *result; @@ -102,7 +103,17 @@ static inline bool calibration_parse(PlaybackJson *j, const char *text, Calibrat return valid; } static inline bool calibration_save(const char *path, const Calibration *value) { - char temp[512], data[2048]; + if (!value || value->kind < CAL_AUTO || value->kind > CAL_CUSTOM || value->count > 16 || + (value->count && (value->count < 2 || !calibration_model_valid(value->model)))) + return false; + for (unsigned i = 0; i < value->count; i++) { + double x = value->points[i].position; + if (!isfinite(x) || x < 0 || x >= 4 || value->points[i].region >= 16 || + (i && x <= value->points[i - 1].position)) + return false; + } + + char data[2048]; int used = snprintf(data, sizeof(data), "{\"profile\":\"%s\"", calibration_key(value->kind)); if (value->count) { used += snprintf(data + used, sizeof(data) - used, ",\"model\":\"%s\",\"points\":[", @@ -114,19 +125,8 @@ static inline bool calibration_save(const char *path, const Calibration *value) used += snprintf(data + used, sizeof(data) - used, "]"); } used += snprintf(data + used, sizeof(data) - used, "}\n"); - if (snprintf(temp, sizeof(temp), "%s.tmp", path) >= (int)sizeof(temp)) - return false; - int fd = open(temp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); - if (fd < 0) - return false; - bool ok = write(fd, data, used) == used && fsync(fd) == 0; - if (close(fd) != 0) - ok = false; - if (ok) - ok = rename(temp, path) == 0; - if (!ok) - unlink(temp); - return ok; + return used > 0 && (size_t)used < sizeof(data) && + atomic_replace(path, data, (size_t)used) != SAVE_UNCHANGED; } static inline void calibration_load(PlaybackJson *j, const char *path) { int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); diff --git a/native/capture_loop.h b/native/capture_loop.h index c8e65dc..5aeedb4 100644 --- a/native/capture_loop.h +++ b/native/capture_loop.h @@ -2,80 +2,115 @@ static int tick(void *unused) { (void)unused; uint64_t now = milliseconds(); + unsigned long acknowledged = receiver_runtime.output.acknowledgements; + output_tick(&receiver_runtime.output, &playback_json, now); + if (receiver_runtime.output.stage == OUTPUT_FAILED && + (capture_ctx.handle || receiver_runtime.starting)) { + char error[128]; + snprintf(error, sizeof(error), "%s", receiver_runtime.output.error); + release_capture(false); + set_error(error); + retry_update(&retries, boot_enabled() && geteuid() == 0, false, now); + retry_failure(&retries, now); + } + if (receiver_runtime.output.stage == OUTPUT_FAILED && receiver_runtime.clear_pending) { + receiver_runtime.clear_pending = false; + output_close(&receiver_runtime.output); + } + if (receiver_runtime.starting && (piccap_owned || capture_source != SOURCE_HISTOGRAM)) { + receiver_runtime.starting = false; + output_close(&receiver_runtime.output); + } + if (receiver_runtime.output.acknowledgements > acknowledged && capture_ctx.handle) { + capture_ctx.frames += receiver_runtime.output.acknowledgements - acknowledged; + capture_ctx.last_output_at = now; + last_error[0] = 0; + } + if (receiver_runtime.clear_pending && receiver_runtime.output.stage == OUTPUT_READY) { + receiver_runtime.clear_pending = false; + output_stop(&receiver_runtime.output, now, true); + } else if (receiver_runtime.starting && receiver_runtime.output.stage == OUTPUT_READY) { + free(receiver_layout.blends); + receiver_layout.blends = receiver_runtime.output.mapping; + receiver_runtime.output.mapping = NULL; + receiver_layout.count = receiver_runtime.output.count; + receiver_runtime.starting = false; + initialize_capture(NULL, NULL); + if (!capture_ctx.handle) + retry_failure(&retries, now); + } model_tick(now); test_tick(&playback_json, now); - layout_refresh_tick(&playback_json, now, capture || capture_source == SOURCE_PICCAP); + layout_refresh_tick(&playback_json, now, capture_ctx.handle || capture_source == SOURCE_PICCAP, + &receiver_layout); stream_tick(&playback_json, now, handover_enabled && !retries.paused && !retries.blocked); bool owned = handover_tick(); - bridge_tick(now); + bridge_tick(now, &capture_ctx); if (owned) return 1; - if (!capture) { + if (!capture_ctx.handle) { + if (receiver_runtime.starting || receiver_runtime.clear_pending || + receiver_runtime.output.closing) + return 1; retry_update(&retries, boot_enabled() && geteuid() == 0, false, milliseconds()); if (retry_take(&retries, milliseconds())) { start(NULL, NULL, NULL); - if (!capture) + if (!capture_ctx.handle && !receiver_runtime.starting) retry_failure(&retries, milliseconds()); } return 1; } - if (revoked) { + if (capture_ctx.revoked) { set_error("LG revoked capture; recovering after source transition"); - capture_recoveries++; - recovery_started = milliseconds(); + capture_ctx.recoveries++; + capture_ctx.recovery_started = milliseconds(); release_capture(false); retry_source_transition(&retries, milliseconds()); return 1; } - if (settling) { - settling--; + if (capture_ctx.settling) { + capture_ctx.settling--; return 1; } - if (!CALIBRATION_ACTIVE && letterbox_mode && capture_preset == 1 && - milliseconds() >= next_border_probe) { - if (vt_preset(capture, 0) != 0) { + if (!CALIBRATION_ACTIVE && capture_ctx.letterbox_mode && capture_ctx.preset == 1 && + milliseconds() >= capture_ctx.next_border_probe) { + if (vt_preset(capture_ctx.handle, 0) != 0) { set_error("Border probe preset failed"); stop_capture(); return 1; } - capture_preset = 0; - settling = 2; + capture_ctx.preset = 0; + capture_ctx.settling = 2; return 1; } Histogram hist[16] = {0}; - int rc = vt_get(capture, hist); + int rc = vt_get(capture_ctx.handle, hist); bool populated = !rc && histogram_populated(hist); if (!populated) { unsigned char held[16][3]; - color_valid = false; - if (!continuity_frame(&continuity, false, rgb, now, held)) { + capture_ctx.color_valid = false; + if (!continuity_frame(&capture_ctx.continuity, false, capture_ctx.rgb, now, held)) { set_error("Histogram data unavailable beyond hold window; recovering capture"); release_capture(false); retry_source_transition(&retries, now); return 1; } - char *line = region_command(held, led_blends, led_count, - (unsigned)(1500 - (now - continuity.missing_at))); - if (!line || send_line(line)) { + char *line = region_command(held, receiver_layout.blends, receiver_layout.count, + (unsigned)(1500 - (now - capture_ctx.continuity.missing_at))); + if (!line || (!output_frame(&receiver_runtime.output, line))) { free(line); set_error("Receiver connection lost while holding colors"); stop_capture(); return 1; } free(line); - if (!receiver_ack()) { - set_error("Receiver did not acknowledge held colors"); - stop_capture(); - return 1; - } - last_output_at = now; return 1; } - sample_at = now; - sample_sequence++; + capture_ctx.sample_at = now; + capture_ctx.sample_sequence++; unsigned aggregate[3] = {0}; for (int i = 0; i < 16; i++) { - black_percent[i] = 100; + capture_ctx.black_percent[i] = 100; for (int c = 0; c < 3; c++) { uint64_t total = 0, black = 0; for (int b = 0; b < 256; b++) { @@ -84,67 +119,60 @@ static int tick(void *unused) { black += hist[i].bins[c][b]; } unsigned fraction = total ? (unsigned)(100 * black / total) : 0; - if (fraction < black_percent[i]) - black_percent[i] = fraction; + if (fraction < capture_ctx.black_percent[i]) + capture_ctx.black_percent[i] = fraction; } } for (int i = 0; i < 16; i++) for (int c = 0; c < 3; c++) { - rgb[i][c] = histogram_mean(hist[i].bins[c]); - aggregate[c] += rgb[i][c]; + capture_ctx.rgb[i][c] = histogram_mean(hist[i].bins[c]); + aggregate[c] += capture_ctx.rgb[i][c]; } - if (!CALIBRATION_ACTIVE && capture_preset == 0) { - int candidate = letterbox_candidate(black_percent, rgb); + if (!CALIBRATION_ACTIVE && capture_ctx.preset == 0) { + int candidate = letterbox_candidate(capture_ctx.black_percent, capture_ctx.rgb); uint64_t now = milliseconds(); if (candidate < 0) - pending_letterbox = -1; - else if (candidate != pending_letterbox) { - pending_letterbox = candidate; - candidate_since = now; - } else if ((unsigned)candidate != letterbox_mode && now - candidate_since >= 2000) - letterbox_mode = (unsigned)candidate; - if (letterbox_mode) { - if (vt_preset(capture, 1) != 0) { + capture_ctx.pending_letterbox = -1; + else if (candidate != capture_ctx.pending_letterbox) { + capture_ctx.pending_letterbox = candidate; + capture_ctx.candidate_since = now; + } else if ((unsigned)candidate != capture_ctx.letterbox_mode && + now - capture_ctx.candidate_since >= 2000) + capture_ctx.letterbox_mode = (unsigned)candidate; + if (capture_ctx.letterbox_mode) { + if (vt_preset(capture_ctx.handle, 1) != 0) { set_error("Letterbox preset failed"); stop_capture(); return 1; } - capture_preset = 1; - settling = 2; - next_border_probe = now + 2000; + capture_ctx.preset = 1; + capture_ctx.settling = 2; + capture_ctx.next_border_probe = now + 2000; return 1; } } for (int c = 0; c < 3; c++) - sent_rgb[c] = (unsigned char)(aggregate[c] / 16); + capture_ctx.sent_rgb[c] = (unsigned char)(aggregate[c] / 16); unsigned char boosted_mean[3]; - boost_color(sent_rgb, boosted_mean); - memcpy(sent_rgb, boosted_mean, 3); + boost_color(capture_ctx.sent_rgb, boosted_mean); + memcpy(capture_ctx.sent_rgb, boosted_mean, 3); unsigned char mapped_rgb[16][3], stable_rgb[16][3]; - calibration_colors(rgb, mapped_rgb); - continuity_frame(&continuity, true, mapped_rgb, now, stable_rgb); - interpolation_enabled = led_count && calibration_effective() != CAL_AVERAGE; - char *line = region_command(stable_rgb, led_blends, led_count, 1500); + calibration_colors(capture_ctx.rgb, mapped_rgb); + continuity_frame(&capture_ctx.continuity, true, mapped_rgb, now, stable_rgb); + receiver_layout.interpolation = receiver_layout.count && calibration_effective() != CAL_AVERAGE; + char *line = region_command(stable_rgb, receiver_layout.blends, receiver_layout.count, 1500); if (!line) { set_error("Color buffer allocation failed"); stop_capture(); return 1; } - int failed = send_line(line); + int failed = (!output_frame(&receiver_runtime.output, line)); free(line); if (failed) { set_error("Receiver connection lost"); stop_capture(); return 1; } - if (!receiver_ack()) { - set_error("Receiver did not acknowledge color data"); - stop_capture(); - return 1; - } - last_error[0] = 0; - color_valid = true; - last_output_at = milliseconds(); - frames++; + capture_ctx.color_valid = true; return 1; } diff --git a/native/diagnostics.h b/native/diagnostics.h index fcd5f47..a2263d0 100644 --- a/native/diagnostics.h +++ b/native/diagnostics.h @@ -135,13 +135,17 @@ static void probe_tick(ReceiverProbe *probe, PlaybackJson *json, uint64_t now) { probe_finish(probe, "Receiver closed the test connection", now); return; } + if (memchr(probe->response + probe->used, 0, (size_t)n)) { + probe_finish(probe, "Receiver sent an embedded NUL", now); + return; + } probe->used += (size_t)n; probe->response[probe->used] = 0; char *end = strchr(probe->response, '\n'); if (!end) return; *end = 0; - void *root = json->parse(probe->response); + void *root = playback_parse_exact(json, probe->response); bool success = playback_true(json, root, "success"); if (root) json->put(root); diff --git a/native/handover.h b/native/handover.h index 99a06fb..79c9ab6 100644 --- a/native/handover.h +++ b/native/handover.h @@ -1,6 +1,7 @@ #ifndef HYPERDRM_HANDOVER_H #define HYPERDRM_HANDOVER_H #include "playback.h" +#include "atomic_file.h" #ifndef CALIBRATION_ACTIVE #define CALIBRATION_ACTIVE false #endif @@ -43,18 +44,7 @@ static inline bool piccap_installed(void) { } static bool handover_save(bool enabled) { const char *data = enabled ? "{\"piccapHandover\":true}\n" : "{\"piccapHandover\":false}\n"; - int fd = open(HANDOVER_PREF ".tmp", O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); - if (fd < 0) - return false; - ssize_t n = write(fd, data, strlen(data)); - bool ok = n == (ssize_t)strlen(data) && fsync(fd) == 0; - if (close(fd) != 0) - ok = false; - if (ok) - ok = rename(HANDOVER_PREF ".tmp", HANDOVER_PREF) == 0; - if (!ok) - unlink(HANDOVER_PREF ".tmp"); - return ok; + return atomic_replace(HANDOVER_PREF, data, strlen(data)) != SAVE_UNCHANGED; } static void handover_load(void) { int fd = open(HANDOVER_PREF, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); diff --git a/native/layout_refresh.h b/native/layout_refresh.h index 19a65ff..8cc2941 100644 --- a/native/layout_refresh.h +++ b/native/layout_refresh.h @@ -10,16 +10,18 @@ static void layout_refresh_reset(uint64_t due) { layout_probe.stage = TEST_IDLE; next_layout_check = due; } -static bool layout_mapping_changed(const LedBlend *next, unsigned count) { - if (count != led_count || !led_blends) +static bool layout_mapping_changed(const ReceiverLayoutContext *layout, const LedBlend *next, + unsigned count) { + if (count != layout->count || !layout->blends) return true; for (unsigned i = 0; i < count; i++) - if (next[i].a != led_blends[i].a || next[i].b != led_blends[i].b || - next[i].weight != led_blends[i].weight) + if (next[i].a != layout->blends[i].a || next[i].b != layout->blends[i].b || + next[i].weight != layout->blends[i].weight) return true; return false; } -static void layout_refresh_tick(PlaybackJson *json, uint64_t now, bool running) { +static void layout_refresh_tick(PlaybackJson *json, uint64_t now, bool running, + ReceiverLayoutContext *layout) { if (!running) { layout_refresh_reset(now + LAYOUT_REFRESH_MS); return; @@ -27,11 +29,11 @@ static void layout_refresh_tick(PlaybackJson *json, uint64_t now, bool running) probe_tick(&layout_probe, json, now); if (layout_probe.stage == TEST_DONE) { if (layout_probe_generation == calibration_generation) { - if (layout_mapping_changed(layout_probe.mapping, layout_probe.leds)) { - free(led_blends); - led_blends = layout_probe.mapping; + if (layout_mapping_changed(layout, layout_probe.mapping, layout_probe.leds)) { + free(layout->blends); + layout->blends = layout_probe.mapping; layout_probe.mapping = NULL; - led_count = layout_probe.leds; + layout->count = layout_probe.leds; layout_updates++; } last_layout_check = now; diff --git a/native/playback.h b/native/playback.h index 5218b9a..04ecd49 100644 --- a/native/playback.h +++ b/native/playback.h @@ -14,6 +14,11 @@ typedef struct { const char *(*string)(const void *); int (*boolean)(const void *); int (*put)(void *); + void *(*tokener_new)(void); + void *(*tokener_parse)(void *, const char *, int); + int (*tokener_error)(void *); + size_t (*tokener_end)(void *); + void (*tokener_free)(void *); } PlaybackJson; static inline bool playback_json_init(PlaybackJson *j) { j->lib = dlopen("libjson-c.so.5", RTLD_NOW); @@ -27,14 +32,46 @@ static inline bool playback_json_init(PlaybackJson *j) { j->string = dlsym(j->lib, "json_object_get_string"); j->boolean = dlsym(j->lib, "json_object_get_boolean"); j->put = dlsym(j->lib, "json_object_put"); + j->tokener_new = dlsym(j->lib, "json_tokener_new"); + j->tokener_parse = dlsym(j->lib, "json_tokener_parse_ex"); + j->tokener_error = dlsym(j->lib, "json_tokener_get_error"); + j->tokener_end = dlsym(j->lib, "json_tokener_get_parse_end"); + j->tokener_free = dlsym(j->lib, "json_tokener_free"); if (!j->parse || !j->get || !j->type || !j->length || !j->at || !j->string || !j->boolean || - !j->put) { + !j->put || !j->tokener_new || !j->tokener_parse || !j->tokener_error || !j->tokener_end || + !j->tokener_free) { dlclose(j->lib); j->lib = NULL; return false; } return true; } +/* Require exactly one complete JSON value. json_tokener_parse alone can accept + * an otherwise valid prefix followed by unrelated or truncated content. */ +static inline void *playback_parse_exact(PlaybackJson *j, const char *text) { + if (!j || !text || !j->tokener_new || !j->tokener_parse || !j->tokener_error || + !j->tokener_end || !j->tokener_free || !j->put) + return NULL; + size_t length = strlen(text); + if (length > 2 * 1024 * 1024) + return NULL; + void *tokener = j->tokener_new(); + if (!tokener) + return NULL; + void *root = j->tokener_parse(tokener, text, (int)length + 1); + size_t end = j->tokener_end(tokener); + bool valid = j->tokener_error(tokener) == 0; + while (end < length && + (text[end] == ' ' || text[end] == '\t' || text[end] == '\r' || text[end] == '\n')) + end++; + valid = valid && end == length; + j->tokener_free(tokener); + if (!valid && root) { + j->put(root); + root = NULL; + } + return root; +} static inline void *playback_field(PlaybackJson *j, void *o, const char *key) { void *v = NULL; return o && j->type(o) == 4 && j->get(o, key, &v) ? v : NULL; diff --git a/native/probe.c b/native/probe.c index 9f35625..9bcc3eb 100644 --- a/native/probe.c +++ b/native/probe.c @@ -7,28 +7,61 @@ #include #include "histogram.h" /* Process-local diagnostic sink. It changes logging only, not capture policy. */ -void printLog(const char *context,int level,const char *message_id, - const char *file,const char *function,int line,const char *format,...) { - (void)context;(void)level;(void)message_id;(void)file; - fprintf(stderr,"LG %s:%d: ",function?function:"?",line); - if(format){va_list args;va_start(args,format);vfprintf(stderr,format,args);va_end(args);}fputc('\n',stderr); +void printLog(const char *context, int level, const char *message_id, const char *file, + const char *function, int line, const char *format, ...) { + (void)context; + (void)level; + (void)message_id; + (void)file; + fprintf(stderr, "LG %s:%d: ", function ? function : "?", line); + if (format) { + va_list args; + va_start(args, format); + vfprintf(stderr, format, args); + va_end(args); + } + fputc('\n', stderr); +} +static bool policy(void) { + fprintf(stderr, "Capture revoked by LG policy\n"); + return true; } -static bool policy(void) { fprintf(stderr,"Capture revoked by LG policy\n"); return true; } int main(void) { - void *lib=dlopen("libvtcapture.so.1",RTLD_NOW); - if(!lib){fprintf(stderr,"dlopen: %s\n",dlerror());return 1;} - void *(*init)(bool(*)(void))=dlsym(lib,"vtCapture_initHistogram"); - int (*preset)(void*,int)=dlsym(lib,"vtCapture_setPresetHistogram"); - int (*get)(void*,Histogram*)=dlsym(lib,"vtCapture_getHistogram"); - int (*finish)(void*)=dlsym(lib,"vtCapture_finalizeHistogram"); - if(!init||!preset||!get||!finish)return 2; - void *handle=init(policy); fprintf(stderr,"handle=%p\n",handle); - if(!handle)return 3; - int rc=preset(handle,0);fprintf(stderr,"preset=%d\n",rc); - Histogram hist[16]; - for(int frame=0;rc==0 && frame<3;frame++) { - usleep(100000); rc=get(handle,hist); printf("frame=%d rc=%d\n",frame,rc); - if(rc==0)for(int i=0;i<16;i++) {printf("region=%u",hist[i].id);for(int c=0;c<3;c++){uint64_t sum=0;for(int b=0;b<256;b++)sum+=hist[i].bins[c][b];printf(" ch%d_count=%llu mean=%u",c,(unsigned long long)sum,histogram_mean(hist[i].bins[c]));} puts("");} - } - fprintf(stderr,"finalize=%d\n",finish(handle)); return rc?4:0; + void *lib = dlopen("libvtcapture.so.1", RTLD_NOW); + if (!lib) { + fprintf(stderr, "dlopen: %s\n", dlerror()); + return 1; + } + void *(*init)(bool (*)(void)) = dlsym(lib, "vtCapture_initHistogram"); + int (*preset)(void *, int) = dlsym(lib, "vtCapture_setPresetHistogram"); + int (*get)(void *, Histogram *) = dlsym(lib, "vtCapture_getHistogram"); + int (*finish)(void *) = dlsym(lib, "vtCapture_finalizeHistogram"); + if (!init || !preset || !get || !finish) + return 2; + void *handle = init(policy); + fprintf(stderr, "handle=%p\n", handle); + if (!handle) + return 3; + int rc = preset(handle, 0); + fprintf(stderr, "preset=%d\n", rc); + Histogram hist[16]; + for (int frame = 0; rc == 0 && frame < 3; frame++) { + usleep(100000); + rc = get(handle, hist); + printf("frame=%d rc=%d\n", frame, rc); + if (rc == 0) + for (int i = 0; i < 16; i++) { + printf("region=%u", hist[i].id); + for (int c = 0; c < 3; c++) { + uint64_t sum = 0; + for (int b = 0; b < 256; b++) + sum += hist[i].bins[c][b]; + printf(" ch%d_count=%llu mean=%u", c, (unsigned long long)sum, + histogram_mean(hist[i].bins[c])); + } + puts(""); + } + } + fprintf(stderr, "finalize=%d\n", finish(handle)); + return rc ? 4 : 0; } diff --git a/native/receiver_output.h b/native/receiver_output.h new file mode 100644 index 0000000..26f8b17 --- /dev/null +++ b/native/receiver_output.h @@ -0,0 +1,263 @@ +#ifndef HYPERDRM_RECEIVER_OUTPUT_H +#define HYPERDRM_RECEIVER_OUTPUT_H +/* Main output connection. Every socket operation is nonblocking, with bounded + * work per tick. One acknowledged command is in flight; only the newest unsent + * color frame is retained. No Luna message or LG capture handle is owned here. */ +typedef enum { + OUTPUT_IDLE, + OUTPUT_CONNECTING, + OUTPUT_LOGIN, + OUTPUT_LAYOUT, + OUTPUT_READY, + OUTPUT_FAILED +} OutputStage; +typedef struct { + int fd; + OutputStage stage; + TargetConfig target; + char *incoming, *request, *latest; + size_t used, sent, length; + uint64_t deadline; + unsigned transaction, awaiting, count; + LedBlend *mapping; + unsigned long acknowledgements; + char error[128]; + bool closing; +} ReceiverOutput; +static void output_close(ReceiverOutput *o) { + if (o->fd >= 0) + close(o->fd); + free(o->incoming); + free(o->request); + free(o->latest); + free(o->mapping); + *o = (ReceiverOutput){.fd = -1}; +} +static void output_fail(ReceiverOutput *o, const char *error) { + output_close(o); + o->stage = OUTPUT_FAILED; + snprintf(o->error, sizeof(o->error), "%s", error); +} +static bool output_request(ReceiverOutput *o, const char *line, uint64_t now) { + size_t n = strlen(line); + while (n && (line[n - 1] == '\n' || line[n - 1] == ' ')) + n--; + if (!n || line[n - 1] != '}' || o->request || o->awaiting) + return false; + o->request = malloc(n + 48); + if (!o->request) + return false; + if (o->transaction >= 2147483647u) + o->transaction = 0; + o->transaction++; + memcpy(o->request, line, n - 1); + snprintf(o->request + n - 1, 48, ",\"tan\":%u}\n", o->transaction); + o->length = strlen(o->request); + o->sent = 0; + o->awaiting = o->transaction; + o->deadline = now + 3000; + return true; +} +static bool output_begin(ReceiverOutput *o, const TargetConfig *target, uint64_t now) { + output_close(o); + o->target = *target; + o->incoming = malloc(2 * 1024 * 1024 + 1); + if (!o->incoming) { + output_fail(o, "Cannot allocate receiver buffer"); + return false; + } + o->fd = target_open(target); + if (o->fd < 0) { + output_fail(o, "Cannot open receiver connection"); + return false; + } + o->stage = OUTPUT_CONNECTING; + o->deadline = now + 3000; + return true; +} +static bool output_frame(ReceiverOutput *o, const char *line) { + if (o->stage != OUTPUT_READY || o->closing) + return false; + if (strlen(line) > 2 * 1024 * 1024) + return false; + char *next = strdup(line); + if (!next) + return false; + free(o->latest); + o->latest = next; + return true; +} +static void output_stop(ReceiverOutput *o, uint64_t now, bool clear) { + if (!clear || o->stage != OUTPUT_READY) { + output_close(o); + return; + } + free(o->latest); + o->latest = strdup("{\"command\":\"clear\",\"priority\":149}\n"); + if (!o->latest) { + output_close(o); + return; + } + o->closing = true; + /* Allow the current transaction and the clear to finish asynchronously. */ + if (!o->awaiting) + o->deadline = now + 3000; +} +static bool output_flush(ReceiverOutput *o, size_t *budget) { + if (!o->request || !*budget) + return true; + size_t amount = o->length - o->sent; + if (amount > *budget) + amount = *budget; + ssize_t n = send(o->fd, o->request + o->sent, amount, MSG_NOSIGNAL); + if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) { + output_fail(o, "Receiver closed during send"); + return false; + } + if (n > 0) { + o->sent += (size_t)n; + *budget -= (size_t)n; + } + if (o->sent == o->length) { + free(o->request); + o->request = NULL; + } + return true; +} +static void output_tick(ReceiverOutput *o, PlaybackJson *json, uint64_t now) { + if (o->stage == OUTPUT_IDLE || o->stage == OUTPUT_FAILED) + return; + if ((o->stage != OUTPUT_READY || o->awaiting || o->closing) && now >= o->deadline) { + if (o->closing) + output_close(o); + else + output_fail(o, "Receiver transaction timed out"); + return; + } + if (o->stage == OUTPUT_CONNECTING) { + struct pollfd p = {o->fd, POLLOUT, 0}; + int error = 0; + socklen_t size = sizeof(error); + if (poll(&p, 1, 0) <= 0) + return; + if (getsockopt(o->fd, SOL_SOCKET, SO_ERROR, &error, &size) || error) { + output_fail(o, "Receiver connection refused or unreachable"); + return; + } + char line[384]; + if (o->target.token[0]) { + snprintf(line, sizeof(line), + "{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n", + o->target.token); + o->stage = OUTPUT_LOGIN; + } else { + snprintf(line, sizeof(line), "{\"command\":\"serverinfo\"}\n"); + o->stage = OUTPUT_LAYOUT; + } + if (!output_request(o, line, now)) { + output_fail(o, "Cannot allocate receiver request"); + return; + } + } + if (o->stage == OUTPUT_READY && !o->awaiting && o->latest) { + if (!output_request(o, o->latest, now)) { + output_fail(o, "Cannot allocate color request"); + return; + } + free(o->latest); + o->latest = NULL; + } + size_t send_budget = 65536; + if (!output_flush(o, &send_budget)) + return; + size_t space = 2 * 1024 * 1024 - o->used; + if (space > 65536) + space = 65536; + if (!space && !memchr(o->incoming, '\n', o->used)) { + output_fail(o, "Receiver response exceeds 2 MiB"); + return; + } + ssize_t n = space ? recv(o->fd, o->incoming + o->used, space, 0) : -1; + if (!space) + errno = EAGAIN; + if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) { + output_fail(o, "Receiver closed during receive"); + return; + } + if (n > 0) { + if (memchr(o->incoming + o->used, 0, (size_t)n)) { + output_fail(o, "Receiver sent an embedded NUL"); + return; + } + o->used += (size_t)n; + } + o->incoming[o->used] = 0; + for (unsigned budget = 0; budget < 8; budget++) { + char *end = memchr(o->incoming, '\n', o->used); + if (!end) + break; + *end = 0; + void *root = playback_parse_exact(json, o->incoming); + if (!root || json->type(root) != 4) { + if (root) + json->put(root); + output_fail(o, "Receiver returned malformed JSON"); + return; + } + void *tan = playback_field(json, root, "tan"); + char *tail = NULL; + unsigned long id = tan && json->type(tan) == 3 ? strtoul(json->string(tan), &tail, 10) : 0; + bool matches = o->awaiting && tail && !*tail && id == o->awaiting; + if (matches) { + if (o->request || !playback_true(json, root, "success")) { + json->put(root); + output_fail(o, o->request ? "Receiver acknowledged an incomplete request" + : o->stage == OUTPUT_LOGIN + ? "Receiver authentication failed; check API token" + : o->stage == OUTPUT_LAYOUT ? "Receiver rejected LED layout request" + : "Receiver rejected color request"); + return; + } + o->awaiting = 0; + if (o->stage == OUTPUT_LOGIN) { + o->stage = OUTPUT_LAYOUT; + if (!output_request(o, "{\"command\":\"serverinfo\"}\n", now)) { + json->put(root); + output_fail(o, "Cannot allocate layout request"); + return; + } + } else if (o->stage == OUTPUT_LAYOUT) { + if (!layout_from_json(o->incoming, &o->mapping, &o->count)) { + json->put(root); + output_fail(o, "Receiver returned an invalid LED layout"); + return; + } + o->stage = OUTPUT_READY; + } else { + o->acknowledgements++; + if (o->closing && !o->latest) { + json->put(root); + output_close(o); + return; + } + } + } + json->put(root); + size_t consumed = (size_t)(end - o->incoming) + 1; + memmove(o->incoming, o->incoming + consumed, o->used - consumed); + o->used -= consumed; + o->incoming[o->used] = 0; + } + /* An ACK can free the slot after the first send pass. Use the remaining + * byte budget to send the newest queued frame in this same tick. */ + if (o->stage == OUTPUT_READY && !o->awaiting && o->latest) { + if (!output_request(o, o->latest, now)) { + output_fail(o, "Cannot allocate color request"); + return; + } + free(o->latest); + o->latest = NULL; + } + output_flush(o, &send_budget); +} +#endif diff --git a/native/receiver_stream.h b/native/receiver_stream.h index 312495c..a2cdc5c 100644 --- a/native/receiver_stream.h +++ b/native/receiver_stream.h @@ -181,6 +181,10 @@ static void stream_tick(PlaybackJson *j, uint64_t now, bool enabled) { stream_close(now); return; } + if (n > 0 && memchr(stream.data + stream.used, 0, (size_t)n)) { + stream_close(now); + return; + } if (n > 0) stream.used += (size_t)n; stream.data[stream.used] = 0; @@ -189,7 +193,7 @@ static void stream_tick(PlaybackJson *j, uint64_t now, bool enabled) { if (!end) break; *end = 0; - void *root = j->parse(stream.data); + void *root = playback_parse_exact(j, stream.data); if (stream.stage == STREAM_AUTHENTICATING) { if (!playback_true(j, root, "success")) { if (root) diff --git a/native/runtime_context.h b/native/runtime_context.h new file mode 100644 index 0000000..d427699 --- /dev/null +++ b/native/runtime_context.h @@ -0,0 +1,26 @@ +#ifndef HYPERDRM_RUNTIME_CONTEXT_H +#define HYPERDRM_RUNTIME_CONTEXT_H +#include +#include "histogram.h" +#include "continuity.h" +typedef struct { + void *handle; + char diagnostic[256]; + unsigned long frames; + unsigned char rgb[16][3], sent_rgb[3]; + unsigned black_percent[16], preset, letterbox_mode, settling; + int pending_letterbox; + uint64_t candidate_since, next_border_probe, sample_at; + unsigned sample_sequence, recoveries; + uint64_t recovery_started, last_recovery_ms, last_output_at; + bool color_valid; + Continuity continuity; + volatile sig_atomic_t revoked; +} CaptureContext; +/* LedBlend is supplied by the layout module (or a portable test fixture). */ +typedef struct { + LedBlend *blends; + unsigned count; + bool interpolation; +} ReceiverLayoutContext; +#endif diff --git a/native/service.c b/native/service.c index b0f86d7..0a66df9 100644 --- a/native/service.c +++ b/native/service.c @@ -29,6 +29,7 @@ #include "spatial_image.h" #include "diagnostics.h" #include "continuity.h" +#include "service_context.h" #define SERVICE "org.webosbrew.histogramhyperion.service" #define PRIORITY 149 typedef struct { @@ -52,20 +53,21 @@ static bool (*error_init)(LSError *); static void (*error_free)(LSError *); static void *(*vt_init)(bool (*)(void)); static int (*vt_preset)(void *, int), (*vt_get)(void *, Histogram *), (*vt_finish)(void *); -static void *capture; -static char lg_diagnostic[256]; +static CaptureContext capture_ctx = {.pending_letterbox = -1}; +static ReceiverLayoutContext receiver_layout; +static CalibrationSessionContext calibration_ctx; /* Process-local LG diagnostics; no changes to the loaded LG library. */ void printLog(const char *context, int level, const char *message_id, const char *file, const char *function, int line, const char *format, ...) { (void)context; (void)message_id; (void)file; - if (capture && level > 3) + if (capture_ctx.handle && level > 3) return; if (format) { va_list args; va_start(args, format); - vsnprintf(lg_diagnostic, sizeof(lg_diagnostic), format, args); + vsnprintf(capture_ctx.diagnostic, sizeof(capture_ctx.diagnostic), format, args); va_end(args); } fprintf(stderr, "LG %s:%d: ", function ? function : "?", line); @@ -77,36 +79,20 @@ void printLog(const char *context, int level, const char *message_id, const char } fputc('\n', stderr); } -static int output = -1; -static unsigned long frames; -static unsigned char rgb[16][3]; -static unsigned char sent_rgb[3]; -static unsigned black_percent[16]; -static unsigned capture_preset; -static unsigned letterbox_mode, settling; -static int pending_letterbox = -1; -static uint64_t candidate_since, next_border_probe; -static bool color_valid; -static Continuity continuity; -static uint64_t sample_at, calibration_until; -static unsigned sample_sequence; -#define CALIBRATION_ACTIVE (calibration_until > milliseconds()) -static unsigned led_count; -static LedBlend *led_blends; -static bool interpolation_enabled; +#define CALIBRATION_ACTIVE (calibration_ctx.until > milliseconds()) static RetryState retries; -static unsigned capture_recoveries; -static uint64_t recovery_started, last_recovery_ms, last_output_at; static uint64_t milliseconds(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return (uint64_t)t.tv_sec * 1000 + t.tv_nsec / 1000000; } static char last_error[256]; -static volatile sig_atomic_t revoked, quitting; +static volatile sig_atomic_t quitting; static int guard_pipe = -1; static pid_t guard_pid = -1; +#ifndef DEV_MODE #define DEV_MODE "/var/luna/preferences/vt_devmode" +#endif /* LG's developer gate is enabled only during initialization. A separate * guardian removes our own marker even if this process aborts or is killed. * An existing marker is preserved, as are files replaced by another owner. */ @@ -169,135 +155,30 @@ static void quit_signal(int s) { quitting = 1; } static bool policy(void) { - revoked = 1; + capture_ctx.revoked = 1; return true; } static void set_error(const char *s) { snprintf(last_error, sizeof(last_error), "%s", s); fprintf(stderr, "%s\n", s); } -static int send_line(const char *line) { - size_t left = strlen(line); - const char *p = line; - while (left) { - ssize_t n = send(output, p, left, MSG_NOSIGNAL); - if (n <= 0) - return -1; - p += n; - left -= n; - } - return 0; -} -static bool receiver_ack(void) { - char answer[4096]; - size_t used = 0; - bool complete = false; - while (used + 1 < sizeof(answer)) { - ssize_t n = recv(output, answer + used, sizeof(answer) - used - 1, 0); - if (n <= 0) - break; - used += (size_t)n; - answer[used] = 0; - if (strchr(answer, '\n')) { - complete = true; - break; - } - } - PlaybackJson json = {0}; - if (!complete || !playback_json_init(&json)) - return false; - void *root = json.parse(answer); - bool ok = playback_true(&json, root, "success"); - if (root) - json.put(root); - dlclose(json.lib); - return ok; -} -static bool read_layout(void) { - const size_t capacity = 2 * 1024 * 1024; - char *data = malloc(capacity + 1); - if (!data) - return false; - bool complete = false; - size_t used = 0; - if (send_line("{\"command\":\"serverinfo\"}\n") == 0) { - while (used < capacity) { - ssize_t n = recv(output, data + used, capacity - used, 0); - if (n <= 0) - break; - used += (size_t)n; - data[used] = 0; - char *end = strchr(data, '\n'); - if (end) { - *end = 0; - complete = true; - break; - } - } - } - LedBlend *mapping = NULL; - unsigned count = 0; - bool ok = complete && layout_from_json(data, &mapping, &count); - if (ok) { - free(led_blends); - led_blends = mapping; - led_count = count; - } - free(data); - interpolation_enabled = ok && calibration_effective() != CAL_AVERAGE; - return ok; -} -static bool target_login(void) { - if (!target.token[0]) - return true; - char request[384], answer[4096]; - size_t used = 0; - snprintf(request, sizeof(request), - "{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n", - target.token); - if (send_line(request) != 0) - return false; - while (used + 1 < sizeof(answer)) { - ssize_t n = recv(output, answer + used, sizeof(answer) - used - 1, 0); - if (n <= 0) - return false; - used += (size_t)n; - answer[used] = 0; - if (strchr(answer, '\n')) - break; - } - PlaybackJson json = {0}; - if (!playback_json_init(&json)) - return false; - void *root = json.parse(answer); - bool ok = playback_true(&json, root, "success"); - if (root) - json.put(root); - dlclose(json.lib); - return ok; -} +static ReceiverRuntime receiver_runtime = {.output = {.fd = -1}}; static void layout_refresh_reset(uint64_t due); static void release_capture(bool clear_output) { layout_refresh_reset(milliseconds() + 5000); - free(led_blends); - led_blends = NULL; - led_count = 0; - interpolation_enabled = false; - color_valid = false; - memset(rgb, 0, sizeof(rgb)); - memset(sent_rgb, 0, sizeof(sent_rgb)); - if (output >= 0) { - if (clear_output && frames) { - char line[80]; - snprintf(line, sizeof(line), "{\"command\":\"clear\",\"priority\":%d}\n", PRIORITY); - send_line(line); - } - close(output); - output = -1; - } - if (capture) { - vt_finish(capture); - capture = NULL; + free(receiver_layout.blends); + receiver_layout.blends = NULL; + receiver_layout.count = 0; + receiver_layout.interpolation = false; + capture_ctx.color_valid = false; + memset(capture_ctx.rgb, 0, sizeof(capture_ctx.rgb)); + memset(capture_ctx.sent_rgb, 0, sizeof(capture_ctx.sent_rgb)); + receiver_runtime.starting = false; + receiver_runtime.clear_pending = false; + output_stop(&receiver_runtime.output, milliseconds(), clear_output); + if (capture_ctx.handle) { + vt_finish(capture_ctx.handle); + capture_ctx.handle = NULL; } } static void stop_capture(void) { @@ -396,6 +277,7 @@ int main(void) { stream_close(milliseconds()); test_close(); stop_capture(); + output_close(&receiver_runtime.output); if (piccap_owned) { handover_enabled = false; retry_pause(&retries); diff --git a/native/service_api.h b/native/service_api.h index 2f8cd1d..0a5544d 100644 --- a/native/service_api.h +++ b/native/service_api.h @@ -20,28 +20,27 @@ static bool start(void *h, void *m, void *ctx) { retry_reset(&retries); if (piccap_owned) return respond(h, m, "{\"returnValue\":true,\"running\":true,\"activeSource\":\"PicCap\"}"); - if (capture) + if (capture_ctx.handle) return respond(h, m, "{\"returnValue\":true,\"running\":true}"); - frames = 0; - revoked = 0; + if (!playback_json.lib) { + set_error("JSON-C is unavailable; cannot start receiver output"); + return start_failure(h, m, "JSON-C is unavailable"); + } + capture_ctx.frames = 0; + capture_ctx.revoked = 0; last_error[0] = 0; - lg_diagnostic[0] = 0; - output = target_connect(&target); - if (output < 0) { - stop_capture(); - set_error("Cannot connect to configured receiver IP and JSON port"); - return start_failure(h, m, "Configured receiver is unavailable"); - } - if (!target_login()) { - stop_capture(); - set_error("Receiver token login failed; check the saved API token"); - return start_failure(h, m, "Receiver authentication failed"); - } - if (!read_layout()) { - stop_capture(); - set_error("Cannot read receiver LED layout; check JSON port and API authorization"); - return start_failure(h, m, "Receiver layout unavailable; check API authorization"); + capture_ctx.diagnostic[0] = 0; + if (receiver_runtime.starting) + return respond(h, m, "{\"returnValue\":true,\"connecting\":true}"); + receiver_runtime.clear_pending = false; + if (!output_begin(&receiver_runtime.output, &target, milliseconds())) { + set_error(receiver_runtime.output.error); + return start_failure(h, m, receiver_runtime.output.error); } + receiver_runtime.starting = true; + return respond(h, m, "{\"returnValue\":true,\"connecting\":true}"); +} +static bool initialize_capture(void *h, void *m) { if (!enter_developer_mode()) { stop_capture(); set_error("Developer initialization needs root. Elevate this own service through Homebrew " @@ -49,54 +48,49 @@ static bool start(void *h, void *m, void *ctx) { return start_failure(h, m, "Root access is required for temporary LG developer initialization"); } - capture = vt_init(policy); + capture_ctx.handle = vt_init(policy); if (!leave_developer_mode()) { stop_capture(); retries.blocked = true; set_error("Developer marker cleanup failed; manual intervention required"); return start_failure(h, m, "Developer marker cleanup failed"); } - if (!capture) { + if (!capture_ctx.handle) { stop_capture(); set_error("LG refused histogram initialization"); return start_failure( h, m, "LG refused histogram initialization. Another capture source may hold VT_CAPTURE."); } - if (vt_preset(capture, 0) != 0) { + if (vt_preset(capture_ctx.handle, 0) != 0) { stop_capture(); set_error("LG histogram preset initialization failed"); return start_failure(h, m, "Histogram preset failed"); } - capture_preset = letterbox_mode = settling = 0; - pending_letterbox = -1; - next_border_probe = 0; + capture_ctx.preset = capture_ctx.letterbox_mode = capture_ctx.settling = 0; + capture_ctx.pending_letterbox = -1; + capture_ctx.next_border_probe = 0; retries.due = 0; retries.attempts = 0; retries.quick_retries = 0; - if (recovery_started) { - last_recovery_ms = milliseconds() - recovery_started; - recovery_started = 0; + if (capture_ctx.recovery_started) { + capture_ctx.last_recovery_ms = milliseconds() - capture_ctx.recovery_started; + capture_ctx.recovery_started = 0; } return respond(h, m, "{\"returnValue\":true,\"running\":true}"); } static bool stop(void *h, void *m, void *ctx) { (void)ctx; - calibration_until = 0; + calibration_ctx.until = 0; retry_pause(&retries); bool bridging = bridge_phase != BRIDGE_IDLE; stream_close(milliseconds()); bridge_phase = BRIDGE_IDLE; - memset(&continuity, 0, sizeof(continuity)); + memset(&capture_ctx.continuity, 0, sizeof(capture_ctx.continuity)); stop_capture(); - if (bridging) { - output = target_connect(&target); - if (output >= 0) { - if (target_login()) - send_line("{\"command\":\"clear\",\"priority\":149}\n"); - close(output); - output = -1; - } + if (bridging && receiver_runtime.output.stage != OUTPUT_READY) { + output_begin(&receiver_runtime.output, &target, milliseconds()); + receiver_runtime.clear_pending = true; } return respond(h, m, "{\"returnValue\":true,\"running\":false}"); } @@ -122,7 +116,7 @@ static bool autostart(void *h, void *m, void *ctx) { "the installed boot script and whether another file occupies the hook path.\"}"); if (enabled) retry_reset(&retries); - retry_update(&retries, enabled, capture != NULL, milliseconds()); + retry_update(&retries, enabled, capture_ctx.handle != NULL, milliseconds()); return respond(h, m, enabled ? "{\"returnValue\":true,\"autostart\":true}" : "{\"returnValue\":true,\"autostart\":false}"); @@ -172,11 +166,11 @@ static bool settings(void *h, void *m, void *ctx) { if (!target_save(TARGET_PREF, &next)) return respond( h, m, "{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}"); - bool active = capture || piccap_owned || retries.due; + bool active = capture_ctx.handle || receiver_runtime.starting || piccap_owned || retries.due; stop_capture(); stream_close(milliseconds()); bridge_phase = BRIDGE_IDLE; - memset(&continuity, 0, sizeof(continuity)); + memset(&capture_ctx.continuity, 0, sizeof(capture_ctx.continuity)); test_close(); connection_test.stage = TEST_IDLE; target = next; diff --git a/native/service_calibration.h b/native/service_calibration.h index 989c17f..d925529 100644 --- a/native/service_calibration.h +++ b/native/service_calibration.h @@ -3,24 +3,22 @@ static bool calibration_session(void *h, void *m, void *ctx) { (void)ctx; const char *p = payload(m); if (!strcmp(p, "{\"enabled\":true}")) { - calibration_until = milliseconds() + 110000; - if (capture && vt_preset(capture, 0) != 0) { - calibration_until = 0; + calibration_ctx.until = milliseconds() + 110000; + if (capture_ctx.handle && vt_preset(capture_ctx.handle, 0) != 0) { + calibration_ctx.until = 0; return respond(h, m, "{\"returnValue\":false,\"errorText\":\"Cannot select full-screen " "calibration capture\"}"); } - capture_preset = letterbox_mode = 0; - settling = 2; - pending_letterbox = -1; + capture_ctx.preset = capture_ctx.letterbox_mode = 0; + capture_ctx.settling = 2; + capture_ctx.pending_letterbox = -1; } else if (!strcmp(p, "{\"enabled\":false}")) - calibration_until = 0; + calibration_ctx.until = 0; else return respond(h, m, "{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}"); return respond(h, m, "{\"returnValue\":true}"); } -static Calibration calibration_draft; -static char calibration_message[256]; static bool report_calibration(void *h, void *m, void *ctx) { if (!playback_json.lib) return respond(h, m, "{\"returnValue\":false,\"errorText\":\"JSON-C is unavailable\"}"); @@ -28,9 +26,9 @@ static bool report_calibration(void *h, void *m, void *ctx) { void *root = playback_json.parse(payload(m)), *message = playback_field(&playback_json, root, "message"); bool valid = message && playback_json.type(message) == 6 && - strlen(playback_json.string(message)) < sizeof(calibration_message); + strlen(playback_json.string(message)) < sizeof(calibration_ctx.message); if (valid) - snprintf(calibration_message, sizeof(calibration_message), "%s", + snprintf(calibration_ctx.message, sizeof(calibration_ctx.message), "%s", playback_json.string(message)); if (root) playback_json.put(root); @@ -46,17 +44,14 @@ static bool draft_calibration(void *h, void *m, void *ctx) { next.kind != CAL_CUSTOM || next.count < 4 || strcmp(next.model, tv_model)) return respond(h, m, "{\"returnValue\":false,\"errorText\":\"Invalid measured model profile\"}"); - calibration_draft = next; + calibration_ctx.draft = next; return respond(h, m, "{\"returnValue\":true}"); } -static bool model_busy; -static unsigned long model_token; -static uint64_t model_deadline, next_model_probe; static bool model_reply(void *h, void *m, void *ctx) { (void)h; (void)ctx; - model_busy = false; - model_token = 0; + calibration_ctx.model_busy = false; + calibration_ctx.model_token = 0; void *root = playback_json.parse(payload(m)), *model = playback_field(&playback_json, root, "modelName"); if (playback_true(&playback_json, root, "returnValue") && model && @@ -70,23 +65,23 @@ static bool model_reply(void *h, void *m, void *ctx) { return true; } static void model_tick(uint64_t now) { - if (model_busy && now >= model_deadline) { + if (calibration_ctx.model_busy && now >= calibration_ctx.model_deadline) { LSError e; error_init(&e); - cancel_call(service_handle, model_token, &e); + cancel_call(service_handle, calibration_ctx.model_token, &e); error_free(&e); - model_busy = false; - model_token = 0; + calibration_ctx.model_busy = false; + calibration_ctx.model_token = 0; } - if (!tv_model[0] && !model_busy && now >= next_model_probe) { + if (!tv_model[0] && !calibration_ctx.model_busy && now >= calibration_ctx.next_model_probe) { LSError e; error_init(&e); - model_busy = - call_once(service_handle, "luna://com.webos.service.tv.systemproperty/getSystemInfo", - "{\"keys\":[\"modelName\"]}", model_reply, NULL, &model_token, &e); + calibration_ctx.model_busy = call_once( + service_handle, "luna://com.webos.service.tv.systemproperty/getSystemInfo", + "{\"keys\":[\"modelName\"]}", model_reply, NULL, &calibration_ctx.model_token, &e); error_free(&e); - model_deadline = now + 3000; - next_model_probe = now + 60000; + calibration_ctx.model_deadline = now + 3000; + calibration_ctx.next_model_probe = now + 60000; } } static bool set_calibration(void *h, void *m, void *ctx) { diff --git a/native/service_context.h b/native/service_context.h new file mode 100644 index 0000000..92a86bf --- /dev/null +++ b/native/service_context.h @@ -0,0 +1,17 @@ +#ifndef HYPERDRM_SERVICE_CONTEXT_H +#define HYPERDRM_SERVICE_CONTEXT_H +#include "runtime_context.h" +#include "receiver_output.h" +/* Runtime ownership is separate from the saved calibration configuration. */ +typedef struct { + uint64_t until, model_deadline, next_model_probe; + unsigned long model_token; + bool model_busy; + Calibration draft; + char message[256]; +} CalibrationSessionContext; +typedef struct { + ReceiverOutput output; + bool starting, clear_pending; +} ReceiverRuntime; +#endif diff --git a/native/service_status.h b/native/service_status.h index b462a3b..cca9ad2 100644 --- a/native/service_status.h +++ b/native/service_status.h @@ -16,19 +16,20 @@ static bool status(void *h, void *m, void *ctx) { uint64_t now = milliseconds(); char report[512]; size_t k = 0; - for (size_t i = 0; calibration_message[i] && k + 2 < sizeof(report); i++) { - char c = calibration_message[i]; + for (size_t i = 0; calibration_ctx.message[i] && k + 2 < sizeof(report); i++) { + char c = calibration_ctx.message[i]; if (c == '"' || c == '\\') report[k++] = '\\'; if ((unsigned char)c >= 32) report[k++] = c; } report[k] = 0; - int used = snprintf(json, sizeof(json), - "{\"calibrationProfile\":\"%s\",\"effectiveCalibration\":\"%s\"," - "\"tvModel\":\"%s\",\"customModel\":\"%s\",\"customPoints\":[", - calibration_key(calibration.kind), calibration_key(calibration_effective()), - tv_model, calibration.model); + int used = snprintf( + json, sizeof(json), + "{\"receiverConnecting\":%s,\"calibrationProfile\":\"%s\",\"effectiveCalibration\":\"%s\"," + "\"tvModel\":\"%s\",\"customModel\":\"%s\",\"customPoints\":[", + receiver_runtime.starting ? "true" : "false", calibration_key(calibration.kind), + calibration_key(calibration_effective()), tv_model, calibration.model); for (unsigned i = 0; i < calibration.count; i++) used += snprintf(json + used, sizeof(json) - used, "%s{\"position\":%.12g,\"region\":%u}", @@ -40,64 +41,69 @@ static bool status(void *h, void *m, void *ctx) { layout_error, piccap_fallbacks, (unsigned long long)(handover_cooldown > now ? handover_cooldown - now : 0)); used += snprintf(json + used, sizeof(json) - used, "\"calibrationMessage\":\"%s\",", report); - used += - snprintf(json + used, sizeof(json) - used, - "\"calibrationDraft\":{\"model\":\"%s\",\"points\":[", calibration_draft.model); - for (unsigned i = 0; i < calibration_draft.count; i++) + used += snprintf(json + used, sizeof(json) - used, + "\"calibrationDraft\":{\"model\":\"%s\",\"points\":[", + calibration_ctx.draft.model); + for (unsigned i = 0; i < calibration_ctx.draft.count; i++) used += snprintf(json + used, sizeof(json) - used, "%s{\"position\":%.12g,\"region\":%u}", - i ? "," : "", calibration_draft.points[i].position, - calibration_draft.points[i].region); + i ? "," : "", calibration_ctx.draft.points[i].position, + calibration_ctx.draft.points[i].region); used += snprintf(json + used, sizeof(json) - used, "]},\"receiverStreamConnected\":%s,", stream.stage == STREAM_SUBSCRIBED ? "true" : "false"); used += snprintf(json + used, sizeof(json) - used, "\"sampleSequence\":%u,\"sampleAgeMs\":%lld,\"holdingColors\":%s," "\"sourceFade\":%s,\"calibrating\":%s,", - sample_sequence, sample_at ? (long long)(now - sample_at) : -1, - continuity.holding ? "true" : "false", - (continuity.fading || bridge_phase == BRIDGE_FADING) ? "true" : "false", + capture_ctx.sample_sequence, + capture_ctx.sample_at ? (long long)(now - capture_ctx.sample_at) : -1, + capture_ctx.continuity.holding ? "true" : "false", + (capture_ctx.continuity.fading || bridge_phase == BRIDGE_FADING) ? "true" + : "false", CALIBRATION_ACTIVE ? "true" : "false"); - used += snprintf(json + used, sizeof(json) - used, - "\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\"," - "\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%" - "lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",", - target.piccap_port, test_stage_name(), connection_test.leds, - (unsigned long long)connection_test.elapsed, connection_test.error, - last_output_at ? (long long)(now - last_output_at) : -1, - (unsigned long long)(retries.due > now ? retries.due - now : 0), - capture_source == SOURCE_PICCAP_STARTING ? "Starting PicCap" - : capture_source == SOURCE_PICCAP_STOPPING ? "Stopping PicCap" - : piccap_owned ? "PicCap" - : capture ? "Histogram" - : "Stopped", - piccap_error); - used += snprintf(json + used, sizeof(json) - used, - "\"version\":\"" HYPERDRM_VERSION - "\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u," - "\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u," - "\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s," - "\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false," - "\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[", - (capture || capture_source == SOURCE_PICCAP) ? "true" : "false", - color_valid ? "true" : "false", frames, capture_recoveries, - (unsigned long long)last_recovery_ms, endpoint, target.host, target.port, - target.receiver, target.token[0] ? "true" : "false", - target_piccap_compatible(&target) ? "true" : "false", PRIORITY, - (unsigned)geteuid(), sent_rgb[0], sent_rgb[1], sent_rgb[2], safe); + used += + snprintf(json + used, sizeof(json) - used, + "\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\"," + "\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%" + "lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",", + target.piccap_port, test_stage_name(), connection_test.leds, + (unsigned long long)connection_test.elapsed, connection_test.error, + capture_ctx.last_output_at ? (long long)(now - capture_ctx.last_output_at) : -1, + (unsigned long long)(retries.due > now ? retries.due - now : 0), + capture_source == SOURCE_PICCAP_STARTING ? "Starting PicCap" + : capture_source == SOURCE_PICCAP_STOPPING ? "Stopping PicCap" + : piccap_owned ? "PicCap" + : capture_ctx.handle ? "Histogram" + : "Stopped", + piccap_error); + used += snprintf( + json + used, sizeof(json) - used, + "\"version\":\"" HYPERDRM_VERSION + "\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u," + "\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u," + "\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s," + "\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false," + "\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[", + (capture_ctx.handle || capture_source == SOURCE_PICCAP) ? "true" : "false", + capture_ctx.color_valid ? "true" : "false", capture_ctx.frames, capture_ctx.recoveries, + (unsigned long long)capture_ctx.last_recovery_ms, endpoint, target.host, target.port, + target.receiver, target.token[0] ? "true" : "false", + target_piccap_compatible(&target) ? "true" : "false", PRIORITY, (unsigned)geteuid(), + capture_ctx.sent_rgb[0], capture_ctx.sent_rgb[1], capture_ctx.sent_rgb[2], safe); for (int i = 0; i < 16; i++) - used += snprintf(json + used, sizeof(json) - used, "%s[%u,%u,%u]", i ? "," : "", rgb[i][0], - rgb[i][1], rgb[i][2]); + used += snprintf(json + used, sizeof(json) - used, "%s[%u,%u,%u]", i ? "," : "", + capture_ctx.rgb[i][0], capture_ctx.rgb[i][1], capture_ctx.rgb[i][2]); used += snprintf(json + used, sizeof(json) - used, - "],\"ledCount\":%u,\"spatialOutput\":%s,\"blackPercent\":[", led_count, - led_count ? "true" : "false"); + "],\"ledCount\":%u,\"spatialOutput\":%s,\"blackPercent\":[", + receiver_layout.count, receiver_layout.count ? "true" : "false"); for (int i = 0; i < 16; i++) - used += snprintf(json + used, sizeof(json) - used, "%s%u", i ? "," : "", black_percent[i]); + used += snprintf(json + used, sizeof(json) - used, "%s%u", i ? "," : "", + capture_ctx.black_percent[i]); snprintf(json + used, sizeof(json) - used, "],\"preset\":%u,\"saturationGain\":1.0,\"brightnessGain\":1.0," "\"spatialInterpolation\":%s,\"blackBarDetection\":true,\"autostart\":%s," "\"retryScheduled\":%s,\"retryPaused\":%s,\"retryBlocked\":%s,\"retryAttempts\":%u," "\"piccapInstalled\":%s,\"piccapHandover\":%s,\"piccapConnected\":%s,\"activeSource\":" "\"%s\",\"playbackProtection\":\"%s\",\"letterboxDetected\":%s}", - capture_preset, interpolation_enabled ? "true" : "false", + capture_ctx.preset, receiver_layout.interpolation ? "true" : "false", boot_enabled() ? "true" : "false", retries.due ? "true" : "false", retries.paused ? "true" : "false", retries.blocked ? "true" : "false", retries.attempts, piccap_installed() ? "true" : "false", @@ -106,6 +112,6 @@ static bool status(void *h, void *m, void *ctx) { protection == PLAYBACK_PROTECTED ? "protected" : protection == PLAYBACK_CLEAR ? "non-secure decoder" : "unknown", - capture && letterbox_mode ? "true" : "false"); + capture_ctx.handle && capture_ctx.letterbox_mode ? "true" : "false"); return respond(h, m, json); } diff --git a/native/source_bridge.h b/native/source_bridge.h index 0ef778a..d1f7f26 100644 --- a/native/source_bridge.h +++ b/native/source_bridge.h @@ -44,7 +44,7 @@ static bool bridge_send(const unsigned char colors[16][3]) { free(line); return ok; } -static void bridge_tick(uint64_t now) { +static void bridge_tick(uint64_t now, CaptureContext *capture) { if (retries.paused || retries.blocked) { if (bridge_phase) bridge_clear(); @@ -54,15 +54,17 @@ static void bridge_tick(uint64_t now) { } if (capture_source != bridge_source) { if (capture_source == SOURCE_PICCAP_STARTING) { - bridge_phase = continuity.available && now - continuity.valid_at < 2000 ? BRIDGE_HOLDING - : BRIDGE_IDLE; + bridge_phase = + capture->continuity.available && now - capture->continuity.valid_at < 2000 + ? BRIDGE_HOLDING + : BRIDGE_IDLE; bridge_started = now; - memcpy(bridge_from, continuity.last, sizeof(bridge_from)); + memcpy(bridge_from, capture->continuity.last, sizeof(bridge_from)); } else if (capture_source == SOURCE_PICCAP_STOPPING || (bridge_source == SOURCE_PICCAP && capture_source == SOURCE_HISTOGRAM)) { bridge_phase = BRIDGE_IDLE; if (stream.valid && now - stream.updated < 1000) { - continuity_seed(&continuity, stream.colors, now); + continuity_seed(&capture->continuity, stream.colors, now); bridge_send(stream.colors); } } diff --git a/native/target.h b/native/target.h index dffc20d..81783c2 100644 --- a/native/target.h +++ b/native/target.h @@ -10,6 +10,7 @@ #include #include #include "playback.h" +#include "atomic_file.h" #define TARGET_PREF \ "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/target.json" typedef struct { @@ -44,7 +45,7 @@ static bool target_valid(const TargetConfig *t) { return true; } static bool target_parse(PlaybackJson *j, const char *text, TargetConfig *out) { - void *root = j->parse(text); + void *root = playback_parse_exact(j, text); if (!root) return false; TargetConfig next = *out; @@ -112,24 +113,13 @@ static bool target_parse(PlaybackJson *j, const char *text, TargetConfig *out) { static bool target_save(const char *path, const TargetConfig *t) { if (!target_valid(t)) return false; - char tmp[512], data[512]; - if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) - return false; + char data[512]; int n = snprintf( data, sizeof(data), "{\"host\":\"%s\",\"port\":%u,\"piccapPort\":%u,\"receiver\":\"%s\",\"token\":\"%s\"}\n", t->host, t->port, t->piccap_port, t->receiver, t->token); - int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); - if (fd < 0) - return false; - bool ok = write(fd, data, n) == n && fsync(fd) == 0; - if (close(fd) != 0) - ok = false; - if (ok) - ok = rename(tmp, path) == 0; - if (!ok) - unlink(tmp); - return ok; + return n > 0 && (size_t)n < sizeof(data) && + atomic_replace(path, data, (size_t)n) != SAVE_UNCHANGED; } static bool target_load(PlaybackJson *j, const char *path, TargetConfig *t) { int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); @@ -178,26 +168,4 @@ static int target_open(const TargetConfig *t) { } return fd; } -static int target_connect(const TargetConfig *t) { - int fd = target_open(t); - if (fd < 0) - return -1; - struct pollfd p = {.fd = fd, .events = POLLOUT}; - int error = 0; - socklen_t size = sizeof(error); - if (poll(&p, 1, 1000) <= 0 || getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &size) != 0 || - error) { - close(fd); - return -1; - } - int flags = fcntl(fd, F_GETFL, 0); - if (flags < 0 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) < 0) { - close(fd); - return -1; - } - struct timeval timeout = {1, 0}; - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); - return fd; -} #endif diff --git a/native/test_atomic_file.c b/native/test_atomic_file.c new file mode 100644 index 0000000..d9dd275 --- /dev/null +++ b/native/test_atomic_file.c @@ -0,0 +1,101 @@ +#define _DEFAULT_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +static bool interrupt_write, short_write, fail_write, fail_sync, fail_rename, fail_commit_sync; +static unsigned directory_syncs; +static ssize_t fixture_write(int fd, const void *data, size_t n) { + if (interrupt_write) { + interrupt_write = false; + errno = EINTR; + return -1; + } + if (fail_write) { + errno = ENOSPC; + return -1; + } + return write(fd, data, short_write && n > 2 ? 2 : n); +} +static int fixture_sync(int fd) { + struct stat info; + assert(!fstat(fd, &info)); + if (S_ISDIR(info.st_mode)) { + directory_syncs++; + if (fail_commit_sync && directory_syncs == 2) { + errno = EIO; + return -1; + } + } else if (fail_sync) { + errno = EIO; + return -1; + } + return fsync(fd); +} +static int fixture_rename(int from, const char *a, int to, const char *b) { + if (fail_rename) { + errno = EACCES; + return -1; + } + return renameat(from, a, to, b); +} +#define ATOMIC_WRITE fixture_write +#define ATOMIC_SYNC fixture_sync +#define ATOMIC_RENAME fixture_rename +#include "atomic_file.h" +static void contents(const char *path, const char *expected) { + char data[64] = {0}; + int fd = open(path, O_RDONLY); + assert(fd >= 0); + assert(read(fd, data, sizeof(data) - 1) == (ssize_t)strlen(expected)); + close(fd); + assert(!strcmp(data, expected)); +} +int main(void) { + char directory[] = "/tmp/hyperdrm-save-XXXXXX"; + assert(mkdtemp(directory)); + char path[128], stale[128], link[128]; + snprintf(path, sizeof(path), "%s/settings.json", directory); + snprintf(stale, sizeof(stale), "%s/settings.json.tmp", directory); + snprintf(link, sizeof(link), "%s/link", directory); + int fd = open(stale, O_CREAT | O_WRONLY, 0600); + assert(fd >= 0); + close(fd); + assert(atomic_replace(path, "old", 3) == SAVE_DURABLE); + interrupt_write = short_write = true; + assert(atomic_replace(path, "partial writes handled", 22) == SAVE_DURABLE); + contents(path, "partial writes handled"); + short_write = false; + fail_write = true; + assert(atomic_replace(path, "new", 3) == SAVE_UNCHANGED); + fail_write = false; + contents(path, "partial writes handled"); + fail_sync = true; + assert(atomic_replace(path, "new", 3) == SAVE_UNCHANGED); + fail_sync = false; + fail_rename = true; + assert(atomic_replace(path, "new", 3) == SAVE_UNCHANGED); + fail_rename = false; + contents(path, "partial writes handled"); + directory_syncs = 0; + fail_commit_sync = true; + assert(atomic_replace(path, "committed", 9) == SAVE_COMMITTED); + contents(path, "committed"); + fail_commit_sync = false; + assert(!symlink(path, link)); + assert(atomic_replace(link, "bad", 3) == SAVE_UNCHANGED); + contents(path, "committed"); + struct stat info; + assert(!stat(path, &info) && (info.st_mode & 0777) == 0600); + assert(!access(stale, F_OK)); + unlink(link); + unlink(path); + unlink(stale); + assert(!rmdir(directory)); + puts("PASS: interrupted/partial writes, write/sync/rename failures, atomic replacement, commit " + "durability, private permissions, symlinks and stale files"); +} diff --git a/native/test_diagnostics.c b/native/test_diagnostics.c index be7790d..58560cb 100644 --- a/native/test_diagnostics.c +++ b/native/test_diagnostics.c @@ -32,7 +32,7 @@ static void fixture(PlaybackJson *json,int kind){ close(server);assert(test_begin(&config,10000));test_tick(json,10010);assert(connection_test.stage==TEST_FAILED); } int main(void){ - (void)target_save;(void)target_load;(void)target_connect;(void)test_stage_name; + (void)target_save;(void)target_load;(void)target_open;(void)test_stage_name; PlaybackJson json={0};assert(playback_json_init(&json)); fixture(&json,0);fixture(&json,1);fixture(&json,2); int peers[2];assert(socketpair(AF_UNIX,SOCK_STREAM,0,peers)==0); diff --git a/native/test_handover.c b/native/test_handover.c index 46e34dd..6fe0119 100644 --- a/native/test_handover.c +++ b/native/test_handover.c @@ -44,7 +44,7 @@ static void connected_start(void){ media(PLAYBACK_CLEAR);handover_tick();answer(0,true,true);assert(capture_source==SOURCE_PICCAP); } int main(void){ - (void)target_save;(void)target_load;(void)target_connect;(void)handover_save;(void)handover_load; + (void)target_save;(void)target_load;(void)target_open;(void)handover_save;(void)handover_load; (void)retry_failure;(void)retry_take;(void)retry_update; assert(playback_json_init(&playback_json));call_once=mock_call;cancel_call=mock_cancel; handover_enabled=true;next_media_probe=UINT64_MAX; diff --git a/native/test_json_protocol.c b/native/test_json_protocol.c new file mode 100644 index 0000000..b07b78e --- /dev/null +++ b/native/test_json_protocol.c @@ -0,0 +1,60 @@ +#define _DEFAULT_SOURCE +#include +#include +#include "target.h" +#include "layout.h" +int main(void) { + (void)target_open; + (void)target_save; + (void)target_load; + (void)layout_from_json; + PlaybackJson json = {0}; + assert(!playback_parse_exact(&json, "{}")); + assert(playback_json_init(&json)); + const char *invalid[] = {"{", + "{\"success\":true", + "{}garbage", + "{} {}", + "[1,", + "{\"success\":tru}", + "{\"a\":\"unterminated}"}; + for (unsigned i = 0; i < sizeof(invalid) / sizeof(*invalid); i++) + assert(!playback_parse_exact(&json, invalid[i])); + void *root = playback_parse_exact(&json, " {\"success\":true} \t\r"); + assert(root && playback_true(&json, root, "success")); + json.put(root); + const char *wrong[] = { + "[]", "true", "1", "\"success\"", "{\"success\":1}", "{\"success\":\"true\"}"}; + for (unsigned i = 0; i < sizeof(wrong) / sizeof(*wrong); i++) { + root = playback_parse_exact(&json, wrong[i]); + assert(!playback_true(&json, root, "success")); + if (root) + json.put(root); + } + TargetConfig original = target, next = original; + assert(!target_parse( + &json, "{\"host\":\"127.0.0.1\",\"receiver\":\"Hyperion\",\"port\":19444}garbage", &next)); + assert(!memcmp(&next, &original, sizeof(next))); + Calibration profile = calibration; + assert(!calibration_parse(&json, "{\"profile\":\"average\"}{}", &profile)); + assert(!memcmp(&profile, &calibration, sizeof(profile))); + /* Deterministic malformed-input mutations; every successful parse is freed. + * This is regression coverage, not a claim of exhaustive fuzzing. */ + const char seed[] = "{\"success\":true,\"tan\":1,\"data\":{\"leds\":[0,255,128]}}"; + uint32_t random = 0x12345678; + for (unsigned i = 0; i < 5000; i++) { + char text[sizeof(seed) + 4]; + memcpy(text, seed, sizeof(seed)); + random = random * 1664525u + 1013904223u; + unsigned position = random % (sizeof(seed) - 1); + text[position] = (char)(1 + (random >> 16) % 126); + if (i % 3 == 0) + text[position] = 0; + root = playback_parse_exact(&json, text); + if (root) + json.put(root); + } + dlclose(json.lib); + puts("PASS: exact JSON framing, truncated/trailing data, unexpected types, unchanged settings " + "on rejection and 5000 deterministic parser mutations"); +} diff --git a/native/test_layout_refresh.c b/native/test_layout_refresh.c index c256324..3989c29 100644 --- a/native/test_layout_refresh.c +++ b/native/test_layout_refresh.c @@ -5,8 +5,8 @@ #include "target.h" #include "layout.h" #include "diagnostics.h" -static unsigned led_count; -static LedBlend *led_blends; +#include "runtime_context.h" +static ReceiverLayoutContext receiver_layout; #include "layout_refresh.h" static void completed(unsigned count,unsigned generation){ layout_probe.mapping=calloc(count,sizeof(LedBlend));assert(layout_probe.mapping); @@ -14,22 +14,22 @@ static void completed(unsigned count,unsigned generation){ layout_probe.leds=count;layout_probe.stage=TEST_DONE;layout_probe_generation=generation; } int main(void){ - (void)target_connect;(void)target_load;(void)target_save; + (void)target_open;(void)target_load;(void)target_save; PlaybackJson json={0};assert(playback_json_init(&json)); calibration.kind=CAL_G4;next_layout_check=UINT64_MAX; - completed(50,calibration_generation);layout_refresh_tick(&json,1000,true); - assert(led_count==50 && layout_updates==1 && last_layout_check==1000); - completed(500,calibration_generation);layout_refresh_tick(&json,2000,true); - assert(led_count==500 && layout_updates==2 && last_layout_check==2000); - completed(500,calibration_generation);layout_refresh_tick(&json,3000,true);assert(layout_updates==2); + completed(50,calibration_generation);layout_refresh_tick(&json,1000,true,&receiver_layout); + assert(receiver_layout.count==50 && layout_updates==1 && last_layout_check==1000); + completed(500,calibration_generation);layout_refresh_tick(&json,2000,true,&receiver_layout); + assert(receiver_layout.count==500 && layout_updates==2 && last_layout_check==2000); + completed(500,calibration_generation);layout_refresh_tick(&json,3000,true,&receiver_layout);assert(layout_updates==2); completed(500,calibration_generation);layout_probe.mapping[42].weight^=512; - layout_refresh_tick(&json,4000,true);assert(layout_updates==3); - LedBlend *previous=led_blends;layout_probe.stage=TEST_FAILED;strcpy(layout_probe.error,"Invalid layout"); - layout_refresh_tick(&json,5000,true);assert(led_blends==previous && led_count==500 && layout_updates==3 && layout_error[0]); + layout_refresh_tick(&json,4000,true,&receiver_layout);assert(layout_updates==3); + LedBlend *previous=receiver_layout.blends;layout_probe.stage=TEST_FAILED;strcpy(layout_probe.error,"Invalid layout"); + layout_refresh_tick(&json,5000,true,&receiver_layout);assert(receiver_layout.blends==previous && receiver_layout.count==500 && layout_updates==3 && layout_error[0]); completed(50,calibration_generation);calibration_generation++; - layout_refresh_tick(&json,6000,true);assert(led_count==500 && led_blends==previous);assert(probe_busy(&layout_probe)); - layout_refresh_tick(&json,6100,false);assert(!probe_busy(&layout_probe) && layout_probe.fd==-1); - assert(led_count==500 && led_blends==previous); - layout_refresh_reset(7000);free(led_blends);test_close(); + layout_refresh_tick(&json,6000,true,&receiver_layout);assert(receiver_layout.count==500 && receiver_layout.blends==previous);assert(probe_busy(&layout_probe)); + layout_refresh_tick(&json,6100,false,&receiver_layout);assert(!probe_busy(&layout_probe) && layout_probe.fd==-1); + assert(receiver_layout.count==500 && receiver_layout.blends==previous); + layout_refresh_reset(7000);free(receiver_layout.blends);test_close(); puts("PASS: automatic 50/500 LED replacement, same-count position changes, unchanged layout, invalid reply retention, obsolete profile result and cancellation");return 0; } diff --git a/native/test_receiver_output.c b/native/test_receiver_output.c new file mode 100644 index 0000000..8d37da4 --- /dev/null +++ b/native/test_receiver_output.c @@ -0,0 +1,181 @@ +#define _DEFAULT_SOURCE +#include +#include +#include +#include +#include +#include "target.h" +#include "layout.h" +#include "receiver_output.h" +static unsigned request_tan(const char *request) { + const char *p = strstr(request, "\"tan\":"); + assert(p); + return (unsigned)strtoul(p + 6, NULL, 10); +} +static void ack(int fd, unsigned id, bool layout) { + char data[256]; + int n = snprintf(data, sizeof(data), + layout ? "{\"success\":true,\"tan\":%u,\"info\":{\"leds\":[{\"hmin\":0," + "\"hmax\":1,\"vmin\":0,\"vmax\":0.1}]}}\n" + : "{\"success\":true,\"tan\":%u}\n", + id); + assert(send(fd, data, 7, MSG_NOSIGNAL) == 7); + usleep(1000); + assert(send(fd, data + 7, n - 7, MSG_NOSIGNAL) == n - 7); +} +static void fixture(PlaybackJson *json, int mode) { + int server = socket(AF_INET, SOCK_STREAM, 0); + assert(server >= 0); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr = {htonl(INADDR_LOOPBACK)}}; + assert(!bind(server, (void *)&address, sizeof(address)) && !listen(server, 1)); + socklen_t size = sizeof(address); + assert(!getsockname(server, (void *)&address, &size)); + TargetConfig config = target; + config.port = ntohs(address.sin_port); + strcpy(config.token, "fixture"); + pid_t child = fork(); + assert(child >= 0); + if (!child) { + signal(SIGPIPE, SIG_IGN); + int fd = accept(server, NULL, NULL); + assert(fd >= 0); + FILE *input = fdopen(dup(fd), "r"); + char line[1024]; + assert(fgets(line, sizeof(line), input)); + assert(strstr(line, "authorize")); + ack(fd, request_tan(line), false); + assert(fgets(line, sizeof(line), input) && strstr(line, "serverinfo")); + unsigned id = request_tan(line); + if (mode == 1) { + send(fd, "{\"success\":true", 15, MSG_NOSIGNAL); + goto done; + } + if (mode == 2) { + send(fd, "{} garbage\n", 11, MSG_NOSIGNAL); + goto done; + } + if (mode == 3) { + send(fd, "[]\n", 3, MSG_NOSIGNAL); + goto done; + } + if (mode == 4) { + char *huge = malloc(2 * 1024 * 1024 + 64); + assert(huge); + memset(huge, 'x', 2 * 1024 * 1024 + 64); + size_t sent = 0; + while (sent < 2 * 1024 * 1024 + 64) { + ssize_t n = send(fd, huge + sent, 2 * 1024 * 1024 + 64 - sent, MSG_NOSIGNAL); + if (n <= 0) + break; + sent += (size_t)n; + } + free(huge); + goto done; + } + if (mode == 6) { + const char bytes[] = "{}\0ignored\n"; + send(fd, bytes, sizeof(bytes) - 1, MSG_NOSIGNAL); + goto done; + } + if (mode == 5) { + char bad[128]; + int n = snprintf(bad, sizeof(bad), "{\"success\":\"true\",\"tan\":%u}\n", id); + send(fd, bad, n, MSG_NOSIGNAL); + goto done; + } + ack(fd, id, true); + assert(fgets(line, sizeof(line), input) && strstr(line, "[1,2,3]")); + id = request_tan(line); + /* Unrelated/stale acknowledgements must not release the in-flight frame. */ + ack(fd, id + 99, false); + usleep(50000); + ack(fd, id, false); + assert(fgets(line, sizeof(line), input) && strstr(line, "[7,8,9]")); + ack(fd, request_tan(line), false); + assert(fgets(line, sizeof(line), input) && strstr(line, "clear")); + ack(fd, request_tan(line), false); + done: + usleep(20000); + fclose(input); + close(fd); + close(server); + _exit(0); + } + ReceiverOutput output = {.fd = -1}; + assert(output_begin(&output, &config, 1000)); + bool first = false, queued = false, stopping = false; + unsigned iterations = 0; + for (; iterations < 5000; iterations++) { + output_tick(&output, json, 1000 + iterations); + if (output.acknowledgements == 1 && queued && !stopping) + assert(output.awaiting && !output.latest); + if (output.stage == OUTPUT_FAILED || (stopping && output.stage == OUTPUT_IDLE)) + break; + if (output.stage == OUTPUT_READY && !first) { + first = true; + assert(output_frame(&output, "{\"command\":\"color\",\"color\":[1,2,3]}\n")); + } else if (first && output.awaiting && !queued) { + queued = true; + assert(output_frame(&output, "{\"command\":\"color\",\"color\":[4,5,6]}\n")); + assert(output_frame(&output, "{\"command\":\"color\",\"color\":[7,8,9]}\n")); + } + if (output.acknowledgements == 2 && !stopping) { + stopping = true; + output_stop(&output, 1000 + iterations, true); + } + usleep(1000); + } + assert(iterations < 5000); + if (mode) + assert(output.stage == OUTPUT_FAILED); + else + assert(first && queued && stopping && output.stage == OUTPUT_IDLE); + output_close(&output); + int status; + waitpid(child, &status, 0); + assert(WIFEXITED(status) && !WEXITSTATUS(status)); + close(server); +} +int main(void) { + (void)target_open; + (void)target_parse; + (void)target_load; + (void)target_save; + PlaybackJson json = {0}; + assert(playback_json_init(&json)); + for (int mode = 0; mode <= 6; mode++) + fixture(&json, mode); + ReceiverOutput output = {.fd = -1}; + int sockets[2]; + assert(!socketpair(AF_UNIX, SOCK_STREAM, 0, sockets)); + output.fd = sockets[0]; + output.stage = OUTPUT_READY; + output.incoming = malloc(2 * 1024 * 1024 + 1); + assert(output.incoming); + assert(!fcntl(sockets[0], F_SETFL, O_NONBLOCK)); + char block[4096] = {0}; + while (send(sockets[0], block, sizeof(block), MSG_NOSIGNAL) > 0) { + } + assert(errno == EAGAIN || errno == EWOULDBLOCK); + assert(output_frame(&output, "{\"command\":\"color\",\"color\":[0,0,0]}\n")); + output_tick(&output, &json, 1000); + assert(output.stage == OUTPUT_READY && output.request && output.sent == 0); + output_tick(&output, &json, 4000); + assert(output.stage == OUTPUT_FAILED); + close(sockets[1]); + output_close(&output); + assert(!socketpair(AF_UNIX, SOCK_STREAM, 0, sockets)); + output.fd = sockets[0]; + output.stage = OUTPUT_READY; + output.incoming = malloc(2 * 1024 * 1024 + 1); + assert(output.incoming); + close(sockets[1]); + assert(output_frame(&output, "{\"command\":\"clear\"}\n")); + output_tick(&output, &json, 5000); + assert(output.stage == OUTPUT_FAILED); + output_close(&output); + dlclose(json.lib); + puts("PASS: asynchronous login/layout, fragmented and unrelated replies, latest-frame " + "coalescing, clear/stop, truncation, invalid JSON/types, oversized responses, send " + "backpressure, timeout and disconnect"); +} diff --git a/native/test_receiver_stream.c b/native/test_receiver_stream.c index 99ba967..aee1188 100644 --- a/native/test_receiver_stream.c +++ b/native/test_receiver_stream.c @@ -104,7 +104,7 @@ int main(void) { assert(!target_parse(&json, "{}", ©)); assert(!target_save("/no/such/profile", ©)); assert(!target_load(&json, "/no/such/profile", ©)); - int fd = target_connect(©); + int fd = target_open(©); if (fd >= 0) close(fd); dlclose(json.lib); diff --git a/native/test_service_lifecycle.c b/native/test_service_lifecycle.c new file mode 100644 index 0000000..39df8c8 --- /dev/null +++ b/native/test_service_lifecycle.c @@ -0,0 +1,162 @@ +#define DEV_MODE "/tmp/hyperdrm-lifecycle-marker" +#define main service_entry_point +#include "service.c" +#undef main +#include +static unsigned initializes, finalizes; +static int fake_handle; +static char answer[8192]; +static bool fake_reply(void *h, void *m, const char *s, LSError *e) { + (void)h; + (void)m; + (void)e; + snprintf(answer, sizeof(answer), "%s", s); + return true; +} +static const char *fake_payload(void *m) { + return m; +} +static bool fake_error_init(LSError *e) { + memset(e, 0, sizeof(*e)); + return true; +} +static void fake_error_free(LSError *e) { + (void)e; +} +static bool fake_call(void *h, const char *u, const char *p, Callback cb, void *ctx, + unsigned long *t, LSError *e) { + (void)h; + (void)u; + (void)p; + (void)cb; + (void)ctx; + (void)t; + (void)e; + return false; +} +static bool fake_cancel(void *h, unsigned long t, LSError *e) { + (void)h; + (void)t; + (void)e; + return true; +} +static void *fake_init(bool (*p)(void)) { + (void)p; + initializes++; + return &fake_handle; +} +static int fake_preset(void *h, int p) { + assert(h == &fake_handle); + (void)p; + return 0; +} +static int fake_get(void *h, Histogram *hist) { + assert(h == &fake_handle); + for (unsigned r = 0; r < 16; r++) + for (unsigned c = 0; c < 3; c++) + hist[r].bins[c][80 + c * 20] = 100; + return 0; +} +static int fake_finish(void *h) { + assert(h == &fake_handle); + finalizes++; + return 0; +} +static unsigned request_id(const char *line) { + const char *p = strstr(line, "\"tan\":"); + assert(p); + return strtoul(p + 6, NULL, 10); +} +static void success(int fd, unsigned id, bool layout) { + char data[256]; + int n = snprintf(data, sizeof(data), + layout ? "{\"success\":true,\"tan\":%u,\"info\":{\"leds\":[{\"hmin\":0," + "\"hmax\":1,\"vmin\":0,\"vmax\":0.1}]}}\n" + : "{\"success\":true,\"tan\":%u}\n", + id); + assert(send(fd, data, n, MSG_NOSIGNAL) == n); +} +int main(void) { + assert(access(DEV_MODE, F_OK) != 0); + reply = fake_reply; + payload = fake_payload; + error_init = fake_error_init; + error_free = fake_error_free; + call_once = fake_call; + cancel_call = fake_cancel; + vt_init = fake_init; + vt_preset = fake_preset; + vt_get = fake_get; + vt_finish = fake_finish; + assert(playback_json_init(&playback_json)); + next_layout_check = UINT64_MAX; + calibration.kind = CAL_G4; + int server = socket(AF_INET, SOCK_STREAM, 0); + assert(server >= 0); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr = {htonl(INADDR_LOOPBACK)}}; + assert(!bind(server, (void *)&address, sizeof(address)) && !listen(server, 2)); + socklen_t size = sizeof(address); + assert(!getsockname(server, (void *)&address, &size)); + target.port = ntohs(address.sin_port); + assert(start((void *)1, "{}", NULL) && strstr(answer, "connecting")); + assert(receiver_runtime.starting && !capture_ctx.handle && !initializes); + assert(stop((void *)1, "{}", NULL)); + assert(!receiver_runtime.starting && receiver_runtime.output.fd < 0 && !initializes); + int cancelled = accept(server, NULL, NULL); + assert(cancelled >= 0); + close(cancelled); + tick(NULL); + assert(!initializes && retries.paused); + pid_t child = fork(); + assert(child >= 0); + if (!child) { + int fd = accept(server, NULL, NULL); + assert(fd >= 0); + FILE *input = fdopen(dup(fd), "r"); + char line[2048]; + assert(fgets(line, sizeof(line), input) && strstr(line, "serverinfo")); + success(fd, request_id(line), true); + assert(fgets(line, sizeof(line), input) && strstr(line, "color")); + unsigned id = request_id(line); + usleep(200000); + success(fd, id, false); + assert(fgets(line, sizeof(line), input) && strstr(line, "clear")); + success(fd, request_id(line), false); + usleep(20000); + fclose(input); + close(fd); + close(server); + _exit(0); + } + assert(start((void *)1, "{}", NULL)); + for (unsigned i = 0; i < 1000 && !receiver_runtime.output.awaiting; i++) { + tick(NULL); + usleep(1000); + } + for (unsigned i = 0; i < 1000 && (!capture_ctx.handle || !receiver_runtime.output.awaiting); + i++) { + tick(NULL); + usleep(1000); + } + assert(capture_ctx.handle && initializes == 1 && receiver_runtime.output.awaiting); + uint64_t before = milliseconds(); + assert(status((void *)1, "{}", NULL)); + assert(milliseconds() - before < 100 && strstr(answer, "\"running\":true")); + before = milliseconds(); + assert(stop((void *)1, "{}", NULL)); + assert(milliseconds() - before < 100); + assert(!capture_ctx.handle && finalizes == 1 && retries.paused); + for (unsigned i = 0; i < 1000 && receiver_runtime.output.stage != OUTPUT_IDLE; i++) { + tick(NULL); + usleep(1000); + } + assert(receiver_runtime.output.stage == OUTPUT_IDLE && initializes == 1 && finalizes == 1); + int result; + waitpid(child, &result, 0); + assert(WIFEXITED(result) && !WEXITSTATUS(result)); + assert(access(DEV_MODE, F_OK) != 0); + close(server); + dlclose(playback_json.lib); + puts("PASS: service Start/Stop during connect, delayed receiver ACK leaves Status/Stop " + "responsive, fake capture lifecycle, asynchronous clear and no restart after Stop"); +} diff --git a/native/test_source_bridge.c b/native/test_source_bridge.c index a506aac..d80eb66 100644 --- a/native/test_source_bridge.c +++ b/native/test_source_bridge.c @@ -14,7 +14,8 @@ static CaptureSource capture_source; static struct {bool paused,blocked;} retries; static struct {char receiver[16];} target={"Hyperion"}; static struct {int stage;bool valid;uint64_t updated;unsigned sequence,count;LedBlend *mapping;unsigned char colors[16][3];} stream; -static Continuity continuity; +#include "runtime_context.h" +static CaptureContext capture_ctx; static bool piccap_connected; static char *queued; static bool stream_queue(const char *line){if(queued)return false;queued=strdup(line);return queued!=NULL;} @@ -23,21 +24,21 @@ static char *spatial_image_command_duration(const unsigned char colors[16][3],un static void consume(void){free(queued);queued=NULL;} int main(void){ LedBlend mapping={0,0,0};stream.mapping=&mapping;stream.count=1;stream.stage=4; - for(unsigned r=0;r<16;r++){continuity.last[r][0]=200;stream.colors[r][2]=200;}continuity.available=true;continuity.valid_at=1000; - capture_source=SOURCE_PICCAP_STARTING;bridge_tick(1000);assert(bridge_phase==1 && strstr(queued,"200,0,0"));consume(); - capture_source=SOURCE_PICCAP;piccap_connected=true;bridge_tick(1200);assert(bridge_phase==2 && strstr(queued,"clear"));consume(); - stream.valid=true;stream.sequence=1;stream.updated=1300;bridge_tick(1300);assert(bridge_phase==2); - stream.sequence=2;stream.updated=1400;bridge_tick(1400);assert(bridge_phase==3 && strstr(queued,"200,0,0"));consume(); - bridge_tick(1650);assert(strstr(queued,"100,0,100"));consume(); - bridge_tick(1900);assert(!bridge_phase && strstr(queued,"clear"));consume(); - capture_source=SOURCE_PICCAP_STOPPING;stream.updated=2000;bridge_tick(2000);assert(continuity.fading && continuity.last[0][2]==200 && strstr(queued,"0,0,200"));consume(); - capture_source=SOURCE_HISTOGRAM;bridge_tick(2100);assert(!queued); - capture_source=SOURCE_PICCAP_STARTING;continuity.valid_at=3000;bridge_tick(3000);consume();bridge_tick(6000);assert(!bridge_phase && strstr(queued,"clear"));consume(); - capture_source=SOURCE_PICCAP_STARTING;bridge_source=SOURCE_HISTOGRAM;continuity.valid_at=7000;bridge_tick(7000);consume();retries.paused=true;bridge_tick(7100);assert(!bridge_phase && strstr(queued,"clear"));consume(); - retries.paused=false;bridge_source=SOURCE_HISTOGRAM;continuity.valid_at=8000;capture_source=SOURCE_PICCAP_STARTING;bridge_tick(8000);consume(); - capture_source=SOURCE_PICCAP;bridge_tick(8100);consume();stream.sequence++;stream.updated=8300;memset(stream.colors,0,sizeof(stream.colors));bridge_tick(8300);assert(!bridge_phase && !queued); - bridge_source=SOURCE_HISTOGRAM;capture_source=SOURCE_PICCAP_STARTING;continuity.valid_at=1000;bridge_tick(9000);assert(!bridge_phase && !queued); - stream.stage=1;bridge_phase=1;bridge_started=9000;bridge_tick(12000);assert(!queued);retries.paused=true;bridge_tick(12100);assert(!bridge_phase && !queued); + for(unsigned r=0;r<16;r++){capture_ctx.continuity.last[r][0]=200;stream.colors[r][2]=200;}capture_ctx.continuity.available=true;capture_ctx.continuity.valid_at=1000; + capture_source=SOURCE_PICCAP_STARTING;bridge_tick(1000,&capture_ctx);assert(bridge_phase==1 && strstr(queued,"200,0,0"));consume(); + capture_source=SOURCE_PICCAP;piccap_connected=true;bridge_tick(1200,&capture_ctx);assert(bridge_phase==2 && strstr(queued,"clear"));consume(); + stream.valid=true;stream.sequence=1;stream.updated=1300;bridge_tick(1300,&capture_ctx);assert(bridge_phase==2); + stream.sequence=2;stream.updated=1400;bridge_tick(1400,&capture_ctx);assert(bridge_phase==3 && strstr(queued,"200,0,0"));consume(); + bridge_tick(1650,&capture_ctx);assert(strstr(queued,"100,0,100"));consume(); + bridge_tick(1900,&capture_ctx);assert(!bridge_phase && strstr(queued,"clear"));consume(); + capture_source=SOURCE_PICCAP_STOPPING;stream.updated=2000;bridge_tick(2000,&capture_ctx);assert(capture_ctx.continuity.fading && capture_ctx.continuity.last[0][2]==200 && strstr(queued,"0,0,200"));consume(); + capture_source=SOURCE_HISTOGRAM;bridge_tick(2100,&capture_ctx);assert(!queued); + capture_source=SOURCE_PICCAP_STARTING;capture_ctx.continuity.valid_at=3000;bridge_tick(3000,&capture_ctx);consume();bridge_tick(6000,&capture_ctx);assert(!bridge_phase && strstr(queued,"clear"));consume(); + capture_source=SOURCE_PICCAP_STARTING;bridge_source=SOURCE_HISTOGRAM;capture_ctx.continuity.valid_at=7000;bridge_tick(7000,&capture_ctx);consume();retries.paused=true;bridge_tick(7100,&capture_ctx);assert(!bridge_phase && strstr(queued,"clear"));consume(); + retries.paused=false;bridge_source=SOURCE_HISTOGRAM;capture_ctx.continuity.valid_at=8000;capture_source=SOURCE_PICCAP_STARTING;bridge_tick(8000,&capture_ctx);consume(); + capture_source=SOURCE_PICCAP;bridge_tick(8100,&capture_ctx);consume();stream.sequence++;stream.updated=8300;memset(stream.colors,0,sizeof(stream.colors));bridge_tick(8300,&capture_ctx);assert(!bridge_phase && !queued); + bridge_source=SOURCE_HISTOGRAM;capture_source=SOURCE_PICCAP_STARTING;capture_ctx.continuity.valid_at=1000;bridge_tick(9000,&capture_ctx);assert(!bridge_phase && !queued); + stream.stage=1;bridge_phase=1;bridge_started=9000;bridge_tick(12000,&capture_ctx);assert(!queued);retries.paused=true;bridge_tick(12100,&capture_ctx);assert(!bridge_phase && !queued); strcpy(target.receiver,"HyperHDR");char *line=region_command(stream.colors,&mapping,1,500);assert(!strcmp(line,"image"));free(line);assert(!region_command(stream.colors,NULL,0,500)); puts("PASS: both source transitions, fresh sample gate, fade midpoint/end, bounded timeout, manual-stop cleanup");return 0; } diff --git a/native/test_target.c b/native/test_target.c index adf6502..59de5e6 100644 --- a/native/test_target.c +++ b/native/test_target.c @@ -3,6 +3,13 @@ #include #include #include "target.h" +static int fixture_connect(const TargetConfig *target){ + int fd=target_open(target);if(fd<0)return -1; + assert(fcntl(fd,F_GETFL,0)&O_NONBLOCK); + struct pollfd event={fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error); + if(poll(&event,1,1000)<=0 || getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size) || error){close(fd);return -1;} + return fd; +} int main(void){ PlaybackJson j={0};assert(playback_json_init(&j));TargetConfig t=target; assert(target_valid(&t));assert(t.piccap_port==19400);assert(target_piccap_local(&t)); @@ -22,7 +29,7 @@ int main(void){ assert(target_parse(&j,"{\"host\":\"127.0.0.1\",\"port\":19444,\"receiver\":\"HyperHDR\",\"clearToken\":true}",&t));assert(!t.token[0]);assert(target_piccap_local(&t));unlink(file); int server=socket(AF_INET,SOCK_STREAM,0);assert(server>=0);struct sockaddr_in a={.sin_family=AF_INET,.sin_addr={htonl(INADDR_LOOPBACK)}}; assert(bind(server,(void*)&a,sizeof(a))==0 && listen(server,1)==0);socklen_t length=sizeof(a);assert(getsockname(server,(void*)&a,&length)==0);t.port=ntohs(a.sin_port); - int client=target_connect(&t);assert(client>=0);int accepted=accept(server,NULL,NULL);assert(accepted>=0);close(accepted);close(client);close(server); - assert(target_connect(&t)<0); - puts("PASS: target validation, token retention/removal, private persistence, IPv6 parsing, bounded TCP connect and refusal");return 0; + int client=fixture_connect(&t);assert(client>=0);int accepted=accept(server,NULL,NULL);assert(accepted>=0);close(accepted);close(client);close(server); + assert(fixture_connect(&t)<0); + puts("PASS: target validation, token retention/removal, private persistence, IPv6 parsing, nonblocking socket and refused connection");return 0; } diff --git a/native/version.h b/native/version.h index 1ccdd7c..880184d 100644 --- a/native/version.h +++ b/native/version.h @@ -1,4 +1,4 @@ #ifndef HYPERDRM_VERSION_H #define HYPERDRM_VERSION_H -#define HYPERDRM_VERSION "0.12.0" +#define HYPERDRM_VERSION "0.13.0" #endif diff --git a/package/packageinfo.json b/package/packageinfo.json index 3c7fd06..74ef435 100644 --- a/package/packageinfo.json +++ b/package/packageinfo.json @@ -1,6 +1,6 @@ { "id": "org.webosbrew.histogramhyperion", - "version": "0.12.0", + "version": "0.13.0", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": [ diff --git a/release.py b/release.py index 3b5a1b0..4b0e8d7 100644 --- a/release.py +++ b/release.py @@ -53,6 +53,7 @@ def verify_package(version): for directory, filename in [('applications', 'appinfo.json'), ('packages', 'packageinfo.json')]: entry = archive.extractfile(f'usr/palm/{directory}/org.webosbrew.histogramhyperion/{filename}') assert json.load(entry)['version'] == version + assert archive.extractfile('usr/palm/applications/org.webosbrew.histogramhyperion/version.js').read() == (ROOT / 'app/version.js').read_bytes() binary = archive.extractfile('usr/palm/services/org.webosbrew.histogramhyperion.service/histogram-service').read() assert binary[:4] == b'\x7fELF' and binary[4] == 1 and binary[18:20] == b'\x28\x00' assert version.encode() in binary @@ -62,6 +63,7 @@ def verify_package(version): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--push', action='store_true') + parser.add_argument('--sanitizers', action='store_true', help='Run parser, atomic-save, output and service lifecycle tests with ASan/UBSan') options = parser.parse_args() version = synchronize() run(sys.executable, '-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py') @@ -75,6 +77,12 @@ def main(): name = '/tmp/hyperdrm-' + test.stem linux('gcc', '-Wall', '-Wextra', '-Werror', '-O2', str(test.relative_to(ROOT)).replace('\\', '/'), '-ldl', '-lm', '-o', name) linux(name) + if options.sanitizers: + for name in ('json_protocol', 'atomic_file', 'receiver_output', 'service_lifecycle'): + destination = '/tmp/hyperdrm-sanitized-' + name + linux('gcc', '-Wall', '-Wextra', '-Werror', '-O1', '-g', '-fsanitize=address,undefined', + 'native/test_' + name + '.c', '-ldl', '-lm', '-o', destination) + linux(destination) linux('arm-linux-gnueabi-gcc', '-Wall', '-Wextra', '-Werror', '-O2', '-rdynamic', 'native/service.c', '-ldl', '-o', 'service/histogram-service') run(sys.executable, 'build_package.py') diff --git a/service/histogram-service b/service/histogram-service index d956250..4f5ad3d 100644 Binary files a/service/histogram-service and b/service/histogram-service differ diff --git a/sync_version.py b/sync_version.py index bc39eca..91d7221 100644 --- a/sync_version.py +++ b/sync_version.py @@ -21,6 +21,10 @@ def synchronize(check=False): path = ROOT / 'native/version.h' if not path.exists() or path.read_text(encoding='utf-8') != header: updates[path] = header + path = ROOT / 'app/version.js' + script = "'use strict';\nvar HYPERDRM_APP_VERSION = '" + version + "';\n" + if not path.exists() or path.read_text(encoding='utf-8') != script: + updates[path] = script if check and updates: raise ValueError('Version metadata is stale; run sync_version.py') for path, content in updates.items(): diff --git a/tests/receiver-ui.test.js b/tests/receiver-ui.test.js index 71936d7..68e8677 100644 --- a/tests/receiver-ui.test.js +++ b/tests/receiver-ui.test.js @@ -1,6 +1,6 @@ 'use strict'; const assert=require('assert'),fs=require('fs'),vm=require('vm'); -const ids=['host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics','profile','customEditor','customProfile','saveProfile','profileStatus','calibrate','validateCalibration','cancelCalibration','editProfile','customProfileField','overviewTab','receiverTab','calibrationTab','calibrationShortcut','hostField','portField','piccapPortField','tokenField','grantRoot','rootHint']; +const ids=['reloadService','versionWarning','host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics','profile','customEditor','customProfile','saveProfile','profileStatus','calibrate','validateCalibration','cancelCalibration','editProfile','customProfileField','overviewTab','receiverTab','calibrationTab','calibrationShortcut','hostField','portField','piccapPortField','tokenField','grantRoot','rootHint']; const elements={}; ids.forEach(id=>elements[id]={value:'',textContent:'',disabled:false,checked:false,attributes:{},style:{},addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}}); const requests=[]; diff --git a/tests/test_version.py b/tests/test_version.py index 8657a32..0b90206 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -34,6 +34,12 @@ class VersionTests(unittest.TestCase): self.assertEqual(app, {'version': '1.2.3', 'id': 'unchanged'}) self.assertIn('"1.2.3"', (sync_version.ROOT / 'native/version.h').read_text()) + def test_ui_drift_is_rejected(self): + sync_version.synchronize() + (sync_version.ROOT / 'app/version.js').write_text("var HYPERDRM_APP_VERSION = 'old';") + with self.assertRaises(ValueError): + sync_version.synchronize(check=True) + def test_invalid_version_is_rejected(self): (sync_version.ROOT / 'VERSION').write_text('1.2\n') with self.assertRaises(ValueError): diff --git a/tests/version-compatibility.test.js b/tests/version-compatibility.test.js new file mode 100644 index 0000000..5e3a836 --- /dev/null +++ b/tests/version-compatibility.test.js @@ -0,0 +1,21 @@ +'use strict'; +const assert=require('assert'),fs=require('fs'),vm=require('vm'); +const elements={},requests=[];let serviceVersion='0.12.0'; +for(const match of fs.readFileSync('app/index.html','utf8').matchAll(/id="([^"]+)"/g))elements[match[1]]={style:{},value:'',textContent:'',disabled:false,attributes:{},addEventListener(){},focus(){},appendChild(){},getAttribute(k){return this.attributes[k];},setAttribute(k,v){this.attributes[k]=v;}}; +class Bridge { + call(uri){requests.push(uri);this.onservicecallback(JSON.stringify(uri.endsWith('/status')?{returnValue:true,version:serviceVersion,piccapInstalled:false,receiverConnecting:true,connectionTest:{state:'Not tested'}}:{returnValue:true}));} + cancel(){} +} +const c={document:{getElementById(id){assert(elements[id],id);return elements[id];},createElement(){return {style:{}};}},window:{},PalmServiceBridge:Bridge,setupRemoteNavigation(){},setInterval(){},setTimeout(){return 1;},clearTimeout(){}}; +vm.createContext(c);vm.runInContext(fs.readFileSync('app/version.js','utf8'),c);vm.runInContext(fs.readFileSync('app/app.js','utf8'),c); +assert(!c.versionCompatible && elements.start.disabled && elements.saveTarget.disabled); +assert(elements.versionWarning.textContent.includes('0.12.0'));assert.equal(elements.reloadService.style.display,'inline-block'); +elements.reloadService.onclick();assert(requests.some(uri=>uri.endsWith('/restartService'))); +const count=requests.length;c.call('start',r=>assert.equal(r.returnValue,false));assert.equal(requests.length,count); +elements.stop.onclick();assert(requests.some(uri=>uri.endsWith('/stop'))); +serviceVersion=c.HYPERDRM_APP_VERSION;c.call('status',c.display); +assert(c.versionCompatible && !elements.start.disabled && elements.versionWarning.style.display==='none'); +assert.equal(elements.reloadService.style.display,'none');assert(elements.handover.disabled);assert(elements.status.textContent.includes('Connecting')); +serviceVersion=undefined;c.call('status',c.display);assert(!c.versionCompatible && elements.versionWarning.textContent.includes('unknown')); +c.display({returnValue:false,errorText:'Disconnected'});assert(!c.versionCompatible); +console.log('PASS: old/missing service versions block mutations, Stop remains available, matching service restores controls and unavailable PicCap remains disabled');