diff --git a/Data/DatabasePath.cs b/Data/DatabasePath.cs
index 2bb96cb..70cfe5a 100644
--- a/Data/DatabasePath.cs
+++ b/Data/DatabasePath.cs
@@ -10,6 +10,24 @@ public static class DatabasePath
{
private static string? _appDataFolder;
private static string? _dbPath;
+ private static string _productFolderName = "UtopiaCanteenSystem";
+
+ ///
+ /// Use "UtopiaCanteenBackend" for the Windows Service; default is legacy WPF folder name.
+ ///
+ public static void UseBackendServiceStorage()
+ {
+ _productFolderName = "UtopiaCanteenBackend";
+ _appDataFolder = null;
+ _dbPath = null;
+ }
+
+ public static void UseClientStorage()
+ {
+ _productFolderName = "UtopiaCanteenClient";
+ _appDataFolder = null;
+ _dbPath = null;
+ }
///
/// Folder under LocalApplicationData for DB and config. Created on first use.
@@ -21,7 +39,7 @@ public static class DatabasePath
_appDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "UtopiaCanteenSystem");
+ _productFolderName);
Directory.CreateDirectory(_appDataFolder);
return _appDataFolder;
}
diff --git a/Services/ClientConfigService.cs b/Services/ClientConfigService.cs
new file mode 100644
index 0000000..f4770a2
--- /dev/null
+++ b/Services/ClientConfigService.cs
@@ -0,0 +1,181 @@
+using System.IO;
+using System.Text.Json;
+using UtopiaCanteenSystem.Data;
+using UtopiaCanteenSystem.Models;
+
+namespace UtopiaCanteenSystem.Services;
+
+///
+/// Scanner frontend configuration: backend URL, device/site, admin UI. No HRMS/production credentials.
+///
+public sealed class ClientConfigService : IConfigService
+{
+ private readonly string _configPath;
+ private ClientAppConfig _config;
+
+ public ClientConfigService()
+ {
+ DatabasePath.UseClientStorage();
+ _configPath = DatabasePath.GetConfigPath();
+ _config = LoadConfig();
+ }
+
+ public string GetSyncApiEndpoint() => string.Empty;
+ public void SetSyncApiEndpoint(string endpoint) { }
+
+ public bool GetSyncServiceEnabled() => false;
+ public void SetSyncServiceEnabled(bool enabled) { }
+
+ public bool GetScannerConnected() => _config.ScannerConnected;
+ public void SetScannerConnected(bool connected)
+ {
+ _config.ScannerConnected = connected;
+ SaveConfig();
+ }
+
+ public int GetScanIntervalDays() => Math.Clamp(_config.ScanIntervalDays, 0, 365);
+ public void SetScanIntervalDays(int value) { _config.ScanIntervalDays = Math.Clamp(value, 0, 365); SaveConfig(); }
+ public int GetScanIntervalHours() => Math.Clamp(_config.ScanIntervalHours, 0, 23);
+ public void SetScanIntervalHours(int value) { _config.ScanIntervalHours = Math.Clamp(value, 0, 23); SaveConfig(); }
+ public int GetScanIntervalMinutes() => Math.Clamp(_config.ScanIntervalMinutes, 0, 59);
+ public void SetScanIntervalMinutes(int value) { _config.ScanIntervalMinutes = Math.Clamp(value, 0, 59); SaveConfig(); }
+ public int GetScanIntervalSeconds() => Math.Clamp(_config.ScanIntervalSeconds, 0, 59);
+ public void SetScanIntervalSeconds(int value) { _config.ScanIntervalSeconds = Math.Clamp(value, 0, 59); SaveConfig(); }
+
+ public TimeSpan GetScanInterval()
+ {
+ var ts = TimeSpan.FromDays(GetScanIntervalDays()) + TimeSpan.FromHours(GetScanIntervalHours())
+ + TimeSpan.FromMinutes(GetScanIntervalMinutes()) + TimeSpan.FromSeconds(GetScanIntervalSeconds());
+ return ts > TimeSpan.Zero ? ts : TimeSpan.FromMinutes(1);
+ }
+
+ public string GetAdminCardId() => _config.AdminCardId ?? "ADMIN";
+ public void SetAdminCardId(string cardId) { _config.AdminCardId = cardId ?? string.Empty; SaveConfig(); }
+
+ public string GetSiteId() => _config.SiteId ?? string.Empty;
+ public void SetSiteId(string siteId) { _config.SiteId = siteId ?? string.Empty; SaveConfig(); }
+
+ public void ApplyLocationSiteIdFromAuth(string? locationSiteId)
+ {
+ if (string.IsNullOrWhiteSpace(locationSiteId))
+ return;
+ var digits = new string(locationSiteId.Where(char.IsDigit).ToArray());
+ if (string.IsNullOrEmpty(digits))
+ return;
+ _config.SiteId = $"SITE : {digits}";
+ SaveConfig();
+ }
+
+ public string GetDeviceId() => _config.DeviceId ?? string.Empty;
+ public void SetDeviceId(string deviceId) { _config.DeviceId = deviceId ?? string.Empty; SaveConfig(); }
+
+ public bool GetRememberAdminCredentials() => _config.RememberAdminCredentials;
+ public void SetRememberAdminCredentials(bool remember) { _config.RememberAdminCredentials = remember; SaveConfig(); }
+ public string GetSavedAdminUsername() => _config.SavedAdminUsername ?? string.Empty;
+ public void SetSavedAdminUsername(string username) { _config.SavedAdminUsername = username ?? string.Empty; SaveConfig(); }
+ public string GetSavedAdminPassword() => DecryptPassword(_config.SavedAdminPasswordProtected ?? string.Empty);
+ public void SetSavedAdminPassword(string password) { _config.SavedAdminPasswordProtected = EncryptPassword(password ?? string.Empty); SaveConfig(); }
+
+ public string GetMySqlConnectionString() => string.Empty;
+ public void SetMySqlConnectionString(string connectionString) { }
+
+ public string GetHrmsLookupConnectionString() => string.Empty;
+ public void SetHrmsLookupConnectionString(string connectionString) { }
+
+ public DateTime? GetLastEmployeeRfidCacheSyncUtc() => null;
+ public void SetLastEmployeeRfidCacheSyncUtc(DateTime utc) { }
+
+ public DateTime? GetLastMealMenuCacheSyncUtc() => null;
+ public void SetLastMealMenuCacheSyncUtc(DateTime utc) { }
+
+ public AppMode GetAppMode() => AppMode.Client;
+ public void SetAppMode(AppMode mode) { }
+
+ public string GetCentralServerBaseUrl() => GetBackendBaseUrl();
+ public void SetCentralServerBaseUrl(string url) => SetBackendBaseUrl(url);
+
+ public string GetBackendBaseUrl() => (_config.BackendBaseUrl ?? string.Empty).TrimEnd('/');
+
+ public void SetBackendBaseUrl(string url)
+ {
+ _config.BackendBaseUrl = (url ?? string.Empty).Trim().TrimEnd('/');
+ SaveConfig();
+ }
+
+ public string GetLocalServerListenUrls() => string.Empty;
+ public void SetLocalServerListenUrls(string urls) { }
+
+ private ClientAppConfig LoadConfig()
+ {
+ try
+ {
+ if (!File.Exists(_configPath))
+ return new ClientAppConfig { BackendBaseUrl = "http://localhost:5000" };
+ var json = File.ReadAllText(_configPath);
+ return JsonSerializer.Deserialize(json) ?? new ClientAppConfig();
+ }
+ catch
+ {
+ return new ClientAppConfig();
+ }
+ }
+
+ private void SaveConfig()
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(_config, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(_configPath, json);
+ }
+ catch (Exception ex)
+ {
+ Logger.Log(ex, "ClientConfigService.SaveConfig");
+ }
+ }
+
+ private static string EncryptPassword(string plain)
+ {
+ if (string.IsNullOrEmpty(plain)) return string.Empty;
+ try
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(plain);
+ var protectedBytes = System.Security.Cryptography.ProtectedData.Protect(bytes, null, System.Security.Cryptography.DataProtectionScope.CurrentUser);
+ return Convert.ToBase64String(protectedBytes);
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
+ private static string DecryptPassword(string protectedBase64)
+ {
+ if (string.IsNullOrEmpty(protectedBase64)) return string.Empty;
+ try
+ {
+ var protectedBytes = Convert.FromBase64String(protectedBase64);
+ var bytes = System.Security.Cryptography.ProtectedData.Unprotect(protectedBytes, null, System.Security.Cryptography.DataProtectionScope.CurrentUser);
+ return System.Text.Encoding.UTF8.GetString(bytes);
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
+ private sealed class ClientAppConfig
+ {
+ public string? BackendBaseUrl { get; set; }
+ public bool ScannerConnected { get; set; }
+ public int ScanIntervalDays { get; set; }
+ public int ScanIntervalHours { get; set; }
+ public int ScanIntervalMinutes { get; set; }
+ public int ScanIntervalSeconds { get; set; } = 5;
+ public string? AdminCardId { get; set; }
+ public string? SiteId { get; set; }
+ public string? DeviceId { get; set; }
+ public bool RememberAdminCredentials { get; set; }
+ public string? SavedAdminUsername { get; set; }
+ public string? SavedAdminPasswordProtected { get; set; }
+ }
+}
diff --git a/Services/ConfigConnectionHelper.cs b/Services/ConfigConnectionHelper.cs
new file mode 100644
index 0000000..e7fdb82
--- /dev/null
+++ b/Services/ConfigConnectionHelper.cs
@@ -0,0 +1,17 @@
+namespace UtopiaCanteenSystem.Services;
+
+///
+/// Resolves the effective HRMS/UIND MySQL connection: explicit HRMS lookup string when set,
+/// otherwise the production value.
+///
+public static class ConfigConnectionHelper
+{
+ public static string GetHrmsOrProductionConnectionString(string? hrmsLookupConnectionString, string? mySqlConnectionString)
+ {
+ var hrms = (hrmsLookupConnectionString ?? string.Empty).Trim();
+ if (!string.IsNullOrWhiteSpace(hrms))
+ return hrms;
+
+ return (mySqlConnectionString ?? string.Empty).Trim();
+ }
+}
diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs
index bef6fac..c995cfc 100644
--- a/Services/ConfigService.cs
+++ b/Services/ConfigService.cs
@@ -218,17 +218,10 @@ public class ConfigService : IConfigService
SaveConfig();
}
- //public string GetHrmsLookupConnectionString() => (_config.HrmsLookupConnectionString ?? string.Empty).Trim();
-
- public string GetHrmsLookupConnectionString()
- {
- var hrms = (_config.HrmsLookupConnectionString ?? string.Empty).Trim();
- if (!string.IsNullOrWhiteSpace(hrms))
- return hrms;
-
- // fallback to production/AWS connection string
- return GetMySqlConnectionString();
- }
+ public string GetHrmsLookupConnectionString() =>
+ ConfigConnectionHelper.GetHrmsOrProductionConnectionString(
+ _config.HrmsLookupConnectionString,
+ _config.MySqlConnectionString);
public void SetHrmsLookupConnectionString(string connectionString)
{
@@ -270,7 +263,9 @@ public class ConfigService : IConfigService
public void SetCentralServerBaseUrl(string url)
{
- _config.CentralServerBaseUrl = url?.Trim() ?? string.Empty;
+ var v = url?.Trim() ?? string.Empty;
+ _config.CentralServerBaseUrl = v;
+ _config.BackendBaseUrl = v;
SaveConfig();
}
@@ -286,6 +281,37 @@ public class ConfigService : IConfigService
SaveConfig();
}
+ public string GetBackendBaseUrl()
+ {
+ if (GetAppMode() == AppMode.Server)
+ return DeriveLocalhostApiBaseUrl(GetLocalServerListenUrls());
+
+ var url = (_config.BackendBaseUrl ?? string.Empty).Trim();
+ if (!string.IsNullOrEmpty(url))
+ return url.TrimEnd('/');
+
+ return GetCentralServerBaseUrl().TrimEnd('/');
+ }
+
+ private static string DeriveLocalhostApiBaseUrl(string listenUrls)
+ {
+ var first = (listenUrls ?? string.Empty)
+ .Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .FirstOrDefault() ?? "http://0.0.0.0:5000";
+
+ first = first.Trim().TrimEnd('/');
+ if (first.Contains("0.0.0.0", StringComparison.Ordinal))
+ first = first.Replace("0.0.0.0", "localhost", StringComparison.Ordinal);
+ if (first.Contains('+'))
+ first = first.Replace("+", "localhost", StringComparison.Ordinal);
+
+ if (!first.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
+ !first.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ first = "http://" + first;
+
+ return first;
+ }
+
private AppConfig LoadConfig()
{
try
@@ -302,6 +328,8 @@ public class ConfigService : IConfigService
var json = File.ReadAllText(_configPath);
var config = JsonSerializer.Deserialize(json);
config ??= new AppConfig();
+ if (string.IsNullOrWhiteSpace(config.BackendBaseUrl) && !string.IsNullOrWhiteSpace(config.CentralServerBaseUrl))
+ config.BackendBaseUrl = config.CentralServerBaseUrl;
MigrateScanIntervalFromSecondsIfNeeded(config);
var changed = EnsureConnectionStringDefaults(config);
if (changed)
@@ -450,10 +478,13 @@ public class ConfigService : IConfigService
/// Server (default) or Client.
public string AppMode { get; set; } = "Server";
- /// Scanner PCs: base URL of central app API (e.g. http://192.168.1.10:5000).
+ /// Legacy name; same as BackendBaseUrl for client PCs.
public string CentralServerBaseUrl { get; set; } = string.Empty;
- /// Central PC: Kestrel listen URL(s), e.g. http://0.0.0.0:5000
+ /// Client: remote backend URL. Server: optional override (defaults to localhost from ListenUrls).
+ public string BackendBaseUrl { get; set; } = string.Empty;
+
+ /// Central PC: API listen URL(s), e.g. http://0.0.0.0:5000
public string LocalServerListenUrls { get; set; } = "http://0.0.0.0:5000";
}
}
diff --git a/Services/IConfigService.cs b/Services/IConfigService.cs
index 1460e04..408d135 100644
--- a/Services/IConfigService.cs
+++ b/Services/IConfigService.cs
@@ -44,7 +44,10 @@ public interface IConfigService
string GetMySqlConnectionString();
void SetMySqlConnectionString(string connectionString);
- /// Connection string for local HRMS MySQL (employee lookup by RFID). Separate from production sync.
+ ///
+ /// MySQL connection for HRMS/UIND (cache sync, employee lookup, lunch_order post).
+ /// Uses HrmsLookupConnectionString when set; otherwise .
+ ///
string GetHrmsLookupConnectionString();
void SetHrmsLookupConnectionString(string connectionString);
@@ -57,10 +60,14 @@ public interface IConfigService
AppMode GetAppMode();
void SetAppMode(AppMode mode);
+ /// Client mode: central server API base URL (e.g. http://192.168.1.10:5000).
string GetCentralServerBaseUrl();
void SetCentralServerBaseUrl(string url);
- /// Kestrel listen URL(s) when (e.g. http://0.0.0.0:5000).
+ /// Backend API base URL for HTTP calls (client: remote server; server: http://localhost:port).
+ string GetBackendBaseUrl();
+
+ /// HttpListener bind URL(s) when (e.g. http://0.0.0.0:5000).
string GetLocalServerListenUrls();
void SetLocalServerListenUrls(string urls);
}