Timers 1.5.0: the LTC capture tool installs itself; tiles warn in one line #27
@@ -10,13 +10,23 @@
|
|||||||
* onUpdate roughly 4×/s:
|
* onUpdate roughly 4×/s:
|
||||||
*
|
*
|
||||||
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
||||||
* { error: '<what is wrong and how to fix it>' } not capturing
|
* { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
|
||||||
*
|
*
|
||||||
* The capture binary is per-machine (gitignored): if it is missing this
|
* Error strings are two-tier on purpose: `error` is what a dashboard tile
|
||||||
* tries one swiftc compile of the source next to it, and otherwise says to
|
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||||
* build it on any same-architecture Mac and copy it over. A dead child is
|
* for the admin health line and the log.
|
||||||
* restarted with backoff — fast for a crash, slow for config-shaped
|
*
|
||||||
* failures (unknown device, ambiguous name) that never fix themselves.
|
* The capture binary is per-machine (gitignored), and a catalog install or
|
||||||
|
* update ships source only — the update even wipes a previously built
|
||||||
|
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
|
||||||
|
* background as part of coming up: kicked when the LTC source starts and by
|
||||||
|
* the admin device picker, never on the event loop (a blocking build once
|
||||||
|
* froze the whole server — see spl-meter's history), built to a temp name
|
||||||
|
* and renamed so a killed build can't leave a half-written "binary", and
|
||||||
|
* rebuilt when a module update leaves the source newer than the binary.
|
||||||
|
* A dead capture child is restarted with backoff — fast for a crash, slow
|
||||||
|
* for config-shaped failures (unknown device, ambiguity) that never fix
|
||||||
|
* themselves.
|
||||||
*
|
*
|
||||||
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
@@ -27,7 +37,7 @@
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn, spawnSync } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
@@ -41,6 +51,100 @@ const RUNNING_WINDOW_MS = 400;
|
|||||||
/** stderr line the capture prints once the device is open. */
|
/** stderr line the capture prints once the device is open. */
|
||||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||||
|
|
||||||
|
/* ── the capture tool: build it when the install didn't bring one ──────── */
|
||||||
|
|
||||||
|
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||||
|
const TOOL_BUILDING = 'Capture tool building…';
|
||||||
|
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||||
|
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
|
||||||
|
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
|
||||||
|
+ 'the module folder) and copy the binary next to ltc-capture.swift';
|
||||||
|
|
||||||
|
let building = false;
|
||||||
|
let buildFailed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure the capture tool exists, building it in the background when it
|
||||||
|
* does not — module scope, so the LTC source can kick it the moment it
|
||||||
|
* starts and the admin device picker can kick it before any device is
|
||||||
|
* configured (a listener-only build would deadlock: no tool → no device
|
||||||
|
* list → no device → no listener). A catalog install ships source only and
|
||||||
|
* a catalog update deletes the module folder (old binary included), so this
|
||||||
|
* IS the install step for the tool.
|
||||||
|
*
|
||||||
|
* Never blocks (a synchronous build once held the whole server's event loop
|
||||||
|
* — every module — for the duration). Builds to a temp name and renames, so
|
||||||
|
* a killed or crashed build can't leave a truncated file that looks like a
|
||||||
|
* ready binary. A binary older than the source (a module update over an
|
||||||
|
* existing folder) keeps serving while a replacement builds behind it.
|
||||||
|
*
|
||||||
|
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
|
||||||
|
* is usable right now; otherwise `error` is the short tile text and
|
||||||
|
* `detail` the full one.
|
||||||
|
*/
|
||||||
|
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||||
|
const bin = path.join(moduleDir, 'ltc-capture');
|
||||||
|
const src = bin + '.swift';
|
||||||
|
const ok = { ready: true, error: '', detail: '' };
|
||||||
|
let haveBin = false;
|
||||||
|
let stale = false;
|
||||||
|
try {
|
||||||
|
haveBin = fs.existsSync(bin);
|
||||||
|
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
|
||||||
|
} catch { /* treat as missing */ }
|
||||||
|
if (haveBin && !stale) return ok;
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
|
||||||
|
}
|
||||||
|
const notYet = haveBin ? ok : {
|
||||||
|
ready: false,
|
||||||
|
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
|
||||||
|
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
|
||||||
|
};
|
||||||
|
if (building || buildFailed) return notYet;
|
||||||
|
|
||||||
|
building = true;
|
||||||
|
const tmp = bin + '.building';
|
||||||
|
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
|
||||||
|
: 'ltc-capture binary missing — building it in the background');
|
||||||
|
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
let buildErr = '';
|
||||||
|
build.stderr.setEncoding('utf8');
|
||||||
|
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||||
|
const giveUp = setTimeout(() => {
|
||||||
|
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||||
|
}, 300000);
|
||||||
|
giveUp.unref?.();
|
||||||
|
build.on('error', (err) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false;
|
||||||
|
buildFailed = true;
|
||||||
|
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||||
|
log(`swiftc could not be run: ${err.message}`);
|
||||||
|
});
|
||||||
|
build.on('exit', (code) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false;
|
||||||
|
if (code === 0 && fs.existsSync(tmp)) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, bin);
|
||||||
|
} catch (err) {
|
||||||
|
buildFailed = true;
|
||||||
|
log(`ltc-capture built but could not be moved into place: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildFailed = false;
|
||||||
|
log('ltc-capture built');
|
||||||
|
if (typeof onReady === 'function') onReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildFailed = true;
|
||||||
|
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||||
|
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||||
|
});
|
||||||
|
return notYet;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
* onUpdate:(u:object)=>void}} opts
|
* onUpdate:(u:object)=>void}} opts
|
||||||
@@ -75,26 +179,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
return 'nosignal';
|
return 'nosignal';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureBinary() {
|
|
||||||
if (fs.existsSync(bin)) return '';
|
|
||||||
const src = bin + '.swift';
|
|
||||||
log('ltc-capture binary missing — trying a one-time swiftc build');
|
|
||||||
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
|
|
||||||
if (built.status === 0 && fs.existsSync(bin)) {
|
|
||||||
log('ltc-capture compiled');
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
|
|
||||||
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
|
|
||||||
+ 'and copy it here';
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const missing = ensureBinary();
|
const tool = ensureCaptureBinary({
|
||||||
if (missing) {
|
moduleDir,
|
||||||
report({ error: missing });
|
log,
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
onReady() {
|
||||||
|
// The build this start() was waiting on landed — go now.
|
||||||
|
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!tool.ready) {
|
||||||
|
report({ error: tool.error, detail: tool.detail });
|
||||||
|
// A build in flight restarts us itself when it lands; anything else is
|
||||||
|
// config-shaped and should back off hard rather than spin.
|
||||||
|
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +212,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
const channels = Number(m[2]);
|
const channels = Number(m[2]);
|
||||||
const rate = Number(m[3]);
|
const rate = Number(m[3]);
|
||||||
if (channel > channels) {
|
if (channel > channels) {
|
||||||
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
report({
|
||||||
|
error: 'Channel not on this device — see Admin',
|
||||||
|
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
|
||||||
|
});
|
||||||
try { child.kill(); } catch { /* exiting anyway */ }
|
try { child.kill(); } catch { /* exiting anyway */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,17 +237,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
report({ error: `LTC capture failed to start: ${err.message}` });
|
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
child = null;
|
child = null;
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
// The capture's own failure text (unknown device, ambiguous name,
|
// The capture's own failure text (unknown device, ambiguous name,
|
||||||
// permission hint) is the most useful thing we can show.
|
// permission hint) is the most useful DETAIL we have; the tile just
|
||||||
|
// needs to know the input is down and where to fix it.
|
||||||
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||||
const quickDeath = Date.now() - startedAt < 3000;
|
const quickDeath = Date.now() - startedAt < 3000;
|
||||||
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
report({
|
||||||
|
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
|
||||||
|
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
|
||||||
|
});
|
||||||
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,4 +294,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createLtcListener };
|
module.exports = { createLtcListener, ensureCaptureBinary };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.4.2",
|
"version": "1.5.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLtcListener } = require('./ltc-listener');
|
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
||||||
const { createTimerPhase } = require('./phase');
|
const { createTimerPhase } = require('./phase');
|
||||||
const manifest = require('./module.json');
|
const manifest = require('./module.json');
|
||||||
|
|
||||||
@@ -396,7 +396,16 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return lastError ? 'error' : 'connecting';
|
return lastError ? 'error' : 'connecting';
|
||||||
}
|
}
|
||||||
|
|
||||||
function message() {
|
/** Short, for the tiles — a dashboard warns, the admin page explains.
|
||||||
|
The client prefixes the source label, so no "ProPresenter" here. */
|
||||||
|
function tileMessage() {
|
||||||
|
if (!configured) return 'Not configured — see Admin';
|
||||||
|
if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||||
|
return 'Unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full story, for the admin health line. */
|
||||||
|
function adminMessage() {
|
||||||
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
|
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
|
||||||
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||||
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
||||||
@@ -404,7 +413,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ status: status(), message: message(), timers, updatedAt });
|
emit({ status: status(), message: tileMessage(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollTick() {
|
async function pollTick() {
|
||||||
@@ -450,7 +459,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: status(), message: message() };
|
return { status: status(), message: adminMessage() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -464,7 +473,9 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
let emit = () => {};
|
let emit = () => {};
|
||||||
let listener = null;
|
let listener = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
|
/** The listener's latest word: { state, time, fps, df, ageMs } |
|
||||||
|
{ error, detail } (error = short tile text, detail = the admin/log
|
||||||
|
story) | null while starting. */
|
||||||
let word = null;
|
let word = null;
|
||||||
|
|
||||||
function rateText() {
|
function rateText() {
|
||||||
@@ -516,8 +527,16 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
kind: 'ltc',
|
kind: 'ltc',
|
||||||
start(e) {
|
start(e) {
|
||||||
emit = e;
|
emit = e;
|
||||||
|
// Kick the capture-tool build right away: a catalog install ships
|
||||||
|
// source only and a catalog update wipes any previously built binary,
|
||||||
|
// so coming up after an install/update must rebuild it without
|
||||||
|
// waiting for a device to be picked or a listener to need it.
|
||||||
|
ensureCaptureBinary({ moduleDir, log });
|
||||||
if (!device) {
|
if (!device) {
|
||||||
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
|
word = {
|
||||||
|
error: 'Not configured — see Admin',
|
||||||
|
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
|
||||||
|
};
|
||||||
publish();
|
publish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -539,7 +558,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
listener = null;
|
listener = null;
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: status(), message: message() };
|
// The admin page gets the full story; the tiles got the short one.
|
||||||
|
return { status: status(), message: (word && word.error && word.detail) || message() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -588,7 +608,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
let silenceTimer = null;
|
let silenceTimer = null;
|
||||||
let backoff = BACKOFF_MIN_MS;
|
let backoff = BACKOFF_MIN_MS;
|
||||||
|
|
||||||
function message() {
|
/** Short, for the tiles; the lastError detail belongs to the admin line.
|
||||||
|
The client prefixes the source label, so it isn't repeated here. */
|
||||||
|
function tileMessage() {
|
||||||
|
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||||
|
if (state === 'connecting') return 'Connecting…';
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminMessage() {
|
||||||
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
|
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
|
||||||
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
||||||
return `unreachable — ${lastError}`;
|
return `unreachable — ${lastError}`;
|
||||||
@@ -596,7 +624,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ label, status: state, message: message(), timers, updatedAt });
|
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
function accept(raw) {
|
function accept(raw) {
|
||||||
@@ -774,7 +802,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
if (!stopped && !stream && via === 'poll') connectStream();
|
if (!stopped && !stream && via === 'poll') connectStream();
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: state, message: message() };
|
return { status: state, message: adminMessage() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1172,7 +1200,7 @@ module.exports = {
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
routes({ log }) {
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
||||||
@@ -1195,7 +1223,15 @@ module.exports = {
|
|||||||
|
|
||||||
// The admin device picker asks this machine what it can hear. The
|
// The admin device picker asks this machine what it can hear. The
|
||||||
// options shape matches the admin select's optionsRoute contract.
|
// options shape matches the admin select's optionsRoute contract.
|
||||||
|
// Opening the picker also kicks the capture-tool build when a catalog
|
||||||
|
// install/update left only the source — without this, no tool meant
|
||||||
|
// no device list, and no device meant nothing would ever build it.
|
||||||
'GET /ltc/devices': (req, res) => {
|
'GET /ltc/devices': (req, res) => {
|
||||||
|
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||||
|
if (!tool.ready) {
|
||||||
|
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const bin = path.join(__dirname, 'ltc-capture');
|
const bin = path.join(__dirname, 'ltc-capture');
|
||||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||||
let err = '';
|
let err = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user