diff --git a/packaging/windows/OVMS_WINDOWS_INSTALLER_MANAGER_PLAN.md b/packaging/windows/OVMS_WINDOWS_INSTALLER_MANAGER_PLAN.md new file mode 100644 index 0000000000..8a0980cd10 --- /dev/null +++ b/packaging/windows/OVMS_WINDOWS_INSTALLER_MANAGER_PLAN.md @@ -0,0 +1,1277 @@ +# OVMS Windows Installer and Manager Plan + +## Goal + +Create a Windows installer and GUI manager that make OpenVINO Model Server +simple to install, configure, run, and maintain on Windows. + +The user-facing experience should feel closer to a local AI desktop server app: + +- Install OVMS with the required OpenVINO runtime dependencies. +- Include GenAI/tokenizer support for LLM use cases when selected. +- Optionally include Python-enabled OVMS support. +- Configure server settings during installation. +- Provide a GUI and taskbar tray icon for later management. +- Support start-at-login and Windows Service modes. +- Allow repair, upgrade, and optional feature installation later. + +The first implementation should live in the `model_server` repository because +the primary product being installed and managed is `ovms.exe`. OpenVINO Runtime +and OpenVINO GenAI are dependencies consumed through the OVMS Windows package. + +## Non-Goals + +- Do not turn `ovms.exe` itself into a GUI application. +- Do not install unrelated OpenVINO SDK/toolkit components unless they are + required by the OVMS package. +- Do not mix arbitrary OpenVINO, GenAI, and OVMS versions. The installer and + manager should keep the installed stack version-matched. +- Do not replace the portable `ovms.zip` package. The installer should exist + alongside it. + +## Recommended Repository Layout + +```text +model_server/ + packaging/ + windows/ + OVMS_WINDOWS_INSTALLER_MANAGER_PLAN.md + README.md + installer/ + README.md + build_installer.ps1 + OVMSInstaller.iss + assets/ + uninstall/ + manager/ + README.md + build_manager.ps1 + OVMS.Manager.sln + src/ + OVMS.Manager/ + OVMS.Manager.Core/ + assets/ + scripts/ + configure-ovms.ps1 + install-service.ps1 + uninstall-service.ps1 + uninstall-ovms.ps1 + repair-package.ps1 + validate-install.ps1 + schemas/ + ovms-manager-settings.schema.json + install-options.schema.json +``` + +Existing root-level packaging scripts should remain in place: + +```text +windows_build.bat +windows_create_package.bat +install_ovms_service.bat +setupvars.bat +setupvars.ps1 +``` + +The Windows installer should consume the existing package output: + +```text +dist/windows/ovms/ +dist/windows/ovms.zip +``` + +Final release artifacts should include: + +```text +dist/windows/ovms.zip +dist/windows/OVMS-Setup.exe +``` + +## Product Architecture + +Keep the server headless and add a Windows UX layer around it. + +```text +OVMS-Setup.exe + installs files + writes first-time config + optionally registers the Windows Service + installs OVMS Manager + +OVMS Manager.exe + owns the GUI and tray icon + controls server startup/shutdown + manages settings, logs, models, and feature repair + +ovms.exe + remains the model server process +``` + +## Installer Technology + +Start with Inno Setup. + +Reasons: + +- Fast path to a polished single-file installer. +- Supports component selection, custom wizard pages, Start Menu shortcuts, + uninstall hooks, and scripted service commands. +- Easier to iterate than WiX/MSI for the first product version. + +WiX/MSI can be considered later if enterprise/GPO deployment becomes a firm +requirement. + +Inno Setup is a build-time dependency only. The generated installer must be a +normal Windows installer that does not require users to install Inno Setup, +Python, .NET, OpenVINO, GenAI, or other runtime packages separately. + +Runtime dependency policy: + +- OVMS, OpenVINO runtime DLLs, OpenVINO GenAI DLLs, tokenizers, and bundled + Python come from the packaged `dist/windows/ovms` payload. +- The tray app must be published self-contained and included in the installer + payload. +- The installer must not download required runtime dependencies during install. +- PowerShell may be used for install-time configuration because it is part of + supported Windows installations, but it must not fetch external packages. + +## Install Locations + +Program files: + +```text +C:\Program Files\OVMS\ +``` + +Persistent data: + +```text +C:\ProgramData\OVMS\ + settings.json + models\ + config.json + logs\ + ovms_server.log + packages\ + downloads\ +``` + +Per-user startup, when using login mode: + +```text +HKCU\Software\Microsoft\Windows\CurrentVersion\Run +``` + +Service configuration, when using service mode: + +```text +HKLM\SYSTEM\CurrentControlSet\Services\ovms +``` + +## Privilege Model + +The installer and Manager must support a clear privilege boundary. + +### Machine-Wide Install + +Default for the first release: + +```text +C:\Program Files\OVMS +C:\ProgramData\OVMS +HKLM service registration +Machine PATH, if selected +``` + +Machine-wide install requires elevation during setup. + +Admin-only actions: + +- Installing to `C:\Program Files\OVMS`. +- Writing machine-wide PATH entries. +- Registering or deleting the `ovms` Windows Service. +- Changing service start type. +- Writing service command-line configuration. +- Opening firewall rules, if added later. + +### Per-User Actions + +These should not require elevation: + +- Starting Manager at user login through HKCU Run. +- Starting or stopping a Manager-owned `ovms.exe` process. +- Editing user-writable settings where permissions allow it. +- Opening logs, model folders, and diagnostics. + +### Manager Elevation Behavior + +Manager should run unelevated by default. When the user performs an admin-only +action, Manager should prompt for elevation for that action only. + +Manager should not require elevation just to show the tray icon, read status, or +start a user-owned OVMS process. + +### Future Per-User Install + +A per-user install may be added later: + +```text +%LOCALAPPDATA%\Programs\OVMS +%LOCALAPPDATA%\OVMS +HKCU startup only +No Windows Service support unless elevated later +``` + +This should be treated as a separate install mode, not an accidental fallback. + +## Installer Wizard + +### Component Selection + +Suggested defaults: + +```text +[x] OpenVINO Model Server +[x] GenAI / LLM support +[x] Python support +[x] OVMS Manager app +[x] Tray icon +[ ] Windows Service +[ ] Start at boot +[x] Add OVMS to PATH +``` + +The UI should present OpenVINO and GenAI as required runtime capabilities of +the selected OVMS package, not as unrelated SDK installs. + +### Existing Install Detection + +Before showing the normal install flow, the installer must check whether OVMS is +already installed. + +Detection sources: + +- Apps & Features uninstall registry entry for OpenVINO Model Server. +- Existing install marker under `C:\Program Files\OVMS`. +- Existing Manager settings at `C:\ProgramData\OVMS\settings.json`. +- Existing `ovms` Windows Service registration. +- Existing PATH entries pointing to an OVMS install directory. +- Running `OVMS Manager.exe` process. +- Running `ovms.exe` process from a known OVMS install directory. + +Install marker: + +```text +C:\ProgramData\OVMS\install.json +``` + +Suggested content: + +```json +{ + "productName": "OpenVINO Model Server", + "installDir": "C:\\Program Files\\OVMS", + "dataDir": "C:\\ProgramData\\OVMS", + "version": "2026.2.1", + "packageVariant": "python_on", + "installedAtUtc": "2026-06-30T15:00:00Z", + "installerVersion": "1.0.0" +} +``` + +If an existing install is detected, the installer should offer: + +```text +Repair existing install +Upgrade existing install +Modify installed features +Uninstall +Cancel +``` + +Rules: + +- Do not silently install over an existing OVMS directory. +- Stop Manager and OVMS before repair, upgrade, modify, or uninstall. +- Preserve `C:\ProgramData\OVMS` by default. +- If multiple possible installs are detected, show the paths and require the user + to choose or cancel. +- If only a running `ovms.exe` is found outside the managed install directory, + warn about the conflict but do not treat it as the managed install. + +### Server Configuration + +Collect: + +```text +REST port: 8000 +gRPC port: 9000 +Bind address: 127.0.0.1 +Model repository: C:\ProgramData\OVMS\models +Log level: INFO +Log path: C:\ProgramData\OVMS\logs\ovms_server.log +``` + +### Runtime Mode + +Offer: + +```text +Run when I sign in with tray icon +Run as Windows Service +Manual only +``` + +Recommended default: + +```text +Run when I sign in with tray icon +``` + +Advanced option: + +```text +Run as Windows Service and optionally start at boot +``` + +### Optional Initial Model + +Offer: + +```text +None +Add local model +Pull model after install +``` + +The MVP can start with `None` only and add model workflows in a later phase. + +### Finish Page + +Offer: + +```text +[x] Launch OVMS Manager +[ ] Start server now +``` + +## Manager Application + +Use .NET WPF or WinUI 3. WPF is the recommended first choice because it is +pragmatic, native-feeling, and works well with tray icons, Windows services, +process control, and file-based settings. + +Suggested projects: + +```text +OVMS.Manager + WPF UI and tray integration + +OVMS.Manager.Core + service/process/config/log/package logic +``` + +### Dashboard + +Show: + +- Server running/stopped state. +- Runtime mode: process, service, or manual. +- Active REST and gRPC ports. +- Bind address. +- Installed package variant. +- Served model count. +- Latest health check result. + +Health check: + +```text +GET http://127.0.0.1:/v3/models +``` + +### Server Controls + +Support: + +- Start server. +- Stop server. +- Restart server. +- Reload status. +- Open logs. +- Open model repository. + +In user-login mode, Manager starts and stops `ovms.exe` directly. + +In service mode, Manager controls the `ovms` Windows Service. + +### Settings + +Support editing: + +- REST port. +- gRPC port. +- Bind address. +- Log level. +- Log path. +- Model repository path. +- Config path. +- Target device defaults, if added later. +- Startup mode. +- Show tray icon. + +Settings changes that affect the process command line should prompt for or +perform a restart. + +### Startup + +Support: + +- Start Manager at user login. +- Show tray icon. +- Start OVMS when Manager starts. +- Register as Windows Service. +- Configure service start type: manual or automatic. + +### Models + +Support: + +- List configured models from `config.json`. +- Add local model. +- Remove model from config. +- Open model folder. +- Refresh/reload config. +- Pull supported model, in a later phase. + +### Features + +Detect: + +- `ovms.exe` exists. +- `openvino_genai.dll` exists. +- `openvino_tokenizers.dll` exists. +- `python\python.exe` exists, for Python-enabled package. +- `ovms.exe --version` succeeds. + +Show: + +```text +GenAI support: Installed / Missing +Python support: Installed / Missing +Package variant: python_on / python_off +``` + +If GenAI or Python support is missing, offer repair or upgrade to a matching +package variant rather than mixing arbitrary DLL versions. + +### Updates + +Support: + +- Check for updates manually from Manager. +- Optionally check for updates periodically. +- Show installed version, latest available version, package variant, and release + notes link. +- Download updates only after user confirmation. +- Reuse the staged upgrade and rollback flow. + +The first release should not silently auto-install updates. + +### Logs + +Support: + +- Tail `ovms_server.log`. +- Open log folder. +- Copy diagnostics summary. +- Show recent service events, if practical. + +### Advanced + +Support: + +- View effective server command line. +- Validate PATH and environment variables. +- Reset config. +- Repair package. +- Check for updates. +- Export diagnostics bundle. + +## Tray Icon + +The tray icon belongs to `OVMS Manager.exe`, not `ovms.exe`. + +Left-click behavior: + +- Open or focus the OVMS Manager window. +- If the Manager window is minimized or hidden, restore it. +- If the Manager window is already open, bring it to the foreground. + +Context menu: + +```text +Open OVMS Manager +Start Server +Stop Server +``` + +Keep the first version intentionally small. Additional items such as restart, +logs, diagnostics, model folder, settings, and quit can be added later from the +Manager UI if needed. + +Notifications: + +- Server started. +- Server stopped. +- Server failed to start. +- Port already in use. +- Model config changed. + +## Configuration Files + +Manager settings: + +```text +C:\ProgramData\OVMS\settings.json +``` + +Initial content: + +```json +{ + "installDir": "C:\\Program Files\\OVMS", + "dataDir": "C:\\ProgramData\\OVMS", + "modelRepositoryPath": "C:\\ProgramData\\OVMS\\models", + "configPath": "C:\\ProgramData\\OVMS\\models\\config.json", + "logPath": "C:\\ProgramData\\OVMS\\logs\\ovms_server.log", + "restPort": 8000, + "grpcPort": 9000, + "bindAddress": "127.0.0.1", + "logLevel": "INFO", + "runMode": "user-login", + "startAtLogin": true, + "showTrayIcon": true, + "serviceAutoStart": false, + "packageVariant": "python_on" +} +``` + +OVMS config: + +```text +C:\ProgramData\OVMS\models\config.json +``` + +Initial content: + +```json +{ + "model_config_list": [] +} +``` + +## Startup Modes + +### User-Login Mode + +Behavior: + +- Manager starts at user login. +- Manager owns the tray icon. +- Manager starts `ovms.exe` hidden. +- Server runs only after user login. + +This is the best Ollama-like default. + +### Service Mode + +Behavior: + +- `ovms` Windows Service owns the server process. +- Service can start before user login. +- Manager controls service state. +- Tray icon is still provided by Manager after login. + +This is best for machine/server reliability. + +### Manual Mode + +Behavior: + +- Nothing starts automatically. +- User launches Manager and starts OVMS manually. + +This is best for development and low-impact installs. + +## Runtime Ownership + +Exactly one runtime owner should control OVMS at a time. + +Runtime owners: + +```text +manager-process +windows-service +manual +none +``` + +Manager must avoid starting a duplicate server when: + +- The `ovms` Windows Service is running. +- A Manager-owned `ovms.exe` process is already running. +- Another process is already listening on the configured REST or gRPC port. +- A lock file indicates another Manager instance is controlling the server. + +State file: + +```text +C:\ProgramData\OVMS\runtime.json +``` + +Suggested content: + +```json +{ + "owner": "manager-process", + "pid": 12345, + "serviceName": "ovms", + "restPort": 8000, + "grpcPort": 9000, + "startedAtUtc": "2026-06-30T15:00:00Z" +} +``` + +Lock file: + +```text +C:\ProgramData\OVMS\ovms-manager.lock +``` + +Ownership rules: + +- User-login mode may start only a Manager-owned process. +- Service mode may start only the `ovms` Windows Service. +- Manual mode never auto-starts OVMS. +- Switching from process mode to service mode must stop the process first. +- Switching from service mode to process mode must stop the service first. +- Manager should show the current owner and block conflicting start actions with + a clear explanation. + +Port preflight: + +- Before starting OVMS, Manager must check configured REST and gRPC ports. +- If a port is occupied by another process, Manager should show the process id + and executable name when available. +- Manager should not blindly kill unrelated processes. + +## Feature and Package Strategy + +Use the OVMS Windows package as the unit of installation. + +Recommended variants: + +```text +Minimal install: OVMS python_off package +LLM/GenAI install: OVMS python_on package +``` + +Do not independently install unmatched OpenVINO Runtime, GenAI, and tokenizer +DLLs. The Manager should install, repair, or upgrade a matching OVMS package +variant. + +This avoids version mismatch issues between: + +- `ovms.exe` +- OpenVINO runtime DLLs +- OpenVINO GenAI DLLs +- OpenVINO tokenizers DLLs +- Python support files + +## Upgrade and Repair Strategy + +Upgrade and repair must be staged and recoverable. + +Package source: + +- Installer builds from local `dist/windows/ovms`. +- Manager repair/upgrade uses a matching OVMS release package or an installer + cache under `C:\ProgramData\OVMS\packages`. + +Verification before use: + +- Validate package version metadata. +- Validate expected files are present. +- Validate checksums or signatures when packages are downloaded. +- Run `ovms.exe --version` from the staged package before swapping. + +Staging path: + +```text +C:\ProgramData\OVMS\packages\staging\\ +``` + +Backup path: + +```text +C:\ProgramData\OVMS\packages\backup\\ +``` + +Upgrade steps: + +1. Stop Manager-owned OVMS process or `ovms` service. +2. Download or locate the matching package. +3. Verify checksum/signature and required files. +4. Extract to staging. +5. Run staged `ovms.exe --version`. +6. Backup current install files. +7. Swap staged files into `C:\Program Files\OVMS`. +8. Preserve `C:\ProgramData\OVMS` settings, models, and logs. +9. Restart OVMS if it was running before upgrade. +10. Run health check. +11. Roll back from backup if version or health validation fails. + +Repair steps: + +- Re-verify installed files. +- Restore missing files from package cache when possible. +- Re-run `ovms.exe --version`. +- Re-register service only if the selected runtime mode requires it. + +The Manager should never mix individual DLLs from unrelated package versions. + +## Update Checking + +Update checking should be explicit, version-aware, and safe. + +Default behavior: + +- Manual update checks are supported. +- Periodic update checks are opt-in. +- Updates are downloaded and installed only after user confirmation. + +Update source: + +- Official OVMS release metadata. +- For development builds, an explicitly configured package feed may be used. + +The Manager should compare: + +- Installed OVMS version. +- Installed package variant, such as `python_on` or `python_off`. +- Installed Manager version. +- Latest compatible OVMS package. +- Latest compatible Manager package. + +Update states: + +```text +Up to date +Update available +Update downloaded +Update failed +Update requires restart +Cannot check for updates +``` + +Update flow: + +1. User clicks "Check for updates". +2. Manager fetches release metadata. +3. Manager shows latest compatible version and release notes link. +4. User confirms download. +5. Manager downloads package to `C:\ProgramData\OVMS\packages`. +6. Manager verifies checksum/signature. +7. Manager stages package. +8. Manager stops OVMS if needed. +9. Manager applies upgrade using the staged upgrade flow. +10. Manager restarts OVMS if it was running before update. +11. Manager reports success or rollback result. + +The update checker must never install a package whose version or variant cannot +be matched to the installed stack. + +## Configuration Safety + +All configuration writes must be validated and recoverable. + +Files controlled by Manager: + +```text +C:\ProgramData\OVMS\settings.json +C:\ProgramData\OVMS\models\config.json +C:\ProgramData\OVMS\runtime.json +``` + +Rules: + +- Validate JSON before saving. +- Validate against schema where a schema exists. +- Write to a temporary file first. +- Atomically replace the destination file. +- Keep a `.bak` copy of the previous valid version. +- Preserve comments only if a comment-preserving parser is adopted; otherwise + treat files as strict JSON. +- If config is malformed on startup, show the error and offer restore from + backup. + +Suggested backup names: + +```text +settings.json.bak +config.json.bak +runtime.json.bak +``` + +Restart behavior: + +- Settings that alter the OVMS command line require restart. +- Settings that only affect Manager UI should apply immediately. +- Manager must validate config before restarting OVMS. + +## Security and Network Exposure + +Default bind address must be local-only: + +```text +127.0.0.1 +``` + +Binding to these addresses is security-sensitive: + +```text +0.0.0.0 +:: +LAN adapter IPs +``` + +If the user selects a non-local bind address, Manager should: + +- Show an explicit warning that the model server may be reachable from other + devices. +- Require confirmation. +- Show the active URLs after restart. +- Avoid adding firewall rules unless the user explicitly opts in. + +If firewall rule management is added later, it must be treated as an admin-only +operation and must be removed during uninstall if created by the installer or +Manager. + +## Model Download and Trust + +Model download can be added after the MVP, but the plan must account for trust +and storage rules. + +Model download requirements: + +- Show source, model id, license link, and estimated size before download. +- Support cancellation. +- Support resume when practical. +- Check free disk space before download. +- Use a cache under `C:\ProgramData\OVMS\downloads` by default. +- Support Hugging Face authentication for gated models if needed. +- Do not auto-accept model licenses on behalf of the user. +- Record downloaded model metadata. + +Metadata path: + +```text +C:\ProgramData\OVMS\models\model-metadata.json +``` + +Suggested metadata: + +```json +{ + "models": [ + { + "name": "OpenVINO/Qwen3-8B-int4-ov", + "source": "huggingface", + "path": "C:\\ProgramData\\OVMS\\models\\OpenVINO\\Qwen3-8B-int4-ov", + "downloadedAtUtc": "2026-06-30T15:00:00Z", + "licenseAccepted": false + } + ] +} +``` + +## Installer Responsibilities + +Install: + +- Copy `dist/windows/ovms` to install directory. +- Copy `OVMS Manager.exe` and supporting files. +- Create `C:\ProgramData\OVMS`. +- Create `install.json`. +- Create default `settings.json`. +- Create default `models\config.json`. +- Add PATH entries if selected. +- Register service if selected. +- Add login startup entry if selected. +- Create Start Menu shortcuts. +- Launch Manager if selected. + +Uninstall: + +- Stop Manager-controlled process if running. +- Stop service if installed. +- Delete service if installed. +- Remove login startup entry. +- Remove PATH entries created by installer. +- Remove program files. +- Ask whether to preserve models, logs, and settings under `C:\ProgramData\OVMS`. + +## Manager Responsibilities + +- Read and write `settings.json`. +- Read `install.json` and report installed version/variant. +- Read and update OVMS `config.json`. +- Build the effective `ovms.exe` command line. +- Start and stop `ovms.exe` in user-login/manual modes. +- Start and stop the `ovms` Windows Service in service mode. +- Update service command line when settings change. +- Run health checks. +- Show logs. +- Detect package capabilities. +- Repair or upgrade the installed package. +- Check for updates. +- Manage startup mode. +- Enforce runtime ownership rules. +- Validate ports before starting OVMS. +- Validate and atomically write configuration. +- Export diagnostics bundles. + +## Build Pipeline + +Initial local pipeline: + +```powershell +.\windows_build.bat --with_python +.\windows_create_package.bat opt --with_python +.\packaging\windows\manager\build_manager.ps1 +.\packaging\windows\installer\build_installer.ps1 +``` + +Expected outputs: + +```text +dist/windows/ovms.zip +dist/windows/OVMS-Setup.exe +``` + +The installer build should fail clearly if `dist/windows/ovms/ovms.exe` is +missing. + +## Uninstaller + +Uninstall support is a first-class deliverable. The Windows installer must +register a standard uninstaller in Windows Apps & Features and provide clean +scripted cleanup for files, service state, startup entries, and environment +changes. + +Uninstaller entry: + +```text +Settings > Apps > Installed apps > OpenVINO Model Server +``` + +Uninstaller responsibilities: + +- Stop `OVMS Manager.exe` if it is running. +- Stop any Manager-launched `ovms.exe` process. +- Stop the `ovms` Windows Service if installed. +- Delete the `ovms` Windows Service if installed. +- Remove login startup registry entries created by the installer. +- Remove PATH entries created by the installer. +- Remove Start Menu and desktop shortcuts. +- Remove installed program files under `C:\Program Files\OVMS`. +- Ask whether to preserve user data under `C:\ProgramData\OVMS`. +- Preserve models, logs, and settings by default unless the user selects full + removal. + +User data removal options: + +```text +Preserve models, logs, and settings +Remove logs and settings but keep models +Remove everything, including models +``` + +The uninstaller should avoid deleting arbitrary user-selected directories unless +they are inside `C:\ProgramData\OVMS` or were explicitly created by the +installer. If the user configured an external model repository, uninstall should +preserve it by default and show the path before offering deletion. + +## Diagnostics + +Diagnostics should be available before release hardening. The Manager should +include a "Copy diagnostics" or "Export diagnostics bundle" action. + +Diagnostics bundle contents: + +- Manager version. +- OVMS version from `ovms.exe --version`. +- Installed package variant. +- Install directory. +- Data directory. +- Effective command line with secrets redacted. +- `settings.json`. +- OVMS `config.json`. +- `runtime.json`. +- Service state from `sc query ovms`, when available. +- Service configuration from `sc qc ovms`, when available. +- Port ownership for configured REST and gRPC ports. +- Last 500 lines of `ovms_server.log`, if present. +- Recent Manager log entries. +- Latest health check result. + +Diagnostics should be written to: + +```text +C:\ProgramData\OVMS\diagnostics\ovms-diagnostics-.zip +``` + +Sensitive values, tokens, and credentials must be redacted. + +## Test Matrix + +The Windows installer and Manager should have explicit install-time and +runtime smoke tests. + +Installer tests: + +- Detect existing install and show repair/upgrade/modify/uninstall choices. +- Clean machine-wide install. +- Install with PATH enabled. +- Install without PATH. +- Install with Manager startup enabled. +- Install with service mode enabled. +- Install to a path containing spaces. +- Install when `C:\ProgramData\OVMS` already exists. +- Install over a previous version. +- Detect existing unmanaged `ovms.exe` and warn without modifying it. + +Manager tests: + +- Start/stop/restart in user-login mode. +- Start/stop/restart in service mode. +- Switch from process mode to service mode. +- Switch from service mode to process mode. +- Block duplicate start when port is occupied. +- Report owner when another OVMS process is running. +- Persist settings changes. +- Restore malformed config from backup. +- Export diagnostics bundle. + +Uninstaller tests: + +- Uninstall after process-mode install. +- Uninstall after service-mode install. +- Preserve models/logs/settings. +- Remove logs/settings but keep models. +- Full removal. +- External model repository is preserved by default. +- PATH and startup entries are removed. +- Service is stopped and deleted. + +Upgrade/repair tests: + +- Repair missing DLL. +- Repair missing Manager file. +- Upgrade python_off to python_on. +- Failed upgrade rolls back. +- Upgrade preserves settings and model repository. +- Update checker reports up-to-date state. +- Update checker reports update-available state. +- Update checker handles offline/network failure. + +Security tests: + +- Default bind is `127.0.0.1`. +- Non-local bind shows warning. +- Firewall changes require explicit opt-in if implemented. + +## Implementation Phases + +### Phase 1: Foundation + +- Add folder layout. +- Add README files. +- Add settings schema. +- Add install-options schema. +- Add install marker schema. +- Add runtime state schema. +- Add validation script. +- Document service/process/startup behavior. +- Document privilege model. +- Document runtime ownership rules. +- Document config safety rules. + +Acceptance criteria: + +- `packaging/windows` documents the intended architecture. +- `validate-install.ps1` can inspect an unpacked OVMS folder and report missing + required files. +- The plan defines admin-only actions and non-admin Manager actions. +- The plan defines duplicate-start prevention. + +### Phase 2: Installer MVP + +- Add Inno Setup script. +- Add existing-install detection. +- Install existing `dist/windows/ovms` package. +- Write `settings.json`. +- Write empty OVMS `config.json`. +- Create Start Menu shortcuts. +- Support Add to PATH. +- Support optional service registration. +- Register a standard Apps & Features uninstaller. +- Support clean uninstall with preserve/remove data options. +- Implement elevated machine-wide install. +- Fail clearly when required elevation is unavailable. + +Acceptance criteria: + +- Fresh install succeeds on Windows. +- Existing install shows repair/upgrade/modify/uninstall choices. +- `ovms.exe --version` works after install. +- User can start OVMS manually from installed files. +- Uninstall removes program files, service registration, startup entries, PATH + changes, and shortcuts. +- Uninstall preserves or removes data according to user choice. +- Installer-created PATH and startup entries are tracked for cleanup. + +### Phase 3: Manager MVP + +- Add WPF Manager shell. +- Add tray icon. +- Add dashboard. +- Add start/stop/restart controls. +- Add settings page. +- Add logs page. +- Add start-at-login toggle. +- Add runtime ownership detection. +- Add port preflight. +- Add minimal diagnostics export. + +Acceptance criteria: + +- Manager starts from Start Menu. +- Tray menu works. +- Manager can start and stop `ovms.exe`. +- Health check reflects the running server state. +- Settings changes persist. +- Manager blocks duplicate starts and reports port conflicts. +- Diagnostics bundle can be exported. + +### Phase 4: Service Mode + +- Manager can register/unregister service. +- Manager can start/stop/restart service. +- Manager can set service start type. +- Installer and Manager use compatible service configuration. +- Switching runtime modes stops the previous owner first. + +Acceptance criteria: + +- `sc query ovms` reflects selected state. +- Service mode starts OVMS with configured port, bind address, config path, and + log path. +- Manager reports service errors clearly. +- Manager does not allow process mode and service mode to run OVMS + simultaneously. + +### Phase 5: Model Management + +- List configured models. +- Add local model to `config.json`. +- Remove model from `config.json`. +- Open model repository. +- Refresh health/model list. +- Validate config before writing. +- Backup config before writing. + +Acceptance criteria: + +- Adding a local model updates `config.json`. +- Removing a model updates `config.json`. +- Manager shows served models when OVMS is running. +- Malformed config can be restored from backup. + +### Phase 6: Feature Management + +- Detect GenAI support. +- Detect Python support. +- Detect package variant. +- Offer repair install. +- Offer upgrade from python_off to python_on using a matching package. +- Add manual update checking. +- Stage package upgrades. +- Verify package before swap. +- Roll back failed upgrades. + +Acceptance criteria: + +- Manager reports installed capabilities accurately. +- Repair verifies required files after completion. +- Upgrade preserves settings and model repository. +- Failed upgrade restores the previous working package. +- Update checker reports current state accurately. + +### Phase 7: Release Hardening + +- Add code signing path. +- Add installer versioning. +- Add upgrade behavior. +- Add modify/repair behavior for existing installs. +- Add update checking and package-feed compatibility rules. +- Add diagnostics bundle export. +- Add CI packaging job. +- Add documentation for support and troubleshooting. +- Add automated installer, Manager, service, uninstall, and upgrade smoke tests. + +Acceptance criteria: + +- Signed installer builds reproducibly. +- Installing over a previous version upgrades cleanly. +- Diagnostics bundle contains settings, logs, version, service state, and health + check output. +- Test matrix runs successfully on a clean Windows worker. + +## Open Questions + +- Should the default REST port be 8000, or should it preserve the current local + AgentTools convention when used there? +- Should the installer default to user-login mode or service mode for non-admin + installs? +- Should the first version support model download during install, or should that + be Manager-only after install? +- Should we ship both python_on and python_off installers, or one installer that + can download the other package variant? +- Should Manager require admin elevation only for service/PATH changes, or run + elevated at startup? +- Should the first release support per-user install, or only elevated + machine-wide install? +- What package signature/hash source should Manager trust for repair and + upgrade downloads? +- Should periodic update checks be opt-in during install or only configurable + later in Manager? +- What registry keys should be authoritative for installed-version detection? +- Should non-local bind addresses be allowed in the installer, or only in + Manager advanced settings? +- How should Manager store Hugging Face tokens if gated model downloads are + supported? + +## First Concrete Tasks + +1. Add `packaging/windows/README.md` summarizing the installer and manager + ownership. +2. Add schemas for `settings.json`, install options, and install marker. +3. Add existing-install detection rules. +4. Add runtime state schema and duplicate-start ownership rules. +5. Add `validate-install.ps1` for unpacked `dist/windows/ovms`. +6. Add an Inno Setup MVP that installs the existing package. +7. Add explicit uninstaller scripts and Apps & Features registration. +8. Add a minimal WPF Manager with tray icon and start/stop controls. +9. Add diagnostics export and port preflight. +10. Add manual update checker design and package metadata contract. diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 0000000000..059ea019ad --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,43 @@ +# OVMS Windows Packaging + +This folder contains the Windows installer, uninstaller, server-control scripts, +and lightweight tray app for OpenVINO Model Server. + +The first implementation intentionally keeps the full configuration UI out of +scope. It provides: + +- An Inno Setup installer that consumes `dist/windows/ovms`. +- A standard Windows uninstaller. +- First-run settings and install markers under `C:\ProgramData\OVMS`. +- A minimal tray app with: + - Open OVMS Manager + - Start Server + - Stop Server + +The installed application should behave like a regular Windows application. It +must not require the user to install Python, .NET, OpenVINO, GenAI, or other +runtime dependencies separately. Those files are bundled in the installer +payload: + +- OpenVINO and GenAI DLLs come from `dist/windows/ovms`. +- Bundled Python support comes from the `python_on` OVMS package. +- The tray app is published self-contained so it does not require a separate + .NET Desktop Runtime install. +- Inno Setup is a build-time tool only; users do not need it installed. + +The full Manager UI is planned as a later phase. + +## Build Order + +```powershell +.\windows_build.bat --with_python +.\windows_create_package.bat opt --with_python +.\packaging\windows\manager\build_manager.ps1 +.\packaging\windows\installer\build_installer.ps1 +``` + +Expected output: + +```text +dist/windows/OVMS-Setup.exe +``` diff --git a/packaging/windows/installer/OVMSInstaller.iss b/packaging/windows/installer/OVMSInstaller.iss new file mode 100644 index 0000000000..97c1d88346 --- /dev/null +++ b/packaging/windows/installer/OVMSInstaller.iss @@ -0,0 +1,315 @@ +#define AppName "OpenVINO Model Server" +#define AppPublisher "OpenVINO" +#define AppExeName "OVMS.Manager.exe" +#ifndef SourceRoot +#define SourceRoot "..\..\.." +#endif +#ifndef OvmsSourceDir +#define OvmsSourceDir SourceRoot + "\dist\windows\ovms" +#endif +#ifndef ManagerPublishDir +#define ManagerPublishDir SourceRoot + "\packaging\windows\manager\artifacts\publish" +#endif +#ifndef OutputDir +#define OutputDir SourceRoot + "\dist\windows" +#endif + +[Setup] +AppId={{9F35B167-C72A-4E1D-93E9-448CE4AE5270} +AppName={#AppName} +AppVersion=1.0.0 +AppPublisher={#AppPublisher} +DefaultDirName={localappdata}\Programs\OVMS +DefaultGroupName=OpenVINO Model Server +DisableProgramGroupPage=yes +OutputDir={#OutputDir} +OutputBaseFilename=OVMS-Setup +Compression=lzma +SolidCompression=yes +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +PrivilegesRequired=lowest +UninstallDisplayName={#AppName} +UninstallDisplayIcon={app}\OVMS.Manager.exe +CloseApplications=yes +RestartApplications=no +WizardStyle=modern +WizardSizePercent=120 +WizardImageFile=assets\ovms-welcome.bmp +WizardSmallImageFile=assets\ovms-small.bmp +WizardImageStretch=yes +DisableWelcomePage=no +ShowLanguageDialog=no +DisableReadyPage=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "path"; Description: "Add OVMS to PATH"; GroupDescription: "Optional configuration:"; Flags: checkedonce +Name: "startup"; Description: "Start tray app when I sign in"; GroupDescription: "Optional configuration:"; Flags: checkedonce + +[Files] +Source: "{#OvmsSourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#ManagerPublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#SourceRoot}\packaging\windows\scripts\*.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion +Source: "{#SourceRoot}\packaging\windows\settings\settings.template.json"; DestDir: "{app}\templates"; Flags: ignoreversion +Source: "{#OvmsSourceDir}\*"; DestDir: "{localappdata}\OVMS\packages\source"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\Open OVMS Manager"; Filename: "{app}\OVMS.Manager.exe" +Name: "{group}\Start OVMS Server"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\start-ovms.ps1"" -DataDir ""{localappdata}\OVMS""" +Name: "{group}\Stop OVMS Server"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\stop-ovms.ps1"" -DataDir ""{localappdata}\OVMS""" +Name: "{group}\Repair OVMS Server"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\repair-package.ps1"" -InstallDir ""{app}"" -DataDir ""{localappdata}\OVMS""" +Name: "{group}\Uninstall OpenVINO Model Server"; Filename: "{uninstallexe}" + +[Registry] +Root: HKCU; Subkey: "Software\OpenVINO\OVMS"; ValueType: string; ValueName: "InstallDir"; ValueData: "{app}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\OpenVINO\OVMS"; ValueType: string; ValueName: "DataDir"; ValueData: "{localappdata}\OVMS"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\OpenVINO\OVMS"; ValueType: string; ValueName: "Version"; ValueData: "1.0.0"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "OVMS Manager"; ValueData: """{app}\OVMS.Manager.exe"""; Tasks: startup; Flags: uninsdeletevalue + +[Run] +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\configure-ovms.ps1"" -InstallDir ""{app}"" -DataDir ""{localappdata}\OVMS"" -PackageVariant ""python_on"""; Flags: runhidden waituntilterminated +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\set-path.ps1"" -InstallDir ""{app}"" -Action Add"; Tasks: path; Flags: runhidden waituntilterminated +Filename: "{app}\OVMS.Manager.exe"; Description: "Launch OVMS Manager"; Flags: nowait postinstall skipifsilent + +[UninstallRun] +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-ovms.ps1"" -InstallDir ""{app}"" -DataDir ""{localappdata}\OVMS"" -DataMode ""{code:GetDataMode}"""; Flags: runhidden waituntilterminated; RunOnceId: "OVMSCleanup" + +[Code] +var + DataModeChoice: String; + // Existing-install detection state, populated in InitializeSetup and + // consumed by InitializeWizard/NextButtonClick to drive the custom page. + ExistingInstallDetected: Boolean; + ExistingInstallDir: String; + ExistingUninstallString: String; + ExistingInstallPage: TWizardPage; + RbRepair: TNewRadioButton; + RbUninstall: TNewRadioButton; + // When True, the "Exit Setup?" confirmation is suppressed so Setup can + // close cleanly after handing off to the existing uninstaller. + SuppressCancelConfirm: Boolean; + +function GetDataMode(Param: String): String; +begin + Result := DataModeChoice; +end; +function QueryExistingManagedInstall(var ExistingDir: String; var UninstallString: String): Boolean; +begin + Result := False; + ExistingDir := ''; + UninstallString := ''; + + if RegQueryStringValue(HKCU, 'Software\OpenVINO\OVMS', 'InstallDir', ExistingDir) then + begin + RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{9F35B167-C72A-4E1D-93E9-448CE4AE5270}_is1', 'UninstallString', UninstallString); + Result := True; + exit; + end; + + if RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{9F35B167-C72A-4E1D-93E9-448CE4AE5270}_is1', 'InstallLocation', ExistingDir) then + begin + RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{9F35B167-C72A-4E1D-93E9-448CE4AE5270}_is1', 'UninstallString', UninstallString); + Result := True; + exit; + end; + + if DirExists(ExpandConstant('{localappdata}\Programs\OVMS')) then + begin + ExistingDir := ExpandConstant('{localappdata}\Programs\OVMS'); + RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{9F35B167-C72A-4E1D-93E9-448CE4AE5270}_is1', 'UninstallString', UninstallString); + Result := True; + end; +end; + +function HasRunningOvmsProcess(): Boolean; +var + ResultCode: Integer; + MarkerPath: String; +begin + MarkerPath := ExpandConstant('{tmp}\ovms-running.txt'); + DeleteFile(MarkerPath); + Exec('powershell.exe', + '-NoProfile -ExecutionPolicy Bypass -Command "if (Get-Process ovms -ErrorAction SilentlyContinue) { ''''running'''' | Set-Content -Path ''''' + MarkerPath + ''''' }"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Result := FileExists(MarkerPath); + DeleteFile(MarkerPath); +end; + +function RunExistingUninstaller(UninstallString: String): Boolean; +var + ResultCode: Integer; +begin + Result := False; + if UninstallString = '' then + begin + MsgBox('An existing OpenVINO Model Server install was detected, but its uninstaller could not be found.', mbError, MB_OK); + exit; + end; + + Exec('cmd.exe', '/C ' + UninstallString, '', SW_SHOWNORMAL, ewWaitUntilTerminated, ResultCode); + Result := ResultCode = 0; +end; + +procedure StopRunningManager(); +var + ResultCode: Integer; +begin + Exec('taskkill.exe', '/IM OVMS.Manager.exe /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +function InitializeSetup(): Boolean; +begin + Result := True; + StopRunningManager(); + + // Existing-install detection: just stash the result in globals. The actual + // repair-vs-uninstall choice is now presented as a custom wizard page + // (see InitializeWizard/NextButtonClick below) instead of a MsgBox here. + // For silent installs there is no UI at all, so default to repair/upgrade + // (i.e. just proceed) -- ExistingInstallDetected is still set, but since + // the custom page is never shown/navigated in silent mode, RbRepair's + // default behavior (proceed) is effectively what happens. + ExistingInstallDetected := QueryExistingManagedInstall(ExistingInstallDir, ExistingUninstallString); + + if HasRunningOvmsProcess() then + begin + if WizardSilent() then + begin + Result := True; + exit; + end; + + if MsgBox( + 'A running ovms.exe process was detected, but it does not appear to be managed by this installer.' + #13#10#13#10 + + 'Setup will not repair or uninstall unmanaged portable OVMS processes. Stop the running server before installing if it uses the same ports.' + #13#10#13#10 + + 'Continue setup?', + mbConfirmation, MB_YESNO) = IDNO then + begin + Result := False; + end; + end; +end; + +procedure InitializeWizard(); +var + Lbl: TNewStaticText; +begin + if not ExistingInstallDetected then + exit; + + ExistingInstallPage := CreateCustomPage(wpWelcome, + 'Existing Installation Found', + 'OpenVINO Model Server is already installed on this computer.'); + + Lbl := TNewStaticText.Create(ExistingInstallPage); + Lbl.Parent := ExistingInstallPage.Surface; + Lbl.Left := 0; + Lbl.Top := 0; + Lbl.Width := ExistingInstallPage.SurfaceWidth; + Lbl.AutoSize := False; + Lbl.WordWrap := True; + Lbl.Caption := + 'An existing install was found at:' + #13#10 + + ExistingInstallDir + #13#10#13#10 + + 'Choose how to continue:'; + Lbl.Height := ScaleY(60); + + RbRepair := TNewRadioButton.Create(ExistingInstallPage); + RbRepair.Parent := ExistingInstallPage.Surface; + RbRepair.Left := 0; + RbRepair.Top := Lbl.Top + Lbl.Height + ScaleY(8); + RbRepair.Width := ExistingInstallPage.SurfaceWidth; + RbRepair.Height := ScaleY(17); + RbRepair.Caption := 'Repair or upgrade the existing installation (recommended)'; + RbRepair.Checked := True; + + RbUninstall := TNewRadioButton.Create(ExistingInstallPage); + RbUninstall.Parent := ExistingInstallPage.Surface; + RbUninstall.Left := 0; + RbUninstall.Top := RbRepair.Top + RbRepair.Height + ScaleY(8); + RbUninstall.Width := ExistingInstallPage.SurfaceWidth; + RbUninstall.Height := ScaleY(17); + RbUninstall.Caption := 'Uninstall the existing installation, then exit'; +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + + if (ExistingInstallPage <> nil) and (CurPageID = ExistingInstallPage.ID) then + begin + if RbUninstall.Checked then + begin + RunExistingUninstaller(ExistingUninstallString); + // Hand-off to the existing uninstaller is done; close Setup cleanly. + // SuppressCancelConfirm tells CancelButtonClick to skip the normal + // "Setup is not complete. Exit Setup?" confirmation, so the user is + // not nagged after deliberately choosing to uninstall and exit. + MsgBox('The existing OpenVINO Model Server installation has been removed.' + #13#10#13#10 + + 'Setup will now close. Run it again to install a fresh copy.', + mbInformation, MB_OK); + SuppressCancelConfirm := True; + WizardForm.Close; + Result := False; + exit; + end; + + // RbRepair.Checked (default): proceed with repair/upgrade as normal. + Result := True; + end; +end; + +procedure CancelButtonClick(CurPageID: Integer; var Cancel, Confirm: Boolean); +begin + // After the "Uninstall existing, then exit" path triggers WizardForm.Close, + // skip the "Setup is not complete" confirmation prompt. + if SuppressCancelConfirm then + Confirm := False; +end; + +function InitializeUninstall(): Boolean; +var + DataDir: String; + KeepEverything: Integer; + RemoveModelsToo: Integer; +begin + Result := True; + DataDir := ExpandConstant('{localappdata}\OVMS'); + DataModeChoice := 'PreserveAll'; + + if UninstallSilent() then + begin + exit; + end; + + KeepEverything := MsgBox( + 'Keep your downloaded models and data under:' + #13#10 + + DataDir + #13#10#13#10 + + 'Yes = keep everything (models, settings, and logs)' + #13#10 + + 'No = choose what to remove', + mbConfirmation, MB_YESNO); + + if KeepEverything = IDYES then + begin + DataModeChoice := 'PreserveAll'; + exit; + end; + + RemoveModelsToo := MsgBox( + 'Also delete your downloaded MODELS?' + #13#10#13#10 + + 'Yes = remove everything, including models' + #13#10 + + 'No = keep models, but remove settings and logs', + mbConfirmation, MB_YESNO); + + if RemoveModelsToo = IDYES then + begin + DataModeChoice := 'RemoveAll'; + end + else + begin + DataModeChoice := 'RemoveSettingsKeepModels'; + end; +end; diff --git a/packaging/windows/installer/README.md b/packaging/windows/installer/README.md new file mode 100644 index 0000000000..8caadaa96f --- /dev/null +++ b/packaging/windows/installer/README.md @@ -0,0 +1,29 @@ +# OVMS Windows Installer + +The installer is built with Inno Setup from `OVMSInstaller.iss`. + +It expects the OVMS portable package to already exist at: + +```text +dist/windows/ovms +``` + +The installer adds: + +- OVMS files under `C:\Program Files\OVMS`. +- Data folders under `C:\ProgramData\OVMS`. +- Default settings and install marker files. +- Start Menu shortcuts. +- Optional PATH entry. +- Optional start-at-login entry for the tray app. +- Optional Windows Service registration. +- A standard Apps & Features uninstaller. + +The installer must be fully offline once built. It should not download Python, +.NET, OpenVINO, GenAI, or model-server dependencies during installation. The +OVMS package and self-contained tray app must already be present in the +installer payload. + +The uninstaller stops managed OVMS processes, removes optional service/startup +configuration, removes installer-created PATH entries, and removes installed +program files. Data under `C:\ProgramData\OVMS` is preserved by default. diff --git a/packaging/windows/installer/assets/ovms-small.bmp b/packaging/windows/installer/assets/ovms-small.bmp new file mode 100644 index 0000000000..134821c273 Binary files /dev/null and b/packaging/windows/installer/assets/ovms-small.bmp differ diff --git a/packaging/windows/installer/assets/ovms-welcome.bmp b/packaging/windows/installer/assets/ovms-welcome.bmp new file mode 100644 index 0000000000..b32e7c5dd1 Binary files /dev/null and b/packaging/windows/installer/assets/ovms-welcome.bmp differ diff --git a/packaging/windows/installer/build_installer.ps1 b/packaging/windows/installer/build_installer.ps1 new file mode 100644 index 0000000000..868738a173 --- /dev/null +++ b/packaging/windows/installer/build_installer.ps1 @@ -0,0 +1,59 @@ +param( + [string]$Configuration = "Release", + [string]$SourceRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path, + [string]$OvmsSourceDir = "", + [string]$OutputDir = "", + [string]$InnoSetupPath = "" +) + +$ErrorActionPreference = "Stop" + +$ovmsDir = if ($OvmsSourceDir) { (Resolve-Path -LiteralPath $OvmsSourceDir).Path } else { Join-Path $SourceRoot "dist\windows\ovms" } +$managerPublishDir = Join-Path $SourceRoot "packaging\windows\manager\artifacts\publish" +$installerScript = Join-Path $PSScriptRoot "OVMSInstaller.iss" +$outputDir = if ($OutputDir) { $OutputDir } else { Join-Path $SourceRoot "dist\windows" } + +if (-not (Test-Path (Join-Path $ovmsDir "ovms.exe"))) { + throw "Missing OVMS package at $ovmsDir. Run windows_create_package.bat first." +} + +if (-not (Test-Path (Join-Path $managerPublishDir "OVMS.Manager.exe"))) { + & (Join-Path $SourceRoot "packaging\windows\manager\build_manager.ps1") -Configuration $Configuration +} + +if (-not $InnoSetupPath) { + $cmd = Get-Command ISCC.exe -ErrorAction SilentlyContinue + if ($cmd) { + $InnoSetupPath = $cmd.Source + } else { + $candidates = @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe" + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + $InnoSetupPath = $candidate + break + } + } + } +} + +if (-not $InnoSetupPath -or -not (Test-Path $InnoSetupPath)) { + throw "Inno Setup compiler ISCC.exe was not found. Install Inno Setup 6 or pass -InnoSetupPath." +} + +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + +& $InnoSetupPath ` + "/DSourceRoot=$SourceRoot" ` + "/DOvmsSourceDir=$ovmsDir" ` + "/DManagerPublishDir=$managerPublishDir" ` + "/DOutputDir=$outputDir" ` + $installerScript + +if ($LASTEXITCODE -ne 0) { + throw "Inno Setup failed with exit code $LASTEXITCODE." +} + +Write-Host "[INFO] Installer created at $(Join-Path $outputDir 'OVMS-Setup.exe')" diff --git a/packaging/windows/manager/.gitignore b/packaging/windows/manager/.gitignore new file mode 100644 index 0000000000..b8c89995ae --- /dev/null +++ b/packaging/windows/manager/.gitignore @@ -0,0 +1,3 @@ +artifacts/ +src/**/bin/ +src/**/obj/ diff --git a/packaging/windows/manager/build_manager.ps1 b/packaging/windows/manager/build_manager.ps1 new file mode 100644 index 0000000000..2b145d5351 --- /dev/null +++ b/packaging/windows/manager/build_manager.ps1 @@ -0,0 +1,22 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$project = Join-Path $PSScriptRoot "src\OVMS.Manager\OVMS.Manager.csproj" +$output = Join-Path $PSScriptRoot "artifacts\publish" + +dotnet publish $project ` + -c $Configuration ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true ` + -o $output +if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed." +} + +Write-Host "[INFO] Manager published to $output" diff --git a/packaging/windows/manager/docs/screenshots/01-dashboard.png b/packaging/windows/manager/docs/screenshots/01-dashboard.png new file mode 100644 index 0000000000..acfbd1a437 Binary files /dev/null and b/packaging/windows/manager/docs/screenshots/01-dashboard.png differ diff --git a/packaging/windows/manager/docs/screenshots/02-settings.png b/packaging/windows/manager/docs/screenshots/02-settings.png new file mode 100644 index 0000000000..27669cfab9 Binary files /dev/null and b/packaging/windows/manager/docs/screenshots/02-settings.png differ diff --git a/packaging/windows/manager/docs/screenshots/03-logs.png b/packaging/windows/manager/docs/screenshots/03-logs.png new file mode 100644 index 0000000000..b85e4d0a4a Binary files /dev/null and b/packaging/windows/manager/docs/screenshots/03-logs.png differ diff --git a/packaging/windows/manager/docs/screenshots/04-advanced.png b/packaging/windows/manager/docs/screenshots/04-advanced.png new file mode 100644 index 0000000000..080dc656dd Binary files /dev/null and b/packaging/windows/manager/docs/screenshots/04-advanced.png differ diff --git a/packaging/windows/manager/docs/screenshots/05-updates.png b/packaging/windows/manager/docs/screenshots/05-updates.png new file mode 100644 index 0000000000..d472e3ef61 Binary files /dev/null and b/packaging/windows/manager/docs/screenshots/05-updates.png differ diff --git a/packaging/windows/manager/src/OVMS.Manager/Assets/ovms-manager.ico b/packaging/windows/manager/src/OVMS.Manager/Assets/ovms-manager.ico new file mode 100644 index 0000000000..c31506f868 Binary files /dev/null and b/packaging/windows/manager/src/OVMS.Manager/Assets/ovms-manager.ico differ diff --git a/packaging/windows/manager/src/OVMS.Manager/CardPanel.cs b/packaging/windows/manager/src/OVMS.Manager/CardPanel.cs new file mode 100644 index 0000000000..cc445a5777 --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/CardPanel.cs @@ -0,0 +1,55 @@ +using System.Drawing.Drawing2D; + +namespace OVMS.Manager; + +/// +/// A lightweight rounded-rectangle card: surface-colored panel with a 1px +/// border, used for dashboard cards and grouped settings sections. +/// Double-buffered to avoid flicker on resize/redraw. +/// +internal sealed class CardPanel : Panel +{ + public int CornerRadius { get; set; } = 8; + public Color BorderColor { get; set; } = Theme.Border; + public Color CardBackColor { get; set; } = Theme.Surface; + + public CardPanel() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = Theme.WindowBackground; + Padding = new Padding(16); + } + + private static GraphicsPath RoundedRect(Rectangle bounds, int radius) + { + var path = new GraphicsPath(); + if (radius <= 0) + { + path.AddRectangle(bounds); + return path; + } + + var d = radius * 2; + path.AddArc(bounds.X, bounds.Y, d, d, 180, 90); + path.AddArc(bounds.Right - d, bounds.Y, d, d, 270, 90); + path.AddArc(bounds.Right - d, bounds.Bottom - d, d, d, 0, 90); + path.AddArc(bounds.X, bounds.Bottom - d, d, d, 90, 90); + path.CloseFigure(); + return path; + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + + var rect = new Rectangle(0, 0, Width - 1, Height - 1); + using var path = RoundedRect(rect, CornerRadius); + using var fillBrush = new SolidBrush(CardBackColor); + using var borderPen = new Pen(BorderColor, 1f); + + e.Graphics.FillPath(fillBrush, path); + e.Graphics.DrawPath(borderPen, path); + + base.OnPaint(e); + } +} diff --git a/packaging/windows/manager/src/OVMS.Manager/MainForm.cs b/packaging/windows/manager/src/OVMS.Manager/MainForm.cs new file mode 100644 index 0000000000..237b7848dc --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/MainForm.cs @@ -0,0 +1,2475 @@ +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using Microsoft.Win32; + +namespace OVMS.Manager; + +/// +/// Main GUI window: app-shell layout with a left nav rail (Dashboard / +/// Settings / Logs / Advanced) and a content area on the right. Pages are +/// plain Panels toggled via Visible, not a TabControl. +/// All process/HTTP work is dispatched off the UI thread via Task.Run and +/// marshalled back through the WinForms SynchronizationContext (async/await). +/// +internal sealed class MainForm : Form +{ + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string RunValueName = "OVMS Manager"; + + private readonly OvmsController controller; + + /// + /// When true, FormClosing will let the close proceed instead of hiding + /// to tray. Set by the tray context's Exit handler. + /// + public bool AllowExit { get; set; } + + // Shell controls + private Panel navRail = null!; + private Panel contentHost = null!; + private Label pageTitleLabel = null!; + private Button headerRefreshButton = null!; + private readonly List navButtons = new(); + private readonly Dictionary pages = new(); + private Label railStatusDot = null!; + private Label railStatusText = null!; + private System.Windows.Forms.Timer? dashboardTimer; + private string currentPage = ""; + + // Dashboard controls + private Label statusValueLabel = null!; + private Label statusDotLabel = null!; + private Label runModeValueLabel = null!; + private Label restUrlValueLabel = null!; + private Label grpcValueLabel = null!; + private Label variantValueLabel = null!; + private Label healthValueLabel = null!; + private Label healthDotLabel = null!; + private Label modelsValueLabel = null!; + private Button startButton = null!; + private Button stopButton = null!; + private Button restartButton = null!; + + // Settings controls + private NumericUpDown restPortInput = null!; + private NumericUpDown grpcPortInput = null!; + private TextBox bindAddressInput = null!; + private ComboBox logLevelInput = null!; + private TextBox logPathInput = null!; + private TextBox modelRepoInput = null!; + private ComboBox runModeInput = null!; + private CheckBox showTrayCheckBox = null!; + private CheckBox startAtLoginCheckBox = null!; + private Label settingsStatusLabel = null!; + + // Logs controls + private TextBox logTextBox = null!; + private Label logPathLabel = null!; + private CheckBox autoScrollCheckBox = null!; + private Label logStatusLabel = null!; + + // Advanced controls + private TextBox advancedTextBox = null!; + private Label advancedStatusLabel = null!; + + // Updates controls + private Label updatesBaseInstalledVersionValue = null!; + private Label updatesBaseLatestVersionValue = null!; + private Label updatesModelServerInstalledVersionValue = null!; + private Label updatesModelServerLatestVersionValue = null!; + private Label updatesGenAiInstalledVersionValue = null!; + private Label updatesGenAiLatestVersionValue = null!; + private Label updatesVariantValue = null!; + private Label updatesManagerVersionValue = null!; + private Label updatesStatusLabel = null!; + private CardPanel updatesBaseCard = null!; + private CardPanel updatesModelServerCard = null!; + private CardPanel updatesGenAiCard = null!; + private CollapsibleActionRow updatesBaseInstallRow; + private CollapsibleActionRow updatesModelServerInstallRow; + private CollapsibleActionRow updatesGenAiInstallRow; + private Button updatesCheckButton = null!; + private Button updatesBaseInstallButton = null!; + private Button updatesModelServerInstallButton = null!; + private Button updatesGenAiInstallButton = null!; + private Button updatesModelServerReleaseButton = null!; + private Button updatesGenAiReleaseButton = null!; + private string updatesModelServerReleaseUrl = ""; + private string updatesGenAiReleaseUrl = ""; + + public MainForm(OvmsController controller) + { + this.controller = controller; + + Text = "OpenVINO Model Server Manager"; + MinimumSize = new Size(1020, 680); + Size = new Size(1220, 780); + StartPosition = FormStartPosition.CenterScreen; + BackColor = Theme.WindowBackground; + Font = Theme.BaseFont; + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); + + try + { + Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); + } + catch + { + Icon = SystemIcons.Application; + } + + BuildShell(); + + FormClosing += OnFormClosing; + FormClosed += (_, _) => StopDashboardTimer(); + Shown += async (_, _) => await RefreshDashboardAsync(); + + SelectPage("Dashboard"); + } + + private void OnFormClosing(object? sender, FormClosingEventArgs e) + { + if (e.CloseReason == CloseReason.UserClosing && !AllowExit) + { + var settings = TryLoadSettings(); + if (settings is { ShowTrayIcon: true }) + { + e.Cancel = true; + Hide(); + } + } + } + + private OvmsSettings? TryLoadSettings() + { + try + { + return controller.LoadSettings(); + } + catch + { + return null; + } + } + + // --------------------------------------------------------------- + // Shell: nav rail + content header + page hosting + // --------------------------------------------------------------- + + private void BuildShell() + { + navRail = new Panel + { + Dock = DockStyle.Left, + Width = 210, + BackColor = Theme.Rail + }; + + var railBorder = new Panel { Dock = DockStyle.Right, Width = 1, BackColor = Theme.Border }; + navRail.Controls.Add(railBorder); + + var headerBlock = new Panel { Dock = DockStyle.Top, Height = 76, BackColor = Theme.Rail }; + var titleLabel = new Label + { + Text = "OVMS Manager", + Font = Theme.SemiboldFont, + ForeColor = Theme.Text, + AutoSize = false, + TextAlign = ContentAlignment.MiddleLeft, + Dock = DockStyle.Fill, + Padding = new Padding(18, 0, 8, 0) + }; + var ringDot = new Label + { + Text = "●", + ForeColor = Theme.Accent, + Font = new Font("Segoe UI", 14f), + AutoSize = true, + Location = new Point(18, 14) + }; + headerBlock.Controls.Add(titleLabel); + headerBlock.Controls.Add(ringDot); + titleLabel.Padding = new Padding(40, 0, 8, 0); + titleLabel.Location = new Point(0, 14); + titleLabel.Size = new Size(210, 40); + + var navFlow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + AutoSize = false, + AutoScroll = true, + BackColor = Theme.Rail, + Padding = new Padding(0, 8, 0, 0) + }; + navFlow.Controls.Add(CreateNavButton("Dashboard", Glyphs.Dashboard)); + navFlow.Controls.Add(CreateNavButton("Settings", Glyphs.Settings)); + navFlow.Controls.Add(CreateNavButton("Logs", Glyphs.Logs)); + navFlow.Controls.Add(CreateNavButton("Advanced", Glyphs.Advanced)); + navFlow.Controls.Add(CreateNavButton("Updates", Glyphs.Update)); + + var statusPill = new Panel { Dock = DockStyle.Bottom, Height = 56, BackColor = Theme.Rail, Padding = new Padding(16, 12, 16, 12) }; + var pillInner = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; + railStatusDot = new Label { Text = "●", ForeColor = Theme.Muted, Font = new Font("Segoe UI", 10f), AutoSize = true, Margin = new Padding(0, 3, 6, 0) }; + railStatusText = new Label { Text = "Checking...", ForeColor = Theme.Muted, Font = Theme.BaseFont, AutoSize = true, Margin = new Padding(0, 4, 0, 0) }; + pillInner.Controls.Add(railStatusDot); + pillInner.Controls.Add(railStatusText); + statusPill.Controls.Add(pillInner); + + // Dock carving order matters: WinForms carves docked children from the + // LAST-added control backwards. We add navFlow (Fill) FIRST so it is + // carved first and would normally claim the whole client area; but + // controls added AFTER it (statusPill=Bottom, headerBlock=Top) are + // carved out of navRail's bounds BEFORE navFlow's Fill is resolved, + // because Fill is always resolved last regardless of add order for + // DockStyle.Fill vs the other dock styles -- WinForms processes + // Top/Bottom/Left/Right docked siblings first (in reverse add order) + // and only then gives the remaining space to the Fill control. So the + // actual requirement is just that statusPill/headerBlock are added + // AFTER navFlow so they end up with a higher z-order and get their + // Top/Bottom slices carved out of the rail before Fill claims the rest. + navRail.Controls.Add(navFlow); + navRail.Controls.Add(statusPill); + navRail.Controls.Add(headerBlock); + + // Content area + var contentArea = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WindowBackground }; + + var contentHeader = new Panel { Dock = DockStyle.Top, Height = 56, BackColor = Theme.WindowBackground, Padding = new Padding(24, 12, 24, 12) }; + pageTitleLabel = new Label { Text = "Dashboard", Font = Theme.PageTitleFont, ForeColor = Theme.Text, AutoSize = true, Dock = DockStyle.Left }; + headerRefreshButton = CreateIconButton(Glyphs.Refresh, "Refresh"); + headerRefreshButton.Dock = DockStyle.Right; + headerRefreshButton.Click += async (_, _) => await RefreshCurrentPageAsync(); + contentHeader.Controls.Add(pageTitleLabel); + contentHeader.Controls.Add(headerRefreshButton); + + contentHost = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WindowBackground, Padding = new Padding(24, 12, 24, 24) }; + SetDoubleBuffered(contentHost); + + pages["Dashboard"] = BuildResponsiveDashboardPage(); + pages["Settings"] = BuildSettingsPage(); + pages["Logs"] = BuildLogsPage(); + pages["Advanced"] = BuildAdvancedPage(); + pages["Updates"] = BuildUpdatesPage(); + + foreach (var page in pages.Values) + { + page.Dock = DockStyle.Fill; + page.Visible = false; + contentHost.Controls.Add(page); + } + + contentArea.Controls.Add(contentHost); + contentArea.Controls.Add(contentHeader); + + Controls.Add(contentArea); + Controls.Add(navRail); + } + + private static void SetDoubleBuffered(Control control) + { + typeof(Control).InvokeMember("DoubleBuffered", + System.Reflection.BindingFlags.SetProperty | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + null, control, new object[] { true }); + } + + private NavButton CreateNavButton(string name, string glyph) + { + var button = new NavButton(name, glyph); + button.Click += (_, _) => SelectPage(name); + navButtons.Add(button); + return button; + } + + private void SelectPage(string name) + { + if (!pages.ContainsKey(name)) + { + return; + } + + currentPage = name; + pageTitleLabel.Text = name; + + foreach (var page in pages) + { + page.Value.Visible = page.Key == name; + } + + foreach (var nav in navButtons) + { + nav.Selected = nav.PageName == name; + } + + if (name == "Dashboard") + { + StartDashboardTimer(); + _ = RefreshDashboardAsync(); + } + else + { + StopDashboardTimer(); + if (name == "Settings") + { + LoadSettingsIntoForm(); + } + else if (name == "Logs") + { + _ = RefreshLogsAsync(); + } + else if (name == "Advanced") + { + RefreshAdvancedInfo(); + } + else if (name == "Updates") + { + RefreshUpdatesInfo(); + } + } + } + + private async Task RefreshCurrentPageAsync() + { + switch (currentPage) + { + case "Dashboard": + await RefreshDashboardAsync(); + break; + case "Logs": + await RefreshLogsAsync(); + break; + case "Advanced": + RefreshAdvancedInfo(); + break; + case "Updates": + RefreshUpdatesInfo(); + break; + case "Settings": + LoadSettingsIntoForm(); + break; + } + } + + private void StartDashboardTimer() + { + if (dashboardTimer != null) + { + return; + } + dashboardTimer = new System.Windows.Forms.Timer { Interval = 5000 }; + dashboardTimer.Tick += async (_, _) => + { + if (currentPage == "Dashboard" && Visible) + { + await RefreshDashboardAsync(); + } + }; + dashboardTimer.Start(); + } + + private void StopDashboardTimer() + { + if (dashboardTimer is null) + { + return; + } + dashboardTimer.Stop(); + dashboardTimer.Dispose(); + dashboardTimer = null; + } + + // --------------------------------------------------------------- + // Shared UI helpers + // --------------------------------------------------------------- + + private static Button CreateIconButton(string glyph, string tooltip) + { + var button = new RuntimeIconButton(glyph, Theme.Muted) + { + BackColor = Theme.Surface, + Size = new Size(42, 38), + MinimumSize = new Size(42, 38), + MaximumSize = new Size(42, 38), + Margin = new Padding(0) + }; + button.Font = Theme.IconFont(12.5f); + var tip = new ToolTip(); + tip.SetToolTip(button, tooltip); + return button; + } + + private static Button CreatePrimaryButton(string glyph, string text, Color fillColor) + { + var button = new Button + { + Text = string.IsNullOrEmpty(glyph) ? text : glyph + " " + text, + Font = Theme.NavFont, + FlatStyle = FlatStyle.Flat, + ForeColor = Color.White, + BackColor = fillColor, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Padding = new Padding(12, 8, 14, 8), + Cursor = Cursors.Hand, + UseVisualStyleBackColor = false, + TextImageRelation = TextImageRelation.ImageBeforeText + }; + button.FlatAppearance.BorderSize = 0; + button.FlatAppearance.MouseOverBackColor = ControlPaint.Dark(fillColor, 0.08f); + return button; + } + + private static Button CreateRuntimeIconButton(string glyph, string tooltip, Color fillColor) + { + var button = new RuntimeIconButton(glyph, fillColor) + { + BackColor = Theme.Surface, + Size = new Size(58, 52), + MinimumSize = new Size(58, 52), + MaximumSize = new Size(58, 52), + Margin = new Padding(4, 0, 0, 0) + }; + var tip = new ToolTip(); + tip.SetToolTip(button, tooltip); + return button; + } + + private static void SetRuntimeIconButtonState(Button button, bool enabled) + { + var activeColor = button.Tag is Color color ? color : Theme.Text; + button.Enabled = enabled; + button.ForeColor = enabled ? activeColor : Color.FromArgb(145, Theme.Muted); + button.BackColor = Theme.Surface; + button.Cursor = enabled ? Cursors.Hand : Cursors.Default; + if (button is RuntimeIconButton iconButton) + { + iconButton.ActiveColor = activeColor; + iconButton.DisabledColor = Color.FromArgb(145, Theme.Muted); + iconButton.Invalidate(); + } + else + { + button.FlatAppearance.MouseOverBackColor = enabled ? Theme.HoverTint : Theme.Surface; + button.FlatAppearance.MouseDownBackColor = enabled ? Theme.WindowBackground : Theme.Surface; + } + } + + private static Button CreateSecondaryButton(string text) + { + var button = new Button + { + Text = text, + Font = Theme.NavFont, + FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, + BackColor = Theme.Surface, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Padding = new Padding(16, 8, 16, 8), + Cursor = Cursors.Hand, + UseVisualStyleBackColor = false + }; + button.FlatAppearance.BorderSize = 1; + button.FlatAppearance.BorderColor = Theme.Border; + button.FlatAppearance.MouseOverBackColor = Theme.HoverTint; + return button; + } + + private static CardPanel CreateCard(int width, int height) + { + return new CardPanel + { + Width = width, + Height = height, + Margin = new Padding(0, 0, 16, 16) + }; + } + + // --------------------------------------------------------------- + // Dashboard + // --------------------------------------------------------------- + + private Panel BuildResponsiveDashboardPage() + { + var page = new Panel { BackColor = Theme.WindowBackground, AutoScroll = true }; + + var dashboardStack = new TableLayoutPanel + { + Location = new Point(0, 0), + AutoSize = false, + ColumnCount = 1, + RowCount = 1, + Height = 456, + Padding = new Padding(0), + Margin = new Padding(0) + }; + dashboardStack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + dashboardStack.RowStyles.Add(new RowStyle(SizeType.Absolute, 456)); + + var metricGrid = new TableLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = false, + ColumnCount = 2, + RowCount = 4, + Padding = new Padding(0), + Margin = new Padding(0) + }; + for (var i = 0; i < 2; i++) + { + metricGrid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + } + + for (var i = 0; i < 4; i++) + { + metricGrid.RowStyles.Add(new RowStyle(SizeType.Absolute, 112)); + } + + startButton = CreateRuntimeIconButton(Glyphs.Play, "Start server", Theme.Success); + stopButton = CreateRuntimeIconButton(Glyphs.Stop, "Stop server", Theme.Danger); + restartButton = CreateRuntimeIconButton(Glyphs.Refresh, "Restart server", Theme.Accent); + + startButton.Click += async (_, _) => await RunControlActionAsync("Start", controller.Start); + stopButton.Click += async (_, _) => await RunControlActionAsync("Stop", controller.Stop); + restartButton.Click += async (_, _) => await RunControlActionAsync("Restart", controller.Restart); + + (Label dot, Label value) AddControlStatusCard(int column, int row) + { + var card = new CardPanel + { + Dock = DockStyle.Fill, + Margin = new Padding(0, 0, 16, 16), + Padding = new Padding(18, 14, 18, 14) + }; + + var cardLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(0) + }; + cardLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + cardLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 230)); + cardLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var statusLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 2, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(0) + }; + statusLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 20)); + statusLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + statusLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); + statusLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var titleLabel = new Label + { + Text = "Status", + Font = Theme.CardTitleFont, + ForeColor = Theme.Muted, + AutoEllipsis = true, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0) + }; + var dotLabel = new Label + { + Text = "\u25CF", + Font = new Font("Segoe UI", 10.5f), + ForeColor = Theme.Muted, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0, 5, 0, 0) + }; + var valueLabel = new Label + { + Text = "-", + Font = Theme.CardValueFont, + ForeColor = Theme.Text, + AutoEllipsis = true, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0, 2, 0, 0) + }; + + statusLayout.Controls.Add(titleLabel, 0, 0); + statusLayout.SetColumnSpan(titleLabel, 2); + statusLayout.Controls.Add(dotLabel, 0, 1); + statusLayout.Controls.Add(valueLabel, 1, 1); + + var actionLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 4, + RowCount = 3, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(0, 0, 6, 0) + }; + actionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + actionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 62)); + actionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 62)); + actionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 62)); + actionLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + actionLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 52)); + actionLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + actionLayout.Controls.Add(startButton, 1, 1); + actionLayout.Controls.Add(stopButton, 2, 1); + actionLayout.Controls.Add(restartButton, 3, 1); + + cardLayout.Controls.Add(statusLayout, 0, 0); + cardLayout.Controls.Add(actionLayout, 1, 0); + card.Controls.Add(cardLayout); + metricGrid.Controls.Add(card, column, row); + return (dotLabel, valueLabel); + } + + (Label dot, Label value) AddStatusCard(string title, string initialValue, bool withDot, int column, int row) + { + var card = new CardPanel + { + Dock = DockStyle.Fill, + Margin = new Padding(0, 0, 16, 16), + Padding = new Padding(18, 14, 18, 14) + }; + + var cardLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = withDot ? 2 : 1, + RowCount = 2, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(0) + }; + if (withDot) + { + cardLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 20)); + cardLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + } + else + { + cardLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + } + cardLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); + cardLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var titleLabel = new Label + { + Text = title, + Font = Theme.CardTitleFont, + ForeColor = Theme.Muted, + AutoEllipsis = true, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0) + }; + var valueLabel = new Label + { + Text = initialValue, + Font = Theme.CardValueFont, + ForeColor = Theme.Text, + AutoEllipsis = true, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0, 2, 0, 0) + }; + + Label? dotLabel = null; + if (withDot) + { + dotLabel = new Label + { + Text = "\u25CF", + Font = new Font("Segoe UI", 10.5f), + ForeColor = Theme.Muted, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0, 5, 0, 0) + }; + cardLayout.Controls.Add(titleLabel, 0, 0); + cardLayout.SetColumnSpan(titleLabel, 2); + cardLayout.Controls.Add(dotLabel, 0, 1); + cardLayout.Controls.Add(valueLabel, 1, 1); + } + else + { + cardLayout.Controls.Add(titleLabel, 0, 0); + cardLayout.Controls.Add(valueLabel, 0, 1); + } + + card.Controls.Add(cardLayout); + metricGrid.Controls.Add(card, column, row); + return (dotLabel!, valueLabel); + } + + (statusDotLabel, statusValueLabel) = AddControlStatusCard(0, 0); + (_, restUrlValueLabel) = AddStatusCard("REST endpoint", "-", withDot: false, 1, 0); + (_, grpcValueLabel) = AddStatusCard("gRPC port", "-", withDot: false, 0, 1); + (_, modelsValueLabel) = AddStatusCard("Models served", "-", withDot: false, 1, 1); + (healthDotLabel, healthValueLabel) = AddStatusCard("Health", "-", withDot: true, 0, 2); + (_, variantValueLabel) = AddStatusCard("Package variant", "-", withDot: false, 1, 2); + (_, runModeValueLabel) = AddStatusCard("Runtime mode", "-", withDot: false, 0, 3); + + dashboardStack.Controls.Add(metricGrid, 0, 0); + page.Controls.Add(dashboardStack); + + void ResizeDashboard() + { + var availableWidth = ClientSize.Width - navRail.Width - contentHost.Padding.Horizontal - 28; + dashboardStack.Width = Math.Max(560, availableWidth); + } + + page.Resize += (_, _) => ResizeDashboard(); + Resize += (_, _) => ResizeDashboard(); + ResizeDashboard(); + return page; + } + + private Panel BuildDashboardPage() + { + var page = new Panel { BackColor = Theme.WindowBackground }; + + var cardFlow = new FlowLayoutPanel + { + Dock = DockStyle.Top, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + AutoSize = true, + Padding = new Padding(0, 0, 0, 8) + }; + + (Label dot, Label value) AddStatusCard(string title, string initialValue, bool withDot) + { + var card = CreateCard(250, 96); + var titleLabel = new Label { Text = title, Font = Theme.CardTitleFont, ForeColor = Theme.Muted, AutoSize = true, Location = new Point(16, 14) }; + Label? dotLabel = null; + var valueLabel = new Label { Text = initialValue, Font = Theme.CardValueFont, ForeColor = Theme.Text, AutoSize = true, Location = new Point(withDot ? 34 : 16, 46) }; + if (withDot) + { + dotLabel = new Label { Text = "●", Font = new Font("Segoe UI", 11f), ForeColor = Theme.Muted, AutoSize = true, Location = new Point(16, 44) }; + card.Controls.Add(dotLabel); + } + card.Controls.Add(titleLabel); + card.Controls.Add(valueLabel); + cardFlow.Controls.Add(card); + return (dotLabel!, valueLabel); + } + + (statusDotLabel, statusValueLabel) = AddStatusCard("Status", "-", withDot: true); + (_, restUrlValueLabel) = AddStatusCard("REST endpoint", "-", withDot: false); + (_, grpcValueLabel) = AddStatusCard("gRPC port", "-", withDot: false); + (_, modelsValueLabel) = AddStatusCard("Models served", "-", withDot: false); + (healthDotLabel, healthValueLabel) = AddStatusCard("Health", "-", withDot: true); + (_, variantValueLabel) = AddStatusCard("Package variant", "-", withDot: false); + (_, runModeValueLabel) = AddStatusCard("Runtime mode", "-", withDot: false); + + var actionsCard = new CardPanel { Dock = DockStyle.Top, Height = 84, Margin = new Padding(0, 8, 0, 0) }; + var actionsFlow = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, AutoSize = false, AutoScroll = true, Padding = new Padding(12, 10, 12, 10) }; + + startButton = CreatePrimaryButton("▶", "Start", Theme.Success); + stopButton = CreatePrimaryButton("■", "Stop", Theme.Danger); + restartButton = CreatePrimaryButton("↻", "Restart", Theme.Accent); + var openLogsButton = CreateSecondaryButton("Open Logs"); + var openModelFolderButton = CreateSecondaryButton("Open Model Folder"); + + foreach (var b in new[] { startButton, stopButton, restartButton, openLogsButton, openModelFolderButton }) + { + b.Margin = new Padding(0, 0, 10, 0); + } + + startButton.Click += async (_, _) => await RunControlActionAsync("Start", controller.Start); + stopButton.Click += async (_, _) => await RunControlActionAsync("Stop", controller.Stop); + restartButton.Click += async (_, _) => await RunControlActionAsync("Restart", controller.Restart); + openLogsButton.Click += (_, _) => OpenFolderFor(TryLoadSettings()?.LogPath); + openModelFolderButton.Click += (_, _) => OpenFolder(TryLoadSettings()?.ModelRepositoryPath); + + actionsFlow.Controls.Add(startButton); + actionsFlow.Controls.Add(stopButton); + actionsFlow.Controls.Add(restartButton); + actionsFlow.Controls.Add(openLogsButton); + actionsFlow.Controls.Add(openModelFolderButton); + actionsCard.Controls.Add(actionsFlow); + + // Dock order: bottom-most added is at the top visually for DockStyle.Top stacking, + // so add actionsCard after cardFlow to place it beneath. + page.Controls.Add(actionsCard); + page.Controls.Add(cardFlow); + return page; + } + + private async Task RunControlActionAsync(string title, Action action) + { + SetControlButtonsEnabled(false); + try + { + await Task.Run(action); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, title, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + SetControlButtonsEnabled(true); + await RefreshDashboardAsync(); + } + } + + private void SetControlButtonsEnabled(bool enabled) + { + if (!enabled) + { + SetRuntimeIconButtonState(startButton, false); + SetRuntimeIconButtonState(stopButton, false); + SetRuntimeIconButtonState(restartButton, false); + } + headerRefreshButton.Enabled = enabled; + } + + public async Task RefreshDashboardAsync() + { + var settings = TryLoadSettings(); + if (settings is null) + { + statusValueLabel.Text = "Settings unavailable"; + SetRuntimeIconButtonState(startButton, false); + SetRuntimeIconButtonState(stopButton, false); + SetRuntimeIconButtonState(restartButton, false); + return; + } + + var runtimeStatus = await Task.Run(controller.GetRuntimeStatus); + statusValueLabel.Text = runtimeStatus.Running ? $"Running (pid {runtimeStatus.Pid})" : "Stopped"; + statusDotLabel.ForeColor = runtimeStatus.Running ? Theme.Success : Theme.Muted; + railStatusDot.ForeColor = runtimeStatus.Running ? Theme.Success : Theme.Muted; + railStatusText.Text = runtimeStatus.Running ? "Running" : "Stopped"; + railStatusText.ForeColor = runtimeStatus.Running ? Theme.Text : Theme.Muted; + SetRuntimeIconButtonState(startButton, !runtimeStatus.Running); + SetRuntimeIconButtonState(stopButton, runtimeStatus.Running); + SetRuntimeIconButtonState(restartButton, runtimeStatus.Running); + + runModeValueLabel.Text = settings.RunMode; + restUrlValueLabel.Text = $"{settings.BindAddress}:{settings.RestPort}"; + grpcValueLabel.Text = settings.GrpcPort > 0 ? settings.GrpcPort.ToString() : "disabled"; + variantValueLabel.Text = settings.PackageVariant; + + healthValueLabel.Text = "Checking..."; + healthDotLabel.ForeColor = Theme.Muted; + modelsValueLabel.Text = "-"; + + var health = await controller.CheckHealthAsync(); + healthValueLabel.Text = health.Ok ? "Healthy" : "Unreachable"; + healthDotLabel.ForeColor = health.Ok ? Theme.Success : Theme.Danger; + modelsValueLabel.Text = health.Ok ? health.ModelCount.ToString() : "-"; + } + + private static void OpenFolderFor(string? filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + return; + } + OpenFolder(Path.GetDirectoryName(filePath)); + } + + private static void OpenFolder(string? folderPath) + { + if (string.IsNullOrEmpty(folderPath)) + { + return; + } + try + { + Directory.CreateDirectory(folderPath); + Process.Start(new ProcessStartInfo { FileName = folderPath, UseShellExecute = true }); + } + catch + { + // Best effort; non-fatal if the folder cannot be opened. + } + } + + // --------------------------------------------------------------- + // Settings + // --------------------------------------------------------------- + + private Panel BuildSettingsPage() + { + var page = new Panel { BackColor = Theme.WindowBackground, AutoScroll = true, Padding = new Padding(0, 0, 10, 0) }; + + var serverCard = CreateSettingsCard(650); + var serverLayout = CreateSettingsTable(); + + var row = 0; + AddSettingsCardHeader(serverLayout, "Server", "Configure endpoints, logging, and where OVMS loads models from.", row++); + AddSettingsSectionHeader(serverLayout, "Network", row++); + restPortInput = new NumericUpDown { Minimum = 1, Maximum = 65535, Width = 150, Anchor = AnchorStyles.Left }; + StyleSettingsField(restPortInput); + AddSettingsRow(serverLayout, "REST port", restPortInput, row++); + grpcPortInput = new NumericUpDown { Minimum = 0, Maximum = 65535, Width = 150, Anchor = AnchorStyles.Left }; + StyleSettingsField(grpcPortInput); + AddSettingsRow(serverLayout, "gRPC port", grpcPortInput, row++); + AddHelperRow(serverLayout, "Set to 0 to disable the gRPC endpoint.", row++); + bindAddressInput = new TextBox { Anchor = AnchorStyles.Left | AnchorStyles.Right }; + StyleSettingsField(bindAddressInput); + AddSettingsRow(serverLayout, "Bind address", bindAddressInput, row++); + AddHelperRow(serverLayout, "Use 127.0.0.1 for local-only access. Other addresses may expose the server to your network.", row++); + + AddSettingsSectionHeader(serverLayout, "Logging and models", row++); + logLevelInput = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 170, Anchor = AnchorStyles.Left, FlatStyle = FlatStyle.Standard }; + StyleSettingsField(logLevelInput); + logLevelInput.Items.AddRange(new object[] { "ERROR", "WARNING", "INFO", "DEBUG", "TRACE" }); + AddSettingsRow(serverLayout, "Log level", logLevelInput, row++); + + logPathInput = new TextBox { Anchor = AnchorStyles.Left | AnchorStyles.Right }; + StyleSettingsField(logPathInput); + var browseLogButton = CreateSecondaryButton("Browse..."); + browseLogButton.Click += (_, _) => + { + using var dialog = new SaveFileDialog + { + Title = "Select log file", + CheckFileExists = false, + OverwritePrompt = false, + FileName = Path.GetFileName(logPathInput.Text), + InitialDirectory = SafeDirectoryName(logPathInput.Text), + Filter = "Log files (*.log)|*.log|All files (*.*)|*.*" + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + logPathInput.Text = dialog.FileName; + } + }; + AddSettingsRowWithButton(serverLayout, "Log path", logPathInput, browseLogButton, row++); + + modelRepoInput = new TextBox { Anchor = AnchorStyles.Left | AnchorStyles.Right }; + StyleSettingsField(modelRepoInput); + var browseModelRepoButton = CreateSecondaryButton("Browse..."); + browseModelRepoButton.Click += (_, _) => + { + using var dialog = new FolderBrowserDialog + { + Description = "Select model repository folder", + SelectedPath = Directory.Exists(modelRepoInput.Text) ? modelRepoInput.Text : "" + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + modelRepoInput.Text = dialog.SelectedPath; + } + }; + var openModelRepoButton = CreateSecondaryButton("Open"); + openModelRepoButton.Click += (_, _) => OpenFolder(modelRepoInput.Text); + AddSettingsRowWithButtons(serverLayout, "Model folder", modelRepoInput, new[] { browseModelRepoButton, openModelRepoButton }, row++); + + serverCard.Controls.Add(serverLayout); + + var startupCard = CreateSettingsCard(330); + var startupLayout = CreateSettingsTable(); + + row = 0; + AddSettingsCardHeader(startupLayout, "Startup tray", "Choose how the manager and server behave when Windows starts.", row++); + AddSettingsSectionHeader(startupLayout, "Launch behavior", row++); + runModeInput = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220, Anchor = AnchorStyles.Left, FlatStyle = FlatStyle.Standard }; + StyleSettingsField(runModeInput); + runModeInput.Items.AddRange(new object[] { "user-login", "service", "manual" }); + AddSettingsRow(startupLayout, "Startup mode", runModeInput, row++); + + showTrayCheckBox = new CheckBox { Text = "Show OVMS in the Windows notification area", AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(3, 8, 3, 5), FlatStyle = FlatStyle.System, BackColor = Theme.Surface }; + AddSettingsRow(startupLayout, "Tray icon", showTrayCheckBox, row++); + + startAtLoginCheckBox = new CheckBox { Text = "Launch OVMS Manager when you sign in", AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(3, 8, 3, 5), FlatStyle = FlatStyle.System, BackColor = Theme.Surface }; + AddSettingsRow(startupLayout, "Start at login", startAtLoginCheckBox, row++); + + startupCard.Controls.Add(startupLayout); + + var footer = new FlowLayoutPanel + { + Dock = DockStyle.Top, + Height = 82, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Padding = new Padding(18, 16, 18, 14), + Margin = new Padding(0, 2, 0, 0), + BackColor = Theme.WindowBackground + }; + var footerRule = new Panel { Dock = DockStyle.Top, Height = 1, BackColor = Theme.Border, Margin = new Padding(0) }; + var saveButton = CreatePrimaryButton("", "Save changes", Theme.Accent); + var resetButton = CreateSecondaryButton("Reset"); + saveButton.Margin = new Padding(0, 0, 10, 0); + resetButton.Margin = new Padding(0, 0, 16, 0); + settingsStatusLabel = new Label + { + Text = "", + AutoSize = true, + Font = Theme.SemiboldFont, + Margin = new Padding(0, 10, 0, 0) + }; + + saveButton.Click += async (_, _) => await SaveSettingsAsync(); + resetButton.Click += (_, _) => + { + LoadSettingsIntoForm(); + settingsStatusLabel.ForeColor = Theme.Muted; + settingsStatusLabel.Text = "Reloaded from disk."; + }; + + footer.Controls.Add(saveButton); + footer.Controls.Add(resetButton); + footer.Controls.Add(settingsStatusLabel); + + // Dock-Top stacking: add bottom-most visual element first so later + // Top-docked controls are carved above it (see BuildShell comment). + page.Controls.Add(footer); + page.Controls.Add(footerRule); + page.Controls.Add(startupCard); + page.Controls.Add(serverCard); + return page; + } + + private static CardPanel CreateSettingsCard(int height) + { + return new CardPanel + { + Dock = DockStyle.Top, + Height = height, + Margin = new Padding(0, 0, 0, 26), + Padding = new Padding(26, 22, 26, 24), + CardBackColor = Theme.Surface, + BorderColor = Color.FromArgb(222, 226, 232) + }; + } + + private static Panel CreateCardSpacer(int height = 18) + { + return new Panel + { + Dock = DockStyle.Top, + Height = height, + BackColor = Theme.WindowBackground + }; + } + + private static TableLayoutPanel CreateSettingsTable() + { + var table = new TableLayoutPanel + { + Dock = DockStyle.Top, + ColumnCount = 2, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Padding = new Padding(0), + BackColor = Theme.Surface + }; + table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 190)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + return table; + } + + private static void AddSettingsCardHeader(TableLayoutPanel table, string title, string subtitle, int row) + { + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 78)); + var header = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0), + BackColor = Theme.Surface + }; + header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); + header.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); + header.Controls.Add(new Label + { + Text = title, + AutoSize = false, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Font = Theme.CardTitleFont, + ForeColor = Theme.Text, + BackColor = Theme.Surface, + Margin = new Padding(0) + }, 0, 0); + header.Controls.Add(new Label + { + Text = subtitle, + AutoSize = false, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.TopLeft, + Font = new Font(Theme.BaseFont.FontFamily, 9.25f), + ForeColor = Theme.Muted, + BackColor = Theme.Surface, + Margin = new Padding(0) + }, 0, 1); + + table.Controls.Add(header, 0, row); + table.SetColumnSpan(header, 2); + } + + private static void AddSettingsSectionHeader(TableLayoutPanel table, string text, int row) + { + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + var header = new Label + { + Text = text, + AutoSize = false, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Font = new Font(Theme.BaseFont.FontFamily, 8.25f, FontStyle.Bold), + ForeColor = Theme.Accent, + BackColor = Theme.Surface, + Margin = new Padding(3, 10, 0, 6) + }; + table.Controls.Add(header, 0, row); + table.SetColumnSpan(header, 2); + } + + private static void AddSettingsRow(TableLayoutPanel table, string label, Control field, int row) + { + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); + table.Controls.Add(CreateSettingsLabel(label), 0, row); + table.Controls.Add(field is CheckBox ? field : CreateCappedSettingsField(field), 1, row); + } + + private static Label AddReadOnlySettingsRow(TableLayoutPanel table, string label, int row) + { + var value = CreateReadOnlyValueLabel(); + AddSettingsRow(table, label, value, row); + return value; + } + + private static void AddSettingsRowWithButton(TableLayoutPanel table, string label, Control field, Control button, int row) + { + AddSettingsRowWithButtons(table, label, field, new[] { button }, row); + } + + private static void AddSettingsRowWithButtons(TableLayoutPanel table, string label, Control field, IReadOnlyList buttons, int row) + { + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 84)); + + var inline = new Panel + { + Dock = DockStyle.Fill, + Margin = new Padding(0), + BackColor = Theme.Surface + }; + var fieldHost = CreateSettingsFieldHost(field); + fieldHost.Location = new Point(3, 8); + inline.Controls.Add(fieldHost); + + for (var i = 0; i < buttons.Count; i++) + { + buttons[i].Anchor = AnchorStyles.Top | AnchorStyles.Left; + buttons[i].AutoSize = false; + buttons[i].Size = new Size(buttons[i].Text == "Open" ? 110 : 150, 52); + buttons[i].MinimumSize = buttons[i].Size; + buttons[i].Margin = new Padding(0); + buttons[i].Location = new Point(0, 6); + inline.Controls.Add(buttons[i]); + } + + void LayoutInline() + { + var buttonWidth = buttons.Count * 10; + foreach (var button in buttons) + { + buttonWidth += button.Width; + } + var fieldWidth = Math.Min(700, Math.Max(420, inline.ClientSize.Width - buttonWidth - 16)); + fieldHost.Size = new Size(fieldWidth, 40); + + var x = fieldHost.Right + 10; + foreach (var button in buttons) + { + button.Location = new Point(x, 6); + x += button.Width + 10; + } + } + + inline.Resize += (_, _) => LayoutInline(); + LayoutInline(); + + table.Controls.Add(CreateSettingsLabel(label), 0, row); + table.Controls.Add(inline, 1, row); + } + + private static void AddHelperRow(TableLayoutPanel table, string text, int row) + { + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 32)); + var helper = new Label + { + Text = text, + AutoSize = false, + Dock = DockStyle.Fill, + Font = new Font(Theme.BaseFont.FontFamily, 8.25f), + ForeColor = Theme.Muted, + BackColor = Theme.Surface, + Margin = new Padding(3, 0, 12, 12) + }; + table.Controls.Add(helper, 1, row); + } + + private static void AddStatusRow(TableLayoutPanel table, Label statusLabel, int row) + { + table.RowStyles.Add(new RowStyle(SizeType.Absolute, 54)); + statusLabel.AutoSize = false; + statusLabel.Dock = DockStyle.Fill; + statusLabel.TextAlign = ContentAlignment.MiddleLeft; + statusLabel.BackColor = Theme.Surface; + statusLabel.Margin = new Padding(3, 4, 12, 4); + table.Controls.Add(statusLabel, 1, row); + } + + private static CollapsibleActionRow AddActionRow(TableLayoutPanel table, string label, string helperText, IReadOnlyList buttons, int row) + { + var stacked = buttons.Count > 1; + var height = stacked ? 118 : 84; + var rowStyle = new RowStyle(SizeType.Absolute, height); + table.RowStyles.Add(rowStyle); + var labelControl = CreateSettingsLabel(label); + table.Controls.Add(labelControl, 0, row); + + var content = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = stacked ? 1 : 3, + RowCount = stacked ? 2 : 1, + Margin = new Padding(0), + BackColor = Theme.Surface + }; + if (stacked) + { + content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + content.RowStyles.Add(new RowStyle(SizeType.Absolute, 36)); + content.RowStyles.Add(new RowStyle(SizeType.Absolute, 66)); + } + else + { + content.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 360)); + content.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + content.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + } + content.Controls.Add(new Label + { + Text = helperText, + AutoSize = false, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Font = new Font(Theme.BaseFont.FontFamily, 8.25f), + ForeColor = Theme.Muted, + BackColor = Theme.Surface, + Margin = new Padding(3, 0, stacked ? 12 : 18, 0) + }, 0, 0); + + var buttonFlow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + BackColor = Theme.Surface, + Margin = new Padding(0), + Padding = new Padding(stacked ? 3 : 0, stacked ? 6 : 14, 0, 0), + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink + }; + foreach (var button in buttons) + { + button.AutoSize = false; + button.Height = 52; + button.Padding = new Padding(14, 4, 14, 6); + var preferredWidth = TextRenderer.MeasureText(button.Text, button.Font).Width + 56; + var minimumWidth = string.Equals(button.Text, "Install Package Update", StringComparison.OrdinalIgnoreCase) + ? 230 + : button.Text.Length > 12 ? 170 : 140; + button.Width = Math.Clamp(preferredWidth, minimumWidth, 240); + button.Margin = new Padding(0, 0, 10, 0); + buttonFlow.Controls.Add(button); + } + content.Controls.Add(buttonFlow, stacked ? 0 : 1, stacked ? 1 : 0); + + table.Controls.Add(content, 1, row); + return new CollapsibleActionRow(rowStyle, labelControl, content, height); + } + + private static Label CreateSettingsLabel(string text) + { + return new Label + { + Text = text, + AutoSize = false, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + ForeColor = Color.FromArgb(55, 65, 81), + Font = new Font(Theme.BaseFont.FontFamily, 9.25f, FontStyle.Bold), + BackColor = Theme.Surface, + Margin = new Padding(3, 0, 18, 0) + }; + } + + private static Label CreateReadOnlyValueLabel() + { + return new Label + { + Text = "", + AutoSize = false, + AutoEllipsis = true, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + ForeColor = Theme.Text, + Font = new Font(Theme.BaseFont.FontFamily, 10f), + BackColor = Theme.FieldBackground, + Margin = new Padding(10, 0, 10, 0) + }; + } + + private static Control CreateCappedSettingsField(Control field) + { + var host = CreateSettingsFieldHost(field); + if (field is NumericUpDown or ComboBox) + { + return host; + } + + var wrapper = new Panel + { + Dock = DockStyle.Fill, + BackColor = Theme.Surface, + Margin = new Padding(0) + }; + host.Width = 900; + host.MaximumSize = new Size(900, 40); + host.Anchor = AnchorStyles.Left | AnchorStyles.Top; + wrapper.Controls.Add(host); + return wrapper; + } + + private static Control CreateSettingsFieldHost(Control field) + { + var fixedWidth = field is NumericUpDown or ComboBox; + var host = new SettingsFieldHost + { + Dock = fixedWidth ? DockStyle.None : DockStyle.Top, + Anchor = fixedWidth ? AnchorStyles.Left : AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top, + Height = 40, + Size = fixedWidth ? new Size(field.Width + 22, 40) : new Size(0, 40), + MinimumSize = fixedWidth ? new Size(field.Width + 22, 40) : new Size(0, 40), + Margin = new Padding(3, 8, 12, 8) + }; + + field.Dock = DockStyle.Fill; + field.Margin = field is ComboBox or NumericUpDown + ? new Padding(10, 5, 8, 4) + : new Padding(10, 8, 10, 6); + + if (field is TextBox textBox) + { + textBox.BorderStyle = BorderStyle.None; + textBox.BackColor = Theme.FieldBackground; + } + else if (field is NumericUpDown numeric) + { + numeric.BorderStyle = BorderStyle.None; + numeric.BackColor = Theme.FieldBackground; + } + else if (field is ComboBox combo) + { + combo.FlatStyle = FlatStyle.Flat; + combo.BackColor = Theme.FieldBackground; + } + + host.Controls.Add(field); + return host; + } + + private static void StyleSettingsField(Control control) + { + control.Font = new Font(Theme.BaseFont.FontFamily, 10f); + control.ForeColor = Theme.Text; + control.BackColor = Theme.FieldBackground; + control.Margin = new Padding(0); + + if (control is TextBox textBox) + { + textBox.BorderStyle = BorderStyle.None; + } + else if (control is NumericUpDown numeric) + { + numeric.BorderStyle = BorderStyle.None; + } + } + + private static string SafeDirectoryName(string path) + { + try + { + var dir = Path.GetDirectoryName(path); + return string.IsNullOrEmpty(dir) ? "" : dir; + } + catch + { + return ""; + } + } + + private static bool IsLocalBindAddress(string bindAddress) + { + return string.Equals(bindAddress, "127.0.0.1", StringComparison.OrdinalIgnoreCase) + || string.Equals(bindAddress, "localhost", StringComparison.OrdinalIgnoreCase) + || string.Equals(bindAddress, "::1", StringComparison.OrdinalIgnoreCase); + } + + private void LoadSettingsIntoForm() + { + var settings = TryLoadSettings(); + if (settings is null) + { + return; + } + + restPortInput.Value = Math.Clamp(settings.RestPort, (int)restPortInput.Minimum, (int)restPortInput.Maximum); + grpcPortInput.Value = Math.Clamp(settings.GrpcPort, (int)grpcPortInput.Minimum, (int)grpcPortInput.Maximum); + bindAddressInput.Text = settings.BindAddress; + logLevelInput.SelectedItem = settings.LogLevel; + logPathInput.Text = settings.LogPath; + modelRepoInput.Text = settings.ModelRepositoryPath; + runModeInput.SelectedItem = settings.RunMode; + showTrayCheckBox.Checked = settings.ShowTrayIcon; + startAtLoginCheckBox.Checked = settings.StartAtLogin; + } + + private async Task SaveSettingsAsync() + { + var current = TryLoadSettings(); + if (current is null) + { + settingsStatusLabel.ForeColor = Theme.Danger; + settingsStatusLabel.Text = "Error: could not load existing settings."; + MessageBox.Show(this, "Could not load existing settings.", "Save", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + if (string.IsNullOrWhiteSpace(bindAddressInput.Text)) + { + settingsStatusLabel.ForeColor = Theme.Danger; + settingsStatusLabel.Text = "Error: bind address cannot be empty."; + MessageBox.Show(this, "Bind address cannot be empty.", "Save", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + var updated = new OvmsSettings + { + InstallDir = current.InstallDir, + DataDir = current.DataDir, + ModelRepositoryPath = modelRepoInput.Text, + ConfigPath = current.ConfigPath, + LogPath = logPathInput.Text, + RestPort = (int)restPortInput.Value, + GrpcPort = (int)grpcPortInput.Value, + BindAddress = bindAddressInput.Text, + LogLevel = logLevelInput.SelectedItem as string ?? current.LogLevel, + RunMode = runModeInput.SelectedItem as string ?? current.RunMode, + StartAtLogin = startAtLoginCheckBox.Checked, + ShowTrayIcon = showTrayCheckBox.Checked, + ServiceAutoStart = current.ServiceAutoStart, + PackageVariant = current.PackageVariant + }; + + var commandLineAffectingChanged = + current.RestPort != updated.RestPort || + current.GrpcPort != updated.GrpcPort || + current.BindAddress != updated.BindAddress || + current.LogPath != updated.LogPath || + current.ConfigPath != updated.ConfigPath || + current.LogLevel != updated.LogLevel; + + try + { + await Task.Run(() => controller.SaveSettings(updated)); + ApplyStartAtLoginRegistration(updated.StartAtLogin); + } + catch (Exception ex) + { + settingsStatusLabel.ForeColor = Theme.Danger; + settingsStatusLabel.Text = $"Error: {ex.Message}"; + MessageBox.Show(this, ex.Message, "Save", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + settingsStatusLabel.ForeColor = Theme.Success; + settingsStatusLabel.Text = "Saved."; + if (!IsLocalBindAddress(updated.BindAddress)) + { + settingsStatusLabel.ForeColor = Theme.Warning; + settingsStatusLabel.Text = "Saved. Non-local bind address - server may be reachable from other devices."; + } + + if (commandLineAffectingChanged) + { + var runtimeStatus = await Task.Run(controller.GetRuntimeStatus); + if (runtimeStatus.Running) + { + var result = MessageBox.Show(this, "Settings that affect the server command line changed. Restart server to apply?", + "Restart required", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (result == DialogResult.Yes) + { + await RunControlActionAsync("Restart", controller.Restart); + } + } + } + + await RefreshDashboardAsync(); + MessageBox.Show(this, "Settings saved.", "Save", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + private static void ApplyStartAtLoginRegistration(bool enabled) + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true) + ?? Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true); + + if (key is null) + { + return; + } + + if (enabled) + { + var exePath = Application.ExecutablePath; + key.SetValue(RunValueName, $"\"{exePath}\" --tray"); + } + else + { + if (key.GetValue(RunValueName) is not null) + { + key.DeleteValue(RunValueName, throwOnMissingValue: false); + } + } + } + catch + { + // Non-fatal: registry write may fail under restricted environments. + } + } + + // --------------------------------------------------------------- + // Logs + // --------------------------------------------------------------- + + private Panel BuildLogsPage() + { + var page = new Panel { BackColor = Theme.WindowBackground }; + + var toolbar = new TableLayoutPanel + { + Dock = DockStyle.Top, + Height = 72, + ColumnCount = 5, + RowCount = 1, + Padding = new Padding(0, 0, 0, 10), + BackColor = Theme.WindowBackground + }; + toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 66)); + toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 66)); + toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 78)); + toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 172)); + toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + toolbar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var openFolderButton = CreateIconButton(Glyphs.OpenFolder, "Open Logs"); + var changeLogPathButton = CreateIconButton(Glyphs.Edit, "Change log location"); + var clearLogsButton = CreateIconButton(Glyphs.Cancel, "Clear (display only)"); + foreach (var button in new[] { openFolderButton, changeLogPathButton, clearLogsButton }) + { + button.Size = new Size(58, 52); + button.MinimumSize = new Size(58, 52); + button.MaximumSize = new Size(58, 52); + } + openFolderButton.Margin = new Padding(0, 0, 8, 0); + changeLogPathButton.Margin = new Padding(0, 0, 8, 0); + clearLogsButton.Margin = new Padding(0, 0, 18, 0); + + autoScrollCheckBox = new CheckBox + { + Text = "Auto-scroll", + Checked = true, + AutoSize = true, + Anchor = AnchorStyles.Left, + BackColor = Theme.WindowBackground, + Margin = new Padding(0, 16, 16, 0), + MinimumSize = new Size(140, 24) + }; + + openFolderButton.Click += (_, _) => OpenFolderFor(TryLoadSettings()?.LogPath); + changeLogPathButton.Click += async (_, _) => await ChangeLogLocationAsync(); + clearLogsButton.Click += (_, _) => + { + logTextBox.Clear(); + logStatusLabel.Text = "Display cleared (file untouched)."; + }; + + logPathLabel = new Label + { + Text = "", + AutoSize = true, + ForeColor = Theme.Muted, + AutoEllipsis = true, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleRight, + Margin = new Padding(8, 10, 0, 0) + }; + + toolbar.Controls.Add(openFolderButton, 0, 0); + toolbar.Controls.Add(changeLogPathButton, 1, 0); + toolbar.Controls.Add(clearLogsButton, 2, 0); + toolbar.Controls.Add(autoScrollCheckBox, 3, 0); + toolbar.Controls.Add(logPathLabel, 4, 0); + + var statusBar = new Panel { Dock = DockStyle.Bottom, Height = 34, Padding = new Padding(0, 7, 0, 0) }; + logStatusLabel = new Label { Text = "", AutoSize = false, AutoEllipsis = true, ForeColor = Theme.Muted, Dock = DockStyle.Fill }; + statusBar.Controls.Add(logStatusLabel); + + var logCard = new CardPanel { Dock = DockStyle.Fill, Padding = new Padding(1), CardBackColor = Theme.LogBackground, BorderColor = Theme.LogBackground }; + logTextBox = new TextBox + { + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Both, + WordWrap = false, + BorderStyle = BorderStyle.None, + BackColor = Theme.LogBackground, + ForeColor = Theme.LogForeground, + Font = Theme.MonoFont + }; + logCard.Controls.Add(logTextBox); + + // Dock-Top stacking: add bottom-most visual element first (see + // BuildShell comment for why this ordering yields correct results). + page.Controls.Add(logCard); + page.Controls.Add(statusBar); + page.Controls.Add(toolbar); + return page; + } + + private async Task RefreshLogsAsync() + { + var settings = TryLoadSettings(); + logPathLabel.Text = settings?.LogPath ?? ""; + + if (settings is null || string.IsNullOrEmpty(settings.LogPath) || !File.Exists(settings.LogPath)) + { + logTextBox.Text = "(log file not found)"; + logStatusLabel.Text = $"0 lines - last refreshed {DateTime.Now:T}"; + return; + } + + var lines = await Task.Run(() => controller.TailLog(500)); + logTextBox.Text = string.Join(Environment.NewLine, lines); + if (autoScrollCheckBox.Checked) + { + logTextBox.SelectionStart = logTextBox.Text.Length; + logTextBox.ScrollToCaret(); + } + + logStatusLabel.Text = $"{lines.Count} lines - last refreshed {DateTime.Now:T}"; + } + + private async Task ChangeLogLocationAsync() + { + var current = TryLoadSettings(); + if (current is null) + { + MessageBox.Show(this, "Could not load existing settings.", "Change Log Location", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + using var dialog = new SaveFileDialog + { + Title = "Select log file", + CheckFileExists = false, + OverwritePrompt = false, + FileName = Path.GetFileName(current.LogPath), + InitialDirectory = SafeDirectoryName(current.LogPath), + Filter = "Log files (*.log)|*.log|All files (*.*)|*.*" + }; + + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + var updated = new OvmsSettings + { + InstallDir = current.InstallDir, + DataDir = current.DataDir, + ModelRepositoryPath = current.ModelRepositoryPath, + ConfigPath = current.ConfigPath, + LogPath = dialog.FileName, + RestPort = current.RestPort, + GrpcPort = current.GrpcPort, + BindAddress = current.BindAddress, + LogLevel = current.LogLevel, + RunMode = current.RunMode, + StartAtLogin = current.StartAtLogin, + ShowTrayIcon = current.ShowTrayIcon, + ServiceAutoStart = current.ServiceAutoStart, + PackageVariant = current.PackageVariant + }; + try + { + await Task.Run(() => controller.SaveSettings(updated)); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Change Log Location", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + logPathInput.Text = updated.LogPath; + logPathLabel.Text = updated.LogPath; + logStatusLabel.Text = "Log location saved."; + + var runtimeStatus = await Task.Run(controller.GetRuntimeStatus); + if (runtimeStatus.Running) + { + var result = MessageBox.Show(this, "The log path changed. Restart server to apply the new log location?", + "Restart required", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (result == DialogResult.Yes) + { + await RunControlActionAsync("Restart", controller.Restart); + } + } + + await RefreshLogsAsync(); + } + + // --------------------------------------------------------------- + // Advanced + // --------------------------------------------------------------- + + private Label advancedInstallDirValue = null!; + private Label advancedDataDirValue = null!; + private Label advancedVersionValue = null!; + private Label advancedConfigPathValue = null!; + + private Panel BuildAdvancedPage() + { + var page = new Panel { BackColor = Theme.WindowBackground, AutoScroll = true, Padding = new Padding(0, 0, 10, 0) }; + + var envCard = CreateSettingsCard(500); + var envLayout = CreateSettingsTable(); + var envRow = 0; + AddSettingsCardHeader(envLayout, "Environment", "Review the installed manager paths and the command OVMS will run.", envRow++); + AddSettingsSectionHeader(envLayout, "Paths and versions", envRow++); + advancedInstallDirValue = AddReadOnlySettingsRow(envLayout, "Install dir", envRow++); + advancedDataDirValue = AddReadOnlySettingsRow(envLayout, "Data dir", envRow++); + advancedVersionValue = AddReadOnlySettingsRow(envLayout, "Manager version", envRow++); + advancedConfigPathValue = AddReadOnlySettingsRow(envLayout, "Config path", envRow++); + + advancedTextBox = new TextBox + { + Anchor = AnchorStyles.Left | AnchorStyles.Right, + ReadOnly = true, + Font = Theme.MonoFont, + BackColor = Theme.FieldBackground, + ForeColor = Theme.Text + }; + StyleSettingsField(advancedTextBox); + + var copyButton = CreateSecondaryButton("Copy"); + copyButton.Click += (_, _) => + { + try + { + var commandLine = advancedTextBox.Text; + if (!string.IsNullOrEmpty(commandLine)) + { + Clipboard.SetText(commandLine); + } + } + catch + { + // Best effort; clipboard access can fail in restricted environments. + } + }; + + AddSettingsSectionHeader(envLayout, "Command", envRow++); + AddSettingsRowWithButton(envLayout, "Effective command line", advancedTextBox, copyButton, envRow++); + + envCard.Controls.Add(envLayout); + + var maintenanceCard = CreateSettingsCard(450); + var maintenanceLayout = CreateSettingsTable(); + + var repairButton = CreateSecondaryButton("Repair Package"); + var validateButton = CreateSecondaryButton("Validate Environment"); + var exportButton = CreateSecondaryButton("Export Diagnostics"); + + repairButton.Click += async (_, _) => await RunAdvancedActionAsync("Repair", () => controller.Repair()); + validateButton.Click += async (_, _) => await RunAdvancedActionAsync("Validate Environment", () => controller.ValidateEnvironment()); + exportButton.Click += async (_, _) => await ExportDiagnosticsAsync(); + + var maintenanceRow = 0; + AddSettingsCardHeader(maintenanceLayout, "Maintenance", "Repair the installation, validate the environment, or export a diagnostics bundle.", maintenanceRow++); + AddSettingsSectionHeader(maintenanceLayout, "Package tools", maintenanceRow++); + AddActionRow(maintenanceLayout, "Repair package", "Verify installed files and restore any that are missing or corrupt.", new[] { repairButton }, maintenanceRow++); + AddActionRow(maintenanceLayout, "Validate environment", "Check that ovms.exe and required files are present and runnable.", new[] { validateButton }, maintenanceRow++); + AddActionRow(maintenanceLayout, "Diagnostics", "Save a zip with settings, logs, versions, and runtime state.", new[] { exportButton }, maintenanceRow++); + + advancedStatusLabel = new Label + { + Text = "", + ForeColor = Theme.Muted, + Font = new Font(Theme.SemiboldFont.FontFamily, 10.5f, FontStyle.Bold) + }; + AddStatusRow(maintenanceLayout, advancedStatusLabel, maintenanceRow++); + + maintenanceCard.Controls.Add(maintenanceLayout); + + // Dock-Top stacking: add bottom-most visual element first (see + // BuildShell comment for why this ordering yields correct results). + page.Controls.Add(maintenanceCard); + page.Controls.Add(envCard); + return page; + } + + private void RefreshAdvancedInfo() + { + var settings = TryLoadSettings(); + if (settings is null) + { + advancedInstallDirValue.Text = "-"; + advancedDataDirValue.Text = "-"; + advancedVersionValue.Text = typeof(MainForm).Assembly.GetName().Version?.ToString() ?? "-"; + advancedConfigPathValue.Text = "-"; + advancedTextBox.Text = "Settings unavailable."; + return; + } + + string commandLine; + try + { + commandLine = controller.EffectiveCommandLine(); + } + catch (Exception ex) + { + commandLine = $"(error building command line: {ex.Message})"; + } + + advancedInstallDirValue.Text = settings.InstallDir; + advancedDataDirValue.Text = controller.DataDir; + advancedVersionValue.Text = typeof(MainForm).Assembly.GetName().Version?.ToString() ?? "-"; + advancedConfigPathValue.Text = settings.ConfigPath; + advancedTextBox.Text = commandLine; + } + + private async Task RunAdvancedActionAsync(string title, Func action) + { + try + { + var output = await Task.Run(action); + advancedStatusLabel.ForeColor = Theme.Success; + advancedStatusLabel.Text = $"{title}: completed."; + MessageBox.Show(this, string.IsNullOrWhiteSpace(output) ? "Completed." : output, title, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + advancedStatusLabel.ForeColor = Theme.Danger; + advancedStatusLabel.Text = $"{title}: failed."; + MessageBox.Show(this, ex.Message, title, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + RefreshAdvancedInfo(); + } + } + + private async Task ExportDiagnosticsAsync() + { + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + try + { + var zipPath = await Task.Run(() => controller.ExportDiagnostics(stamp)); + advancedStatusLabel.ForeColor = Theme.Success; + advancedStatusLabel.Text = "Export Diagnostics: completed."; + MessageBox.Show(this, $"Diagnostics exported to:{Environment.NewLine}{zipPath}", "Export Diagnostics", MessageBoxButtons.OK, MessageBoxIcon.Information); + OpenFolderFor(zipPath); + } + catch (Exception ex) + { + advancedStatusLabel.ForeColor = Theme.Danger; + advancedStatusLabel.Text = "Export Diagnostics: failed."; + MessageBox.Show(this, ex.Message, "Export Diagnostics", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + // --------------------------------------------------------------- + // Updates + // --------------------------------------------------------------- + + private Panel BuildUpdatesPage() + { + var page = new Panel { BackColor = Theme.WindowBackground, AutoScroll = true, Padding = new Padding(0, 0, 10, 0) }; + + var actionCard = CreateSettingsCard(320); + var actionLayout = CreateSettingsTable(); + + updatesCheckButton = CreateSecondaryButton("Check for Updates"); + updatesCheckButton.Click += async (_, _) => await CheckForUpdatesAsync(); + + updatesModelServerReleaseButton = CreateSecondaryButton("Model Server Notes"); + updatesModelServerReleaseButton.Enabled = false; + updatesModelServerReleaseButton.Click += (_, _) => OpenReleaseNotes(updatesModelServerReleaseUrl, "Model Server Release Notes"); + + updatesGenAiReleaseButton = CreateSecondaryButton("GenAI Notes"); + updatesGenAiReleaseButton.Enabled = false; + updatesGenAiReleaseButton.Click += (_, _) => OpenReleaseNotes(updatesGenAiReleaseUrl, "GenAI Release Notes"); + + var actionRow = 0; + AddSettingsCardHeader(actionLayout, "Update check", "Check for a newer compatible release. This will not download or install anything.", actionRow++); + AddSettingsSectionHeader(actionLayout, "Manual check", actionRow++); + AddActionRow(actionLayout, "Available updates", "Compare the package, Model Server, and GenAI versions with release metadata.", new[] { updatesCheckButton, updatesModelServerReleaseButton, updatesGenAiReleaseButton }, actionRow++); + updatesStatusLabel = new Label + { + Text = "Ready to check for updates.", + ForeColor = Theme.Muted, + Font = new Font(Theme.SemiboldFont.FontFamily, 10.5f, FontStyle.Bold) + }; + AddStatusRow(actionLayout, updatesStatusLabel, actionRow++); + + actionCard.Controls.Add(actionLayout); + + updatesBaseCard = CreateSettingsCard(360); + var baseLayout = CreateSettingsTable(); + var baseRow = 0; + AddSettingsCardHeader(baseLayout, "Base Package", "Installed package metadata and matching package release.", baseRow++); + AddSettingsSectionHeader(baseLayout, "Version details", baseRow++); + updatesBaseInstalledVersionValue = AddReadOnlySettingsRow(baseLayout, "Installed version", baseRow++); + updatesBaseLatestVersionValue = AddReadOnlySettingsRow(baseLayout, "Latest version", baseRow++); + updatesVariantValue = AddReadOnlySettingsRow(baseLayout, "Package variant", baseRow++); + updatesManagerVersionValue = AddReadOnlySettingsRow(baseLayout, "Manager version", baseRow++); + updatesBaseInstallButton = CreateSecondaryButton("Install Package Update"); + updatesBaseInstallButton.Click += async (_, _) => await InstallPackageUpdateAsync("Base Package"); + updatesBaseInstallRow = AddActionRow(baseLayout, "Install update", "Download, verify, stage, and replace the installed package.", new[] { updatesBaseInstallButton }, baseRow++); + updatesBaseCard.Controls.Add(baseLayout); + + updatesGenAiCard = CreateSettingsCard(260); + var genAiLayout = CreateSettingsTable(); + var genAiRow = 0; + AddSettingsCardHeader(genAiLayout, "GenAI", "OpenVINO GenAI backend bundled with this package.", genAiRow++); + AddSettingsSectionHeader(genAiLayout, "Version details", genAiRow++); + updatesGenAiInstalledVersionValue = AddReadOnlySettingsRow(genAiLayout, "Installed version", genAiRow++); + updatesGenAiLatestVersionValue = AddReadOnlySettingsRow(genAiLayout, "Latest version", genAiRow++); + updatesGenAiInstallButton = CreateSecondaryButton("Install Package Update"); + updatesGenAiInstallButton.Click += async (_, _) => await InstallPackageUpdateAsync("GenAI"); + updatesGenAiInstallRow = AddActionRow(genAiLayout, "Install update", "Install the matching package update so GenAI stays version-aligned.", new[] { updatesGenAiInstallButton }, genAiRow++); + updatesGenAiCard.Controls.Add(genAiLayout); + + updatesModelServerCard = CreateSettingsCard(260); + var modelServerLayout = CreateSettingsTable(); + var modelServerRow = 0; + AddSettingsCardHeader(modelServerLayout, "Model Server", "OpenVINO Model Server runtime bundled with this package.", modelServerRow++); + AddSettingsSectionHeader(modelServerLayout, "Version details", modelServerRow++); + updatesModelServerInstalledVersionValue = AddReadOnlySettingsRow(modelServerLayout, "Installed version", modelServerRow++); + updatesModelServerLatestVersionValue = AddReadOnlySettingsRow(modelServerLayout, "Latest version", modelServerRow++); + updatesModelServerInstallButton = CreateSecondaryButton("Install Package Update"); + updatesModelServerInstallButton.Click += async (_, _) => await InstallPackageUpdateAsync("Model Server"); + updatesModelServerInstallRow = AddActionRow(modelServerLayout, "Install update", "Install the matching package update so Model Server and dependencies stay aligned.", new[] { updatesModelServerInstallButton }, modelServerRow++); + updatesModelServerCard.Controls.Add(modelServerLayout); + + SetInstallRowsVisible(basePackage: false, modelServer: false, genAi: false); + + // Dock-Top stacking: add bottom-most visual element first. + page.Controls.Add(updatesModelServerCard); + page.Controls.Add(CreateCardSpacer(20)); + page.Controls.Add(updatesGenAiCard); + page.Controls.Add(CreateCardSpacer(20)); + page.Controls.Add(updatesBaseCard); + page.Controls.Add(CreateCardSpacer(20)); + page.Controls.Add(actionCard); + return page; + } + + private void RefreshUpdatesInfo() + { + try + { + var info = controller.GetPackageVersionInfo(); + updatesBaseInstalledVersionValue.Text = info.BasePackageVersion; + updatesBaseLatestVersionValue.Text = "-"; + updatesVariantValue.Text = info.PackageVariant; + updatesManagerVersionValue.Text = info.ManagerVersion; + updatesModelServerInstalledVersionValue.Text = info.ModelServerVersion; + updatesModelServerLatestVersionValue.Text = "-"; + updatesGenAiInstalledVersionValue.Text = info.GenAiVersion; + updatesGenAiLatestVersionValue.Text = "-"; + updatesStatusLabel.ForeColor = Theme.Muted; + updatesStatusLabel.Text = "Ready to check for updates."; + updatesModelServerReleaseUrl = ""; + updatesGenAiReleaseUrl = ""; + updatesModelServerReleaseButton.Enabled = false; + updatesGenAiReleaseButton.Enabled = false; + SetInstallRowsVisible(basePackage: false, modelServer: false, genAi: false); + } + catch (Exception ex) + { + updatesBaseInstalledVersionValue.Text = "-"; + updatesBaseLatestVersionValue.Text = "-"; + updatesVariantValue.Text = "-"; + updatesManagerVersionValue.Text = typeof(MainForm).Assembly.GetName().Version?.ToString() ?? "-"; + updatesModelServerInstalledVersionValue.Text = "-"; + updatesModelServerLatestVersionValue.Text = "-"; + updatesGenAiInstalledVersionValue.Text = "-"; + updatesGenAiLatestVersionValue.Text = "-"; + updatesStatusLabel.ForeColor = Theme.Danger; + updatesStatusLabel.Text = $"Cannot read package information: {ex.Message}"; + updatesModelServerReleaseUrl = ""; + updatesGenAiReleaseUrl = ""; + updatesModelServerReleaseButton.Enabled = false; + updatesGenAiReleaseButton.Enabled = false; + SetInstallRowsVisible(basePackage: false, modelServer: false, genAi: false); + } + } + + private async Task CheckForUpdatesAsync() + { + updatesCheckButton.Enabled = false; + updatesStatusLabel.ForeColor = Theme.Muted; + updatesStatusLabel.Text = "Checking for updates..."; + + try + { + var result = await controller.CheckForUpdatesAsync(); + updatesBaseInstalledVersionValue.Text = result.BasePackageVersion; + updatesBaseLatestVersionValue.Text = result.LatestBasePackageVersion; + updatesModelServerInstalledVersionValue.Text = result.ModelServerVersion; + updatesModelServerLatestVersionValue.Text = result.LatestModelServerVersion; + updatesGenAiInstalledVersionValue.Text = result.GenAiVersion; + updatesGenAiLatestVersionValue.Text = result.LatestGenAiVersion; + updatesVariantValue.Text = result.PackageVariant; + updatesModelServerReleaseUrl = result.ModelServerReleaseUrl; + updatesGenAiReleaseUrl = result.GenAiReleaseUrl; + updatesModelServerReleaseButton.Enabled = !string.IsNullOrWhiteSpace(updatesModelServerReleaseUrl); + updatesGenAiReleaseButton.Enabled = !string.IsNullOrWhiteSpace(updatesGenAiReleaseUrl); + SetInstallRowsVisible(result.BasePackageUpdateAvailable, result.ModelServerUpdateAvailable, result.GenAiUpdateAvailable); + updatesStatusLabel.ForeColor = result.UpdateAvailable + ? Theme.Warning + : result.LatestIsOlderThanInstalled ? Theme.Muted : Theme.Success; + updatesStatusLabel.Text = result.Message; + } + catch (Exception ex) + { + updatesStatusLabel.ForeColor = Theme.Danger; + updatesStatusLabel.Text = $"Cannot check for updates: {ex.Message}"; + updatesModelServerReleaseUrl = ""; + updatesGenAiReleaseUrl = ""; + updatesModelServerReleaseButton.Enabled = false; + updatesGenAiReleaseButton.Enabled = false; + SetInstallRowsVisible(basePackage: false, modelServer: false, genAi: false); + } + finally + { + updatesCheckButton.Enabled = true; + } + } + + private async Task InstallPackageUpdateAsync(string sectionName) + { + var result = MessageBox.Show(this, + $"{sectionName} updates are installed by replacing the matched OVMS Windows package as a unit. This keeps Base Package, Model Server, and GenAI versions aligned.{Environment.NewLine}{Environment.NewLine}Download and install the latest package now?", + "Install Package Update", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + if (result != DialogResult.Yes) + { + return; + } + + SetUpdateInstallButtonsEnabled(false); + updatesStatusLabel.ForeColor = Theme.Muted; + updatesStatusLabel.Text = "Installing package update..."; + + try + { + var output = await Task.Run(controller.InstallPackageUpdate); + updatesStatusLabel.ForeColor = Theme.Success; + updatesStatusLabel.Text = "Package update installed."; + MessageBox.Show(this, string.IsNullOrWhiteSpace(output) ? "Package update installed." : output, "Install Package Update", MessageBoxButtons.OK, MessageBoxIcon.Information); + RefreshUpdatesInfo(); + } + catch (Exception ex) + { + updatesStatusLabel.ForeColor = Theme.Danger; + updatesStatusLabel.Text = "Package update failed."; + MessageBox.Show(this, ex.Message, "Install Package Update", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + SetUpdateInstallButtonsEnabled(true); + } + } + + private void SetUpdateInstallButtonsEnabled(bool enabled) + { + updatesCheckButton.Enabled = enabled; + updatesBaseInstallButton.Enabled = enabled; + updatesModelServerInstallButton.Enabled = enabled; + updatesGenAiInstallButton.Enabled = enabled; + } + + private void SetInstallRowsVisible(bool basePackage, bool modelServer, bool genAi) + { + updatesBaseInstallRow.SetVisible(basePackage); + updatesModelServerInstallRow.SetVisible(modelServer); + updatesGenAiInstallRow.SetVisible(genAi); + + updatesBaseCard.Height = basePackage ? 444 : 360; + updatesModelServerCard.Height = modelServer ? 344 : 260; + updatesGenAiCard.Height = genAi ? 344 : 260; + } + + private void OpenReleaseNotes(string releaseUrl, string title) + { + if (string.IsNullOrWhiteSpace(releaseUrl)) + { + return; + } + + try + { + Process.Start(new ProcessStartInfo + { + FileName = releaseUrl, + UseShellExecute = true + }); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, title, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } +} + +internal readonly struct CollapsibleActionRow +{ + private readonly RowStyle rowStyle; + private readonly Control label; + private readonly Control content; + private readonly float expandedHeight; + + public CollapsibleActionRow(RowStyle rowStyle, Control label, Control content, float expandedHeight) + { + this.rowStyle = rowStyle; + this.label = label; + this.content = content; + this.expandedHeight = expandedHeight; + } + + public void SetVisible(bool visible) + { + rowStyle.Height = visible ? expandedHeight : 0; + label.Visible = visible; + content.Visible = visible; + } +} + +internal sealed class RuntimeIconButton : Button +{ + private bool hovered; + private bool pressed; + + public Color ActiveColor { get; set; } + public Color DisabledColor { get; set; } = Theme.Muted; + + public RuntimeIconButton(string glyph, Color activeColor) + { + Text = glyph; + ActiveColor = activeColor; + Tag = activeColor; + Font = new Font("Segoe MDL2 Assets", 13.5f, FontStyle.Regular, GraphicsUnit.Point); + FlatStyle = FlatStyle.Flat; + FlatAppearance.BorderSize = 0; + UseVisualStyleBackColor = false; + TextAlign = ContentAlignment.MiddleCenter; + Padding = new Padding(0); + TabStop = false; + Cursor = Cursors.Hand; + SetStyle( + ControlStyles.AllPaintingInWmPaint | + ControlStyles.UserPaint | + ControlStyles.OptimizedDoubleBuffer | + ControlStyles.ResizeRedraw, + true); + } + + protected override void OnMouseEnter(EventArgs e) + { + hovered = true; + Invalidate(); + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + hovered = false; + pressed = false; + Invalidate(); + base.OnMouseLeave(e); + } + + protected override void OnMouseDown(MouseEventArgs mevent) + { + if (Enabled && mevent.Button == MouseButtons.Left) + { + pressed = true; + Invalidate(); + } + base.OnMouseDown(mevent); + } + + protected override void OnMouseUp(MouseEventArgs mevent) + { + pressed = false; + Invalidate(); + base.OnMouseUp(mevent); + } + + protected override void OnEnabledChanged(EventArgs e) + { + hovered = false; + pressed = false; + Invalidate(); + base.OnEnabledChanged(e); + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + e.Graphics.Clear(ResolveCanvasColor(this)); + + var buttonRect = new Rectangle(7, 6, Width - 14, Height - 13); + if (pressed) + { + buttonRect.Offset(0, 1); + } + + if (Enabled && hovered) + { + using var ambientPath = RoundedRect(new Rectangle(buttonRect.X - 2, buttonRect.Y, buttonRect.Width + 4, buttonRect.Height + 4), 12); + using var dropPath = RoundedRect(new Rectangle(buttonRect.X - 1, buttonRect.Y + 5, buttonRect.Width + 2, buttonRect.Height), 12); + using var ambientBrush = new SolidBrush(Color.FromArgb(18, 13, 24, 38)); + using var dropBrush = new SolidBrush(Color.FromArgb(48, 13, 24, 38)); + e.Graphics.FillPath(ambientBrush, ambientPath); + e.Graphics.FillPath(dropBrush, dropPath); + } + + var borderColor = Enabled + ? (hovered ? Color.FromArgb(86, ActiveColor) : Color.FromArgb(34, 13, 24, 38)) + : Color.FromArgb(32, Theme.Muted); + var fillColor = Enabled + ? (hovered ? Color.FromArgb(254, 255, 255) : Color.FromArgb(248, 249, 251)) + : Color.FromArgb(246, 247, 249); + + using (var buttonPath = RoundedRect(buttonRect, 11)) + using (var fillBrush = new SolidBrush(fillColor)) + using (var borderPen = new Pen(borderColor)) + { + e.Graphics.FillPath(fillBrush, buttonPath); + e.Graphics.DrawPath(borderPen, buttonPath); + } + + var color = Enabled ? ActiveColor : DisabledColor; + using var textBrush = new SolidBrush(color); + using var iconPath = new GraphicsPath(); + using var format = StringFormat.GenericTypographic; + iconPath.AddString(Text, Font.FontFamily, (int)Font.Style, e.Graphics.DpiY * Font.Size / 72f, Point.Empty, format); + var bounds = iconPath.GetBounds(); + var targetCenter = new PointF(buttonRect.Left + buttonRect.Width / 2f, buttonRect.Top + buttonRect.Height / 2f + (pressed ? 1f : 0f)); + using var transform = new Matrix(); + transform.Translate(targetCenter.X - (bounds.Left + bounds.Width / 2f), targetCenter.Y - (bounds.Top + bounds.Height / 2f)); + iconPath.Transform(transform); + e.Graphics.FillPath(textBrush, iconPath); + } + + private static GraphicsPath RoundedRect(Rectangle bounds, int radius) + { + var diameter = radius * 2; + var path = new GraphicsPath(); + path.AddArc(bounds.Left, bounds.Top, diameter, diameter, 180, 90); + path.AddArc(bounds.Right - diameter, bounds.Top, diameter, diameter, 270, 90); + path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(bounds.Left, bounds.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } + + private static Color ResolveCanvasColor(Control control) + { + for (var current = control; current is not null; current = current.Parent) + { + if (current.BackColor != Color.Transparent && current.BackColor.A > 0) + { + return current.BackColor; + } + } + + return Theme.Surface; + } +} + +internal sealed class SettingsFieldHost : Panel +{ + public SettingsFieldHost() + { + BackColor = Theme.Surface; + Padding = new Padding(1); + SetStyle( + ControlStyles.AllPaintingInWmPaint | + ControlStyles.UserPaint | + ControlStyles.OptimizedDoubleBuffer | + ControlStyles.ResizeRedraw, + true); + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + e.Graphics.Clear(Parent?.BackColor ?? Theme.Surface); + + var rect = new Rectangle(0, 0, Width - 1, Height - 1); + using var path = RoundedRect(rect, 7); + using var fillBrush = new SolidBrush(Theme.FieldBackground); + using var borderPen = new Pen(Theme.FieldBorder); + e.Graphics.FillPath(fillBrush, path); + e.Graphics.DrawPath(borderPen, path); + + base.OnPaint(e); + } + + private static GraphicsPath RoundedRect(Rectangle bounds, int radius) + { + var diameter = radius * 2; + var path = new GraphicsPath(); + path.AddArc(bounds.Left, bounds.Top, diameter, diameter, 180, 90); + path.AddArc(bounds.Right - diameter, bounds.Top, diameter, diameter, 270, 90); + path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(bounds.Left, bounds.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } +} + +/// +/// Flat nav-rail button: glyph + label, with a 4px accent left-bar and +/// tinted background when selected, subtle hover highlight otherwise. +/// +internal sealed class NavButton : Panel +{ + public string PageName { get; } + private readonly string glyph; + private readonly Label glyphLabel; + private readonly Label textLabel; + private bool selected; + private bool hovered; + + public bool Selected + { + get => selected; + set + { + selected = value; + ApplyState(); + } + } + + public NavButton(string pageName, string glyph) + { + PageName = pageName; + this.glyph = glyph; + + Width = 210; + Height = 44; + Margin = new Padding(0); + Cursor = Cursors.Hand; + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); + + glyphLabel = new Label + { + Text = glyph, + Font = Theme.IconFont(14f), + AutoSize = false, + Size = new Size(36, 44), + TextAlign = ContentAlignment.MiddleCenter, + Location = new Point(10, 0), + BackColor = Color.Transparent + }; + textLabel = new Label + { + Text = pageName, + Font = Theme.NavFont, + AutoSize = false, + Size = new Size(158, 44), + TextAlign = ContentAlignment.MiddleLeft, + Location = new Point(44, 0), + BackColor = Color.Transparent + }; + + Controls.Add(glyphLabel); + Controls.Add(textLabel); + + // Only forward the child labels' clicks to the panel's OnClick. Do NOT + // subscribe the panel's own Click to OnClick -- OnClick raises Click, + // which would re-enter the handler and recurse until a stack overflow. + glyphLabel.Click += (_, _) => OnClick(EventArgs.Empty); + textLabel.Click += (_, _) => OnClick(EventArgs.Empty); + + foreach (var c in new Control[] { this, glyphLabel, textLabel }) + { + c.MouseEnter += (_, _) => { hovered = true; ApplyState(); }; + c.MouseLeave += (_, _) => { hovered = false; ApplyState(); }; + } + + ApplyState(); + } + + private void ApplyState() + { + if (selected) + { + BackColor = Theme.AccentTint; + glyphLabel.ForeColor = Theme.Accent; + textLabel.ForeColor = Theme.Accent; + textLabel.Font = Theme.SemiboldFont; + } + else + { + BackColor = hovered ? Theme.HoverTint : Color.Transparent; + glyphLabel.ForeColor = Theme.Muted; + textLabel.ForeColor = Theme.Muted; + textLabel.Font = Theme.NavFont; + } + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + if (selected) + { + using var brush = new SolidBrush(Theme.Accent); + e.Graphics.FillRectangle(brush, 0, 0, 4, Height); + } + } +} diff --git a/packaging/windows/manager/src/OVMS.Manager/OVMS.Manager.csproj b/packaging/windows/manager/src/OVMS.Manager/OVMS.Manager.csproj new file mode 100644 index 0000000000..b8b0a94ba1 --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/OVMS.Manager.csproj @@ -0,0 +1,12 @@ + + + WinExe + net8.0-windows + true + enable + enable + OVMS.Manager + OVMS.Manager + Assets\ovms-manager.ico + + diff --git a/packaging/windows/manager/src/OVMS.Manager/OvmsController.cs b/packaging/windows/manager/src/OVMS.Manager/OvmsController.cs new file mode 100644 index 0000000000..ffbbd4ed53 --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/OvmsController.cs @@ -0,0 +1,657 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OVMS.Manager; + +/// +/// Runtime status snapshot read from runtime.json. +/// +internal readonly struct RuntimeStatus +{ + public bool Running { get; init; } + public int Pid { get; init; } + public string Owner { get; init; } +} + +/// +/// Result of a health check against the REST /v3/models endpoint. +/// +internal readonly struct HealthResult +{ + public bool Ok { get; init; } + public int ModelCount { get; init; } + public string Message { get; init; } +} + +internal readonly struct PackageVersionInfo +{ + public string BasePackageVersion { get; init; } + public string ModelServerVersion { get; init; } + public string GenAiVersion { get; init; } + public string PackageVariant { get; init; } + public string ManagerVersion { get; init; } +} + +internal readonly struct UpdateCheckResult +{ + public string BasePackageVersion { get; init; } + public string LatestBasePackageVersion { get; init; } + public string ModelServerVersion { get; init; } + public string LatestModelServerVersion { get; init; } + public string GenAiVersion { get; init; } + public string LatestGenAiVersion { get; init; } + public string PackageVariant { get; init; } + public bool BasePackageUpdateAvailable { get; init; } + public bool ModelServerUpdateAvailable { get; init; } + public bool GenAiUpdateAvailable { get; init; } + public bool UpdateAvailable { get; init; } + public bool LatestIsOlderThanInstalled { get; init; } + public string ModelServerReleaseUrl { get; init; } + public string GenAiReleaseUrl { get; init; } + public string Message { get; init; } +} + +internal readonly struct ReleaseMetadata +{ + public string Version { get; init; } + public string Url { get; init; } + public string PackageUrl { get; init; } + public string Sha256Url { get; init; } +} + +/// +/// Pure logic layer for the Manager: settings persistence, process control, +/// health checks and diagnostics. Contains no UI code so it can be safely +/// invoked from background threads. +/// +internal sealed class OvmsController : IDisposable +{ + private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + + private readonly HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(3) }; + + public string DataDir { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OVMS"); + + public string AppDir { get; } = AppContext.BaseDirectory; + + public string SettingsPath => Path.Combine(DataDir, "settings.json"); + + public string RuntimePath => Path.Combine(DataDir, "runtime.json"); + + public string InstallMarkerPath => Path.Combine(DataDir, "install.json"); + + public OvmsSettings LoadSettings() + { + if (!File.Exists(SettingsPath)) + { + var defaults = new OvmsSettings + { + InstallDir = AppDir.TrimEnd(Path.DirectorySeparatorChar), + DataDir = DataDir, + ModelRepositoryPath = Path.Combine(DataDir, "models"), + ConfigPath = Path.Combine(DataDir, "models", "config.json"), + LogPath = Path.Combine(DataDir, "logs", "ovms_server.log"), + BindAddress = "127.0.0.1", + RestPort = 8000, + GrpcPort = 0, + LogLevel = "INFO", + RunMode = "user-login", + StartAtLogin = true, + ShowTrayIcon = true, + ServiceAutoStart = false, + PackageVariant = "python_on" + }; + return defaults; + } + + var json = File.ReadAllText(SettingsPath); + var settings = JsonSerializer.Deserialize(json, ReadOptions); + return settings ?? throw new InvalidOperationException($"Could not parse settings: {SettingsPath}"); + } + + /// + /// Atomically persists settings: writes to a temp file, validates it + /// round-trips, backs up the previous file, then replaces it. + /// + public void SaveSettings(OvmsSettings settings) + { + Directory.CreateDirectory(DataDir); + + var json = JsonSerializer.Serialize(settings, WriteOptions); + + // Validate the JSON round-trips before touching anything on disk. + var roundTrip = JsonSerializer.Deserialize(json, ReadOptions) + ?? throw new InvalidOperationException("Settings failed to round-trip; refusing to save."); + _ = roundTrip; + + var tmpPath = SettingsPath + ".tmp"; + var bakPath = SettingsPath + ".bak"; + + File.WriteAllText(tmpPath, json); + + if (File.Exists(SettingsPath)) + { + File.Replace(tmpPath, SettingsPath, bakPath); + } + else + { + File.Move(tmpPath, SettingsPath); + } + } + + public RuntimeStatus GetRuntimeStatus() + { + if (!File.Exists(RuntimePath)) + { + return new RuntimeStatus { Running = false, Pid = 0, Owner = "" }; + } + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(RuntimePath)); + var root = doc.RootElement; + var owner = root.TryGetProperty("owner", out var ownerEl) ? ownerEl.GetString() ?? "" : ""; + + if (root.TryGetProperty("pid", out var pidEl) && pidEl.TryGetInt32(out var pid)) + { + var process = Process.GetProcessById(pid); + if (string.Equals(process.ProcessName, "ovms", StringComparison.OrdinalIgnoreCase)) + { + return new RuntimeStatus { Running = true, Pid = pid, Owner = owner }; + } + } + } + catch + { + // Stale or unreadable runtime file: treat as not running. + } + + return new RuntimeStatus { Running = false, Pid = 0, Owner = "" }; + } + + public void Start() + { + RunPowerShell("start-ovms.ps1"); + } + + public void Stop() + { + RunPowerShell("stop-ovms.ps1"); + } + + public void Restart() + { + Stop(); + Start(); + } + + public string Repair() + { + var settings = LoadSettings(); + return RunPowerShell("repair-package.ps1", $"-InstallDir \"{settings.InstallDir}\" -DataDir \"{DataDir}\"", includeDataDirArg: false); + } + + public string ValidateEnvironment() + { + var settings = LoadSettings(); + return RunPowerShell("validate-install.ps1", $"-InstallDir \"{settings.InstallDir}\"", includeDataDirArg: false); + } + + public PackageVersionInfo GetPackageVersionInfo() + { + var settings = LoadSettings(); + var versionOutput = GetOvmsVersionOutput(settings.InstallDir).Trim(); + var parsed = ParseVersionOutput(versionOutput); + var basePackageVersion = ReadInstallMarkerVersion(); + if (string.IsNullOrWhiteSpace(basePackageVersion) || string.Equals(basePackageVersion, "unknown", StringComparison.OrdinalIgnoreCase)) + { + basePackageVersion = parsed.ModelServerVersion; + } + + return new PackageVersionInfo + { + BasePackageVersion = string.IsNullOrWhiteSpace(basePackageVersion) ? "unknown" : FirstNonEmptyLine(basePackageVersion), + ModelServerVersion = string.IsNullOrWhiteSpace(parsed.ModelServerVersion) ? "unknown" : parsed.ModelServerVersion, + GenAiVersion = string.IsNullOrWhiteSpace(parsed.GenAiVersion) ? DetectGenAiSupport(settings.InstallDir) : parsed.GenAiVersion, + PackageVariant = settings.PackageVariant, + ManagerVersion = typeof(OvmsController).Assembly.GetName().Version?.ToString() ?? "unknown" + }; + } + + public async Task CheckForUpdatesAsync() + { + var packageInfo = GetPackageVersionInfo(); + var modelServerRelease = await GetLatestGitHubReleaseAsync("openvinotoolkit", "model_server").ConfigureAwait(false); + var genAiRelease = await GetLatestGitHubReleaseAsync("openvinotoolkit", "openvino.genai").ConfigureAwait(false); + + var baseUpdateAvailable = IsUpdateAvailable(packageInfo.BasePackageVersion, modelServerRelease.Version); + var modelServerUpdateAvailable = IsUpdateAvailable(packageInfo.ModelServerVersion, modelServerRelease.Version); + var genAiUpdateAvailable = IsUpdateAvailable(packageInfo.GenAiVersion, genAiRelease.Version); + var latestIsOlderThanInstalled = + IsInstalledNewerThanLatest(packageInfo.BasePackageVersion, modelServerRelease.Version) + || IsInstalledNewerThanLatest(packageInfo.ModelServerVersion, modelServerRelease.Version) + || IsInstalledNewerThanLatest(packageInfo.GenAiVersion, genAiRelease.Version); + var updateAvailable = baseUpdateAvailable || modelServerUpdateAvailable || genAiUpdateAvailable; + + return new UpdateCheckResult + { + BasePackageVersion = packageInfo.BasePackageVersion, + LatestBasePackageVersion = modelServerRelease.Version, + ModelServerVersion = packageInfo.ModelServerVersion, + LatestModelServerVersion = modelServerRelease.Version, + GenAiVersion = packageInfo.GenAiVersion, + LatestGenAiVersion = genAiRelease.Version, + PackageVariant = packageInfo.PackageVariant, + BasePackageUpdateAvailable = baseUpdateAvailable, + ModelServerUpdateAvailable = modelServerUpdateAvailable, + GenAiUpdateAvailable = genAiUpdateAvailable, + UpdateAvailable = updateAvailable, + LatestIsOlderThanInstalled = latestIsOlderThanInstalled, + ModelServerReleaseUrl = modelServerRelease.Url, + GenAiReleaseUrl = genAiRelease.Url, + Message = updateAvailable + ? "Update available." + : latestIsOlderThanInstalled ? "Installed version is newer than the latest stable release." : "Up to date." + }; + } + + public string InstallPackageUpdate() + { + var settings = LoadSettings(); + var release = GetLatestGitHubReleaseAsync("openvinotoolkit", "model_server", settings.PackageVariant).GetAwaiter().GetResult(); + if (string.IsNullOrWhiteSpace(release.PackageUrl)) + { + throw new InvalidOperationException($"No Windows package asset found for variant '{settings.PackageVariant}' in the latest Model Server release."); + } + + var args = string.Join(' ', new[] + { + $"-InstallDir \"{settings.InstallDir}\"", + $"-DataDir \"{DataDir}\"", + $"-PackageUrl \"{release.PackageUrl}\"", + string.IsNullOrWhiteSpace(release.Sha256Url) ? "" : $"-Sha256Url \"{release.Sha256Url}\"", + $"-Version \"{release.Version}\"", + $"-PackageVariant \"{settings.PackageVariant}\"" + }.Where(arg => !string.IsNullOrWhiteSpace(arg))); + + return RunPowerShell("upgrade-package.ps1", args, includeDataDirArg: false); + } + + public async Task CheckHealthAsync() + { + var settings = LoadSettings(); + var url = $"http://{settings.BindAddress}:{settings.RestPort}/v3/models"; + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + using var response = await httpClient.GetAsync(url, cts.Token).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return new HealthResult { Ok = false, ModelCount = 0, Message = $"HTTP {(int)response.StatusCode}" }; + } + + var body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + var modelCount = 0; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array) + { + modelCount = data.GetArrayLength(); + } + } + catch + { + // Tolerate unparsable bodies; we still know the endpoint responded OK. + } + + return new HealthResult { Ok = true, ModelCount = modelCount, Message = "OK" }; + } + catch (Exception ex) + { + return new HealthResult { Ok = false, ModelCount = 0, Message = ex.Message }; + } + } + + public static bool IsPortFree(int port) + { + try + { + using var client = new TcpClient(); + var connectTask = client.ConnectAsync("127.0.0.1", port); + var completed = connectTask.Wait(TimeSpan.FromMilliseconds(300)); + return !(completed && client.Connected); + } + catch + { + return true; + } + } + + public string EffectiveCommandLine() + { + var settings = LoadSettings(); + var ovmsExe = Path.Combine(settings.InstallDir, "ovms.exe"); + + var args = new List + { + "--rest_port", settings.RestPort.ToString(), + "--rest_bind_address", settings.BindAddress, + "--config_path", settings.ConfigPath, + "--log_level", settings.LogLevel, + "--log_path", settings.LogPath + }; + + if (settings.GrpcPort > 0) + { + args.Add("--port"); + args.Add(settings.GrpcPort.ToString()); + } + + return $"\"{ovmsExe}\" {string.Join(' ', args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a))}"; + } + + public IReadOnlyList TailLog(int lines) + { + var settings = LoadSettings(); + if (string.IsNullOrEmpty(settings.LogPath) || !File.Exists(settings.LogPath)) + { + return Array.Empty(); + } + + using var stream = new FileStream(settings.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + + var buffer = new Queue(lines + 1); + string? line; + while ((line = reader.ReadLine()) is not null) + { + if (buffer.Count == lines) + { + buffer.Dequeue(); + } + buffer.Enqueue(line); + } + + return buffer.ToList(); + } + + /// + /// Builds a diagnostics zip. The timestamp is supplied by the caller so + /// this method stays deterministic/testable. + /// + public string ExportDiagnostics(string stamp) + { + var diagDir = Path.Combine(DataDir, "diagnostics"); + Directory.CreateDirectory(diagDir); + + var zipPath = Path.Combine(diagDir, $"ovms-diagnostics-{stamp}.zip"); + if (File.Exists(zipPath)) + { + File.Delete(zipPath); + } + + var settings = LoadSettings(); + + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + + AddFileIfExists(archive, SettingsPath, "settings.json"); + AddFileIfExists(archive, settings.ConfigPath, "models/config.json"); + AddFileIfExists(archive, RuntimePath, "runtime.json"); + + var versionsEntry = archive.CreateEntry("versions.txt"); + using (var writer = new StreamWriter(versionsEntry.Open())) + { + writer.WriteLine($"Manager version: {typeof(OvmsController).Assembly.GetName().Version}"); + writer.WriteLine($"ovms.exe --version output:"); + writer.WriteLine(GetOvmsVersionOutput(settings.InstallDir)); + } + + var logLines = TailLog(500); + if (logLines.Count > 0) + { + var logEntry = archive.CreateEntry("server.log.tail.txt"); + using var writer = new StreamWriter(logEntry.Open()); + foreach (var line in logLines) + { + writer.WriteLine(line); + } + } + + return zipPath; + } + + private static void AddFileIfExists(ZipArchive archive, string path, string entryName) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + archive.CreateEntryFromFile(path, entryName); + } + } + + private static string GetOvmsVersionOutput(string installDir) + { + try + { + var ovmsExe = Path.Combine(installDir, "ovms.exe"); + if (!File.Exists(ovmsExe)) + { + return "(ovms.exe not found)"; + } + + var startInfo = new ProcessStartInfo + { + FileName = ovmsExe, + Arguments = "--version", + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return "(failed to start ovms.exe)"; + } + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(5000); + + return string.IsNullOrWhiteSpace(stdout) ? stderr : stdout; + } + catch (Exception ex) + { + return $"(error: {ex.Message})"; + } + } + + private string ReadInstallMarkerVersion() + { + if (!File.Exists(InstallMarkerPath)) + { + return ""; + } + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(InstallMarkerPath)); + return doc.RootElement.TryGetProperty("version", out var versionEl) ? versionEl.GetString() ?? "" : ""; + } + catch + { + return ""; + } + } + + private static (string ModelServerVersion, string GenAiVersion) ParseVersionOutput(string versionOutput) + { + var modelServerVersion = ""; + var genAiVersion = ""; + + foreach (var line in versionOutput.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim())) + { + if (line.StartsWith("OpenVINO Model Server", StringComparison.OrdinalIgnoreCase)) + { + modelServerVersion = line; + } + else if (line.StartsWith("OpenVINO GenAI backend", StringComparison.OrdinalIgnoreCase)) + { + genAiVersion = line; + } + } + + return (modelServerVersion, genAiVersion); + } + + private static string DetectGenAiSupport(string installDir) + { + return File.Exists(Path.Combine(installDir, "openvino_genai.dll")) ? "Installed" : "Missing"; + } + + private async Task GetLatestGitHubReleaseAsync(string owner, string repo, string? packageVariant = null) + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{owner}/{repo}/releases/latest"); + request.Headers.UserAgent.ParseAdd("OVMS-Manager"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var response = await httpClient.SendAsync(request, cts.Token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var tag = root.TryGetProperty("tag_name", out var tagEl) ? tagEl.GetString() ?? "" : ""; + var htmlUrl = root.TryGetProperty("html_url", out var urlEl) ? urlEl.GetString() ?? "" : ""; + var packageUrl = ""; + var sha256Url = ""; + + if (!string.IsNullOrWhiteSpace(packageVariant) && root.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array) + { + foreach (var asset in assets.EnumerateArray()) + { + var name = asset.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var url = asset.TryGetProperty("browser_download_url", out var assetUrlEl) ? assetUrlEl.GetString() ?? "" : ""; + if (name.StartsWith("ovms_windows_", StringComparison.OrdinalIgnoreCase) + && name.EndsWith($"_{packageVariant}.zip", StringComparison.OrdinalIgnoreCase)) + { + packageUrl = url; + } + else if (name.StartsWith("ovms_windows_", StringComparison.OrdinalIgnoreCase) + && (name.EndsWith($"_{packageVariant}.zip.sha256", StringComparison.OrdinalIgnoreCase) + || name.EndsWith($"_{packageVariant}.sha256", StringComparison.OrdinalIgnoreCase))) + { + sha256Url = url; + } + } + } + + return new ReleaseMetadata + { + Version = string.IsNullOrWhiteSpace(tag) ? "unknown" : tag, + Url = htmlUrl, + PackageUrl = packageUrl, + Sha256Url = sha256Url + }; + } + + private static bool IsUpdateAvailable(string installed, string latest) + { + return CompareVersionStrings(NormalizeVersion(installed), NormalizeVersion(latest)) < 0; + } + + private static bool IsInstalledNewerThanLatest(string installed, string latest) + { + return CompareVersionStrings(NormalizeVersion(installed), NormalizeVersion(latest)) > 0; + } + + private static string FirstNonEmptyLine(string value) + { + return value.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None) + .Select(line => line.Trim()) + .FirstOrDefault(line => !string.IsNullOrWhiteSpace(line)) ?? "unknown"; + } + + private static string NormalizeVersion(string value) + { + var match = Regex.Match(value, @"\d+(?:\.\d+)+(?:[.-][A-Za-z0-9]+)*"); + return match.Success ? match.Value.TrimStart('v', 'V') : ""; + } + + private static int CompareVersionStrings(string installed, string latest) + { + if (string.IsNullOrWhiteSpace(installed) || string.IsNullOrWhiteSpace(latest)) + { + return 0; + } + + var installedParts = Regex.Matches(installed, @"\d+").Select(m => int.Parse(m.Value)).ToArray(); + var latestParts = Regex.Matches(latest, @"\d+").Select(m => int.Parse(m.Value)).ToArray(); + var length = Math.Max(installedParts.Length, latestParts.Length); + for (var i = 0; i < length; i++) + { + var installedPart = i < installedParts.Length ? installedParts[i] : 0; + var latestPart = i < latestParts.Length ? latestParts[i] : 0; + if (installedPart != latestPart) + { + return installedPart.CompareTo(latestPart); + } + } + + return 0; + } + + private string RunPowerShell(string scriptName, string? extraArgs = null, bool includeDataDirArg = true) + { + var scriptPath = Path.Combine(AppDir, "scripts", scriptName); + if (!File.Exists(scriptPath)) + { + throw new FileNotFoundException("Missing control script.", scriptPath); + } + + var arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{scriptPath}\""; + if (includeDataDirArg) + { + arguments += $" -DataDir \"{DataDir}\""; + } + if (!string.IsNullOrEmpty(extraArgs)) + { + arguments += " " + extraArgs; + } + + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = arguments, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start PowerShell."); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr); + } + + return string.IsNullOrWhiteSpace(stdout) ? stderr : stdout; + } + + public void Dispose() + { + httpClient.Dispose(); + } +} diff --git a/packaging/windows/manager/src/OVMS.Manager/OvmsSettings.cs b/packaging/windows/manager/src/OVMS.Manager/OvmsSettings.cs new file mode 100644 index 0000000000..44b25be2d9 --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/OvmsSettings.cs @@ -0,0 +1,25 @@ +namespace OVMS.Manager; + +/// +/// Plain settings POCO persisted to %LOCALAPPDATA%\OVMS\settings.json. +/// Property names match the JSON keys case-insensitively (System.Text.Json +/// is configured with PropertyNameCaseInsensitive = true wherever this is +/// deserialized). +/// +internal sealed class OvmsSettings +{ + public string InstallDir { get; set; } = ""; + public string DataDir { get; set; } = ""; + public string ModelRepositoryPath { get; set; } = ""; + public string ConfigPath { get; set; } = ""; + public string LogPath { get; set; } = ""; + public int RestPort { get; set; } = 8000; + public int GrpcPort { get; set; } = 0; + public string BindAddress { get; set; } = "127.0.0.1"; + public string LogLevel { get; set; } = "INFO"; + public string RunMode { get; set; } = "user-login"; + public bool StartAtLogin { get; set; } = true; + public bool ShowTrayIcon { get; set; } = true; + public bool ServiceAutoStart { get; set; } = false; + public string PackageVariant { get; set; } = "python_on"; +} diff --git a/packaging/windows/manager/src/OVMS.Manager/Program.cs b/packaging/windows/manager/src/OVMS.Manager/Program.cs new file mode 100644 index 0000000000..b2598aa51a --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/Program.cs @@ -0,0 +1,27 @@ +namespace OVMS.Manager; + +internal static class Program +{ + private const string SingleInstanceMutexName = "OVMS.Manager.SingleInstance"; + + [STAThread] + private static void Main(string[] args) + { + using var singleInstanceMutex = new Mutex(initiallyOwned: true, SingleInstanceMutexName, out var createdNew); + if (!createdNew) + { + // Another instance already owns the Manager; exit quietly. + return; + } + + var startHidden = args.Any(a => + string.Equals(a, "--tray", StringComparison.OrdinalIgnoreCase) || + string.Equals(a, "/tray", StringComparison.OrdinalIgnoreCase)); + + ApplicationConfiguration.Initialize(); + using var app = new TrayApplicationContext(startHidden); + Application.Run(app); + + GC.KeepAlive(singleInstanceMutex); + } +} diff --git a/packaging/windows/manager/src/OVMS.Manager/Theme.cs b/packaging/windows/manager/src/OVMS.Manager/Theme.cs new file mode 100644 index 0000000000..16691c80bf --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/Theme.cs @@ -0,0 +1,97 @@ +using System.Drawing; + +namespace OVMS.Manager; + +/// +/// Centralized color/font tokens for the Manager's light theme. +/// +internal static class Theme +{ + public static readonly Color WindowBackground = ColorTranslator.FromHtml("#F3F4F6"); + public static readonly Color Surface = ColorTranslator.FromHtml("#FFFFFF"); + public static readonly Color Rail = ColorTranslator.FromHtml("#F8F7FC"); + public static readonly Color Accent = ColorTranslator.FromHtml("#6C2BD9"); + public static readonly Color AccentHover = ColorTranslator.FromHtml("#5B21B6"); + public static readonly Color Text = ColorTranslator.FromHtml("#1F2937"); + public static readonly Color Muted = ColorTranslator.FromHtml("#6B7280"); + public static readonly Color Border = ColorTranslator.FromHtml("#E5E7EB"); + public static readonly Color FieldBorder = ColorTranslator.FromHtml("#CBD5E1"); + public static readonly Color FieldBackground = ColorTranslator.FromHtml("#F8FAFC"); + public static readonly Color Success = ColorTranslator.FromHtml("#16A34A"); + public static readonly Color Danger = ColorTranslator.FromHtml("#DC2626"); + public static readonly Color Warning = ColorTranslator.FromHtml("#D97706"); + + public static readonly Color AccentTint = ColorTranslator.FromHtml("#EFE6FB"); + public static readonly Color HoverTint = ColorTranslator.FromHtml("#F1F0F4"); + + public static readonly Color LogBackground = ColorTranslator.FromHtml("#1E1E1E"); + public static readonly Color LogForeground = ColorTranslator.FromHtml("#D4D4D4"); + + public static Font BaseFont { get; } = new("Segoe UI", 9.75f); + public static Font SemiboldFont { get; } = new("Segoe UI Semibold", 9.75f); + public static Font PageTitleFont { get; } = new("Segoe UI Semibold", 16f); + public static Font CardTitleFont { get; } = new("Segoe UI Semibold", 9.75f); + public static Font CardValueFont { get; } = new("Segoe UI Semibold", 13f); + public static Font NavFont { get; } = new("Segoe UI", 10f); + public static Font MonoFont { get; } = new("Consolas", 9.5f); + + private static Font? iconFont; + + /// + /// Returns a font for rendering Segoe Fluent/MDL2 glyphs. If neither + /// family is installed, GDI silently substitutes a fallback font, so + /// this never throws -- worst case the glyph renders as a box/blank. + /// + public static Font IconFont(float size = 13f) + { + if (iconFont != null && Math.Abs(iconFont.Size - size) < 0.01f) + { + return iconFont; + } + + Font font; + try + { + font = new Font("Segoe Fluent Icons", size, FontStyle.Regular, GraphicsUnit.Point); + if (!string.Equals(font.Name, "Segoe Fluent Icons", StringComparison.OrdinalIgnoreCase)) + { + font.Dispose(); + font = new Font("Segoe MDL2 Assets", size, FontStyle.Regular, GraphicsUnit.Point); + } + } + catch + { + font = new Font("Segoe MDL2 Assets", size, FontStyle.Regular, GraphicsUnit.Point); + } + + iconFont = font; + return iconFont; + } +} + +/// +/// Segoe Fluent / MDL2 Assets glyph code points used throughout the UI. +/// Written as \uXXXX escapes (Private Use Area) to avoid any source-encoding +/// ambiguity. +/// +internal static class Glyphs +{ + public const string Dashboard = "\uE80F"; // Home + public const string Settings = "\uE713"; // Settings gear + public const string Logs = "\uE9D9"; // Diagnostic + public const string Advanced = "\uE90F"; // Repair/tools (Build) + public const string Update = "\uE895"; // Download/update + + public const string Play = "\uE768"; // Play + public const string Stop = "\uE71A"; // Stop + public const string Restart = "\uE777"; // Sync/restart + public const string Refresh = "\uE72C"; // Refresh + public const string OpenFolder = "\uE838"; // OpenFolderHorizontal + public const string Edit = "\uE70F"; // Edit + public const string Cancel = "\uE711"; // Cancel + public const string Repair = "\uE90F"; // Repair/build tools + public const string Save = "\uE74E"; // Save + public const string Health = "\uEB51"; // HealthSolid + public const string Export = "\uEDE1"; // Export +} + diff --git a/packaging/windows/manager/src/OVMS.Manager/TrayApplicationContext.cs b/packaging/windows/manager/src/OVMS.Manager/TrayApplicationContext.cs new file mode 100644 index 0000000000..d2f7bdd5f4 --- /dev/null +++ b/packaging/windows/manager/src/OVMS.Manager/TrayApplicationContext.cs @@ -0,0 +1,128 @@ +using System.Drawing; + +namespace OVMS.Manager; + +/// +/// Owns the NotifyIcon and a single lazily-created MainForm instance. +/// +internal sealed class TrayApplicationContext : ApplicationContext +{ + private readonly NotifyIcon notifyIcon; + private readonly ToolStripMenuItem startItem; + private readonly ToolStripMenuItem stopItem; + private readonly OvmsController controller; + private MainForm? mainForm; + private readonly bool startHidden; + + public TrayApplicationContext(bool startHidden = false) + { + this.startHidden = startHidden; + controller = new OvmsController(); + + startItem = new ToolStripMenuItem("Start Server", null, async (_, _) => await RunTrayActionAsync("Start Server", controller.Start)); + stopItem = new ToolStripMenuItem("Stop Server", null, async (_, _) => await RunTrayActionAsync("Stop Server", controller.Stop)); + + var openItem = new ToolStripMenuItem("Open OVMS Manager", null, (_, _) => OpenManager()); + var exitItem = new ToolStripMenuItem("Exit", null, (_, _) => ExitApplication()); + + var menu = new ContextMenuStrip(); + menu.Items.Add(openItem); + menu.Items.Add(startItem); + menu.Items.Add(stopItem); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add(exitItem); + + notifyIcon = new NotifyIcon + { + Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath) ?? SystemIcons.Application, + Text = "OpenVINO Model Server", + ContextMenuStrip = menu, + Visible = true + }; + notifyIcon.MouseClick += (_, args) => + { + if (args.Button == MouseButtons.Left) + { + OpenManager(); + } + }; + + if (!startHidden) + { + OpenManager(); + } + } + + private MainForm GetOrCreateMainForm() + { + if (mainForm is null || mainForm.IsDisposed) + { + mainForm = new MainForm(controller); + } + return mainForm; + } + + private void OpenManager() + { + var form = GetOrCreateMainForm(); + + if (!form.Visible) + { + form.Show(); + } + + if (form.WindowState == FormWindowState.Minimized) + { + form.WindowState = FormWindowState.Normal; + } + + form.Activate(); + form.BringToFront(); + } + + private async Task RunTrayActionAsync(string title, Action action) + { + startItem.Enabled = false; + stopItem.Enabled = false; + try + { + await Task.Run(action); + notifyIcon.ShowBalloonTip(3000, "OpenVINO Model Server", $"{title} completed.", ToolTipIcon.Info); + if (mainForm is { IsDisposed: false }) + { + await mainForm.RefreshDashboardAsync(); + } + } + catch (Exception ex) + { + notifyIcon.ShowBalloonTip(5000, "OpenVINO Model Server", $"{title} failed: {ex.Message}", ToolTipIcon.Error); + } + finally + { + startItem.Enabled = true; + stopItem.Enabled = true; + } + } + + private void ExitApplication() + { + notifyIcon.Visible = false; + if (mainForm is { IsDisposed: false }) + { + mainForm.AllowExit = true; + mainForm.Close(); + } + ExitThread(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + notifyIcon.Visible = false; + notifyIcon.Dispose(); + controller.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/packaging/windows/scripts/configure-ovms.ps1 b/packaging/windows/scripts/configure-ovms.ps1 new file mode 100644 index 0000000000..b12f7305d6 --- /dev/null +++ b/packaging/windows/scripts/configure-ovms.ps1 @@ -0,0 +1,75 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$DataDir, + [string]$PackageVariant = "python_on", + [int]$RestPort = 8000, + [int]$GrpcPort = 0, + [string]$BindAddress = "127.0.0.1", + [string]$LogLevel = "INFO" +) + +$ErrorActionPreference = "Stop" + +$modelsDir = Join-Path $DataDir "models" +$logsDir = Join-Path $DataDir "logs" +$configPath = Join-Path $modelsDir "config.json" +$settingsPath = Join-Path $DataDir "settings.json" +$installMarkerPath = Join-Path $DataDir "install.json" +$logPath = Join-Path $logsDir "ovms_server.log" +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) + +New-Item -ItemType Directory -Force -Path $DataDir, $modelsDir, $logsDir, (Join-Path $DataDir "packages"), (Join-Path $DataDir "downloads"), (Join-Path $DataDir "diagnostics") | Out-Null + +if (-not (Test-Path $configPath)) { + [System.IO.File]::WriteAllText($configPath, '{"model_config_list":[]}', $utf8NoBom) +} + +$settings = [ordered]@{ + installDir = $InstallDir + dataDir = $DataDir + modelRepositoryPath = $modelsDir + configPath = $configPath + logPath = $logPath + restPort = $RestPort + grpcPort = $GrpcPort + bindAddress = $BindAddress + logLevel = $LogLevel + runMode = "user-login" + startAtLogin = $true + showTrayIcon = $true + serviceAutoStart = $false + packageVariant = $PackageVariant +} + +$tmpSettings = "$settingsPath.tmp" +[System.IO.File]::WriteAllText($tmpSettings, ($settings | ConvertTo-Json -Depth 10), $utf8NoBom) +Move-Item -Force -Path $tmpSettings -Destination $settingsPath + +$version = "unknown" +$ovmsExe = Join-Path $InstallDir "ovms.exe" +if (Test-Path $ovmsExe) { + try { + $versionOutput = & $ovmsExe --version 2>$null + if ($versionOutput) { + $version = ($versionOutput | Select-Object -First 1).ToString() + } + } catch { + $version = "unknown" + } +} + +$installMarker = [ordered]@{ + productName = "OpenVINO Model Server" + installDir = $InstallDir + dataDir = $DataDir + version = $version + packageVariant = $PackageVariant + installedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + installerVersion = "1.0.0" +} + +$tmpInstall = "$installMarkerPath.tmp" +[System.IO.File]::WriteAllText($tmpInstall, ($installMarker | ConvertTo-Json -Depth 10), $utf8NoBom) +Move-Item -Force -Path $tmpInstall -Destination $installMarkerPath + +Write-Host "[INFO] OVMS configured at $DataDir" diff --git a/packaging/windows/scripts/install-service.ps1 b/packaging/windows/scripts/install-service.ps1 new file mode 100644 index 0000000000..74985ac9d4 --- /dev/null +++ b/packaging/windows/scripts/install-service.ps1 @@ -0,0 +1,46 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$DataDir +) + +$ErrorActionPreference = "Stop" + +$settingsPath = Join-Path $DataDir "settings.json" +if (-not (Test-Path $settingsPath)) { + throw "Settings file not found: $settingsPath" +} + +$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json +$ovmsExe = Join-Path $InstallDir "ovms.exe" +if (-not (Test-Path $ovmsExe)) { + throw "ovms.exe not found: $ovmsExe" +} + +. (Join-Path $PSScriptRoot "ovms-env.ps1") -InstallDir $InstallDir -PersistMachine + +$binPath = "`"$ovmsExe`" --rest_port $($settings.restPort) --rest_bind_address `"$($settings.bindAddress)`" --config_path `"$($settings.configPath)`" --log_level $($settings.logLevel) --log_path `"$($settings.logPath)`"" +if ($settings.grpcPort -and [int]$settings.grpcPort -gt 0) { + $binPath += " --port $($settings.grpcPort)" +} + +$existing = Get-Service ovms -ErrorAction SilentlyContinue +if ($existing) { + & sc.exe config ovms binPath= $binPath DisplayName= "OpenVINO Model Server" | Out-Host +} else { + & sc.exe create ovms binPath= $binPath DisplayName= "OpenVINO Model Server" start= demand | Out-Host +} +if ($LASTEXITCODE -ne 0) { + throw "Failed to create or update ovms service." +} + +try { + & $ovmsExe install | Out-Host +} catch { + Write-Warning "ovms.exe install returned an error: $_" +} + +$settings.runMode = "service" +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($settingsPath, ($settings | ConvertTo-Json -Depth 10), $utf8NoBom) + +Write-Host "[INFO] OVMS service installed." diff --git a/packaging/windows/scripts/ovms-env.ps1 b/packaging/windows/scripts/ovms-env.ps1 new file mode 100644 index 0000000000..561f547cc6 --- /dev/null +++ b/packaging/windows/scripts/ovms-env.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [switch]$PersistMachine +) + +$ErrorActionPreference = "Stop" + +$installDirFull = (Resolve-Path -LiteralPath $InstallDir).Path.TrimEnd("\") +$pythonHome = Join-Path $installDirFull "python" +$pythonScripts = Join-Path $pythonHome "Scripts" +$espeakData = Join-Path $installDirFull "espeak-ng-data" + +$env:OVMS_DIR = $installDirFull +if (Test-Path -LiteralPath $pythonHome) { + $env:PYTHONHOME = $pythonHome + $env:PATH = (($installDirFull, $pythonHome, $pythonScripts, $env:PATH) -join ";") +} else { + $env:PATH = (($installDirFull, $env:PATH) -join ";") +} + +if (Test-Path -LiteralPath $espeakData) { + $env:ESPEAK_DATA_PATH = $espeakData +} + +if ($PersistMachine) { + [Environment]::SetEnvironmentVariable("OVMS_DIR", $installDirFull, "Machine") + if (Test-Path -LiteralPath $pythonHome) { + [Environment]::SetEnvironmentVariable("PYTHONHOME", $pythonHome, "Machine") + } + if (Test-Path -LiteralPath $espeakData) { + [Environment]::SetEnvironmentVariable("ESPEAK_DATA_PATH", $espeakData, "Machine") + } +} diff --git a/packaging/windows/scripts/repair-package.ps1 b/packaging/windows/scripts/repair-package.ps1 new file mode 100644 index 0000000000..66a36f13ad --- /dev/null +++ b/packaging/windows/scripts/repair-package.ps1 @@ -0,0 +1,91 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$DataDir, + [string]$PackageSource = "" +) + +$ErrorActionPreference = "Stop" + +$packageCacheDir = Join-Path $DataDir "packages\source" + +$resolvedSource = "" +if ($PackageSource -and (Test-Path (Join-Path $PackageSource "ovms.exe"))) { + $resolvedSource = $PackageSource +} elseif (Test-Path (Join-Path $packageCacheDir "ovms.exe")) { + $resolvedSource = $packageCacheDir +} + +if (-not $resolvedSource) { + Write-Warning "No package source available to restore missing files from. Falling back to self-verify only." + Write-Warning "Repair cannot restore missing files without a valid package source (checked: '$PackageSource', '$packageCacheDir')." +} + +$required = @( + "ovms.exe", + "setupvars.bat", + "setupvars.ps1", + "install_ovms_service.bat" +) + +$repairedFiles = @() +foreach ($file in $required) { + $destPath = Join-Path $InstallDir $file + if (-not (Test-Path $destPath)) { + if ($resolvedSource) { + $srcPath = Join-Path $resolvedSource $file + if (Test-Path $srcPath) { + $destDir = Split-Path -Parent $destPath + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Force -Path $destDir | Out-Null + } + Copy-Item -Path $srcPath -Destination $destPath -Force + $repairedFiles += $file + Write-Host "[INFO] Restored missing file: $file" + } else { + Write-Warning "Missing file '$file' could not be restored: not found in package source." + } + } else { + Write-Warning "Missing file '$file' could not be restored: no package source available." + } + } +} + +$genAiInstalled = (Test-Path (Join-Path $InstallDir "openvino_genai.dll")) -and (Test-Path (Join-Path $InstallDir "openvino_tokenizers.dll")) +$pythonInstalled = Test-Path (Join-Path $InstallDir "python\python.exe") + +Write-Host "[INFO] GenAI support: $(if ($genAiInstalled) { 'Installed' } else { 'Missing' })" +Write-Host "[INFO] Python support: $(if ($pythonInstalled) { 'Installed' } else { 'Missing' })" + +$ovmsExe = Join-Path $InstallDir "ovms.exe" +if (-not (Test-Path $ovmsExe)) { + throw "ovms.exe is missing from $InstallDir and could not be restored. Repair cannot continue." +} + +. (Join-Path $PSScriptRoot "ovms-env.ps1") -InstallDir $InstallDir + +try { + $versionOutput = & $ovmsExe --version 2>$null + if ($LASTEXITCODE -ne 0) { + throw "ovms.exe --version exited with code $LASTEXITCODE." + } +} catch { + throw "ovms.exe --version failed during repair: $_" +} + +$settingsPath = Join-Path $DataDir "settings.json" +$configPath = Join-Path $DataDir "models\config.json" + +if ((-not (Test-Path $settingsPath)) -or (-not (Test-Path $configPath))) { + Write-Host "[INFO] Regenerating missing configuration via configure-ovms.ps1." + & (Join-Path $PSScriptRoot "configure-ovms.ps1") -InstallDir $InstallDir -DataDir $DataDir +} + +Write-Host "" +if ($repairedFiles.Count -gt 0) { + Write-Host "[INFO] Repair restored $($repairedFiles.Count) file(s): $($repairedFiles -join ', ')" +} else { + Write-Host "[INFO] No required files were missing; no file repair was needed." +} +Write-Host "[INFO] GenAI support: $(if ($genAiInstalled) { 'Installed' } else { 'Missing' })" +Write-Host "[INFO] Python support: $(if ($pythonInstalled) { 'Installed' } else { 'Missing' })" +Write-Host "[INFO] Repair complete." diff --git a/packaging/windows/scripts/set-path.ps1 b/packaging/windows/scripts/set-path.ps1 new file mode 100644 index 0000000000..eae7760340 --- /dev/null +++ b/packaging/windows/scripts/set-path.ps1 @@ -0,0 +1,44 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [ValidateSet("Add", "Remove")][string]$Action +) + +$ErrorActionPreference = "Stop" + +$installDirFull = (Resolve-Path -LiteralPath $InstallDir).Path.TrimEnd("\") +$pythonHome = Join-Path $installDirFull "python" +$pythonScripts = Join-Path $pythonHome "Scripts" +$required = @($installDirFull) +if (Test-Path -LiteralPath $pythonHome) { + $required += $pythonHome + $required += $pythonScripts +} + +$target = "User" +$current = [Environment]::GetEnvironmentVariable("Path", $target) +$parts = @() +if (-not [string]::IsNullOrWhiteSpace($current)) { + $parts = @($current -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} + +if ($Action -eq "Add") { + foreach ($req in $required) { + $exists = $false + foreach ($part in $parts) { + if ($part.TrimEnd("\") -ieq $req.TrimEnd("\")) { + $exists = $true + break + } + } + if (-not $exists) { + $parts = @($req) + $parts + } + } +} else { + $parts = @($parts | Where-Object { + $candidate = $_.TrimEnd("\") + -not ($required | Where-Object { $_.TrimEnd("\") -ieq $candidate }) + }) +} + +[Environment]::SetEnvironmentVariable("Path", ($parts -join ";"), $target) diff --git a/packaging/windows/scripts/start-ovms.ps1 b/packaging/windows/scripts/start-ovms.ps1 new file mode 100644 index 0000000000..9a8df6770e --- /dev/null +++ b/packaging/windows/scripts/start-ovms.ps1 @@ -0,0 +1,86 @@ +param( + [string]$DataDir = "$env:LOCALAPPDATA\OVMS" +) + +$ErrorActionPreference = "Stop" + +$settingsPath = Join-Path $DataDir "settings.json" +if (-not (Test-Path $settingsPath)) { + throw "Settings file not found: $settingsPath" +} + +$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json +$runtimePath = Join-Path $DataDir "runtime.json" +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) + +$service = Get-Service ovms -ErrorAction SilentlyContinue +if ($service -and $settings.runMode -eq "service") { + if ($service.Status -ne "Running") { + Start-Service ovms + } + Write-Host "[INFO] OVMS service is running." + exit 0 +} + +$restPort = [int]$settings.restPort +$grpcPort = [int]$settings.grpcPort +$ports = @($restPort, $grpcPort) | Where-Object { $_ -gt 0 } | Select-Object -Unique +foreach ($port in $ports) { + $listeners = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue + foreach ($listener in $listeners) { + if ($listener.OwningProcess -ne $PID) { + throw "Port $port is already in use by process id $($listener.OwningProcess)." + } + } +} + +if (Test-Path $runtimePath) { + try { + $runtime = Get-Content $runtimePath -Raw | ConvertFrom-Json + if ($runtime.pid) { + $existing = Get-Process -Id ([int]$runtime.pid) -ErrorAction SilentlyContinue + if ($existing -and $existing.ProcessName -ieq "ovms") { + Write-Host "[INFO] OVMS is already running with pid $($runtime.pid)." + exit 0 + } + } + } catch { + Write-Warning "Ignoring invalid runtime state: $runtimePath" + } +} + +$ovmsExe = Join-Path $settings.installDir "ovms.exe" +if (-not (Test-Path $ovmsExe)) { + throw "ovms.exe not found: $ovmsExe" +} + +. (Join-Path $PSScriptRoot "ovms-env.ps1") -InstallDir $settings.installDir + +$args = @( + "--rest_port", $settings.restPort, + "--rest_bind_address", $settings.bindAddress, + "--config_path", $settings.configPath, + "--log_level", $settings.logLevel, + "--log_path", $settings.logPath +) + +if ($settings.grpcPort -and [int]$settings.grpcPort -gt 0) { + $args += @("--port", $settings.grpcPort) +} + +$process = Start-Process -FilePath $ovmsExe -ArgumentList $args -WindowStyle Hidden -PassThru + +$runtimeState = [ordered]@{ + owner = "manager-process" + pid = $process.Id + serviceName = "ovms" + restPort = [int]$settings.restPort + grpcPort = [int]$settings.grpcPort + startedAtUtc = (Get-Date).ToUniversalTime().ToString("o") +} + +$tmpRuntime = "$runtimePath.tmp" +[System.IO.File]::WriteAllText($tmpRuntime, ($runtimeState | ConvertTo-Json -Depth 10), $utf8NoBom) +Move-Item -Force -Path $tmpRuntime -Destination $runtimePath + +Write-Host "[INFO] Started OVMS with pid $($process.Id)." diff --git a/packaging/windows/scripts/stop-ovms.ps1 b/packaging/windows/scripts/stop-ovms.ps1 new file mode 100644 index 0000000000..061fa9940b --- /dev/null +++ b/packaging/windows/scripts/stop-ovms.ps1 @@ -0,0 +1,38 @@ +param( + [string]$DataDir = "$env:LOCALAPPDATA\OVMS" +) + +$ErrorActionPreference = "Stop" + +$settingsPath = Join-Path $DataDir "settings.json" +$runtimePath = Join-Path $DataDir "runtime.json" + +if (Test-Path $settingsPath) { + $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json + if ($settings.runMode -eq "service") { + $service = Get-Service ovms -ErrorAction SilentlyContinue + if ($service -and $service.Status -ne "Stopped") { + Stop-Service ovms -ErrorAction Stop + Write-Host "[INFO] Stopped OVMS service." + exit 0 + } + } +} + +if (Test-Path $runtimePath) { + try { + $runtime = Get-Content $runtimePath -Raw | ConvertFrom-Json + if ($runtime.pid) { + $process = Get-Process -Id ([int]$runtime.pid) -ErrorAction SilentlyContinue + if ($process -and $process.ProcessName -ieq "ovms") { + Stop-Process -Id $process.Id -Force + Write-Host "[INFO] Stopped OVMS process $($process.Id)." + } + } + } finally { + Remove-Item -Force -Path $runtimePath -ErrorAction SilentlyContinue + } +} else { + Write-Host "[INFO] No OVMS runtime state found." +} + diff --git a/packaging/windows/scripts/uninstall-ovms.ps1 b/packaging/windows/scripts/uninstall-ovms.ps1 new file mode 100644 index 0000000000..b051b0c2d4 --- /dev/null +++ b/packaging/windows/scripts/uninstall-ovms.ps1 @@ -0,0 +1,71 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$DataDir, + [ValidateSet("PreserveAll", "RemoveSettingsKeepModels", "RemoveAll")][string]$DataMode = "PreserveAll" +) + +$ErrorActionPreference = "Stop" + +Get-Process -Name "OVMS.Manager" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + +$runtimePath = Join-Path $DataDir "runtime.json" +if (Test-Path $runtimePath) { + try { + $runtime = Get-Content $runtimePath -Raw | ConvertFrom-Json + if ($runtime.pid) { + $process = Get-Process -Id ([int]$runtime.pid) -ErrorAction SilentlyContinue + if ($process -and $process.ProcessName -ieq "ovms") { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + } + } catch { + Write-Warning "Could not read runtime state: $_" + } + Remove-Item -Force -Path $runtimePath -ErrorAction SilentlyContinue +} + +& (Join-Path $PSScriptRoot "uninstall-service.ps1") +& (Join-Path $PSScriptRoot "set-path.ps1") -InstallDir $InstallDir -Action Remove +[Environment]::SetEnvironmentVariable("OVMS_DIR", $null, "User") +[Environment]::SetEnvironmentVariable("PYTHONHOME", $null, "User") +[Environment]::SetEnvironmentVariable("ESPEAK_DATA_PATH", $null, "User") + +Remove-Item -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "OVMS Manager" -ErrorAction SilentlyContinue + +switch ($DataMode) { + "PreserveAll" { + # Keep everything under $DataDir. + } + "RemoveSettingsKeepModels" { + Remove-Item -Force -Path (Join-Path $DataDir "settings.json") -ErrorAction SilentlyContinue + Remove-Item -Force -Path (Join-Path $DataDir "install.json") -ErrorAction SilentlyContinue + Remove-Item -Force -Path (Join-Path $DataDir "runtime.json") -ErrorAction SilentlyContinue + + $logsDir = Join-Path $DataDir "logs" + if (Test-Path $logsDir) { + Remove-Item -LiteralPath $logsDir -Recurse -Force -ErrorAction SilentlyContinue + } + + $packagesDir = Join-Path $DataDir "packages" + if (Test-Path $packagesDir) { + Remove-Item -LiteralPath $packagesDir -Recurse -Force -ErrorAction SilentlyContinue + } + + $downloadsDir = Join-Path $DataDir "downloads" + if (Test-Path $downloadsDir) { + Remove-Item -LiteralPath $downloadsDir -Recurse -Force -ErrorAction SilentlyContinue + } + + $diagnosticsDir = Join-Path $DataDir "diagnostics" + if (Test-Path $diagnosticsDir) { + Remove-Item -LiteralPath $diagnosticsDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + "RemoveAll" { + if (Test-Path $DataDir) { + Remove-Item -LiteralPath $DataDir -Recurse -Force + } + } +} + +Write-Host "[INFO] OVMS uninstall cleanup complete." diff --git a/packaging/windows/scripts/uninstall-service.ps1 b/packaging/windows/scripts/uninstall-service.ps1 new file mode 100644 index 0000000000..ed6f0b8c61 --- /dev/null +++ b/packaging/windows/scripts/uninstall-service.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" + +$service = Get-Service ovms -ErrorAction SilentlyContinue +if ($service) { + if ($service.Status -ne "Stopped") { + Stop-Service ovms -ErrorAction SilentlyContinue + } + & sc.exe delete ovms | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Failed to delete ovms service." + } +} + diff --git a/packaging/windows/scripts/upgrade-package.ps1 b/packaging/windows/scripts/upgrade-package.ps1 new file mode 100644 index 0000000000..6733e6b1ae --- /dev/null +++ b/packaging/windows/scripts/upgrade-package.ps1 @@ -0,0 +1,100 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$DataDir, + [Parameter(Mandatory = $true)][string]$PackageUrl, + [string]$Sha256Url = "", + [Parameter(Mandatory = $true)][string]$Version, + [string]$PackageVariant = "python_on" +) + +$ErrorActionPreference = "Stop" + +$downloadsDir = Join-Path $DataDir "downloads" +$packagesDir = Join-Path $DataDir "packages" +$stagingRoot = Join-Path $packagesDir "staging" +$backupRoot = Join-Path $packagesDir "backup" +$safeVersion = ($Version -replace '[^A-Za-z0-9._-]', '_') +$stagingDir = Join-Path $stagingRoot $safeVersion +$backupDir = Join-Path $backupRoot ("{0}-{1}" -f $safeVersion, (Get-Date -Format "yyyyMMdd-HHmmss")) +$zipPath = Join-Path $downloadsDir ([IO.Path]::GetFileName(([Uri]$PackageUrl).AbsolutePath)) +$shaPath = if ($Sha256Url) { Join-Path $downloadsDir ([IO.Path]::GetFileName(([Uri]$Sha256Url).AbsolutePath)) } else { "" } + +New-Item -ItemType Directory -Force -Path $downloadsDir, $packagesDir, $stagingRoot, $backupRoot | Out-Null + +Write-Host "[INFO] Downloading package: $PackageUrl" +Invoke-WebRequest -Uri $PackageUrl -OutFile $zipPath -UseBasicParsing + +if ($Sha256Url) { + Write-Host "[INFO] Downloading checksum: $Sha256Url" + Invoke-WebRequest -Uri $Sha256Url -OutFile $shaPath -UseBasicParsing + $expected = ((Get-Content $shaPath -Raw) -split '\s+')[0].Trim().ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 -Path $zipPath).Hash.ToLowerInvariant() + if ($expected -and $actual -ne $expected) { + throw "SHA256 mismatch. Expected $expected but got $actual." + } + Write-Host "[INFO] SHA256 verified." +} + +if (Test-Path $stagingDir) { + Remove-Item -Recurse -Force -Path $stagingDir +} +New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + +Write-Host "[INFO] Extracting package to staging: $stagingDir" +Expand-Archive -Path $zipPath -DestinationPath $stagingDir -Force + +$stagedOvms = Get-ChildItem -Path $stagingDir -Recurse -Filter "ovms.exe" | Select-Object -First 1 +if (-not $stagedOvms) { + throw "Staged package does not contain ovms.exe." +} +$sourceDir = $stagedOvms.Directory.FullName + +Write-Host "[INFO] Validating staged ovms.exe." +$versionOutput = & $stagedOvms.FullName --version 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "Staged ovms.exe --version failed: $versionOutput" +} + +$runtimePath = Join-Path $DataDir "runtime.json" +$wasRunning = $false +if (Test-Path $runtimePath) { + try { + $runtime = Get-Content $runtimePath -Raw | ConvertFrom-Json + if ($runtime.pid) { + $process = Get-Process -Id ([int]$runtime.pid) -ErrorAction SilentlyContinue + $wasRunning = $process -and $process.ProcessName -ieq "ovms" + } + } catch { + $wasRunning = $false + } +} + +& (Join-Path $PSScriptRoot "stop-ovms.ps1") -DataDir $DataDir + +Write-Host "[INFO] Backing up current install to: $backupDir" +New-Item -ItemType Directory -Force -Path $backupDir | Out-Null +Get-ChildItem -LiteralPath $InstallDir -Force | + Where-Object { $_.Name -notlike "unins*" -and $_.Name -notin @("OVMS.Manager.exe", "OVMS.Manager.pdb", "scripts", "templates") } | + ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $backupDir $_.Name) -Recurse -Force + } + +Write-Host "[INFO] Applying staged package." +Copy-Item -Path (Join-Path $sourceDir "*") -Destination $InstallDir -Recurse -Force + +$installedOvms = Join-Path $InstallDir "ovms.exe" +$installedVersionOutput = & $installedOvms --version 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Warning "Installed ovms.exe validation failed. Restoring backup." + Copy-Item -Path (Join-Path $backupDir "*") -Destination $InstallDir -Recurse -Force + throw "Installed ovms.exe --version failed after upgrade: $installedVersionOutput" +} + +& (Join-Path $PSScriptRoot "configure-ovms.ps1") -InstallDir $InstallDir -DataDir $DataDir -PackageVariant $PackageVariant | Out-Host + +if ($wasRunning) { + & (Join-Path $PSScriptRoot "start-ovms.ps1") -DataDir $DataDir +} + +Write-Host "[INFO] Upgrade complete." +Write-Host $installedVersionOutput diff --git a/packaging/windows/scripts/validate-install.ps1 b/packaging/windows/scripts/validate-install.ps1 new file mode 100644 index 0000000000..2e472905b9 --- /dev/null +++ b/packaging/windows/scripts/validate-install.ps1 @@ -0,0 +1,36 @@ +param( + [string]$InstallDir +) + +$ErrorActionPreference = "Stop" + +if (-not $InstallDir) { + $sourceRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path + $InstallDir = Join-Path $sourceRoot "dist\windows\ovms" +} + +$required = @( + "ovms.exe", + "setupvars.bat", + "setupvars.ps1", + "install_ovms_service.bat" +) + +$missing = @() +foreach ($file in $required) { + $path = Join-Path $InstallDir $file + if (-not (Test-Path $path)) { + $missing += $file + } +} + +if ($missing.Count -gt 0) { + throw "Missing required files in ${InstallDir}: $($missing -join ', ')" +} + +& (Join-Path $InstallDir "ovms.exe") --version +if ($LASTEXITCODE -ne 0) { + throw "ovms.exe --version failed." +} + +Write-Host "[INFO] Install folder validation passed: $InstallDir" diff --git a/packaging/windows/settings/settings.template.json b/packaging/windows/settings/settings.template.json new file mode 100644 index 0000000000..3f5734aa38 --- /dev/null +++ b/packaging/windows/settings/settings.template.json @@ -0,0 +1,16 @@ +{ + "installDir": "", + "dataDir": "", + "modelRepositoryPath": "", + "configPath": "", + "logPath": "", + "restPort": 8000, + "grpcPort": 0, + "bindAddress": "127.0.0.1", + "logLevel": "INFO", + "runMode": "user-login", + "startAtLogin": true, + "showTrayIcon": true, + "serviceAutoStart": false, + "packageVariant": "python_on" +}