Files
wwtools/mxaccesscli/src/MxAccess.Cli/Mx/MxSession.cs
T
Joseph Doherty 68eb9adae7 mxaccesscli: catch ArgumentException from AuthenticateUser as auth-failed
Under eOSUserBased galaxy security mode the LMXProxyServer raises
ArgumentException("Value does not fall within the expected range")
for bad credentials instead of silently returning 0 like permissive
(eNone) galaxies do. Both shapes mean "auth failed"; MxSession.Authenticate
now normalizes them into a 0 return so WriteCommand reports a clean
"authentication-failed" envelope and exits 1 — instead of crashing
with a stack trace.

Verified live against the ZB galaxy in eOSUserBased mode:
  bad password  -> ok=false, error="authentication-failed", exit 1
  good password -> ok=true,  auth_user_id=1, exit 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:43:55 -04:00

196 lines
7.6 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Windows.Forms;
using ArchestrA.MxAccess;
namespace MxAccess.Cli.Mx
{
/// Wraps the MxAccess COM proxy with caller-friendly primitives.
///
/// MxAccess events are dispatched as COM messages on the apartment that
/// called Register. Pure Monitor / AutoResetEvent waits do *not* drain
/// those messages reliably even on STA, so MxSession exposes a
/// `WaitForUpdate` that polls Application.DoEvents() instead. This is the
/// pattern Object Viewer / WindowViewer / aaTagViewer all use under the
/// hood — see docs/api-notes.md "Threading model".
public sealed class MxSession : IDisposable
{
private readonly LMXProxyServerClass _proxy;
private readonly int _hServer;
private readonly object _lock = new object();
private readonly Dictionary<int, MxItem> _itemsByHandle = new Dictionary<int, MxItem>();
private readonly ConcurrentQueue<MxUpdate> _updates = new ConcurrentQueue<MxUpdate>();
private bool _disposed;
public MxSession(string clientName)
{
_proxy = new LMXProxyServerClass();
_proxy.OnDataChange += OnDataChange;
_proxy.OnWriteComplete += OnWriteComplete;
_proxy.OperationComplete += OnOperationComplete;
_hServer = _proxy.Register(string.IsNullOrWhiteSpace(clientName) ? "mxa" : clientName);
}
public int ServerHandle => _hServer;
public MxItem AddItem(string itemRef)
{
if (string.IsNullOrWhiteSpace(itemRef))
throw new ArgumentException("Item reference must be non-empty.", nameof(itemRef));
var hItem = _proxy.AddItem(_hServer, itemRef);
var item = new MxItem(this, _proxy, _hServer, hItem, itemRef);
lock (_lock) _itemsByHandle[hItem] = item;
return item;
}
/// Authenticate a user against the galaxy and return the user id
/// to pass to subsequent Write/WriteSecured calls. Returns 0 on
/// failure (bad credentials, unknown user, galaxy auth disabled).
///
/// `verifyUser` is the credential string the proxy expects for its
/// configured galaxy authentication mode:
/// - osAuthenticationMode → `<domain-or-host>\<username>`
/// - galaxyAuthenticationMode → `<username>` only
/// - mixed/AAD → `<UPN>` (e.g. `user@example.com`)
public int Authenticate(string verifyUser, string password)
{
if (string.IsNullOrEmpty(verifyUser))
throw new ArgumentException("verifyUser must be non-empty.", nameof(verifyUser));
// Some galaxy configurations (e.g. eOSUserBased) cause the proxy to
// throw `ArgumentException: Value does not fall within the expected
// range` for bad credentials instead of returning 0 like the
// permissive (eNone) configuration does. Both shapes mean "auth
// failed"; the caller distinguishes via a non-zero return value.
try
{
return _proxy.AuthenticateUser(_hServer, verifyUser, password ?? string.Empty);
}
catch (ArgumentException)
{
return 0;
}
}
/// Pump COM messages while watching for an update that matches the predicate.
/// Returns true when one is captured, false on timeout.
public bool WaitForUpdate(Predicate<MxUpdate> match, TimeSpan timeout, out MxUpdate captured)
{
captured = null;
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
Application.DoEvents();
while (_updates.TryDequeue(out var u))
{
if (match(u)) { captured = u; return true; }
}
Thread.Sleep(20);
}
// One last drain after the deadline so we don't miss an event that arrived
// between the final Sleep and the loop exit.
Application.DoEvents();
while (_updates.TryDequeue(out var u))
{
if (match(u)) { captured = u; return true; }
}
return false;
}
/// Drain all pending updates without blocking. Caller must call PumpOnce()
/// in their own loop to keep the COM message queue moving.
public IEnumerable<MxUpdate> DrainUpdates()
{
while (_updates.TryDequeue(out var u)) yield return u;
}
/// Pump COM messages once. Used by streaming subscribers between drains.
public void PumpOnce(TimeSpan slice)
{
Application.DoEvents();
if (slice > TimeSpan.Zero) Thread.Sleep(slice);
}
internal void RemoveItem(int hItem)
{
lock (_lock) _itemsByHandle.Remove(hItem);
}
// ---- Event plumbing ----
private void OnDataChange(int hServer, int hItem, object value, int quality, object timestamp,
ref MXSTATUS_PROXY[] vars)
{
string itemRef;
lock (_lock) itemRef = _itemsByHandle.TryGetValue(hItem, out var it) ? it.Reference : null;
_updates.Enqueue(new MxUpdate
{
Kind = MxUpdateKind.DataChange,
ItemHandle = hItem,
ItemReference = itemRef,
Value = value,
Quality = quality,
Timestamp = TryFiletimeToDateTime(timestamp),
Statuses = MxStatusInfo.From(vars),
});
}
private void OnWriteComplete(int hServer, int hItem, ref MXSTATUS_PROXY[] vars)
{
string itemRef;
lock (_lock) itemRef = _itemsByHandle.TryGetValue(hItem, out var it) ? it.Reference : null;
_updates.Enqueue(new MxUpdate
{
Kind = MxUpdateKind.WriteComplete,
ItemHandle = hItem,
ItemReference = itemRef,
Statuses = MxStatusInfo.From(vars),
});
}
private void OnOperationComplete(int hServer, int hItem, ref MXSTATUS_PROXY[] vars)
{
string itemRef;
lock (_lock) itemRef = _itemsByHandle.TryGetValue(hItem, out var it) ? it.Reference : null;
_updates.Enqueue(new MxUpdate
{
Kind = MxUpdateKind.OperationComplete,
ItemHandle = hItem,
ItemReference = itemRef,
Statuses = MxStatusInfo.From(vars),
});
}
private static DateTime? TryFiletimeToDateTime(object ft)
{
if (ft == null) return null;
try
{
var asLong = Convert.ToInt64(ft);
return DateTime.FromFileTimeUtc(asLong).ToLocalTime();
}
catch { return null; }
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
MxItem[] items;
lock (_lock)
{
items = new MxItem[_itemsByHandle.Count];
_itemsByHandle.Values.CopyTo(items, 0);
_itemsByHandle.Clear();
}
foreach (var it in items)
{
try { it.Dispose(); } catch { }
}
try { _proxy.Unregister(_hServer); } catch { }
}
}
}