using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using System.Threading;
using System.Web.Script.Serialization;
using System.Windows.Forms;
// mscorlib keeps obsolete copies of these three in the parent namespace, so
// spelling out which one is meant is not optional — the aliases win the tie.
using TYPEATTR = System.Runtime.InteropServices.ComTypes.TYPEATTR;
using FUNCDESC = System.Runtime.InteropServices.ComTypes.FUNCDESC;
using IMPLTYPEFLAGS = System.Runtime.InteropServices.ComTypes.IMPLTYPEFLAGS;
using ParameterModifier = System.Reflection.ParameterModifier;
namespace ZKBridge
{
///
/// Exposes the ZK4500 over http://localhost:8787 so the ERP, running in a
/// browser, can use a USB scanner it could never reach on its own.
///
/// Quasar app --HTTP--> ZKBridge --ZKFPEngX (ActiveX)--> ZK4500
///
/// This build targets the classic ZKFinger SDK: the ZKFPEngX control,
/// CLSID {CA69969C-2F27-41D3-954D-A48B941C3BA7}, the one the SDK's own
/// Demo.exe uses. Everything is late-bound COM, so nothing has to be
/// compiled against the vendor's DLLs — if the demo runs on this PC,
/// this runs on this PC.
///
/// The control is an apartment-threaded ActiveX object that reports
/// captures through COM events, so one STA thread owns it and pumps
/// messages; HTTP threads hand it work and wait on signals.
///
internal static class Program
{
private const string Prefix = "http://localhost:8787/";
private static readonly Guid Clsid = new Guid("CA69969C-2F27-41D3-954D-A48B941C3BA7");
private const int DefaultTimeoutMs = 10000;
private const int EnrollTimeoutMs = 45000;
// ── State owned by the STA thread ─────────────────────────────────
private static dynamic _fp; // the ZKFPEngX control
private static Control _pump; // hidden control: Invoke marshals onto the STA
private static Form _host; // invisible window the OCX is sited on
private static Ax _ax; // its AxHost wrapper
private static bool _hosted; // windowed (like the demo) or bare COM
private static Guid _sourceIid;
private static readonly List _sinks = new List(); // keep delegates alive
private static int _cacheDb = -1;
private static int _loaded;
private static string _initError = "Starting…";
// ── Waiters the event handlers complete ───────────────────────────
private static readonly object Gate = new object(); // one scanner, one operation
private static ManualResetEventSlim _captureDone;
private static string _captureTemplate, _captureImage;
private static ManualResetEventSlim _enrollDone;
private static string _enrollTemplate, _enrollImage;
private static bool _enrollOk, _enrolling;
private static Mutex _single; // one bridge per machine
[STAThread]
private static void Main()
{
Console.Title = "ZK4500 Bridge (ZKFPEngX)";
// Two bridges fight over one scanner and both lose — the second
// window refuses to start instead.
_single = new Mutex(true, "ZKBridge-8787", out bool firstInstance);
if (!firstInstance)
{
Say("Another ZKBridge window is already running — use that one.");
Say("Press Enter to close this window.");
Console.ReadLine();
return;
}
// The control lives on this thread; WinForms gives it a message
// pump so its events actually arrive.
_pump = new Control();
var _ = _pump.Handle; // force handle creation
string problem = Open();
Say(problem ?? Ready());
var listener = new HttpListener();
listener.Prefixes.Add(Prefix);
try { listener.Start(); }
catch (HttpListenerException e)
{
Say("Cannot listen on " + Prefix + " — " + e.Message);
Say("If another ZKBridge window is open, close it. Otherwise run ZKBridge.exe as Administrator once, then start it normally.");
Console.ReadLine();
return;
}
Say("Listening on " + Prefix + " (close this window to stop)");
// Accept on a worker; handle each request on the pool. The STA
// thread stays free to pump COM events.
new Thread(() =>
{
while (listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = listener.GetContext(); } catch { break; }
ThreadPool.QueueUserWorkItem(delegate { Handle(ctx); });
}
}) { IsBackground = true }.Start();
Application.Run(); // the message pump
}
private static string Ready()
{
return string.Format("Scanner ready ({0}) — {1}x{2}, engine {3}{4}",
_hosted ? "windowed host" : "bare host",
(int)_fp.ImageWidth, (int)_fp.ImageHeight, (string)_fp.FPEngineVersion,
string.IsNullOrEmpty((string)_fp.SensorSN) ? "" : ", serial " + (string)_fp.SensorSN);
}
// ── Device lifecycle (STA only) ───────────────────────────────────
/// Creates and initialises the control. Null on success.
private static string Open()
{
try
{
Close();
Type t = Type.GetTypeFromCLSID(Clsid);
if (t == null) return Broken("The ZKFPEngX control is not registered. Run the SDK's setup.exe, or regsvr32 ZKFPEngX.ocx.");
// Site the control on a real (invisible) window, exactly the
// way the SDK's own demo hosts it on a dialog. Created bare,
// this control initialises and answers properties but never
// starts its capture loop — connected, yet deaf to fingers.
try
{
_host = new Form
{
ShowInTaskbar = false,
FormBorderStyle = FormBorderStyle.None,
StartPosition = FormStartPosition.Manual,
Location = new System.Drawing.Point(-2000, -2000),
Size = new System.Drawing.Size(1, 1),
Opacity = 0,
};
_ax = new Ax(Clsid.ToString());
_host.Controls.Add(_ax);
_host.Show(); // off-screen and transparent
_ax.CreateControl();
_fp = _ax.Ocx;
_hosted = _fp != null;
}
catch (Exception ex)
{
Say("Windowed hosting failed (" + ex.Message + ") — using bare COM instead.");
DisposeHost();
}
if (_fp == null)
{
_fp = Activator.CreateInstance(t);
_hosted = false;
}
int rc = (int)_fp.InitEngine();
if (rc != 0)
{
return Broken("InitEngine failed (code " + rc + "). Close Demo.exe and any other ZKBridge window, replug the reader, then press Reconnect.");
}
if ((int)_fp.SensorCount < 1)
{
return Broken("The engine started but no reader was found. Replug the ZK4500, then press Reconnect.");
}
// 10.0 is the modern template format; every stored print and
// the whole matching path stay in one consistent format.
try { _fp.FPEngineVersion = "10.0"; } catch { }
_fp.EnrollCount = 3;
// Capture mode until an enrolment explicitly starts. Register
// mode left on is the classic reason presses go silent.
try { _fp.IsRegister = false; } catch { }
HookEvents();
_fp.Active = true; // start listening for fingers
_initError = null;
return null;
}
catch (COMException e)
{
return Broken("COM error opening the scanner: " + e.Message);
}
catch (Exception e)
{
return Broken(e.Message);
}
}
///
/// A failed open, done properly: release the control (a half-open one
/// keeps the device hostage and makes every retry fail too) and store
/// the reason where /status can report it.
///
private static string Broken(string why)
{
Close();
_initError = why;
return why;
}
private static void Close()
{
_loaded = 0;
if (_fp != null)
{
try { if (_cacheDb > 0) _fp.FreeFPCacheDB(_cacheDb); } catch { }
_cacheDb = -1;
try { _fp.Active = false; } catch { }
try { _fp.EndEngine(); } catch { }
_fp = null;
}
DisposeHost();
}
private static void DisposeHost()
{
try { _ax?.Dispose(); } catch { }
try { _host?.Dispose(); } catch { }
_ax = null;
_host = null;
_hosted = false;
}
/// AxHost with the guts exposed — real OLE siting for the OCX.
private sealed class Ax : AxHost
{
public Ax(string clsid) : base(clsid) { }
public object Ocx { get { return GetOcx(); } }
}
// ── COM events, without an interop assembly ───────────────────────
//
// The dispids of OnCapture/OnEnroll differ between OCX builds, so they
// are read from the control's own type library at runtime: find the
// coclass's [default, source] dispinterface, note its IID, and map
// event names to dispids. ComEventsHelper then does the sink plumbing.
[ComImport, Guid("B196B283-BAB4-101A-B69C-00AA00341D07"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IProvideClassInfo
{
[return: MarshalAs(UnmanagedType.Interface)]
ITypeInfo GetClassInfo();
}
private delegate void Ev0();
private delegate void Ev1(object a);
private delegate void Ev2(object a, object b);
private delegate void Ev3(object a, object b, object c);
private delegate void Ev4(object a, object b, object c, object d);
private static void HookEvents()
{
// name, dispid, parameter count — read from the control's own
// type library, because dispids differ between OCX builds.
var events = new List>();
ITypeInfo coclass = ((IProvideClassInfo)_fp).GetClassInfo();
coclass.GetTypeAttr(out IntPtr pAttr);
var attr = (TYPEATTR)Marshal.PtrToStructure(pAttr, typeof(TYPEATTR));
int implCount = attr.cImplTypes;
coclass.ReleaseTypeAttr(pAttr);
for (int i = 0; i < implCount; i++)
{
coclass.GetImplTypeFlags(i, out IMPLTYPEFLAGS flags);
const IMPLTYPEFLAGS wanted = IMPLTYPEFLAGS.IMPLTYPEFLAG_FDEFAULT | IMPLTYPEFLAGS.IMPLTYPEFLAG_FSOURCE;
if ((flags & wanted) != wanted) continue;
coclass.GetRefTypeOfImplType(i, out int href);
coclass.GetRefTypeInfo(href, out ITypeInfo source);
source.GetTypeAttr(out IntPtr pSrcAttr);
var srcAttr = (TYPEATTR)Marshal.PtrToStructure(pSrcAttr, typeof(TYPEATTR));
_sourceIid = srcAttr.guid;
int funcs = srcAttr.cFuncs;
source.ReleaseTypeAttr(pSrcAttr);
for (int f = 0; f < funcs; f++)
{
source.GetFuncDesc(f, out IntPtr pFunc);
var fd = (FUNCDESC)Marshal.PtrToStructure(pFunc, typeof(FUNCDESC));
var names = new string[1];
source.GetNames(fd.memid, names, 1, out int _);
if (!string.IsNullOrEmpty(names[0])) events.Add(Tuple.Create(names[0], fd.memid, (int)fd.cParams));
source.ReleaseFuncDesc(pFunc);
}
break;
}
if (_sourceIid == Guid.Empty)
throw new InvalidOperationException("The control's event interface was not found in its type library.");
// Every event gets a sink: the interesting ones drive the logic,
// the rest are logged so a silent scanner can be diagnosed from
// this console alone.
var wired = new List();
foreach (var e in events)
{
string name = e.Item1;
Delegate handler;
switch (e.Item3)
{
case 0: handler = new Ev0(() => OnComEvent(name)); break;
case 1: handler = new Ev1(a => OnComEvent(name, a)); break;
case 2: handler = new Ev2((a, b) => OnComEvent(name, a, b)); break;
case 3: handler = new Ev3((a, b, c) => OnComEvent(name, a, b, c)); break;
case 4: handler = new Ev4((a, b, c, d) => OnComEvent(name, a, b, c, d)); break;
default: continue; // RFID card events we never use
}
ComEventsHelper.Combine(_fp, _sourceIid, e.Item2, handler);
_sinks.Add(handler);
wired.Add(name);
}
Say("Events wired: " + string.Join(", ", wired));
}
/// Every control event lands here: log it, then act on it.
private static void OnComEvent(string name, params object[] args)
{
try
{
Say(" · " + name + Fmt(args));
switch (name.ToLowerInvariant())
{
case "oncapture": CaptureArrived(args); break;
case "onenroll": EnrollArrived(args); break;
case "onfeatureinfo": FeatureArrived(); break;
}
}
catch (Exception e) { Say(name + ": " + e.Message); }
}
private static string Fmt(object[] args)
{
if (args == null || args.Length == 0) return "";
var parts = new List();
foreach (object a in args)
parts.Add(a == null ? "null" : (a is string s ? s : (a.GetType().IsArray ? "bytes" : a.ToString())));
return "(" + string.Join(", ", parts) + ")";
}
/// A print in capture mode. Fulfils a waiting /capture.
private static void CaptureArrived(object[] args)
{
if (_enrolling) return;
if (args.Length > 0 && !Truthy(args[0])) return;
Fulfil(TemplateFrom(args.Length > 1 ? args[1] : null));
}
///
/// Features extracted from a press — the earliest moment a template
/// exists. Some OCX builds never raise OnCapture outside register
/// mode, so a waiting /capture is fulfilled from here as well; Fulfil
/// keeps only the first result.
///
private static void FeatureArrived()
{
if (_enrolling || _captureDone == null) return;
Fulfil(TemplateFrom(null));
}
private static void Fulfil(string template)
{
var waiter = _captureDone;
if (waiter == null || waiter.IsSet || string.IsNullOrEmpty(template)) return;
_captureTemplate = template;
_captureImage = GrabImage();
waiter.Set();
}
/// Third press of an enrolment. The control merged the template itself.
private static void EnrollArrived(object[] args)
{
_enrolling = false;
try { _fp.IsRegister = false; } catch { }
if (_enrollDone == null) return;
_enrollOk = args.Length > 0 && Truthy(args[0]);
if (_enrollOk)
{
_enrollTemplate = TemplateFrom(args.Length > 1 ? args[1] : null);
_enrollImage = GrabImage();
_enrollOk = !string.IsNullOrEmpty(_enrollTemplate);
}
_enrollDone.Set();
}
/// The event's own template if usable, else the control's last one.
private static string TemplateFrom(object variantTemplate)
{
string s = null;
if (variantTemplate != null)
{
try { s = (string)_fp.EncodeTemplate1(variantTemplate); } catch { }
}
if (string.IsNullOrEmpty(s))
{
try { s = (string)_fp.GetTemplateAsString(); } catch { }
}
if (string.IsNullOrEmpty(s))
{
try { object v = _fp.GetVerTemplate(); s = (string)_fp.EncodeTemplate1(v); } catch { }
}
return s;
}
private static bool Truthy(object v)
{
try { return Convert.ToBoolean(v); } catch { return v != null; }
}
/// The last scanned image, as a data URI the app can show directly.
private static string GrabImage()
{
string path = Path.Combine(Path.GetTempPath(), "zkbridge-" + Guid.NewGuid().ToString("N") + ".jpg");
try
{
_fp.SaveJPG(path);
return "data:image/jpeg;base64," + Convert.ToBase64String(File.ReadAllBytes(path));
}
catch { return null; }
finally { try { File.Delete(path); } catch { } }
}
// ── Run-on-the-STA helper ─────────────────────────────────────────
private static T Sta(Func work)
{
return (T)_pump.Invoke(work);
}
// ── Endpoints ─────────────────────────────────────────────────────
private static Dictionary Status()
{
return Sta(() =>
{
bool open = _fp != null;
return new Dictionary
{
{ "success", true },
{ "connected", open && (int)_fp.SensorCount > 0 },
{ "width", open ? (int)_fp.ImageWidth : 0 },
{ "height", open ? (int)_fp.ImageHeight : 0 },
{ "loaded", _loaded },
{ "serial", open ? (string)_fp.SensorSN : null },
{ "host", open ? (_hosted ? "windowed" : "bare") : null },
{ "engine", open ? (string)_fp.FPEngineVersion : null },
{ "enrolling", _enrolling },
{ "enroll_remaining", open && _enrolling ? (object)(int)_fp.EnrollIndex : null },
{ "message", _initError },
};
});
}
private static Dictionary Reconnect()
{
lock (Gate)
{
string problem = Sta(() => Open());
return problem == null ? Reply(true, "Scanner reconnected.") : Fail(problem);
}
}
private static Dictionary Capture(Dictionary body)
{
lock (Gate)
{
if (NotOpen(out var no)) return no;
int timeout = Int(body, "timeoutMs", DefaultTimeoutMs);
_captureTemplate = _captureImage = null;
// Make sure the control is in capture mode and armed — both
// are no-ops when already true, and their absence is silence.
Sta