From 92b8746971d25b5087474a3009e4fd3b4f353473 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 21 Sep 2026 20:08:15 +0200 Subject: [PATCH] (feature): add portable install layout, relicense to Apache 2.0, and fix Flatpak GNOME launch --- CHANGELOG.md | 15 + CONTRIBUTING.md | 4 + LICENSE.md | 214 ++++++++++++-- README.md | 68 ++++- data/com.maks_it.uscheduler.desktop | 12 + data/com.maks_it.uscheduler.metainfo.xml | 49 ++++ src/MaksIT.UScheduler.Shared/Configuration.cs | 16 +- .../ConfigurationFileService.cs | 9 +- .../HostDataDirectories.cs | 107 +++++-- src/MaksIT.UScheduler.Shared/HostPaths.cs | 124 +++++++- .../ConfigurationFileServiceTests.cs | 99 +++++++ .../HostDataDirectoriesTests.cs | 51 ++++ .../MaksIT.UScheduler.UI.csproj | 10 +- src/MaksIT.UScheduler.UI/Program.cs | 2 + .../Services/AppSettingsService.cs | 9 +- .../Services/UISettingsService.cs | 15 +- .../ViewModels/MainViewModel.cs | 19 +- .../MaksIT.UScheduler.csproj | 2 +- src/MaksIT.UScheduler/Program.cs | 26 +- .../release/custom/BundleCustomization.psm1 | 4 +- utils/engines/release/scriptSettings.json | 19 +- utils/plugins/Desktop/DesktopPackSupport.psm1 | 270 ++++++++++++++---- utils/plugins/Desktop/FlatpakPack.psm1 | 132 +++++++-- utils/plugins/Desktop/WindowsInstaller.psm1 | 9 +- .../Desktop/brand/HyperlinkSidebarTheme.xml | 18 +- .../brand/brand/HyperlinkSidebarTheme.xml | 139 +++++++++ utils/plugins/Desktop/brand/brand/logo-64.png | Bin 0 -> 2170 bytes utils/plugins/Desktop/brand/brand/mark.ico | Bin 0 -> 23823 bytes utils/plugins/Desktop/brand/brand/mark.png | Bin 0 -> 21108 bytes utils/plugins/Desktop/brand/brand/mark.svg | 27 ++ utils/plugins/Desktop/brand/brand/sidebar.png | Bin 0 -> 16934 bytes 31 files changed, 1280 insertions(+), 189 deletions(-) create mode 100644 data/com.maks_it.uscheduler.desktop create mode 100644 data/com.maks_it.uscheduler.metainfo.xml create mode 100644 utils/plugins/Desktop/brand/brand/HyperlinkSidebarTheme.xml create mode 100644 utils/plugins/Desktop/brand/brand/logo-64.png create mode 100644 utils/plugins/Desktop/brand/brand/mark.ico create mode 100644 utils/plugins/Desktop/brand/brand/mark.png create mode 100644 utils/plugins/Desktop/brand/brand/mark.svg create mode 100644 utils/plugins/Desktop/brand/brand/sidebar.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e992713..63371c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-09-21 + +### Added + +- Windows setup exe offers **Standard** or **Portable** install. Portable keeps binaries, scripts, logs, and settings in the selected folder (`Scripts`, `Logs`, and `Data` subfolders). `--prepare-data --portable` does the same from the CLI; the portable zip includes a `portable` marker so unpacked copies stay self-contained. + +### Changed + +- Relicensed the project from MIT to **Apache 2.0**. + +### Fixed + +- Linux Flatpak GNOME app icon uses X11/XWayland (`UsePlatformDetect` only). Avalonia 12.1.2 native Wayland still hangs on GNOME `xdg_toplevel.configure(0, 0)`. App id is lowercase `com.maks_it.uscheduler`. Uninstall the old `com.maks_it.UScheduler` id before installing a new bundle. AppStream lives in `data/`. +- Flatpak AppStream version is the same shared release version as zip and MSI (`DotNetReleaseVersion` from the UI csproj / `Directory.Build.props`), so `flatpak info` matches the bundle file name. + ## [1.1.1] - 2026-09-06 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2efd32c..1c14ef3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -173,4 +173,8 @@ Follow [Keep a Changelog](https://keepachangelog.com/) format: - Bug fixes ``` +## License + +By contributing, you agree that your contributions are licensed under the terms in [LICENSE.md](LICENSE.md) (Apache 2.0). + --- diff --git a/LICENSE.md b/LICENSE.md index f5aa10f..9a7ec9a 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,201 @@ -MIT License +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ -Copyright (c) 2017 - 2026 Maksym Sadovnychyy (Maks-IT) +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +1. Definitions. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017-2026 Maksym Sadovnychyy (MAKS-IT) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index e04d750..f7f14e2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-7%25-red) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-38.8%25-yellow) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) -![License](https://img.shields.io/badge/License-MIT-blue) +![License](https://img.shields.io/badge/License-Apache%202.0-blue) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6) A modern scheduler built on **.NET 10** for running PowerShell scripts and console applications on **Windows and Linux**. @@ -57,7 +57,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and ## Scripts Examples -> **Note:** These examples are **bundled with the release** and copied to `C:\MaksIT\Scripts` by the Windows setup exe (and by `--install` / `--prepare-data`) **only if that folder or script is not already present**. Existing files and script folders are never overwritten or merged. They are listed in the default configuration but **disabled by default**. To enable an example, set `"Disabled": false` in `%ProgramData%\MaksIT\UScheduler\settings.json` (or use the UI). +> **Note:** These examples are **bundled with the release** and copied to `C:\MaksIT\Scripts` (standard) or the install folder's `Scripts` directory (portable) by the Windows setup exe (and by `--install` / `--prepare-data`) **only if that folder or script is not already present**. Existing files and script folders are never overwritten or merged. They are listed in the default configuration but **disabled by default**. To enable an example, set `"Disabled": false` in the shared `settings.json` (or use the UI). - [Hyper-V Backup](./src/Scripts/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management - [Native-Sync](./src/Scripts/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies @@ -88,6 +88,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and ### Install layout +The Windows setup exe lets you choose **Standard** or **Portable**. + +**Standard** (default): + | Location | Purpose | Who can write | |----------|---------|----------------| | `C:\Program Files\MaksIT\UScheduler` | Worker (`MaksIT.UScheduler.exe`) and UI (`MaksIT.UScheduler.UI.exe`) | Administrators | @@ -96,7 +100,19 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, commit format, and | `%ProgramData%\MaksIT\UScheduler\settings.json` | Shared schedule configuration | Users (after install) | | `%AppData%\MaksIT\UScheduler\settings.json` | Per-user UI prefs (service bin path override) | Current user | -The Windows setup exe, registering the service, or `MaksIT.UScheduler --prepare-data` creates the `C:\MaksIT` and ProgramData folders, copies bundled example scripts when missing, and grants the Users group modify rights so the UI can stay unelevated. +**Portable** (everything in one folder): + +| Location | Purpose | +|----------|---------| +| *Install folder* (you choose it) | Worker and UI | +| *Install folder*`\Scripts` | Scheduled scripts | +| *Install folder*`\Logs` | Service and script logs | +| *Install folder*`\Data\settings.json` | Shared schedule configuration | +| *Install folder*`\Data\ui-settings.json` | UI prefs | + +A `portable` file in that folder (or a parent folder) marks the layout. Pick a writable location such as `C:\MaksIT\UScheduler` if you do not want Program Files. The Users group is granted modify rights on `Scripts`, `Logs`, and `Data` so the UI can stay unelevated. + +The Windows setup exe, registering the service, or `MaksIT.UScheduler --prepare-data` creates the data folders, copies bundled example scripts when missing, and grants the Users group modify rights so the UI can stay unelevated. Add `--portable` to keep data next to the binaries instead of `C:\MaksIT` and ProgramData. ### Using CLI Commands @@ -123,6 +139,9 @@ MaksIT.UScheduler --help # Create data folders and ACLs without installing the service MaksIT.UScheduler --prepare-data + +# Portable layout: scripts, logs, and settings next to the executable +MaksIT.UScheduler --prepare-data --portable ``` On Windows the file is `MaksIT.UScheduler.exe`. @@ -134,7 +153,8 @@ On Windows the file is `MaksIT.UScheduler.exe`. | `--start` | | Start the service | | `--stop` | | Stop the service | | `--status` | | Query service status | -| `--prepare-data` | | Create `C:\MaksIT\Scripts`, `C:\MaksIT\Logs`, and shared settings (elevated) | +| `--prepare-data` | | Create scripts, logs, and shared settings (elevated) | +| `--portable` | | With `--install` / `--prepare-data`: keep data in the install folder | | `--help` | `-h` | Show help message | > **Note:** Service management commands require administrator / root privileges. @@ -172,11 +192,35 @@ The UI is an **Avalonia** desktop app (Windows and Linux) for service registrati ### Getting Started -When you unpack the portable zip, launch `MaksIT.UScheduler.UI.exe`. GitHub releases also ship a Windows setup exe (installs worker + UI to `C:\Program Files\MaksIT\UScheduler`) and a Flatpak of the UI. +When you unpack the portable zip, launch `MaksIT.UScheduler.UI.exe` (or `Start-UScheduler.bat`). The zip is a portable layout: scripts, logs, and settings stay in the extracted folder. GitHub releases also ship a Windows setup exe (worker + UI; choose Standard or Portable on the install page) and a Flatpak of the UI. + +#### Linux (Flatpak) + +**User** (this account only): + +```bash +flatpak install --user ./maksit-uscheduler-{version}.flatpak +flatpak run com.maks_it.uscheduler +``` + +**System** (all users): + +```bash +sudo flatpak install --system ./maksit-uscheduler-{version}.flatpak +flatpak run com.maks_it.uscheduler +``` + +Uninstall: `flatpak uninstall --user com.maks_it.uscheduler` or `sudo flatpak uninstall --system com.maks_it.uscheduler`. + +The previous id `com.maks_it.UScheduler` is replaced by this lowercase id. Uninstall the old app before installing the new bundle if it was installed. + +If GNOME or KDE does not show a launcher icon, `flatpak run` may warn that `/var/lib/flatpak/exports/share` and `~/.local/share/flatpak/exports/share` are not on `XDG_DATA_DIRS`. Log out and back in once so the session picks up those paths. + +Linux uses X11/XWayland (Avalonia native Wayland still hangs on GNOME). The sandbox grants `--filesystem=home` for scripts and logs. The Flatpak is the UI; the worker is a separate Linux systemd install. AppStream and the desktop file live in [`data/`](data/). ![Manager launcher](./assets/explorer_6Ai8GBZ7xg.png) -> **Note:** Service management (register, start, stop, unregister) prompts for administrator approval without restarting the UI. Schedule edits go to `%ProgramData%\MaksIT\UScheduler\settings.json` and do not require elevation after the first install. +> **Note:** Service management (register, start, stop, unregister) prompts for administrator approval without restarting the UI. Schedule edits go to `%ProgramData%\MaksIT\UScheduler\settings.json` on a standard install (or `Data\settings.json` next to a portable install) and do not require elevation after the first install. ### Settings View @@ -186,12 +230,12 @@ The Settings view is your starting point for configuring UScheduler. | Feature | Description | |---------|-------------| -| **Service Bin Path** | Path to the worker folder (auto-detected from Program Files or the UI directory; override stored in `%AppData%/MaksIT/UScheduler/settings.json`) | +| **Service Bin Path** | Path to the worker folder (auto-detected from Program Files, the UI directory, or a portable folder; override stored in `%AppData%/MaksIT/UScheduler/settings.json` or `Data\ui-settings.json` when portable) | | **Service Status** | Real-time status indicator (Running, Stopped, Starting, Stopping, Paused, Not Installed) | | **Register/Unregister** | Install or remove the Windows service or systemd unit (UAC / polkit prompt, UI stays open) | | **Start/Stop** | Control the service state (same in-app elevation) | | **Refresh** | Update the current service status display | -| **Reload Settings** | Refresh shared configuration from `%ProgramData%\MaksIT\UScheduler\settings.json` | +| **Reload Settings** | Refresh shared configuration from `%ProgramData%\MaksIT\UScheduler\settings.json` (or `Data\settings.json` when portable) | ### Main View — Schedule Management @@ -262,9 +306,9 @@ Features: ### Machine-wide `settings.json` -Host logging (log levels, Event Log source) stays in shipped `appsettings.json` next to `MaksIT.UScheduler.exe` under Program Files. Schedule configuration is **not** written there — a leftover `Configuration` block is copied once into the machine-wide file: +Host logging (log levels, Event Log source) stays in shipped `appsettings.json` next to `MaksIT.UScheduler.exe`. Schedule configuration is **not** written there — a leftover `Configuration` block is copied once into the machine-wide file: -`%ProgramData%\MaksIT\UScheduler\settings.json` +`%ProgramData%\MaksIT\UScheduler\settings.json` (standard) or `{install folder}\Data\settings.json` (portable) ```json { @@ -438,7 +482,7 @@ Invoke-ScheduledExecution -Config $Config -Automated:$Automated -CurrentDateTime 1. Copy template 2. Modify `$Config` 3. Implement `Start-BusinessLogic` -4. Add script to `%ProgramData%\MaksIT\UScheduler\settings.json` (or use the UI) +4. Add script to the shared `settings.json` (ProgramData on a standard install, or `Data\settings.json` when portable — or use the UI) That’s it — the full scheduling engine is reused automatically. @@ -515,6 +559,6 @@ Email: maksym.sadovnychyy@gmail.com ## License -This project is licensed under the MIT License. See [LICENSE.md](LICENSE.md) for details. +Apache 2.0 — see [LICENSE.md](LICENSE.md). --- diff --git a/data/com.maks_it.uscheduler.desktop b/data/com.maks_it.uscheduler.desktop new file mode 100644 index 0000000..c0bd7c4 --- /dev/null +++ b/data/com.maks_it.uscheduler.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=UScheduler +GenericName=Job scheduler +Comment=Schedule PowerShell scripts and processes +Exec=maksit-uscheduler +Icon=com.maks_it.uscheduler +StartupWMClass=com.maks_it.uscheduler +StartupNotify=true +Terminal=false +Categories=Utility;System; +Keywords=scheduler;powershell;cron;jobs; diff --git a/data/com.maks_it.uscheduler.metainfo.xml b/data/com.maks_it.uscheduler.metainfo.xml new file mode 100644 index 0000000..bba8c16 --- /dev/null +++ b/data/com.maks_it.uscheduler.metainfo.xml @@ -0,0 +1,49 @@ + + + com.maks_it.uscheduler + UScheduler + Schedule PowerShell scripts and processes + + MaksIT + + CC0-1.0 + Apache-2.0 + +

Schedule and run PowerShell scripts and console processes from an Avalonia desktop UI. Register the worker, edit schedules, and follow logs without a browser.

+

The Flatpak is the UI. Script files and logs live under the home folder. The worker is a separate install on Linux (systemd) or Windows (service).

+
    +
  • Script list, schedules, and log viewing
  • +
  • Register, start, and stop the worker (prompts for elevation)
  • +
  • Windows and Linux script compatibility flags
  • +
+
+ com.maks_it.uscheduler.desktop + + com.maks_it.UScheduler + + + com.maks_it.UScheduler + + https://maks-it.com/ + https://github.com/MAKS-IT-COM/uscheduler/issues + https://github.com/MAKS-IT-COM/uscheduler + https://www.buymeacoffee.com/maksitcom + + #33A5CF + #003D7A + + + + keyboard + pointing + 768 + + + + https://github.com/MAKS-IT-COM/uscheduler/blob/v1.2.0/CHANGELOG.md + +

Linux Flatpak uses X11/XWayland from the GNOME app icon and a lowercase app id.

+
+
+
+
diff --git a/src/MaksIT.UScheduler.Shared/Configuration.cs b/src/MaksIT.UScheduler.Shared/Configuration.cs index e45e46b..3b17bd9 100644 --- a/src/MaksIT.UScheduler.Shared/Configuration.cs +++ b/src/MaksIT.UScheduler.Shared/Configuration.cs @@ -62,7 +62,8 @@ public class ProcessConfiguration : TaskConfiguration { /// /// Root configuration class for the UScheduler service. -/// Stored in machine-wide settings.json (ProgramData), not next to the exe. +/// Stored in machine-wide settings.json (ProgramData), or under the install +/// folder when a portable layout is used. /// public class Configuration { /// @@ -103,12 +104,17 @@ public class Configuration { Processes ??= []; } - public string GetEffectiveScriptsDirectory() => - HostPaths.ResolveScriptsDirectory(ScriptsDir); + public string GetEffectiveScriptsDirectory(string? baseDirectory = null) => + HostPaths.ResolveScriptsDirectory(ScriptsDir, baseDirectory); public string GetEffectiveLogDirectory(string? baseDirectory = null) { - var logDir = string.IsNullOrWhiteSpace(LogDir) ? HostPaths.DefaultLogDirectory : LogDir; - return PathHelper.ResolvePath(logDir, baseDirectory ?? AppContext.BaseDirectory); + var start = baseDirectory ?? AppContext.BaseDirectory; + if (string.IsNullOrWhiteSpace(LogDir)) { + var root = HostPaths.FindPortableRoot(start); + return root is null ? HostPaths.DefaultLogDirectory : HostPaths.GetPortableLogDirectory(root); + } + + return PathHelper.ResolvePath(LogDir, start); } public string ResolveScriptPath(string path) => diff --git a/src/MaksIT.UScheduler.Shared/ConfigurationFileService.cs b/src/MaksIT.UScheduler.Shared/ConfigurationFileService.cs index 3ab5ae4..72062d4 100644 --- a/src/MaksIT.UScheduler.Shared/ConfigurationFileService.cs +++ b/src/MaksIT.UScheduler.Shared/ConfigurationFileService.cs @@ -6,8 +6,10 @@ namespace MaksIT.UScheduler.Shared; /// /// Loads and saves machine-wide Configuration under ProgramData -/// (all-users AppData). Shipped appsettings.json next to the worker is -/// seed-only; host logging stays in that file and is never copied. +/// (all-users AppData), or under the portable Data folder when a +/// file is present. +/// Shipped appsettings.json next to the worker is seed-only; host +/// logging stays in that file and is never copied. /// public sealed class ConfigurationFileService { public const string ProductFolder = HostPaths.ProductFolder; @@ -27,7 +29,7 @@ public sealed class ConfigurationFileService { public ConfigurationFileService(string? configurationPath = null, string? seedPath = null) { FilePath = string.IsNullOrWhiteSpace(configurationPath) - ? HostPaths.SharedSettingsFile + ? HostPaths.ResolveSharedSettingsFile() : configurationPath; if (!string.IsNullOrWhiteSpace(seedPath)) @@ -93,6 +95,7 @@ public sealed class ConfigurationFileService { return; try { + HostPaths.ApplyPortableDefaults(_current, Path.GetDirectoryName(FilePath)); Save(_current); } catch (UnauthorizedAccessException) { diff --git a/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs b/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs index a0ef38e..b60e7b3 100644 --- a/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs +++ b/src/MaksIT.UScheduler.Shared/HostDataDirectories.cs @@ -4,39 +4,88 @@ using System.Runtime.Versioning; namespace MaksIT.UScheduler.Shared; +/// +/// Scripts, logs, and shared-settings locations for one install. +/// +public readonly record struct HostDataLayout( + string Root, + string ScriptsDirectory, + string LogDirectory, + string SharedSettingsFile, + bool Portable); + /// /// Creates machine-wide data folders (scripts, logs, shared settings) and /// grants the Users group modify rights so the UI can run without elevation. /// Call from an elevated --install / --prepare-data process. +/// Pass --portable (or drop a +/// file) to keep scripts, logs, and settings under the install folder. /// public static class HostDataDirectories { /// Windows Built-in Users (locale-independent). private const string BuiltinUsersSid = "*S-1-5-32-545"; - public static HostServiceOperationResult Prepare(string? installDirectory = null) { + public static HostDataLayout ResolveLayout(string? installDirectory = null, bool portableRequested = false) { + var installDir = string.IsNullOrWhiteSpace(installDirectory) + ? AppContext.BaseDirectory + : Path.GetFullPath(installDirectory); + + var portableRoot = HostPaths.FindPortableRoot(installDir); + var portable = portableRequested || portableRoot is not null; + if (!portable) { + return new HostDataLayout( + HostPaths.DefaultDataRoot, + HostPaths.DefaultScriptsDirectory, + HostPaths.DefaultLogDirectory, + HostPaths.SharedSettingsFile, + false); + } + + var root = portableRoot ?? installDir; + return new HostDataLayout( + root, + HostPaths.GetPortableScriptsDirectory(root), + HostPaths.GetPortableLogDirectory(root), + HostPaths.GetPortableSharedSettingsFile(root), + true); + } + + public static HostServiceOperationResult Prepare(string? installDirectory = null, bool portable = false) { var installDir = string.IsNullOrWhiteSpace(installDirectory) ? AppContext.BaseDirectory : installDirectory; var messages = new List(); try { - Directory.CreateDirectory(HostPaths.DefaultDataRoot); - Directory.CreateDirectory(HostPaths.DefaultScriptsDirectory); - Directory.CreateDirectory(HostPaths.DefaultLogDirectory); + if (portable) + HostPaths.WritePortableMarker(Path.GetFullPath(installDir)); - var settingsDir = Path.GetDirectoryName(HostPaths.SharedSettingsFile); + var layout = ResolveLayout(installDir, portable); + Directory.CreateDirectory(layout.Root); + Directory.CreateDirectory(layout.ScriptsDirectory); + Directory.CreateDirectory(layout.LogDirectory); + + var settingsDir = Path.GetDirectoryName(layout.SharedSettingsFile); if (!string.IsNullOrEmpty(settingsDir)) Directory.CreateDirectory(settingsDir); if (OperatingSystem.IsWindows()) { - GrantUsersModify(HostPaths.DefaultDataRoot, messages); - if (!string.IsNullOrEmpty(settingsDir)) - GrantUsersModify(settingsDir, messages); + if (layout.Portable) { + GrantUsersModify(layout.ScriptsDirectory, messages); + GrantUsersModify(layout.LogDirectory, messages); + if (!string.IsNullOrEmpty(settingsDir)) + GrantUsersModify(settingsDir, messages); + } + else { + GrantUsersModify(layout.Root, messages); + if (!string.IsNullOrEmpty(settingsDir)) + GrantUsersModify(settingsDir, messages); + } } else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - GrantUnixGroupWrite(HostPaths.DefaultDataRoot, messages); - GrantUnixGroupWrite(HostPaths.DefaultScriptsDirectory, messages); - GrantUnixGroupWrite(HostPaths.DefaultLogDirectory, messages); + GrantUnixGroupWrite(layout.Root, messages); + GrantUnixGroupWrite(layout.ScriptsDirectory, messages); + GrantUnixGroupWrite(layout.LogDirectory, messages); if (!string.IsNullOrEmpty(settingsDir)) GrantUnixGroupWrite(settingsDir, messages); } @@ -44,23 +93,26 @@ public static class HostDataDirectories { var source = HostPaths.FindBundledScriptsDirectory(installDir); if (source is null || !Directory.Exists(source)) { messages.Add( - $"Bundled scripts were not found next to '{installDir}'; left {HostPaths.DefaultScriptsDirectory} unchanged."); + $"Bundled scripts were not found next to '{installDir}'; left {layout.ScriptsDirectory} unchanged."); } - else if (PathsEqual(source, HostPaths.DefaultScriptsDirectory)) { - messages.Add($"Scripts directory: {HostPaths.DefaultScriptsDirectory}"); + else if (PathsEqual(source, layout.ScriptsDirectory)) { + messages.Add($"Scripts directory: {layout.ScriptsDirectory}"); } else { - var copy = CopySeedScriptsIfMissing(source, HostPaths.DefaultScriptsDirectory); + var copy = CopySeedScriptsIfMissing(source, layout.ScriptsDirectory); if (copy.CopiedFiles > 0) - messages.Add($"Copied {copy.CopiedFiles} new seed script file(s) to {HostPaths.DefaultScriptsDirectory}."); + messages.Add($"Copied {copy.CopiedFiles} new seed script file(s) to {layout.ScriptsDirectory}."); if (copy.SkippedItems > 0) - messages.Add($"Left {copy.SkippedItems} existing script folder(s)/file(s) unchanged in {HostPaths.DefaultScriptsDirectory}."); + messages.Add($"Left {copy.SkippedItems} existing script folder(s)/file(s) unchanged in {layout.ScriptsDirectory}."); if (copy.CopiedFiles == 0 && copy.SkippedItems == 0) - messages.Add($"Scripts directory: {HostPaths.DefaultScriptsDirectory}"); + messages.Add($"Scripts directory: {layout.ScriptsDirectory}"); } - messages.Add($"Logs directory: {HostPaths.DefaultLogDirectory}"); - messages.Add($"Shared settings: {HostPaths.SharedSettingsFile}"); + WritePortableSettingsIfNeeded(layout, installDir); + messages.Add($"Logs directory: {layout.LogDirectory}"); + messages.Add($"Shared settings: {layout.SharedSettingsFile}"); + if (layout.Portable) + messages.Add("Install type: portable (everything under the install folder)."); return new HostServiceOperationResult(true, string.Join(Environment.NewLine, messages)); } catch (Exception ex) { @@ -102,6 +154,16 @@ public static class HostDataDirectories { return new SeedCopyResult(copied, skipped); } + private static void WritePortableSettingsIfNeeded(HostDataLayout layout, string installDir) { + if (!layout.Portable) + return; + + var seed = Path.Combine(installDir, ConfigurationFileService.SeedFileName); + _ = new ConfigurationFileService( + layout.SharedSettingsFile, + File.Exists(seed) ? seed : null); + } + private static int CopyNewDirectory(string source, string destination) { Directory.CreateDirectory(destination); var copied = 0; @@ -146,10 +208,7 @@ public static class HostDataDirectories { } private static bool PathsEqual(string left, string right) => - string.Equals( - Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + HostPaths.PathsEqual(left, right); private static (int ExitCode, string Message) RunProcess(string fileName, string arguments) { using var process = Process.Start(new ProcessStartInfo { diff --git a/src/MaksIT.UScheduler.Shared/HostPaths.cs b/src/MaksIT.UScheduler.Shared/HostPaths.cs index 800c171..f624d92 100644 --- a/src/MaksIT.UScheduler.Shared/HostPaths.cs +++ b/src/MaksIT.UScheduler.Shared/HostPaths.cs @@ -4,13 +4,21 @@ using MaksIT.UScheduler.Shared.Helpers; namespace MaksIT.UScheduler.Shared; /// -/// Default install and data locations. Binaries live under Program Files; -/// scripts, logs, and machine settings are outside Program Files so normal -/// users can read and write them after --prepare-data / --install. +/// Default install and data locations. Standard layout keeps binaries under +/// Program Files and scripts, logs, and machine settings outside Program Files +/// so normal users can read and write them after --prepare-data / +/// --install. A file next to the +/// worker (or in a parent folder) switches to a portable layout: scripts, logs, +/// and settings stay under that folder. /// public static class HostPaths { public const string ProductFolder = "UScheduler"; public const string Manufacturer = UserSettingsPath.Manufacturer; + public const string PortableMarkerFileName = "portable"; + public const string PortableScriptsFolderName = "Scripts"; + public const string PortableLogsFolderName = "Logs"; + public const string PortableDataFolderName = "Data"; + public const string PortableMarkerContent = "MaksIT.UScheduler portable layout"; public static string SharedSettingsFile => UserSettingsPath.GetShared(ProductFolder); @@ -53,21 +61,111 @@ public static class HostPaths { public static string DefaultDataRoot => OperatingSystem.IsWindows() ? @"C:\MaksIT" : "/var/lib/maksit"; + public static bool IsPortableLayout(string? startDirectory = null) => + FindPortableRoot(startDirectory) is not null; + /// - /// Scripts folder to use at runtime: configured path if it exists, then - /// , then a bundled/dev tree. + /// Directory that contains , walking up + /// from (or the current app base). /// - public static string ResolveScriptsDirectory(string? configured = null) { + public static string? FindPortableRoot(string? startDirectory = null) { + var current = startDirectory ?? AppContext.BaseDirectory; + if (string.IsNullOrWhiteSpace(current)) + return null; + + try { + current = Path.GetFullPath(current); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { + return null; + } + + if (File.Exists(current)) + current = Path.GetDirectoryName(current) ?? current; + + for (var i = 0; i < 10 && !string.IsNullOrEmpty(current); i++) { + if (File.Exists(Path.Combine(current, PortableMarkerFileName))) + return current; + + current = Directory.GetParent(current)?.FullName; + } + + return null; + } + + public static string GetPortableScriptsDirectory(string portableRoot) => + Path.Combine(portableRoot, PortableScriptsFolderName); + + public static string GetPortableLogDirectory(string portableRoot) => + Path.Combine(portableRoot, PortableLogsFolderName); + + public static string GetPortableDataDirectory(string portableRoot) => + Path.Combine(portableRoot, PortableDataFolderName); + + public static string GetPortableSharedSettingsFile(string portableRoot) => + Path.Combine(GetPortableDataDirectory(portableRoot), "settings.json"); + + public static string GetPortableUiSettingsFile(string portableRoot) => + Path.Combine(GetPortableDataDirectory(portableRoot), "ui-settings.json"); + + public static string ResolveSharedSettingsFile(string? startDirectory = null) { + var root = FindPortableRoot(startDirectory); + return root is null ? SharedSettingsFile : GetPortableSharedSettingsFile(root); + } + + public static string ResolveUiSettingsFile(string? startDirectory = null) { + var root = FindPortableRoot(startDirectory); + return root is null ? UserSettingsPath.Get(ProductFolder) : GetPortableUiSettingsFile(root); + } + + public static void WritePortableMarker(string portableRoot) { + Directory.CreateDirectory(portableRoot); + var path = Path.Combine(portableRoot, PortableMarkerFileName); + if (!File.Exists(path)) + File.WriteAllText(path, PortableMarkerContent + Environment.NewLine); + } + + /// + /// When a portable root is present, replace machine-wide default script/log + /// paths with folders under that root. Custom paths are left unchanged. + /// + public static void ApplyPortableDefaults(Configuration configuration, string? startDirectory = null) { + ArgumentNullException.ThrowIfNull(configuration); + var root = FindPortableRoot(startDirectory); + if (root is null) + return; + + if (string.IsNullOrWhiteSpace(configuration.LogDir) || PathsEqual(configuration.LogDir, DefaultLogDirectory)) + configuration.LogDir = GetPortableLogDirectory(root); + + if (string.IsNullOrWhiteSpace(configuration.ScriptsDir) || PathsEqual(configuration.ScriptsDir, DefaultScriptsDirectory)) + configuration.ScriptsDir = GetPortableScriptsDirectory(root); + } + + /// + /// Scripts folder to use at runtime: configured path if it exists, then a + /// portable Scripts folder, then , + /// then a bundled/dev tree. + /// + public static string ResolveScriptsDirectory(string? configured = null, string? startDirectory = null) { + var start = startDirectory ?? AppContext.BaseDirectory; if (!string.IsNullOrWhiteSpace(configured)) { - var resolved = PathHelper.ResolvePath(configured); + var resolved = PathHelper.ResolvePath(configured, start); if (Directory.Exists(resolved)) return resolved; } + var portableRoot = FindPortableRoot(start); + if (portableRoot is not null) { + var portableScripts = GetPortableScriptsDirectory(portableRoot); + if (Directory.Exists(portableScripts)) + return portableScripts; + } + if (Directory.Exists(DefaultScriptsDirectory)) return DefaultScriptsDirectory; - var bundled = FindBundledScriptsDirectory(); + var bundled = FindBundledScriptsDirectory(start); return bundled ?? DefaultScriptsDirectory; } @@ -75,8 +173,8 @@ public static class HostPaths { var current = startDirectory ?? AppContext.BaseDirectory; for (var i = 0; i < 10 && !string.IsNullOrEmpty(current); i++) { foreach (var candidate in new[] { - Path.Combine(current, "Scripts"), - Path.Combine(current, "src", "Scripts") + Path.Combine(current, PortableScriptsFolderName), + Path.Combine(current, "src", PortableScriptsFolderName) }) { if (LooksLikeScriptsRoot(candidate)) return candidate; @@ -140,6 +238,12 @@ public static class HostPaths { return null; } + internal static bool PathsEqual(string left, string right) => + string.Equals( + Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + private static bool LooksLikeScriptsRoot(string path) => Directory.Exists(path) && (Directory.Exists(Path.Combine(path, "File-Sync")) diff --git a/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs index 10c677c..a22819d 100644 --- a/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs +++ b/src/MaksIT.UScheduler.Tests/ConfigurationFileServiceTests.cs @@ -173,6 +173,105 @@ public class HostPathsTests { path); } + [Fact] + public void FindPortableRoot_returns_null_without_marker() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-no-portable-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try { + Assert.Null(HostPaths.FindPortableRoot(root)); + Assert.False(HostPaths.IsPortableLayout(root)); + } + finally { + Directory.Delete(root, true); + } + } + + [Fact] + public void FindPortableRoot_finds_marker_in_start_directory() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-portable-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + File.WriteAllText(Path.Combine(root, HostPaths.PortableMarkerFileName), HostPaths.PortableMarkerContent); + try { + Assert.Equal(Path.GetFullPath(root), HostPaths.FindPortableRoot(root)); + Assert.True(HostPaths.IsPortableLayout(root)); + Assert.Equal( + Path.GetFullPath(Path.Combine(root, "Data", "settings.json")), + Path.GetFullPath(HostPaths.ResolveSharedSettingsFile(root))); + Assert.Equal( + Path.GetFullPath(Path.Combine(root, "Data", "ui-settings.json")), + Path.GetFullPath(HostPaths.ResolveUiSettingsFile(root))); + } + finally { + Directory.Delete(root, true); + } + } + + [Fact] + public void FindPortableRoot_walks_up_to_parent_marker() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-portable-parent-{Guid.NewGuid():N}"); + var worker = Path.Combine(root, "MaksIT.UScheduler"); + Directory.CreateDirectory(worker); + File.WriteAllText(Path.Combine(root, HostPaths.PortableMarkerFileName), HostPaths.PortableMarkerContent); + try { + Assert.Equal(Path.GetFullPath(root), HostPaths.FindPortableRoot(worker)); + } + finally { + Directory.Delete(root, true); + } + } + + [Fact] + public void ApplyPortableDefaults_replaces_machine_wide_paths() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-portable-defaults-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + File.WriteAllText(Path.Combine(root, HostPaths.PortableMarkerFileName), HostPaths.PortableMarkerContent); + try { + var config = new Configuration { + LogDir = HostPaths.DefaultLogDirectory, + ScriptsDir = HostPaths.DefaultScriptsDirectory + }; + HostPaths.ApplyPortableDefaults(config, root); + Assert.Equal(Path.GetFullPath(Path.Combine(root, "Logs")), Path.GetFullPath(config.LogDir)); + Assert.Equal(Path.GetFullPath(Path.Combine(root, "Scripts")), Path.GetFullPath(config.ScriptsDir)); + } + finally { + Directory.Delete(root, true); + } + } + + [Fact] + public void Copies_seed_into_portable_data_folder_with_local_paths() { + var root = Path.Combine(Path.GetTempPath(), $"uscheduler-portable-seed-{Guid.NewGuid():N}"); + var data = Path.Combine(root, "Data"); + Directory.CreateDirectory(data); + File.WriteAllText(Path.Combine(root, HostPaths.PortableMarkerFileName), HostPaths.PortableMarkerContent); + var seed = Path.Combine(root, "appsettings.json"); + var user = Path.Combine(data, "settings.json"); + var defaultLog = HostPaths.DefaultLogDirectory.Replace("\\", "\\\\", StringComparison.Ordinal); + var defaultScripts = HostPaths.DefaultScriptsDirectory.Replace("\\", "\\\\", StringComparison.Ordinal); + File.WriteAllText(seed, $$""" + { + "Logging": { "LogLevel": { "Default": "Information" } }, + "Configuration": { + "LogDir": "{{defaultLog}}", + "ScriptsDir": "{{defaultScripts}}", + "Powershell": [ { "Path": "File-Sync\\file-sync.ps1", "Disabled": true } ] + } + } + """); + + try { + var service = new ConfigurationFileService(user, seed); + Assert.True(File.Exists(user)); + Assert.Equal(Path.GetFullPath(Path.Combine(root, "Logs")), Path.GetFullPath(service.Current.LogDir)); + Assert.Equal(Path.GetFullPath(Path.Combine(root, "Scripts")), Path.GetFullPath(service.Current.ScriptsDir)); + Assert.Single(service.Current.Powershell); + } + finally { + Directory.Delete(root, true); + } + } + [Fact] public void FindBundledScriptsDirectory_finds_scripts_next_to_start() { var root = Path.Combine(Path.GetTempPath(), $"uscheduler-bundled-{Guid.NewGuid():N}"); diff --git a/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs b/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs index f4aeeb7..6ec48c0 100644 --- a/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs +++ b/src/MaksIT.UScheduler.Tests/HostDataDirectoriesTests.cs @@ -99,4 +99,55 @@ public class HostDataDirectoriesTests { Directory.Delete(root, true); } } + + [Fact] + public void ResolveLayout_standard_uses_machine_wide_paths() { + var install = Path.Combine(Path.GetTempPath(), $"uscheduler-layout-std-{Guid.NewGuid():N}"); + Directory.CreateDirectory(install); + try { + var layout = HostDataDirectories.ResolveLayout(install); + Assert.False(layout.Portable); + Assert.Equal(HostPaths.DefaultDataRoot, layout.Root); + Assert.Equal(HostPaths.DefaultScriptsDirectory, layout.ScriptsDirectory); + Assert.Equal(HostPaths.DefaultLogDirectory, layout.LogDirectory); + Assert.Equal(HostPaths.SharedSettingsFile, layout.SharedSettingsFile); + } + finally { + Directory.Delete(install, true); + } + } + + [Fact] + public void ResolveLayout_portable_requested_keeps_everything_under_install_folder() { + var install = Path.Combine(Path.GetTempPath(), $"uscheduler-layout-port-{Guid.NewGuid():N}"); + Directory.CreateDirectory(install); + try { + var layout = HostDataDirectories.ResolveLayout(install, portableRequested: true); + Assert.True(layout.Portable); + Assert.Equal(Path.GetFullPath(install), Path.GetFullPath(layout.Root)); + Assert.Equal(Path.GetFullPath(Path.Combine(install, "Scripts")), Path.GetFullPath(layout.ScriptsDirectory)); + Assert.Equal(Path.GetFullPath(Path.Combine(install, "Logs")), Path.GetFullPath(layout.LogDirectory)); + Assert.Equal( + Path.GetFullPath(Path.Combine(install, "Data", "settings.json")), + Path.GetFullPath(layout.SharedSettingsFile)); + } + finally { + Directory.Delete(install, true); + } + } + + [Fact] + public void ResolveLayout_detects_existing_portable_marker() { + var install = Path.Combine(Path.GetTempPath(), $"uscheduler-layout-mark-{Guid.NewGuid():N}"); + Directory.CreateDirectory(install); + File.WriteAllText(Path.Combine(install, HostPaths.PortableMarkerFileName), HostPaths.PortableMarkerContent); + try { + var layout = HostDataDirectories.ResolveLayout(install); + Assert.True(layout.Portable); + Assert.Equal(Path.GetFullPath(install), Path.GetFullPath(layout.Root)); + } + finally { + Directory.Delete(install, true); + } + } } diff --git a/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj b/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj index 43e22d3..81a5462 100644 --- a/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj +++ b/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj @@ -4,7 +4,7 @@ WinExe net10.0 MaksIT.UScheduler.UI - 1.1.1 + 1.2.0 enable enable true @@ -14,10 +14,10 @@ - - - - + + + + diff --git a/src/MaksIT.UScheduler.UI/Program.cs b/src/MaksIT.UScheduler.UI/Program.cs index 8e4de6a..35e5a55 100644 --- a/src/MaksIT.UScheduler.UI/Program.cs +++ b/src/MaksIT.UScheduler.UI/Program.cs @@ -9,6 +9,8 @@ internal static class Program { public static void Main(string[] args) => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + // Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's + // xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon). public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() diff --git a/src/MaksIT.UScheduler.UI/Services/AppSettingsService.cs b/src/MaksIT.UScheduler.UI/Services/AppSettingsService.cs index c5b9d22..a3eccf8 100644 --- a/src/MaksIT.UScheduler.UI/Services/AppSettingsService.cs +++ b/src/MaksIT.UScheduler.UI/Services/AppSettingsService.cs @@ -5,7 +5,8 @@ using MaksIT.UScheduler.Shared.Helpers; namespace MaksIT.UScheduler.UI.Services; /// -/// Loads and saves machine-wide service configuration (ProgramData settings.json). +/// Loads and saves machine-wide service configuration (ProgramData settings.json, +/// or the portable Data folder). /// Shipped appsettings.json next to the worker is seed-only. /// public class AppSettingsService { @@ -24,10 +25,10 @@ public class AppSettingsService { if (string.IsNullOrEmpty(seed) && !string.IsNullOrEmpty(serviceDirectory)) seed = Path.Combine(serviceDirectory, ConfigurationFileService.SeedFileName); - _files = new ConfigurationFileService(HostPaths.SharedSettingsFile, seed); + _files = new ConfigurationFileService(HostPaths.ResolveSharedSettingsFile(serviceDirectory), seed); var config = _files.Current; config.EnsureDefaults(); - ScriptsDirectory = config.GetEffectiveScriptsDirectory(); + ScriptsDirectory = config.GetEffectiveScriptsDirectory(_serviceDirectory); return config; } @@ -37,7 +38,7 @@ public class AppSettingsService { try { _files.Save(configuration); - ScriptsDirectory = configuration.GetEffectiveScriptsDirectory(); + ScriptsDirectory = configuration.GetEffectiveScriptsDirectory(_serviceDirectory); return true; } catch { diff --git a/src/MaksIT.UScheduler.UI/Services/UISettingsService.cs b/src/MaksIT.UScheduler.UI/Services/UISettingsService.cs index 16a54d5..78f7d4b 100644 --- a/src/MaksIT.UScheduler.UI/Services/UISettingsService.cs +++ b/src/MaksIT.UScheduler.UI/Services/UISettingsService.cs @@ -7,7 +7,8 @@ using MaksIT.UScheduler.Shared.Helpers; namespace MaksIT.UScheduler.UI.Services; /// -/// Per-user UI prefs stored in roaming AppData (not next to the exe). +/// Per-user UI prefs stored in roaming AppData (not next to the exe), or under +/// the portable Data folder when a portable layout is used. /// public class UISettings { /// @@ -18,17 +19,19 @@ public class UISettings { } /// -/// Service for loading and saving UI settings under the current user's AppData. -/// Machine-wide schedule configuration lives in ProgramData via -/// . +/// Service for loading and saving UI settings under the current user's AppData +/// (or the portable Data folder). Machine-wide schedule configuration lives in +/// ProgramData (or that same Data folder) via . /// public class UISettingsService { public const string ProductFolder = HostPaths.ProductFolder; private readonly string _settingsFilePath; - public UISettingsService() { - _settingsFilePath = UserSettingsPath.Get(ProductFolder); + public UISettingsService(string? settingsFilePath = null) { + _settingsFilePath = string.IsNullOrWhiteSpace(settingsFilePath) + ? HostPaths.ResolveUiSettingsFile() + : settingsFilePath; } public UISettings Load() { diff --git a/src/MaksIT.UScheduler.UI/ViewModels/MainViewModel.cs b/src/MaksIT.UScheduler.UI/ViewModels/MainViewModel.cs index 5f7b5c0..d68b9f8 100644 --- a/src/MaksIT.UScheduler.UI/ViewModels/MainViewModel.cs +++ b/src/MaksIT.UScheduler.UI/ViewModels/MainViewModel.cs @@ -78,7 +78,8 @@ public partial class MainViewModel : ObservableObject { var seedPath = string.IsNullOrEmpty(bin) ? null : Path.Combine(bin, ConfigurationFileService.SeedFileName); - var sharedExists = File.Exists(HostPaths.SharedSettingsFile); + var sharedSettingsPath = HostPaths.ResolveSharedSettingsFile(bin); + var sharedExists = File.Exists(sharedSettingsPath); var seedExists = !string.IsNullOrEmpty(seedPath) && File.Exists(seedPath); if (!sharedExists && !seedExists) { @@ -86,8 +87,8 @@ public partial class MainViewModel : ObservableObject { AppSettingsLoadError = "Set Service Bin Path to the worker folder (seed appsettings.json), or register the service to create shared settings."; ServiceName = string.Empty; LogDirectory = string.Empty; - ScriptsDirectory = HostPaths.DefaultScriptsDirectory; - SharedSettingsPath = HostPaths.SharedSettingsFile; + ScriptsDirectory = HostPaths.ResolveScriptsDirectory(startDirectory: bin); + SharedSettingsPath = string.IsNullOrEmpty(bin) ? HostPaths.ResolveSharedSettingsFile() : sharedSettingsPath; ProcessList.Clear(); return; } @@ -98,8 +99,8 @@ public partial class MainViewModel : ObservableObject { _serviceManager = new HostServiceManager(_serviceConfig.ServiceName); ServiceName = _serviceConfig.ServiceName; LogDirectory = _serviceConfig.GetEffectiveLogDirectory(bin); - ScriptsDirectory = _serviceConfig.GetEffectiveScriptsDirectory(); - SharedSettingsPath = _appSettingsService.SettingsPath ?? HostPaths.SharedSettingsFile; + ScriptsDirectory = _serviceConfig.GetEffectiveScriptsDirectory(bin); + SharedSettingsPath = _appSettingsService.SettingsPath ?? HostPaths.ResolveSharedSettingsFile(bin); ProcessList.Clear(); foreach (var p in _serviceConfig.Processes) ProcessList.Add(p); @@ -108,8 +109,8 @@ public partial class MainViewModel : ObservableObject { AppSettingsLoadError = "Failed to load shared settings. Register the service once to create writable data folders."; ServiceName = string.Empty; LogDirectory = string.Empty; - ScriptsDirectory = HostPaths.DefaultScriptsDirectory; - SharedSettingsPath = HostPaths.SharedSettingsFile; + ScriptsDirectory = HostPaths.ResolveScriptsDirectory(startDirectory: bin); + SharedSettingsPath = HostPaths.ResolveSharedSettingsFile(bin); ProcessList.Clear(); } } @@ -132,7 +133,7 @@ public partial class MainViewModel : ObservableObject { private string _scriptsDirectory = HostPaths.DefaultScriptsDirectory; [ObservableProperty] - private string _sharedSettingsPath = HostPaths.SharedSettingsFile; + private string _sharedSettingsPath = HostPaths.ResolveSharedSettingsFile(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasAppSettingsLoadError))] @@ -205,7 +206,7 @@ public partial class MainViewModel : ObservableObject { if (string.IsNullOrWhiteSpace(ResolvedExecutablePath) || !File.Exists(ResolvedExecutablePath)) { await _dialogs.ShowMessageAsync( "Register Service", - "Could not find MaksIT.UScheduler.exe. Set Service Bin Path to the Program Files install folder."); + "Could not find MaksIT.UScheduler.exe. Set Service Bin Path to the worker install folder."); return; } diff --git a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj index adc2e14..f94de6f 100644 --- a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj +++ b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj @@ -3,7 +3,7 @@ net10.0 $(MSBuildProjectName.Replace(" ", "_")) - 1.1.1 + 1.2.0 enable enable dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66 diff --git a/src/MaksIT.UScheduler/Program.cs b/src/MaksIT.UScheduler/Program.cs index 3b14aa9..055ce50 100644 --- a/src/MaksIT.UScheduler/Program.cs +++ b/src/MaksIT.UScheduler/Program.cs @@ -9,18 +9,20 @@ using MaksIT.UScheduler.BackgroundServices; var basePath = AppContext.BaseDirectory; var seedPath = Path.Combine(basePath, ConfigurationFileService.SeedFileName); -var settingsPath = HostPaths.SharedSettingsFile; +var portableRequested = HasFlag(args, "--portable"); if (args.Length > 0) { var early = args[0].ToLowerInvariant(); if (early is "--install" or "-i" or "--prepare-data") { - var prepared = HostDataDirectories.Prepare(basePath); + var prepared = HostDataDirectories.Prepare(basePath, portableRequested); Console.WriteLine(prepared.Message); if (!prepared.Success) return 1; } } +var settingsPath = HostPaths.ResolveSharedSettingsFile(basePath); + _ = new ConfigurationFileService(settingsPath, seedPath); var configurationRoot = new ConfigurationBuilder() @@ -63,7 +65,7 @@ if (args.Length > 0) { return GetServiceStatus(serviceName); case "--prepare-data": - return PrepareDataDirectories(basePath); + return PrepareDataDirectories(basePath, portableRequested); case "--help": case "-h": @@ -123,11 +125,12 @@ static void PrintHelp(string name) => --start Start the service --stop Stop the service --status Query service status - --prepare-data Create C:\MaksIT\Scripts, C:\MaksIT\Logs, and shared settings (elevated) + --prepare-data Create scripts, logs, and shared settings (elevated) + --portable With --install / --prepare-data: keep data in the install folder --help, -h Show this help message Service Name: {name} - Config File: {HostPaths.SharedSettingsFile} + Config File: {HostPaths.ResolveSharedSettingsFile()} Seed File: appsettings.json (next to the executable, logging + first-run seed) Note: Install/uninstall/prepare-data typically require administrator / root privileges. @@ -187,14 +190,19 @@ static int InstallService(string name, string exePath, string description) { return 1; } -static int PrepareDataDirectories(string installDirectory) { +static int PrepareDataDirectories(string installDirectory, bool portable) { + var layout = HostDataDirectories.ResolveLayout(installDirectory, portable); Console.WriteLine($"Install: {installDirectory}"); - Console.WriteLine($"Settings: {HostPaths.SharedSettingsFile}"); - Console.WriteLine($"Scripts: {HostPaths.DefaultScriptsDirectory}"); - Console.WriteLine($"Logs: {HostPaths.DefaultLogDirectory}"); + Console.WriteLine($"Type: {(layout.Portable ? "portable" : "standard")}"); + Console.WriteLine($"Settings: {layout.SharedSettingsFile}"); + Console.WriteLine($"Scripts: {layout.ScriptsDirectory}"); + Console.WriteLine($"Logs: {layout.LogDirectory}"); return 0; } +static bool HasFlag(string[] args, string name) => + args.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase)); + static int UninstallService(string name) { if (OperatingSystem.IsWindows()) return UninstallWindowsService(name); diff --git a/utils/engines/release/custom/BundleCustomization.psm1 b/utils/engines/release/custom/BundleCustomization.psm1 index 6be446b..cb47527 100644 --- a/utils/engines/release/custom/BundleCustomization.psm1 +++ b/utils/engines/release/custom/BundleCustomization.psm1 @@ -245,7 +245,9 @@ DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) exec "$DIR/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI" "$@" '@ Set-Content -Path $shPath -Value $shBody -Encoding utf8 - Write-Log -Level "OK" -Message " Created launchers." + $portableMarkerPath = Join-Path $bundleDirectory "portable" + Set-Content -Path $portableMarkerPath -Value "MaksIT.UScheduler portable layout" -Encoding ascii + Write-Log -Level "OK" -Message " Created launchers and portable marker." $installerPayload = if ($pluginSettings.PSObject.Properties['installerPayloadDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.installerPayloadDir)) { Resolve-PluginPath -Path ([string]$pluginSettings.installerPayloadDir) -BasePath $scriptDir diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index aac89d1..a5ac627 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft-07/schema", "title": "MaksIT.UScheduler Release Script Settings", - "description": "Community desktop pipeline. GitHub assets are siblings: portable zip (win-x64 worker, UI, Scripts), Windows setup exe (Avalonia UI), Flatpak (Avalonia UI). The zip never contains the installer or Flatpak.", + "description": "Community desktop pipeline. GitHub assets are siblings: portable zip (win-x64 worker, UI, Scripts, portable marker), Windows setup exe (Avalonia UI; Standard or Portable layout), Flatpak (Avalonia UI). The zip never contains the installer or Flatpak.", "plugins": [ { "name": "DotNetReleaseVersion", @@ -88,15 +88,28 @@ "name": "FlatpakPack", "stageLabel": "build", "enabled": true, - "appId": "com.maks_it.UScheduler", + "appId": "com.maks_it.uscheduler", "appName": "MaksIT UScheduler", "command": "maksit-uscheduler", "executableName": "MaksIT.UScheduler.UI", "runtimeIdentifier": "linux-x64", "publishDir": "..\\..\\..\\releases\\MaksIT.UScheduler.UI\\linux-x64", "summary": "Schedule PowerShell scripts and processes", - "projectLicense": "MIT", + "description": "Schedule and run PowerShell scripts and console processes from an Avalonia desktop UI.", + "projectLicense": "Apache-2.0", + "categories": "Utility;System;", "iconPath": "..\\..\\..\\src\\MaksIT.UScheduler.UI\\Assets\\icon.png", + "metainfoPath": "..\\..\\..\\data\\com.maks_it.uscheduler.metainfo.xml", + "desktopPath": "..\\..\\..\\data\\com.maks_it.uscheduler.desktop", + "finishArgs": [ + "--share=ipc", + "--share=network", + "--socket=wayland", + "--socket=x11", + "--socket=pulseaudio", + "--device=dri", + "--filesystem=home" + ], "bundleNamePattern": "maksit-uscheduler-{version}.flatpak", "buildBundle": true, "attachSourceZip": false, diff --git a/utils/plugins/Desktop/DesktopPackSupport.psm1 b/utils/plugins/Desktop/DesktopPackSupport.psm1 index 9375ebf..7277fde 100644 --- a/utils/plugins/Desktop/DesktopPackSupport.psm1 +++ b/utils/plugins/Desktop/DesktopPackSupport.psm1 @@ -283,13 +283,7 @@ function New-WixPackageXml { [string]$Architecture = 'x64', [Parameter(Mandatory = $false)] - [string]$IconPath, - - [Parameter(Mandatory = $false)] - [string]$PostInstallFileName, - - [Parameter(Mandatory = $false)] - [string]$PostInstallArguments + [string]$IconPath ) $ns = 'http://wixtoolset.org/schemas/v4/wxs' @@ -377,7 +371,6 @@ function New-WixPackageXml { '' = $installFolder } $componentIds = [System.Collections.Generic.List[string]]::new() - $postInstallFileId = $null $publishRoot = [System.IO.Path]::GetFullPath($PublishDirectory) $files = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File) @@ -422,12 +415,6 @@ function New-WixPackageXml { $null = $fileNode.SetAttribute('KeyPath', 'yes') $null = $component.AppendChild($fileNode) $null = $parent.AppendChild($component) - - if (-not [string]::IsNullOrWhiteSpace($PostInstallFileName) ` - -and [string]::IsNullOrWhiteSpace($relativeDir) ` - -and [string]::Equals($file.Name, $PostInstallFileName, [System.StringComparison]::OrdinalIgnoreCase)) { - $postInstallFileId = $fileNode.GetAttribute('Id') - } } $exeName = [System.IO.Path]::GetFileName($ExecutablePath) @@ -502,34 +489,6 @@ function New-WixPackageXml { $null = $package.AppendChild($feature) - if (-not [string]::IsNullOrWhiteSpace($PostInstallFileName)) { - if ([string]::IsNullOrWhiteSpace($postInstallFileId)) { - throw "WindowsInstaller postInstallExecutableName '$PostInstallFileName' was not harvested from $publishRoot." - } - - if ([string]::IsNullOrWhiteSpace($PostInstallArguments)) { - throw "WindowsInstaller postInstallArguments is required when postInstallExecutableName is set." - } - - $ca = $xml.CreateElement('CustomAction', $ns) - $null = $ca.SetAttribute('Id', 'PostInstallPrepareData') - $null = $ca.SetAttribute('FileRef', $postInstallFileId) - $arguments = if ($null -eq $PostInstallArguments) { '' } else { $PostInstallArguments } - $null = $ca.SetAttribute('ExeCommand', $arguments) - $null = $ca.SetAttribute('Execute', 'deferred') - $null = $ca.SetAttribute('Impersonate', 'no') - $null = $ca.SetAttribute('Return', 'check') - $null = $package.AppendChild($ca) - - $seq = $xml.CreateElement('InstallExecuteSequence', $ns) - $custom = $xml.CreateElement('Custom', $ns) - $null = $custom.SetAttribute('Action', 'PostInstallPrepareData') - $null = $custom.SetAttribute('After', 'InstallFiles') - $null = $custom.SetAttribute('Condition', 'NOT REMOVE') - $null = $seq.AppendChild($custom) - $null = $package.AppendChild($seq) - } - return $xml } @@ -537,9 +496,10 @@ function Get-DefaultFlatpakFinishArgs { return @( '--share=ipc', '--share=network', - '--socket=fallback-x11', + '--socket=x11', '--socket=wayland', '--device=dri', + '--socket=pulseaudio', '--filesystem=home' ) } @@ -565,12 +525,118 @@ function New-FlatpakDesktopEntry { "Name=$AppName", "Exec=$Command", "Icon=$AppId", + "StartupWMClass=$AppId", + 'StartupNotify=true', 'Terminal=false', "Categories=$Categories" ) return (($lines -join "`n") + "`n") } +function Copy-FlatpakHicolorIcons { + param( + [Parameter(Mandatory = $true)] + [string]$AppId, + + [Parameter(Mandatory = $true)] + [string]$HicolorRoot, + + [Parameter(Mandatory = $false)] + [string]$IconPath, + + [Parameter(Mandatory = $false)] + [string]$SvgIconPath + ) + + $pngSource = $null + $svgSource = $null + + if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) { + $ext = [System.IO.Path]::GetExtension($IconPath) + if ($ext -ieq '.png') { + $pngSource = $IconPath + } + elseif ($ext -ieq '.svg') { + $svgSource = $IconPath + } + } + + if (-not [string]::IsNullOrWhiteSpace($SvgIconPath) -and (Test-Path -LiteralPath $SvgIconPath -PathType Leaf)) { + $svgSource = $SvgIconPath + } + + if ([string]::IsNullOrWhiteSpace($svgSource) -and -not [string]::IsNullOrWhiteSpace($pngSource)) { + $siblingSvg = [System.IO.Path]::ChangeExtension($pngSource, '.svg') + if (Test-Path -LiteralPath $siblingSvg -PathType Leaf) { + $svgSource = $siblingSvg + } + } + + if ([string]::IsNullOrWhiteSpace($pngSource) -and -not [string]::IsNullOrWhiteSpace($svgSource)) { + $siblingPng = [System.IO.Path]::ChangeExtension($svgSource, '.png') + if (Test-Path -LiteralPath $siblingPng -PathType Leaf) { + $pngSource = $siblingPng + } + } + + $installed = $false + + if (-not [string]::IsNullOrWhiteSpace($pngSource)) { + foreach ($size in @('48x48', '64x64', '128x128', '256x256')) { + $pngDir = Join-Path $HicolorRoot "$size\apps" + New-Item -ItemType Directory -Path $pngDir -Force | Out-Null + Copy-Item -LiteralPath $pngSource -Destination (Join-Path $pngDir "$AppId.png") -Force + } + + $installed = $true + } + + if (-not [string]::IsNullOrWhiteSpace($svgSource)) { + $svgDir = Join-Path $HicolorRoot 'scalable\apps' + New-Item -ItemType Directory -Path $svgDir -Force | Out-Null + Copy-Item -LiteralPath $svgSource -Destination (Join-Path $svgDir "$AppId.svg") -Force + $installed = $true + } + + return $installed +} + +function ConvertTo-FlatpakMetainfoDescriptionXml { + param( + [Parameter(Mandatory = $true)] + [string]$AppName, + + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Summary, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$Description + ) + + $text = $Description + if ([string]::IsNullOrWhiteSpace($text)) { + $text = "$AppName is a desktop application. $Summary".Trim() + } + + $paragraphs = [System.Collections.Generic.List[string]]::new() + foreach ($chunk in @($text -split '(\r?\n){2,}')) { + $trimmed = $chunk.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + continue + } + + $paragraphs.Add('

' + [System.Security.SecurityElement]::Escape($trimmed) + '

') + } + + if ($paragraphs.Count -eq 0) { + $paragraphs.Add('

' + [System.Security.SecurityElement]::Escape("$AppName is a desktop application.") + '

') + } + + return ($paragraphs -join "`n") +} + function New-FlatpakMetainfoXml { param( [Parameter(Mandatory = $true)] @@ -582,26 +648,124 @@ function New-FlatpakMetainfoXml { [Parameter(Mandatory = $true)] [string]$Summary, + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$Description = '', + [Parameter(Mandatory = $false)] [string]$ProjectLicense = 'MIT', [Parameter(Mandatory = $false)] - [string]$MetadataLicense = 'CC0-1.0' + [string]$MetadataLicense = 'CC0-1.0', + + [Parameter(Mandatory = $false)] + [string]$Version = '' ) $escapedName = [System.Security.SecurityElement]::Escape($AppName) $escapedSummary = [System.Security.SecurityElement]::Escape($Summary) - return @" + $descriptionXml = ConvertTo-FlatpakMetainfoDescriptionXml -AppName $AppName -Summary $Summary -Description $Description + $xml = @" $AppId $escapedName $escapedSummary + +$descriptionXml + $AppId.desktop $MetadataLicense $ProjectLicense "@ + if ([string]::IsNullOrWhiteSpace($Version)) { + return $xml + } + + return Set-FlatpakMetainfoRelease -XmlText $xml -Version $Version +} + +function Set-FlatpakMetainfoRelease { + param( + [Parameter(Mandatory = $true)] + [string]$XmlText, + + [Parameter(Mandatory = $true)] + [string]$Version, + + [Parameter(Mandatory = $false)] + [string]$Date = '' + ) + + if ([string]::IsNullOrWhiteSpace($Version)) { + throw "Set-FlatpakMetainfoRelease requires Version." + } + + if ([string]::IsNullOrWhiteSpace($Date)) { + $Date = [datetime]::UtcNow.ToString('yyyy-MM-dd') + } + + $doc = New-Object System.Xml.XmlDocument + $doc.PreserveWhitespace = $false + $doc.LoadXml($XmlText) + $component = $doc.SelectSingleNode('/component') + if ($null -eq $component) { + throw "Flatpak metainfo has no root." + } + + $releases = $component.SelectSingleNode('releases') + if ($null -eq $releases) { + $releases = $doc.CreateElement('releases') + $null = $component.AppendChild($releases) + } + + $match = $null + foreach ($node in @($releases.SelectNodes('release'))) { + if ([string]$node.GetAttribute('version') -eq $Version) { + $match = $node + break + } + } + + $first = $releases.SelectSingleNode('release') + if ($null -ne $match) { + $match.SetAttribute('date', $Date) + if (-not [object]::ReferenceEquals($match, $first)) { + $null = $releases.InsertBefore($match, $first) + } + } + else { + $release = $doc.CreateElement('release') + $release.SetAttribute('version', $Version) + $release.SetAttribute('date', $Date) + if ($null -eq $first) { + $null = $releases.AppendChild($release) + } + else { + $null = $releases.InsertBefore($release, $first) + } + } + + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Indent = $true + $settings.OmitXmlDeclaration = $false + $settings.Encoding = [System.Text.UTF8Encoding]::new($false) + $stream = New-Object System.IO.MemoryStream + try { + $writer = [System.Xml.XmlWriter]::Create($stream, $settings) + try { + $doc.Save($writer) + } + finally { + $writer.Dispose() + } + + return [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) + } + finally { + $stream.Dispose() + } } function New-FlatpakManifestObject { @@ -638,8 +802,8 @@ function New-FlatpakManifestObject { if ($null -eq $BuildCommands -or $BuildCommands.Count -eq 0) { $BuildCommands = @( 'mkdir -p /app/lib /app/share', - 'cp -a lib /app/lib', - 'cp -a share /app/share', + 'cp -a lib/. /app/lib/', + 'cp -a share/. /app/share/', "install -Dm755 bin/$Command /app/bin/$Command", "chmod +x /app/lib/$ModuleName/$Command" ) @@ -677,7 +841,15 @@ function New-FlatpakLaunchScript { [string]$ExecutableFileName ) - return "#!/bin/sh`nexec /app/lib/$ModuleName/$ExecutableFileName `"`$@`"`n" + $lib = "/app/lib/$ModuleName" + return @" +#!/bin/sh +LIB='$lib' +if [ -f "`$LIB/flatpak-native.env" ]; then + . "`$LIB/flatpak-native.env" +fi +exec `$LIB/$ExecutableFileName "`$`@" +"@ } function Assert-FlatpakAppId { @@ -1061,7 +1233,9 @@ Export-ModuleMember -Function ` New-WixBundleXml, ` Get-DefaultFlatpakFinishArgs, ` New-FlatpakDesktopEntry, ` + Copy-FlatpakHicolorIcons, ` New-FlatpakMetainfoXml, ` + Set-FlatpakMetainfoRelease, ` New-FlatpakManifestObject, ` New-FlatpakLaunchScript, ` Assert-FlatpakAppId, ` diff --git a/utils/plugins/Desktop/FlatpakPack.psm1 b/utils/plugins/Desktop/FlatpakPack.psm1 index b04338c..f8ee5c0 100644 --- a/utils/plugins/Desktop/FlatpakPack.psm1 +++ b/utils/plugins/Desktop/FlatpakPack.psm1 @@ -13,6 +13,8 @@ (default Debian) on the Linux filesystem, then copies the bundle back. Docker Desktop WSL distros are never used. whenBuilderMissing skip|fail applies when neither native nor WSL builder is available. + AppStream and the bundle file name use the same shared + release version as zip/MSI (DotNetReleaseVersion: csproj or Directory.Build.props). #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -34,6 +36,37 @@ function Get-FlatpakWhenBuilderMissing { return $value } +function Resolve-FlatpakPackOptionalFile { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$Setting, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Setting)) { + return $null + } + + $path = if ([System.IO.Path]::IsPathRooted($Setting)) { + $Setting + } + else { + [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Setting)) + } + + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "FlatpakPack $Name not found: $path" + } + + return $path +} + function Test-FlatpakBuilderMissingException { param( [Parameter(Mandatory = $true)] @@ -188,6 +221,9 @@ function Invoke-Plugin { $scriptDir = $sharedSettings.scriptDir $version = [string]$sharedSettings.version $artifactsDirectory = $sharedSettings.artifactsDirectory + if ([string]::IsNullOrWhiteSpace($version)) { + throw "FlatpakPack requires a release version in the shared context (same as zip/MSI: DotNetReleaseVersion from csproj / Directory.Build.props)." + } $appId = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appId') $appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName') @@ -206,9 +242,13 @@ function Invoke-Plugin { $runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'linux-x64') $publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir') $iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath') + $svgIconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'svgIconPath') $summary = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'summary' -Default $appName) + $description = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'description' -Default '') $projectLicense = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'projectLicense' -Default 'MIT') $categories = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'categories' -Default 'Utility;') + $metainfoSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'metainfoPath' -Default '') + $desktopFileSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'desktopPath' -Default '') $runtime = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtime' -Default 'org.freedesktop.Platform') $runtimeVersion = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeVersion' -Default '24.08') $sdk = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'sdk' -Default 'org.freedesktop.Sdk') @@ -264,8 +304,8 @@ function Invoke-Plugin { $binDir = Join-Path $filesRoot 'bin' $shareAppDir = Join-Path $filesRoot 'share\applications' $shareMetaDir = Join-Path $filesRoot 'share\metainfo' - $shareIconDir = Join-Path $filesRoot 'share\icons\hicolor\256x256\apps' - New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $shareIconDir -Force | Out-Null + $hicolorRoot = Join-Path $filesRoot 'share\icons\hicolor' + New-Item -ItemType Directory -Path $libDir, $binDir, $shareAppDir, $shareMetaDir, $hicolorRoot -Force | Out-Null Write-Log -Level "STEP" -Message "Staging Flatpak layout from $publishDirectory" Copy-Item -Path (Join-Path $publishDirectory '*') -Destination $libDir -Recurse -Force @@ -274,20 +314,56 @@ function Invoke-Plugin { $launchPath = Join-Path $binDir $command [System.IO.File]::WriteAllText($launchPath, $launchScript.Replace("`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) - $desktopPath = Join-Path $shareAppDir "$appId.desktop" - [System.IO.File]::WriteAllText( - $desktopPath, - (New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"), - [System.Text.UTF8Encoding]::new($false) - ) + $desktopDest = Join-Path $shareAppDir "$appId.desktop" + $upstreamDesktop = Resolve-FlatpakPackOptionalFile -ScriptDir $scriptDir -Setting $desktopFileSetting -Name 'desktopPath' + if ($null -ne $upstreamDesktop) { + $desktopText = [System.IO.File]::ReadAllText($upstreamDesktop) -replace "`r`n", "`n" -replace "`r", "`n" + if (-not $desktopText.EndsWith("`n")) { + $desktopText += "`n" + } - $metaPath = Join-Path $shareMetaDir "$appId.metainfo.xml" - [System.IO.File]::WriteAllText( - $metaPath, - (New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -ProjectLicense $projectLicense).Replace("`r`n", "`n"), - [System.Text.UTF8Encoding]::new($false) - ) + [System.IO.File]::WriteAllText($desktopDest, $desktopText, [System.Text.UTF8Encoding]::new($false)) + } + else { + [System.IO.File]::WriteAllText( + $desktopDest, + (New-FlatpakDesktopEntry -AppId $appId -AppName $appName -Command $command -Categories $categories).Replace("`r`n", "`n"), + [System.Text.UTF8Encoding]::new($false) + ) + } + $metaDest = Join-Path $shareMetaDir "$appId.metainfo.xml" + $upstreamMetainfo = Resolve-FlatpakPackOptionalFile -ScriptDir $scriptDir -Setting $metainfoSetting -Name 'metainfoPath' + if ($null -ne $upstreamMetainfo) { + $metaText = [System.IO.File]::ReadAllText($upstreamMetainfo) -replace "`r`n", "`n" -replace "`r", "`n" + if (-not $metaText.EndsWith("`n")) { + $metaText += "`n" + } + + $metaText = $metaText.Replace('{version}', $version) + $metaText = Set-FlatpakMetainfoRelease -XmlText $metaText -Version $version + if (-not $metaText.EndsWith("`n")) { + $metaText += "`n" + } + + [System.IO.File]::WriteAllText($metaDest, $metaText, [System.Text.UTF8Encoding]::new($false)) + } + else { + [System.IO.File]::WriteAllText( + $metaDest, + (New-FlatpakMetainfoXml -AppId $appId -AppName $appName -Summary $summary -Description $description -ProjectLicense $projectLicense -Version $version).Replace("`r`n", "`n"), + [System.Text.UTF8Encoding]::new($false) + ) + } + + $stagedMeta = [System.IO.File]::ReadAllText($metaDest) + if ($stagedMeta -notmatch [regex]::Escape("version=`"$version`"")) { + throw "FlatpakPack failed to stamp AppStream release version '$version' into $metaDest." + } + + Write-Log -Level "OK" -Message " AppStream release version: $version" + + $iconPath = $null if (-not [string]::IsNullOrWhiteSpace($iconSetting)) { $iconPath = if ([System.IO.Path]::IsPathRooted($iconSetting)) { $iconSetting @@ -299,21 +375,39 @@ function Invoke-Plugin { if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) { throw "FlatpakPack iconPath not found: $iconPath" } + } - if ([System.IO.Path]::GetExtension($iconPath) -ine '.png') { - Write-Log -Level "WARN" -Message " Flatpak icon should be PNG; skipping copy of $iconPath" + $svgIconPath = $null + if (-not [string]::IsNullOrWhiteSpace($svgIconSetting)) { + $svgIconPath = if ([System.IO.Path]::IsPathRooted($svgIconSetting)) { + $svgIconSetting } else { - Copy-Item -LiteralPath $iconPath -Destination (Join-Path $shareIconDir "$appId.png") -Force + [System.IO.Path]::GetFullPath((Join-Path $scriptDir $svgIconSetting)) } + + if (-not (Test-Path -LiteralPath $svgIconPath -PathType Leaf)) { + throw "FlatpakPack svgIconPath not found: $svgIconPath" + } + } + + $iconInstalled = Copy-FlatpakHicolorIcons ` + -AppId $appId ` + -HicolorRoot $hicolorRoot ` + -IconPath $iconPath ` + -SvgIconPath $svgIconPath + + if (-not $iconInstalled -and -not [string]::IsNullOrWhiteSpace($iconPath)) { + Write-Log -Level "WARN" -Message " Flatpak icon should be PNG or SVG; skipping copy of $iconPath" } $buildCommands = @( 'mkdir -p /app/lib /app/share', "cp -a lib/$moduleName /app/lib/$moduleName", - 'cp -a share /app/share', + 'cp -a share/. /app/share/', "install -Dm755 bin/$command /app/bin/$command", - "chmod +x /app/lib/$moduleName/$executableFileName" + "chmod +x /app/lib/$moduleName/$executableFileName", + 'gtk-update-icon-cache -f /app/share/icons/hicolor >/dev/null 2>&1 || true' ) $manifest = New-FlatpakManifestObject ` diff --git a/utils/plugins/Desktop/WindowsInstaller.psm1 b/utils/plugins/Desktop/WindowsInstaller.psm1 index ec9e558..ce27788 100644 --- a/utils/plugins/Desktop/WindowsInstaller.psm1 +++ b/utils/plugins/Desktop/WindowsInstaller.psm1 @@ -9,8 +9,6 @@ Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps it in a Burn bootstrapper .exe. The .exe is the GitHub asset; the MSI/WXS stay in a staging folder and are not added to the portable zip. - Optional postInstallExecutableName + postInstallArguments run a deferred - elevated custom action after InstallFiles (used to seed C:\MaksIT\Scripts). `wix build -arch` follows `runtimeIdentifier` (default win-x64 → x64) so per-machine installs go to `C:\Program Files`, not Program Files (x86). Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the @@ -215,9 +213,6 @@ function Invoke-Plugin { $msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi') $bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs') - $postInstallFileName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'postInstallExecutableName' -Default '') - $postInstallArguments = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'postInstallArguments' -Default '') - Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory ($wixArch)" $xml = New-WixPackageXml ` -AppName $appName ` @@ -229,9 +224,7 @@ function Invoke-Plugin { -InstallScope $installScope ` -InstallFolderName $installFolderName ` -Architecture $wixArch ` - -IconPath $iconPath ` - -PostInstallFileName $postInstallFileName ` - -PostInstallArguments $postInstallArguments + -IconPath $iconPath $xml.Save($wxsPath) Write-Log -Level "OK" -Message " WiX source: $wxsPath" diff --git a/utils/plugins/Desktop/brand/HyperlinkSidebarTheme.xml b/utils/plugins/Desktop/brand/HyperlinkSidebarTheme.xml index 4cca596..1493296 100644 --- a/utils/plugins/Desktop/brand/HyperlinkSidebarTheme.xml +++ b/utils/plugins/Desktop/brand/HyperlinkSidebarTheme.xml @@ -26,17 +26,25 @@ -