Compare commits

..

1 Commits
v1.1.1 ... main

Author SHA1 Message Date
Maksym Sadovnychyy
92b8746971 (feature): add portable install layout, relicense to Apache 2.0, and fix Flatpak GNOME launch 2026-09-21 20:08:15 +02:00
31 changed files with 1280 additions and 189 deletions

View File

@ -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

View File

@ -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).
---

View File

@ -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.

View File

@ -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).
---

View File

@ -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;

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.maks_it.uscheduler</id>
<name>UScheduler</name>
<summary>Schedule PowerShell scripts and processes</summary>
<developer id="com.maks_it">
<name>MaksIT</name>
</developer>
<metadata_license>CC0-1.0</metadata_license>
<project_license>Apache-2.0</project_license>
<description>
<p>Schedule and run PowerShell scripts and console processes from an Avalonia desktop UI. Register the worker, edit schedules, and follow logs without a browser.</p>
<p>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).</p>
<ul>
<li>Script list, schedules, and log viewing</li>
<li>Register, start, and stop the worker (prompts for elevation)</li>
<li>Windows and Linux script compatibility flags</li>
</ul>
</description>
<launchable type="desktop-id">com.maks_it.uscheduler.desktop</launchable>
<provides>
<id>com.maks_it.UScheduler</id>
</provides>
<replaces>
<id>com.maks_it.UScheduler</id>
</replaces>
<url type="homepage">https://maks-it.com/</url>
<url type="bugtracker">https://github.com/MAKS-IT-COM/uscheduler/issues</url>
<url type="vcs-browser">https://github.com/MAKS-IT-COM/uscheduler</url>
<url type="donation">https://www.buymeacoffee.com/maksitcom</url>
<branding>
<color type="primary" scheme_preference="light">#33A5CF</color>
<color type="primary" scheme_preference="dark">#003D7A</color>
</branding>
<content_rating type="oars-1.1"/>
<requires>
<control>keyboard</control>
<control>pointing</control>
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="1.2.0" date="2026-09-21">
<url type="details">https://github.com/MAKS-IT-COM/uscheduler/blob/v1.2.0/CHANGELOG.md</url>
<description>
<p>Linux Flatpak uses X11/XWayland from the GNOME app icon and a lowercase app id.</p>
</description>
</release>
</releases>
</component>

View File

@ -62,7 +62,8 @@ public class ProcessConfiguration : TaskConfiguration {
/// <summary>
/// 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.
/// </summary>
public class Configuration {
/// <summary>
@ -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) =>

View File

@ -6,8 +6,10 @@ namespace MaksIT.UScheduler.Shared;
/// <summary>
/// Loads and saves machine-wide <c>Configuration</c> under ProgramData
/// (all-users AppData). Shipped <c>appsettings.json</c> 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
/// <see cref="HostPaths.PortableMarkerFileName"/> file is present.
/// Shipped <c>appsettings.json</c> next to the worker is seed-only; host
/// logging stays in that file and is never copied.
/// </summary>
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) {

View File

@ -4,39 +4,88 @@ using System.Runtime.Versioning;
namespace MaksIT.UScheduler.Shared;
/// <summary>
/// Scripts, logs, and shared-settings locations for one install.
/// </summary>
public readonly record struct HostDataLayout(
string Root,
string ScriptsDirectory,
string LogDirectory,
string SharedSettingsFile,
bool Portable);
/// <summary>
/// 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 <c>--install</c> / <c>--prepare-data</c> process.
/// Pass <c>--portable</c> (or drop a <see cref="HostPaths.PortableMarkerFileName"/>
/// file) to keep scripts, logs, and settings under the install folder.
/// </summary>
public static class HostDataDirectories {
/// <summary>Windows Built-in Users (locale-independent).</summary>
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<string>();
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 {

View File

@ -4,13 +4,21 @@ using MaksIT.UScheduler.Shared.Helpers;
namespace MaksIT.UScheduler.Shared;
/// <summary>
/// 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 <c>--prepare-data</c> / <c>--install</c>.
/// 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 <c>--prepare-data</c> /
/// <c>--install</c>. A <see cref="PortableMarkerFileName"/> file next to the
/// worker (or in a parent folder) switches to a portable layout: scripts, logs,
/// and settings stay under that folder.
/// </summary>
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;
/// <summary>
/// Scripts folder to use at runtime: configured path if it exists, then
/// <see cref="DefaultScriptsDirectory"/>, then a bundled/dev tree.
/// Directory that contains <see cref="PortableMarkerFileName"/>, walking up
/// from <paramref name="startDirectory"/> (or the current app base).
/// </summary>
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);
}
/// <summary>
/// When a portable root is present, replace machine-wide default script/log
/// paths with folders under that root. Custom paths are left unchanged.
/// </summary>
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);
}
/// <summary>
/// Scripts folder to use at runtime: configured path if it exists, then a
/// portable <c>Scripts</c> folder, then <see cref="DefaultScriptsDirectory"/>,
/// then a bundled/dev tree.
/// </summary>
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"))

View File

@ -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}");

View File

@ -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);
}
}
}

View File

@ -4,7 +4,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>MaksIT.UScheduler.UI</RootNamespace>
<Version>1.1.1</Version>
<Version>1.2.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
@ -14,10 +14,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.0" />
<PackageReference Include="Avalonia" Version="12.1.2" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
<PackageReference Include="Avalonia.Themes.Simple" Version="12.1.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>

View File

@ -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<App>()
.UsePlatformDetect()

View File

@ -5,7 +5,8 @@ using MaksIT.UScheduler.Shared.Helpers;
namespace MaksIT.UScheduler.UI.Services;
/// <summary>
/// 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.
/// </summary>
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 {

View File

@ -7,7 +7,8 @@ using MaksIT.UScheduler.Shared.Helpers;
namespace MaksIT.UScheduler.UI.Services;
/// <summary>
/// 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.
/// </summary>
public class UISettings {
/// <summary>
@ -18,17 +19,19 @@ public class UISettings {
}
/// <summary>
/// Service for loading and saving UI settings under the current user's AppData.
/// Machine-wide schedule configuration lives in ProgramData via
/// <see cref="ConfigurationFileService"/>.
/// 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 <see cref="ConfigurationFileService"/>.
/// </summary>
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() {

View File

@ -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;
}

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<Version>1.1.1</Version>
<Version>1.2.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>

View File

@ -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);

View File

@ -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

View File

@ -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,

View File

@ -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(' <p>' + [System.Security.SecurityElement]::Escape($trimmed) + '</p>')
}
if ($paragraphs.Count -eq 0) {
$paragraphs.Add(' <p>' + [System.Security.SecurityElement]::Escape("$AppName is a desktop application.") + '</p>')
}
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 = @"
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>$AppId</id>
<name>$escapedName</name>
<summary>$escapedSummary</summary>
<description>
$descriptionXml
</description>
<launchable type="desktop-id">$AppId.desktop</launchable>
<metadata_license>$MetadataLicense</metadata_license>
<project_license>$ProjectLicense</project_license>
</component>
"@
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 <component> 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, `

View File

@ -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 <release version> 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 `

View File

@ -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"

View File

@ -26,17 +26,25 @@
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.InstallHeader)</Label>
<Label X="185" Y="91" Width="-11" Height="40" FontId="3" DisablePrefix="yes">
<Label X="185" Y="91" Width="-11" Height="24" FontId="3" DisablePrefix="yes">
<Text Condition="WixStdBASuppressOptionsUI">#(loc.InstallMessage)</Text>
<Text Condition="NOT WixStdBASuppressOptionsUI">#(loc.InstallMessageOptions)</Text>
</Label>
<Label X="185" Y="138" Width="-11" Height="17" FontId="3">#(loc.OptionsLocationLabel)</Label>
<Editbox Name="InstallFolder" X="185" Y="160" Width="-91" Height="21" TabStop="yes" FontId="3" FileSystemAutoComplete="yes" />
<Button Name="InstallFolderBrowseButton" X="-11" Y="159" Width="75" Height="23" TabStop="yes" FontId="3">
<RadioButtons Name="InstallLayout">
<RadioButton X="185" Y="116" Height="18" Width="-11" Value="standard" TabStop="yes" FontId="3">Standard — Program Files, data in C:\MaksIT</RadioButton>
<RadioButton X="185" Y="134" Height="18" Width="-11" Value="portable" TabStop="yes" FontId="3">Portable — everything in one folder</RadioButton>
</RadioButtons>
<Label X="185" Y="156" Width="-11" Height="17" FontId="3">
<Text Condition="InstallLayout = &quot;portable&quot;">Folder for program, scripts, logs, and settings</Text>
<Text Condition="InstallLayout &lt;&gt; &quot;portable&quot;">#(loc.OptionsLocationLabel)</Text>
</Label>
<Editbox Name="InstallFolder" X="185" Y="174" Width="-91" Height="21" TabStop="yes" FontId="3" FileSystemAutoComplete="yes" />
<Button Name="InstallFolderBrowseButton" X="-11" Y="173" Width="75" Height="23" TabStop="yes" FontId="3">
<Text>#(loc.OptionsBrowseButton)</Text>
<BrowseDirectoryAction VariableName="InstallFolder" />
</Button>
<Checkbox Name="InstallDesktopShortcut" X="185" Y="192" Width="-11" Height="17" TabStop="yes" FontId="3">Place a shortcut on the desktop</Checkbox>
<Checkbox Name="InstallDesktopShortcut" X="185" Y="200" Width="-11" Height="17" TabStop="yes" FontId="3">Place a shortcut on the desktop</Checkbox>
<Label X="185" Y="220" Width="-11" Height="32" FontId="3" DisablePrefix="yes" VisibleCondition="InstallLayout = &quot;portable&quot;">Scripts, logs, and settings stay in this folder (Scripts, Logs, and Data subfolders). Pick a writable location if you do not want Program Files.</Label>
<Hypertext Name="EulaHyperlink" X="185" Y="-111" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallLicenseLinkText)</Hypertext>
<Label Name="InstallVersion" X="185" Y="-81" Width="-11" Height="17" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBAShowVersion">#(loc.InstallVersion)</Label>
<Checkbox Name="EulaAcceptCheckbox" X="185" Y="-51" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallAcceptCheckbox)</Checkbox>

View File

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. -->
<Theme xmlns="http://wixtoolset.org/schemas/v4/thmutil">
<Font Id="0" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
<Font Id="1" Height="-24" Weight="500" Foreground="windowtext">Segoe UI</Font>
<Font Id="2" Height="-22" Weight="500" Foreground="graytext">Segoe UI</Font>
<Font Id="3" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font>
<Window Width="600" Height="450" HexStyle="100a0000" FontId="0" Caption="#(loc.Caption)">
<Page Name="Help">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.HelpHeader)</Label>
<Label X="11" Y="121" Width="-11" Height="-35" FontId="3" DisablePrefix="yes">#(loc.HelpText)</Label>
<Button Name="HelpCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.HelpCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Loading">
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes" Visible="no" Name="CheckingForUpdatesLabel" />
</Page>
<Page Name="Install">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="32" FontId="2" DisablePrefix="yes">#(loc.InstallHeader)</Label>
<Label X="185" Y="91" Width="-11" Height="40" FontId="3" DisablePrefix="yes">
<Text Condition="WixStdBASuppressOptionsUI">#(loc.InstallMessage)</Text>
<Text Condition="NOT WixStdBASuppressOptionsUI">#(loc.InstallMessageOptions)</Text>
</Label>
<Label X="185" Y="138" Width="-11" Height="17" FontId="3">#(loc.OptionsLocationLabel)</Label>
<Editbox Name="InstallFolder" X="185" Y="160" Width="-91" Height="21" TabStop="yes" FontId="3" FileSystemAutoComplete="yes" />
<Button Name="InstallFolderBrowseButton" X="-11" Y="159" Width="75" Height="23" TabStop="yes" FontId="3">
<Text>#(loc.OptionsBrowseButton)</Text>
<BrowseDirectoryAction VariableName="InstallFolder" />
</Button>
<Checkbox Name="InstallDesktopShortcut" X="185" Y="192" Width="-11" Height="17" TabStop="yes" FontId="3">Place a shortcut on the desktop</Checkbox>
<Hypertext Name="EulaHyperlink" X="185" Y="-111" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallLicenseLinkText)</Hypertext>
<Label Name="InstallVersion" X="185" Y="-81" Width="-11" Height="17" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBAShowVersion">#(loc.InstallVersion)</Label>
<Checkbox Name="EulaAcceptCheckbox" X="185" Y="-51" Width="-11" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallAcceptCheckbox)</Checkbox>
<Button Name="InstallUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
<Button Name="OptionsButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" VisibleCondition="NOT WixStdBASuppressOptionsUI">
<Text>#(loc.InstallOptionsButton)</Text>
<ChangePageAction Page="Options" />
</Button>
<Button Name="InstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.InstallInstallButton)</Button>
<Button Name="InstallCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.InstallCancelButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Options">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.OptionsHeader)</Label>
<RadioButtons Name="WixStdBAScope">
<RadioButton X="11" Y="180" Height="20" Width="-11" Value="PerUser" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerUserScopeText)</RadioButton>
<RadioButton X="11" Y="200" Height="20" Width="-11" Value="PerMachine" EnableCondition="WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3" HideWhenDisabled="yes" FontId="3">#(loc.OptionsPerMachineScopeText)</RadioButton>
</RadioButtons>
<Button Name="OptionsOkButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.OptionsOkButton)</Text>
<ChangePageAction Page="Install" />
</Button>
<Button Name="OptionsCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.OptionsCancelButton)</Text>
<ChangePageAction Page="Install" Cancel="yes" />
</Button>
</Page>
<Page Name="Progress">
<Label X="80" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png"/>
<Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ProgressHeader)</Label>
<Label X="11" Y="141" Width="70" Height="17" FontId="3" DisablePrefix="yes">#(loc.ProgressLabel)</Label>
<Label Name="OverallProgressPackageText" X="85" Y="141" Width="-11" Height="17" FontId="3" DisablePrefix="yes">#(loc.OverallProgressPackageText)</Label>
<Progressbar Name="OverallCalculatedProgressbar" X="11" Y="163" Width="-11" Height="20" />
<Button Name="ProgressCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">#(loc.ProgressCancelButton)</Button>
</Page>
<Page Name="Modify">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ModifyHeader)</Label>
<Button Name="ModifyUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button>
<Button Name="RepairButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyRepairButton)</Button>
<Button Name="UninstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" ElevationShieldCondition='(WixBundleAuthoredScope = 1 AND NOT WixBundleElevated) OR ((WixBundleAuthoredScope = 2 OR WixBundleAuthoredScope = 3) AND WixStdBAScope = "PerMachine")'>#(loc.ModifyUninstallButton)</Button>
<Button Name="ModifyCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.ModifyCancelButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Success">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
<Text>#(loc.SuccessHeader)</Text>
<Text Condition="WixBundleAction = 2">#(loc.SuccessLayoutHeader)</Text>
<Text Condition="WixBundleAction = 3">#(loc.SuccessUnsafeUninstallHeader)</Text>
<Text Condition="WixBundleAction = 4">#(loc.SuccessUninstallHeader)</Text>
<Text Condition="WixBundleAction = 5">#(loc.SuccessCacheHeader)</Text>
<Text Condition="WixBundleAction = 6">#(loc.SuccessInstallHeader)</Text>
<Text Condition="WixBundleAction = 7">#(loc.SuccessModifyHeader)</Text>
<Text Condition="WixBundleAction = 8">#(loc.SuccessRepairHeader)</Text>
</Label>
<Button Name="LaunchButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessLaunchButton)</Button>
<Label X="185" Y="-51" Width="400" Height="34" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">
<Text>#(loc.SuccessRestartText)</Text>
<Text Condition="WixBundleAction = 3">#(loc.SuccessUninstallRestartText)</Text>
</Label>
<Button Name="SuccessRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessRestartButton)</Button>
<Button Name="SuccessCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.SuccessCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
<Page Name="Failure">
<Label X="185" Y="11" Width="-11" Height="32" FontId="1" DisablePrefix="yes">#(loc.Title)</Label>
<ImageControl X="11" Y="11" Width="165" Height="400" ImageFile="logoside.png"/>
<Label X="185" Y="50" Width="-11" Height="30" FontId="2" DisablePrefix="yes">
<Text>#(loc.FailureHeader)</Text>
<Text Condition="WixBundleAction = 2">#(loc.FailureLayoutHeader)</Text>
<Text Condition="WixBundleAction = 3">#(loc.FailureUnsafeUninstallHeader)</Text>
<Text Condition="WixBundleAction = 4">#(loc.FailureUninstallHeader)</Text>
<Text Condition="WixBundleAction = 5">#(loc.FailureCacheHeader)</Text>
<Text Condition="WixBundleAction = 6">#(loc.FailureInstallHeader)</Text>
<Text Condition="WixBundleAction = 7">#(loc.FailureModifyHeader)</Text>
<Text Condition="WixBundleAction = 8">#(loc.FailureRepairHeader)</Text>
</Label>
<Hypertext Name="FailureLogFileLink" X="185" Y="121" Width="-11" Height="68" FontId="3" TabStop="yes" HideWhenDisabled="yes">#(loc.FailureHyperlinkLogText)</Hypertext>
<Hypertext Name="FailureMessageText" X="185" Y="-115" Width="-11" Height="80" FontId="3" TabStop="yes" HideWhenDisabled="yes" />
<Label X="185" Y="-57" Width="-11" Height="80" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">#(loc.FailureRestartText)</Label>
<Button Name="FailureRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.FailureRestartButton)</Button>
<Button Name="FailureCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">
<Text>#(loc.FailureCloseButton)</Text>
<CloseWindowAction />
</Button>
</Page>
</Window>
</Theme>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="MAKS.IT">
<defs>
<linearGradient id="hi" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#33A5CF"/>
<stop offset="1" stop-color="#006199"/>
</linearGradient>
<linearGradient id="mid" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#0795C5"/>
<stop offset="1" stop-color="#003D7A"/>
</linearGradient>
<linearGradient id="deep" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00427E"/>
<stop offset="1" stop-color="#002A6A"/>
</linearGradient>
<linearGradient id="navy" x1="1" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#00306F"/>
<stop offset="1" stop-color="#001428"/>
</linearGradient>
</defs>
<!-- Origami M: sharp facets, light from upper-left. No wordmark. -->
<polygon fill="url(#hi)" points="28,40 78,40 62,216 12,216"/>
<polygon fill="url(#navy)" points="78,40 118,128 102,216 62,216"/>
<polygon fill="url(#mid)" points="118,128 158,40 174,216 102,216"/>
<polygon fill="url(#hi)" points="158,40 228,40 244,216 174,216"/>
<polygon fill="url(#deep)" points="28,40 78,40 70,128 20,128"/>
<polygon fill="url(#navy)" points="158,40 228,40 220,128 166,128"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB