Generate the collector script per site, and bring EventSaver into the repo
A site adopting ShopDB had to be handed two files and told what to edit in them. Both are now the product's, and one of them the server writes for you. GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with this site's values already in it: site_base_url becomes the -ApiUrl default and the new computers_routableranges setting becomes -AllowedRanges. Only the PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift from the first - and everything stamped stays overridable by argument or registry, because a bay may need to differ from its site. Settings > Computers > Asset reporter edits the ranges, downloads the script and shows its SHA-256. The collector key is deliberately not stamped in, and a test fails if it ever is. That file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly; it stays in the registry, provisioned per ADOPTING-AT-ANOTHER-SITE.md. The routable ranges are the last thing that was hardcoded in that script. They are now a setting, so West Jefferson's two CIDRs move out of source code and into that site's own configuration - which is what ADR-015 asks for - and a site that sets nothing still works, because the script falls back to the NIC carrying the default route. EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and EventSaver.ini, no compiled .scr - a binary is a release asset, like the installer exe. The share path that was compiled into Config.Folder is gone. It used to be the fallback when the ini was missing, which silently pointed a new site at the reference site's file server; it is now empty, and failing visibly beats displaying another site's slides. Verified by compiling the edited source in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0. Also: the DSC example in the adoption guide gains a CollectorRanges resource and stops passing -ApiUrl to a script that already reads BaseUrl from the registry the same example writes, and the guide points at the generated download instead of hand-editing a URL. The contract test caught the endpoint importing shopdb directly for the version string, which ADR-002 forbids a plugin from doing. The product and contract versions are in app.config now, which a plugin reads through current_app. Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and let the bay install them, with what the fleet data says about drivers - HP and Xerox cover 41 of 44 printers with universal drivers, there are no Brother printers at all despite 208 files of Brother inkjet drivers in the installer, and printerdrivers holds one row pointing at a per-model folder instead of a universal driver.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
"""Computers plugin API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, request, Response, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||
|
||||
computers_bp = Blueprint('computers', __name__)
|
||||
|
||||
@@ -1064,3 +1064,112 @@ def dashboard_sharedmachines():
|
||||
|
||||
out.sort(key=lambda r: -r['pccount'])
|
||||
return success_response(out)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Collector client script
|
||||
# =============================================================================
|
||||
|
||||
CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1'
|
||||
|
||||
|
||||
def _client_script_path():
|
||||
"""The reporter shipped with this plugin, which is the single source."""
|
||||
import os
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'client', CLIENT_SCRIPT_NAME)
|
||||
|
||||
|
||||
def _setting_value(key):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
return ((row.value if row else '') or '').strip()
|
||||
|
||||
|
||||
def _generate_client_script(source: str, baseurl: str, ranges: str,
|
||||
version: str, generatedon: str) -> str:
|
||||
"""Stamp a site's own values into the reporter's parameter defaults.
|
||||
|
||||
ONLY the defaults are substituted, never the body: the file in the repo
|
||||
stays runnable as-is, so there is no second copy to drift. Everything
|
||||
stamped here is overridable at runtime - the parameter still wins, then the
|
||||
registry - because a bay may need to differ from its site.
|
||||
|
||||
The collector key is NOT stamped in. This file lands on every shop-floor PC,
|
||||
and a token in a file on hundreds of bays cannot be rotated quietly; it is
|
||||
read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per
|
||||
ADOPTING-AT-ANOTHER-SITE.md.
|
||||
"""
|
||||
apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else ''
|
||||
header = (
|
||||
'# GENERATED by ShopDB {version} on {generatedon}\n'
|
||||
'# for {baseurl}\n'
|
||||
'#\n'
|
||||
'# Re-download after upgrading ShopDB: this copy matches that server\'s\n'
|
||||
'# collector contract. Edits here are lost on the next download - change\n'
|
||||
'# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n'
|
||||
'#\n'
|
||||
'# The collector key is deliberately NOT in this file. Provision it as\n'
|
||||
'# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n'
|
||||
'\n'
|
||||
).format(version=version, generatedon=generatedon,
|
||||
baseurl=baseurl or 'an unconfigured site (set site_base_url)')
|
||||
|
||||
out = source
|
||||
if apiurl:
|
||||
old = "[string]$ApiUrl = ''"
|
||||
assert old in out, 'the reporter no longer declares $ApiUrl as expected'
|
||||
out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1)
|
||||
if ranges:
|
||||
old = "[string]$AllowedRanges = ''"
|
||||
assert old in out, 'the reporter no longer declares $AllowedRanges as expected'
|
||||
out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1)
|
||||
return header + out
|
||||
|
||||
|
||||
@computers_bp.route('/client-script', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def download_client_script():
|
||||
"""The collector reporter, stamped with THIS site's values.
|
||||
|
||||
Admin-only. It carries no secret, but it does state a site's URL and its
|
||||
internal ranges, which is configuration rather than something to hand out.
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
path = _client_script_path()
|
||||
if not os.path.isfile(path):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'The collector script is not present in this install',
|
||||
http_code=404)
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
source = handle.read()
|
||||
|
||||
# A site that has not set its public URL still gets a usable script: the
|
||||
# browsing origin is the server the admin is talking to right now.
|
||||
baseurl = _setting_value('site_base_url') or request.url_root
|
||||
# From config, not an import: a plugin reaching into core is an ADR-002
|
||||
# violation and the contract test fails the build for it.
|
||||
version = current_app.config.get('VERSION') or 'unknown'
|
||||
generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
|
||||
|
||||
try:
|
||||
body = _generate_client_script(
|
||||
source, baseurl.strip(), _setting_value('computers_routableranges'),
|
||||
version, generatedon)
|
||||
except AssertionError as exc:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
|
||||
|
||||
digest = hashlib.sha256(body.encode('utf-8')).hexdigest()
|
||||
return Response(
|
||||
body,
|
||||
mimetype='text/plain; charset=utf-8',
|
||||
headers={
|
||||
'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME),
|
||||
# Published so a deployment can verify what it fetched, the same way
|
||||
# the installer publishes one.
|
||||
'X-Script-Sha256': digest,
|
||||
})
|
||||
|
||||
@@ -39,6 +39,12 @@ export default [
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/collector',
|
||||
name: 'collector-settings',
|
||||
component: () => import('./views/CollectorSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
|
||||
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Asset reporter</h2>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Shop-floor PCs report what they are to this server. The script below is
|
||||
generated with THIS site's values, so it downloads ready to deploy - there
|
||||
is nothing in it to find and edit.
|
||||
</p>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Routable ranges</label>
|
||||
<input v-model="ranges" type="text" class="form-control"
|
||||
placeholder="10.20.0.0/23,10.21.4.0/26" />
|
||||
<p class="field-hint">
|
||||
Comma-separated CIDRs for this site's corporate network. A bay with two
|
||||
NICs - a private controller NIC and a routable one - reports the
|
||||
address in these ranges. Leave it empty and the PC reports whichever
|
||||
NIC carries the default route, which is correct at most sites and needs
|
||||
no configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<h3>Download the reporter</h3>
|
||||
<p class="field-hint">
|
||||
Stamped with this server's URL and the ranges above, and with the version
|
||||
that generated it, so a script found on a bay can be traced back here.
|
||||
Re-download after upgrading ShopDB.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-secondary" @click="download" :disabled="downloading">
|
||||
{{ downloading ? 'Generating...' : 'Download Report-AssetToShopDB.ps1' }}
|
||||
</button>
|
||||
|
||||
<p v-if="digest" class="field-hint mono">
|
||||
SHA-256 {{ digest }}
|
||||
</p>
|
||||
|
||||
<p class="field-hint">
|
||||
<strong>The collector key is not in this file, deliberately.</strong> It
|
||||
lands on every shop-floor PC, and a token spread across hundreds of bays
|
||||
cannot be rotated quietly. Mint a token scoped to
|
||||
<code>collector.ingest</code> and provision it as
|
||||
<code>HKLM:\SOFTWARE\GE\ShopDB</code> value <code>CollectorKey</code> -
|
||||
the adoption guide has worked examples for Intune, DSC and GE-Enforce.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import api from '@/api'
|
||||
|
||||
const RANGES_KEY = 'computers_routableranges'
|
||||
|
||||
const ranges = ref('')
|
||||
const saving = ref(false)
|
||||
const downloading = ref(false)
|
||||
const digest = ref('')
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'computers' })
|
||||
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
|
||||
if (row) ranges.value = row.value || ''
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
await settingsApi.update(RANGES_KEY, String(ranges.value ?? ''))
|
||||
message.value = 'Saved. Re-download the script so it carries the new ranges.'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
downloading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// responseType text: this is a script, not JSON, and the hash the server
|
||||
// publishes is of exactly these bytes.
|
||||
const response = await api.get('/computers/client-script', { responseType: 'text' })
|
||||
digest.value = response.headers['x-script-sha256'] || ''
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'Report-AssetToShopDB.ps1'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (downloadError) {
|
||||
error.value = downloadError.response?.status === 403
|
||||
? 'Only an administrator can download the reporter'
|
||||
: 'Could not generate the script'
|
||||
console.error(downloadError)
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono { font-family: monospace; word-break: break-all; }
|
||||
.form-card h3 { margin-top: 0; }
|
||||
</style>
|
||||
@@ -162,6 +162,25 @@ class ComputersPlugin(BasePlugin):
|
||||
},
|
||||
}
|
||||
|
||||
def get_settings_cards(self) -> List[dict]:
|
||||
"""The asset reporter's own settings page.
|
||||
|
||||
It is a settings card rather than a docs page because it does two things
|
||||
an operator needs at the same moment: name this site's routable ranges,
|
||||
and download the reporter that carries them.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'Computers',
|
||||
'to': '/settings/collector',
|
||||
'icon': 'download',
|
||||
'title': 'Asset reporter',
|
||||
'description': 'Download the collector script stamped with this '
|
||||
'site\'s URL and ranges',
|
||||
'position': 26,
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""Settings this plugin owns.
|
||||
|
||||
@@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin):
|
||||
'description': 'Hours without a collector report before a PC '
|
||||
'is listed as not reporting on the dashboard.',
|
||||
},
|
||||
{
|
||||
'key': 'computers_routableranges',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'computers',
|
||||
'description': 'Comma-separated CIDRs naming this site\'s '
|
||||
'routable ranges, e.g. 10.20.0.0/23,10.21.4.0/26. '
|
||||
'Stamped into the collector script this server '
|
||||
'generates, so a bay with a controller NIC and a '
|
||||
'corporate NIC reports the right one. Blank uses '
|
||||
'the NIC carrying the default route, which needs '
|
||||
'no knowledge of a site\'s addressing.',
|
||||
},
|
||||
{
|
||||
'key': 'computers_machinelink_alerts',
|
||||
'value': 'false',
|
||||
|
||||
495
plugins/slides/client/EventSaver.cs
Normal file
495
plugins/slides/client/EventSaver.cs
Normal file
@@ -0,0 +1,495 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
19
plugins/slides/client/EventSaver.ini
Normal file
19
plugins/slides/client/EventSaver.ini
Normal file
@@ -0,0 +1,19 @@
|
||||
# EventSaver config. Lives next to EventSaver.scr.
|
||||
# Change these without recompiling. Screensaver re-reads on each launch.
|
||||
|
||||
# HTTP mode (recommended): pull slides from shopdb over HTTP, cache locally.
|
||||
# No file share needed. Point at the shopdb slides feed (/api/slides/feed) for this
|
||||
# surface. FIX THE BASE URL if the shopdb path differs on the live box.
|
||||
url=https://shopdb.example.net/api/slides/feed?surface=shopfloor
|
||||
|
||||
# Folder mode (fallback): used only if url is blank. SMB/local path.
|
||||
# folder=\\fileserver.example.net\share\tv\shopfloor
|
||||
|
||||
# Seconds per image (default when a slide has no per-slide time).
|
||||
interval=10
|
||||
|
||||
# 1 = random order, 0 = ordered. Ignored when order.txt / API order is present.
|
||||
shuffle=0
|
||||
|
||||
# Crossfade length in ms (0 = hard cut). Reserved - hard cut in v1.
|
||||
fadems=600
|
||||
Reference in New Issue
Block a user