Assign printers to a MACHINE and let whichever PC controls it install them, with the observed half kept strictly apart from the assigned half: a collector report can never write an assignment row. Adoption is explicit. Adds a singular relationship type so defaultprinter can mean exactly one, client scripts that converge a bay's queues and correct a drifted one in place, and a wave gate so a fleet-wide driver rollout cannot stampede its own share. On the container side: the compose stack never persisted the instance directory, so a rebuild discarded plugins.json and every upload. It is a volume now, the image ships instance/ owned by the app user so the mountpoint is writable, and the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended since 8.0 went end of life. The image also could not build at all: the frontend stage never copied the plugin staging script it runs. Adds a topology migration guide covering IIS to Docker and back.
496 lines
22 KiB
C#
496 lines
22 KiB
C#
// EventSaver - shopfloor event-advert screensaver.
|
|
// Two source modes, set in EventSaver.ini next to the .scr (no recompile):
|
|
// url=https://.../shopdb/api/slides/feed?surface=shopfloor -> pull from shopdb over
|
|
// HTTP, cache images locally, rotate the cache (no file share needed).
|
|
// folder=\\server\share\path -> read an SMB/local folder.
|
|
// url wins if both set. Strict order + per-slide seconds via order.txt (or the
|
|
// API's slides[].seconds). Cache survives a network blip (keeps last-good).
|
|
//
|
|
// Screensaver arg contract:
|
|
// /s show (fullscreen)
|
|
// /p <hwnd> preview (we no-op - keeps Windows happy)
|
|
// /c config (points user at the ini)
|
|
//
|
|
// Build (in-box .NET Framework, no SDK):
|
|
// C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
|
|
// /target:winexe /out:EventSaver.scr ^
|
|
// /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll,System.Web.Extensions.dll ^
|
|
// EventSaver.cs
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.Drawing.Drawing2D;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Web.Script.Serialization;
|
|
using System.Windows.Forms;
|
|
|
|
namespace EventSaver
|
|
{
|
|
internal static class Program
|
|
{
|
|
[STAThread]
|
|
private static void Main(string[] args)
|
|
{
|
|
string mode = "/s";
|
|
if (args.Length > 0) mode = args[0].ToLowerInvariant().Trim();
|
|
// strip a trailing ":hwnd" some callers append (e.g. /p:12345)
|
|
if (mode.StartsWith("/p")) mode = "/p";
|
|
if (mode.StartsWith("/c")) mode = "/c";
|
|
if (mode.StartsWith("/s")) mode = "/s";
|
|
|
|
if (mode == "/test")
|
|
{
|
|
// headless self-test: print the resolved playlist order and exit.
|
|
// lets CI / a display-less VM verify folder-read + order.txt + sort.
|
|
// winexe has no console in session 0, so write results to a
|
|
// file next to the exe (and Console too, for interactive runs).
|
|
Config tc = Config.Load();
|
|
string tf = tc.SourceFolder(true); // http mode: sync cache first
|
|
List<Slide> pl = Playlist.Build(tf, tc.Shuffle);
|
|
List<string> lines = new List<string>();
|
|
lines.Add((tc.Url.Length > 0 ? "url=" + tc.Url + " cache=" : "folder=") + tf);
|
|
lines.Add("interval=" + tc.IntervalSeconds + " shuffle=" + tc.Shuffle);
|
|
lines.Add("count=" + pl.Count);
|
|
for (int i = 0; i < pl.Count; i++)
|
|
lines.Add(string.Format("{0,2}: {1} (secs={2})", i + 1, Path.GetFileName(pl[i].Path), pl[i].Seconds));
|
|
foreach (string l in lines) Console.WriteLine(l);
|
|
try
|
|
{
|
|
string outDir = Path.GetDirectoryName(Application.ExecutablePath);
|
|
File.WriteAllLines(Path.Combine(outDir, "eventsaver-test-out.txt"), lines.ToArray());
|
|
}
|
|
catch { }
|
|
return;
|
|
}
|
|
if (mode == "/render")
|
|
{
|
|
// headless render check: draw the first slide onto a 1280x720
|
|
// black canvas with the same fit logic as OnPaint, save a PNG.
|
|
// Lets a display-less VM prove decode + letterbox actually work.
|
|
Config rc = Config.Load();
|
|
List<Slide> rpl = Playlist.Build(rc.SourceFolder(true), rc.Shuffle);
|
|
string outPng = args.Length > 1 ? args[1] : Path.Combine(
|
|
Path.GetDirectoryName(Application.ExecutablePath), "eventsaver-render.png");
|
|
using (Bitmap canvas = new Bitmap(1280, 720))
|
|
using (Graphics g = Graphics.FromImage(canvas))
|
|
{
|
|
g.Clear(Color.Black);
|
|
if (rpl.Count > 0)
|
|
{
|
|
using (FileStream fs = new FileStream(rpl[0].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|
using (Image img = Image.FromStream(fs))
|
|
{
|
|
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
|
Rectangle r = SaverForm.FitZoomPublic(img.Size, canvas.Size);
|
|
g.DrawImage(img, r);
|
|
}
|
|
}
|
|
canvas.Save(outPng, System.Drawing.Imaging.ImageFormat.Png);
|
|
}
|
|
return;
|
|
}
|
|
if (mode == "/c")
|
|
{
|
|
MessageBox.Show(
|
|
"Edit EventSaver.ini next to EventSaver.scr to set the image folder, interval, and order.",
|
|
"EventSaver", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
|
return;
|
|
}
|
|
if (mode == "/p")
|
|
{
|
|
// preview pane - do nothing, exit clean
|
|
return;
|
|
}
|
|
|
|
Application.EnableVisualStyles();
|
|
Application.SetCompatibleTextRenderingDefault(false);
|
|
|
|
Config cfg = Config.Load();
|
|
|
|
// one form per screen: primary shows slideshow, others stay black
|
|
List<Form> forms = new List<Form>();
|
|
Screen primary = Screen.PrimaryScreen;
|
|
foreach (Screen scr in Screen.AllScreens)
|
|
{
|
|
bool isPrimary = scr.Equals(primary);
|
|
SaverForm f = new SaverForm(scr, isPrimary ? cfg : null);
|
|
forms.Add(f);
|
|
}
|
|
foreach (Form f in forms) f.Show();
|
|
|
|
// Keep the monitor awake while the screensaver shows, so a shorter
|
|
// monitor-sleep policy can't blank the ads out from under us. Held
|
|
// for the life of the message loop, released on exit.
|
|
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
|
|
Application.Run(forms[0]);
|
|
SetThreadExecutionState(ES_CONTINUOUS);
|
|
}
|
|
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint SetThreadExecutionState(uint esFlags);
|
|
private const uint ES_CONTINUOUS = 0x80000000;
|
|
private const uint ES_DISPLAY_REQUIRED = 0x00000002;
|
|
private const uint ES_SYSTEM_REQUIRED = 0x00000001;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ config
|
|
internal sealed class Config
|
|
{
|
|
// No default: a share path belongs to a site, not to this program
|
|
// (ADR-015). Folder mode is the fallback for a site with no HTTP
|
|
// reach to ShopDB, and it must name its own path in EventSaver.ini.
|
|
public string Folder = "";
|
|
public string Url = ""; // set -> HTTP mode (pull from shopdb)
|
|
public string CacheDir = ""; // local cache for HTTP mode (computed)
|
|
public int IntervalSeconds = 10;
|
|
public bool Shuffle = false;
|
|
public int FadeMs = 600;
|
|
|
|
public static Config Load()
|
|
{
|
|
Config c = new Config();
|
|
try
|
|
{
|
|
string dir = Path.GetDirectoryName(Application.ExecutablePath);
|
|
string ini = Path.Combine(dir, "EventSaver.ini");
|
|
if (!File.Exists(ini)) return c;
|
|
|
|
foreach (string raw in File.ReadAllLines(ini))
|
|
{
|
|
string line = raw.Trim();
|
|
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
|
|
int eq = line.IndexOf('=');
|
|
if (eq <= 0) continue;
|
|
string key = line.Substring(0, eq).Trim().ToLowerInvariant();
|
|
string val = line.Substring(eq + 1).Trim();
|
|
|
|
if (key == "url" && val.Length > 0) c.Url = val;
|
|
else if (key == "folder" && val.Length > 0) c.Folder = val;
|
|
else if (key == "interval") { int n; if (int.TryParse(val, out n) && n > 0) c.IntervalSeconds = n; }
|
|
else if (key == "shuffle") c.Shuffle = (val == "1" || val.ToLowerInvariant() == "true");
|
|
else if (key == "fadems") { int n; if (int.TryParse(val, out n) && n >= 0) c.FadeMs = n; }
|
|
}
|
|
}
|
|
catch { /* bad ini - fall back to defaults */ }
|
|
return c;
|
|
}
|
|
|
|
// Folder the playlist reads: the local cache in HTTP mode (synced first
|
|
// when sync=true), else the configured share/folder. HTTP failures leave
|
|
// the last-good cache in place.
|
|
public string SourceFolder(bool sync)
|
|
{
|
|
if (Url.Length == 0) return Folder;
|
|
if (CacheDir.Length == 0)
|
|
CacheDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"EventSaver", "cache");
|
|
if (sync) { try { HttpSync.Sync(Url, CacheDir); } catch { } }
|
|
return CacheDir;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- http sync
|
|
// Pull the slide list from shopdb (/api/slides/feed), download images into a
|
|
// local cache dir, and write order.txt there so the normal Playlist logic
|
|
// reads the cache exactly like a folder. Idempotent: only downloads images
|
|
// not already cached, prunes ones no longer listed, keeps last-good on error.
|
|
internal static class HttpSync
|
|
{
|
|
public static void Sync(string apiUrl, string cacheDir)
|
|
{
|
|
try { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; } catch { }
|
|
if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir);
|
|
|
|
string json;
|
|
using (WebClient wc = new WebClient()) { wc.Encoding = Encoding.UTF8; json = wc.DownloadString(apiUrl); }
|
|
|
|
JavaScriptSerializer js = new JavaScriptSerializer();
|
|
IDictionary<string, object> root = js.DeserializeObject(json) as IDictionary<string, object>;
|
|
if (root == null) return;
|
|
object ok;
|
|
if (!root.TryGetValue("success", out ok) || !(ok is bool) || !((bool)ok)) return;
|
|
string basepath = root.ContainsKey("basepath") ? Convert.ToString(root["basepath"]) : "";
|
|
object slidesObj;
|
|
if (!root.TryGetValue("slides", out slidesObj)) return;
|
|
object[] arr = slidesObj as object[];
|
|
if (arr == null) return;
|
|
|
|
Uri apiUri = new Uri(apiUrl);
|
|
// The feed's basepath is host-absolute (/api/slides/img/...) and omits
|
|
// the app's mount (e.g. /shopdb) - the web client adds it via withBase,
|
|
// so we must too, else images resolve to the host root and 404. Derive
|
|
// the mount from the feed URL's path (everything before "/api/").
|
|
string mount = "";
|
|
int apiIdx = apiUri.AbsolutePath.IndexOf("/api/", StringComparison.OrdinalIgnoreCase);
|
|
if (apiIdx > 0) mount = apiUri.AbsolutePath.Substring(0, apiIdx);
|
|
HashSet<string> keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
List<string> order = new List<string>();
|
|
order.Add("# generated by EventSaver from " + apiUrl);
|
|
|
|
foreach (object o in arr)
|
|
{
|
|
IDictionary<string, object> s = o as IDictionary<string, object>;
|
|
if (s == null) continue;
|
|
string fn = s.ContainsKey("filename") ? Convert.ToString(s["filename"]) : null;
|
|
if (string.IsNullOrEmpty(fn)) continue;
|
|
string safe = Path.GetFileName(fn); // strip any path component
|
|
if (safe.Length == 0) continue;
|
|
int secs = 0;
|
|
if (s.ContainsKey("seconds")) { int n; if (int.TryParse(Convert.ToString(s["seconds"]), out n) && n > 0) secs = n; }
|
|
|
|
string local = Path.Combine(cacheDir, safe);
|
|
if (!File.Exists(local))
|
|
{
|
|
try
|
|
{
|
|
// Host-absolute basepath -> prepend the mount; a full URL passes through.
|
|
string imgRef = basepath.StartsWith("/") ? mount + basepath : basepath;
|
|
Uri img = new Uri(apiUri, imgRef + Uri.EscapeDataString(safe));
|
|
using (WebClient wc = new WebClient()) { byte[] b = wc.DownloadData(img); File.WriteAllBytes(local, b); }
|
|
}
|
|
catch { continue; } // couldn't fetch this one - skip it this round
|
|
}
|
|
keep.Add(safe);
|
|
order.Add(secs > 0 ? safe + "|" + secs : safe);
|
|
}
|
|
|
|
try { File.WriteAllLines(Path.Combine(cacheDir, "order.txt"), order.ToArray()); } catch { }
|
|
|
|
// prune cache images no longer referenced
|
|
foreach (string f in Directory.GetFiles(cacheDir))
|
|
{
|
|
string n = Path.GetFileName(f);
|
|
if (string.Equals(n, "order.txt", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (!keep.Contains(n)) { try { File.Delete(f); } catch { } }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- playlist
|
|
// Builds ordered file list. order.txt wins (strict sequence, one name per
|
|
// line, optional "name|seconds" per-slide duration). Else sort by name.
|
|
internal sealed class Slide
|
|
{
|
|
public string Path;
|
|
public int Seconds; // 0 = use default interval
|
|
}
|
|
|
|
internal static class Playlist
|
|
{
|
|
private static readonly string[] Exts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
|
|
|
|
public static List<Slide> Build(string folder, bool shuffle)
|
|
{
|
|
List<Slide> list = new List<Slide>();
|
|
try
|
|
{
|
|
if (!Directory.Exists(folder)) return list;
|
|
|
|
string orderFile = Path.Combine(folder, "order.txt");
|
|
if (File.Exists(orderFile))
|
|
{
|
|
// strict sequence from order.txt
|
|
foreach (string raw in File.ReadAllLines(orderFile))
|
|
{
|
|
string line = raw.Trim();
|
|
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
|
|
int secs = 0;
|
|
string name = line;
|
|
int bar = line.IndexOf('|');
|
|
if (bar > 0)
|
|
{
|
|
name = line.Substring(0, bar).Trim();
|
|
int n; if (int.TryParse(line.Substring(bar + 1).Trim(), out n) && n > 0) secs = n;
|
|
}
|
|
string full = Path.Combine(folder, name);
|
|
if (IsImage(full) && File.Exists(full))
|
|
list.Add(new Slide { Path = full, Seconds = secs });
|
|
}
|
|
return list; // order.txt is authoritative - do not append extras
|
|
}
|
|
|
|
// no order.txt - all images, sorted by filename
|
|
List<string> files = new List<string>();
|
|
foreach (string f in Directory.GetFiles(folder))
|
|
if (IsImage(f)) files.Add(f);
|
|
files.Sort(StringComparer.OrdinalIgnoreCase);
|
|
if (shuffle) Shuf(files);
|
|
foreach (string f in files) list.Add(new Slide { Path = f, Seconds = 0 });
|
|
}
|
|
catch { /* share unreachable - return what we have (maybe empty) */ }
|
|
return list;
|
|
}
|
|
|
|
private static bool IsImage(string path)
|
|
{
|
|
string e = Path.GetExtension(path).ToLowerInvariant();
|
|
foreach (string x in Exts) if (x == e) return true;
|
|
return false;
|
|
}
|
|
|
|
// deterministic-enough shuffle; screensaver so exact randomness irrelevant
|
|
private static void Shuf(List<string> l)
|
|
{
|
|
Random r = new Random();
|
|
for (int i = l.Count - 1; i > 0; i--)
|
|
{
|
|
int j = r.Next(i + 1);
|
|
string t = l[i]; l[i] = l[j]; l[j] = t;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- saver form
|
|
internal sealed class SaverForm : Form
|
|
{
|
|
private readonly Config cfg; // null on non-primary screens (black only)
|
|
private readonly Timer timer;
|
|
private List<Slide> slides = new List<Slide>();
|
|
private int idx = -1;
|
|
private Image current;
|
|
private Point lastMouse = Point.Empty;
|
|
private bool mouseSeen = false;
|
|
private DateTime lastScan = DateTime.MinValue;
|
|
|
|
public SaverForm(Screen screen, Config c)
|
|
{
|
|
cfg = c;
|
|
FormBorderStyle = FormBorderStyle.None;
|
|
Bounds = screen.Bounds;
|
|
StartPosition = FormStartPosition.Manual;
|
|
BackColor = Color.Black;
|
|
TopMost = true;
|
|
ShowInTaskbar = false;
|
|
DoubleBuffered = true;
|
|
Cursor.Hide();
|
|
|
|
KeyPreview = true;
|
|
// Left/Right step through the slides by hand; ANY other key still
|
|
// wakes the machine, which is what a screensaver must do. Without
|
|
// that exception an operator tapping an arrow to get back to work
|
|
// would be stuck watching slides.
|
|
KeyDown += (s, e) =>
|
|
{
|
|
if (e.KeyCode == Keys.Left) { e.Handled = true; Step(-1); return; }
|
|
if (e.KeyCode == Keys.Right) { e.Handled = true; Step(1); return; }
|
|
Quit();
|
|
};
|
|
MouseDown += (s, e) => Quit();
|
|
MouseMove += OnMove;
|
|
|
|
if (cfg != null)
|
|
{
|
|
cfg.SourceFolder(true); // HTTP mode: initial sync + set CacheDir
|
|
Rescan();
|
|
timer = new Timer();
|
|
timer.Interval = 1000; // tick every second; advance when slide's time is up
|
|
timer.Tick += OnTick;
|
|
timer.Start();
|
|
Advance(); // show first immediately
|
|
}
|
|
}
|
|
|
|
private int slideElapsed = 0;
|
|
private DateTime lastSync = DateTime.Now; // ctor already did the first sync
|
|
private void OnTick(object sender, EventArgs e)
|
|
{
|
|
// HTTP mode: re-pull from shopdb every 60s so manager edits propagate.
|
|
if (cfg.Url.Length > 0 && (DateTime.Now - lastSync).TotalSeconds >= 60)
|
|
{
|
|
lastSync = DateTime.Now;
|
|
try { HttpSync.Sync(cfg.Url, cfg.CacheDir); } catch { }
|
|
lastScan = DateTime.MinValue; // force the rescan below
|
|
}
|
|
// periodic rescan so edits appear without restarting the saver
|
|
if ((DateTime.Now - lastScan).TotalSeconds >= 30) Rescan();
|
|
|
|
slideElapsed++;
|
|
int want = (slides.Count > 0 && idx >= 0 && slides[idx].Seconds > 0)
|
|
? slides[idx].Seconds : cfg.IntervalSeconds;
|
|
if (slideElapsed >= want) Advance();
|
|
}
|
|
|
|
private void Rescan()
|
|
{
|
|
lastScan = DateTime.Now;
|
|
List<Slide> fresh = Playlist.Build(cfg.SourceFolder(false), cfg.Shuffle);
|
|
slides = fresh;
|
|
if (idx >= slides.Count) idx = -1;
|
|
}
|
|
|
|
private void Advance()
|
|
{
|
|
Step(1);
|
|
}
|
|
|
|
// delta of +1 is the timer advancing, -1 is the operator going back.
|
|
// Resets the dwell timer either way: stepping by hand and then having it
|
|
// move again a moment later, because the tick was nearly up, reads as
|
|
// the screensaver ignoring the keypress.
|
|
private void Step(int delta)
|
|
{
|
|
slideElapsed = 0;
|
|
if (slides.Count == 0) { SetImage(null); return; }
|
|
if (idx < 0) idx = (delta < 0) ? 0 : -1; // first Step lands on slide 0
|
|
idx = ((idx + delta) % slides.Count + slides.Count) % slides.Count;
|
|
try
|
|
{
|
|
// load without locking the file on the share
|
|
Image img;
|
|
using (FileStream fs = new FileStream(slides[idx].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|
img = Image.FromStream(fs);
|
|
SetImage(img);
|
|
}
|
|
catch { SetImage(null); }
|
|
}
|
|
|
|
private void SetImage(Image img)
|
|
{
|
|
Image old = current;
|
|
current = img;
|
|
if (old != null) old.Dispose();
|
|
Invalidate();
|
|
}
|
|
|
|
protected override void OnPaint(PaintEventArgs e)
|
|
{
|
|
e.Graphics.Clear(Color.Black);
|
|
if (current == null) return;
|
|
e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
|
Rectangle r = FitZoom(current.Size, ClientSize);
|
|
e.Graphics.DrawImage(current, r);
|
|
}
|
|
|
|
// test hook - same math as OnPaint, exposed for the /render self-check.
|
|
public static Rectangle FitZoomPublic(Size img, Size box) { return FitZoom(img, box); }
|
|
|
|
// scale image to fit while preserving aspect (letterbox)
|
|
private static Rectangle FitZoom(Size img, Size box)
|
|
{
|
|
if (img.Width == 0 || img.Height == 0) return new Rectangle(0, 0, box.Width, box.Height);
|
|
double s = Math.Min((double)box.Width / img.Width, (double)box.Height / img.Height);
|
|
int w = (int)(img.Width * s);
|
|
int h = (int)(img.Height * s);
|
|
return new Rectangle((box.Width - w) / 2, (box.Height - h) / 2, w, h);
|
|
}
|
|
|
|
private void OnMove(object sender, MouseEventArgs e)
|
|
{
|
|
// ignore the first synthetic move; require real movement to exit
|
|
if (!mouseSeen) { mouseSeen = true; lastMouse = e.Location; return; }
|
|
if (Math.Abs(e.X - lastMouse.X) > 8 || Math.Abs(e.Y - lastMouse.Y) > 8) Quit();
|
|
}
|
|
|
|
private void Quit()
|
|
{
|
|
try { Cursor.Show(); } catch { }
|
|
Application.Exit();
|
|
}
|
|
}
|
|
}
|