diff --git a/CLAUDE.md b/CLAUDE.md
index ce5f63f..1d23814 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,7 @@ Source layout under `src/OrthoStreamXP/`. Sub-folders are for navigation only: *
- `Aircraft/`: `AircraftPositionListener` (X-Plane UDP), `ViewTracker`, `SceneryLoads`.
- `Colour/`: `ColourMatcher` (evens patchwork), `SeasonClock` (seasonal tint by simulated date/location).
- `Textures/` — imagery to DDS: `TextureBuilder` (fetch → stitch → resize → mipmaps → BC1 encode → atomic write; own encoder, SkiaSharp is decode-only), `TextureQuality`, `Images` at the root; `Tiles/` (`TileSource`/`TileStore`: download + cache), `Dds/`, `Scheduling/` (priority gates), `Seasons/`, `Colour/` (`ColourMatch`, `Exposure`, `ColourTable` = the shared BC1 lookup-table recolouring, `PhotoColours` = match → season → exposure as X-Plane reads, also used by the Settings preview).
-- `Setup/` — `Settings/` (`SettingsDocument`, ini under `%LOCALAPPDATA%\OrthoStreamXP`, `QualityPreset`, `ConnectionTest`), `Cache/` (inventory, areas, eviction), `Installation/` (`AppPaths`, `AppUpdates`, `LegacyNames` migrating older settings/cache locations, `SystemCheck`, Velopack hooks).
+- `Setup/` — `Settings/` (`SettingsDocument`, ini under `%LOCALAPPDATA%\OrthoStreamXP`, `QualityPreset`, `ConnectionTest`), `Cache/` (inventory, areas, eviction), `Packages/` (downloads AutoOrtho's mesh regions from GitHub into the packages folder as `OSXP_*` packages: `MeshCatalog`, `AssetDownload`, `ZipUnpacker`, `OverlayPackage`, `RegionInstaller`), `Installation/` (`AppPaths`, `AppUpdates`, `LegacyNames` migrating older settings/cache locations, `SystemCheck`, Velopack hooks).
- `Tuning/` — flight reports, facts and the preset/setting recommendations.
- Logs go to `%LOCALAPPDATA%\OrthoStreamXP\logs`.
diff --git a/README.md b/README.md
index 87e1a4b..5226af7 100644
--- a/README.md
+++ b/README.md
@@ -47,16 +47,17 @@ Before installing OrthoStreamXP, make sure you have:
- **Windows 10 or 11 (64-bit)**
- **X-Plane 12**
- **WinFsp**, a free Windows component used to make the streamed scenery available to X-Plane. The setup process checks for it and provides a download link.
-- **Existing scenery packages** for the regions you want to fly over.
+- **Scenery packages** for the regions you want to fly over: a region's mesh downloaded from within OrthoStreamXP, or your own.
- **An internet connection** for downloading new scenery.
- **Disk space for the scenery cache** — 25 GB is a reasonable starting point; more space lets you keep more areas available.
- A graphics card with enough memory for your chosen quality preset.
The installer also installs the .NET 10 desktop runtime if it is missing.
-**Important:** OrthoStreamXP streams imagery; it does not create terrain meshes or provide ready-made scenery packages. You must supply compatible base packages from a supported scenery source.
+**Important:** OrthoStreamXP streams imagery onto scenery packages; it does not create terrain meshes itself. There are two ways to get them:
-Compatible packages can come from tools such as Ortho4XP, OrthoStudio XP, AutoOrtho, and XPME. You can select an existing package folder during setup.
+1. **Download a region from within OrthoStreamXP:** a whole region's mesh (Europe, North America, ...), during setup or later from the **Packages** page.
+2. **Use your own packages:** compatible packages can come from tools such as Ortho4XP, OrthoStudio XP, AutoOrtho, and XPME. You can select an existing package folder during setup.
## Installation
@@ -116,7 +117,7 @@ Water, roads, buildings, rock, and sand retain their original colours. Seasonal
OrthoStreamXP is currently in beta. Please keep the following in mind:
- **Windows only:** the application depends on WinFsp.
-- **You supply the scenery:** compatible base packages are required for the regions you want to fly.
+- **Scenery packages are required:** download a region's mesh (6 to 33 GB per region) from the Packages page, or bring your own.
- **Internet required for new areas:** scenery loads from the internet on first use; subsequent flights can reuse cached imagery.
- **Imagery varies by location:** quality, capture date, colours, and transitions between imagery sources may differ.
- **Performance depends on your setup:** connection speed, disk performance, and graphics memory affect the experience.
diff --git a/build-user-guide.ps1 b/build-user-guide.ps1
index d03e52a..438bee1 100644
--- a/build-user-guide.ps1
+++ b/build-user-guide.ps1
@@ -24,7 +24,7 @@ if (-not $browser) { throw 'Google Chrome or Microsoft Edge is needed to print t
# The order of the guide's contents (docs\README.md). The pages keep their names so that pandoc's --file-scope
# turns the links between them (settings.md#quality) into links inside the PDF.
-$pages = 'README', 'installation', 'first-setup', 'flying', 'settings', 'cache', 'troubleshooting', 'faq'
+$pages = 'README', 'installation', 'first-setup', 'flying', 'settings', 'cache', 'packages', 'troubleshooting', 'faq'
$work = 'artifacts\user-guide'
if (Test-Path $work) { Remove-Item $work -Recurse -Force }
New-Item -ItemType Directory $work | Out-Null
diff --git a/docs/OrthoStreamXP-UserGuide.pdf b/docs/OrthoStreamXP-UserGuide.pdf
index 5fb6a66..325274e 100644
Binary files a/docs/OrthoStreamXP-UserGuide.pdf and b/docs/OrthoStreamXP-UserGuide.pdf differ
diff --git a/docs/README.md b/docs/README.md
index a6831c2..92d1d55 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -11,13 +11,14 @@ OrthoStreamXP adds real-world aerial photos to your X-Plane 12 scenery while you
3. [Flying](flying.md): the start order, the Home page, and the report you get after each flight.
4. [Settings](settings.md): quality, folders, downloads, colours, seasons and exposure.
5. [Cache](cache.md): how much disk space the scenery uses and how to free it.
-6. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug.
-7. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho.
+6. [Packages](packages.md): downloading the ground mesh of a whole region, the quickest way to get scenery packages.
+7. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug.
+8. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho.
## The short version
1. Install [WinFsp](https://winfsp.dev/rel/), then OrthoStreamXP, then restart your computer.
-2. Open OrthoStreamXP and finish the setup: X-Plane folder, scenery packages folder, cache folder, quality.
+2. Open OrthoStreamXP and finish the setup: X-Plane folder, scenery packages (download a region there, or choose the folder of your own), cache folder, quality.
3. Every time you fly, click **Start and fly** in OrthoStreamXP. It starts the scenery first and then X-Plane.
4. Load a flight. A new area takes a few minutes the first time; after that it comes from the cache.
diff --git a/docs/faq.md b/docs/faq.md
index 13f9d68..08db240 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -14,7 +14,7 @@ It is free and open source, and it goes up to imagery zoom level 19 (the **Extra
### Does it make the terrain (the mesh) too?
-No. OrthoStreamXP only provides the photos. The mesh, the water masks and the coastlines come from scenery packages you already have, made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME. See [Installation › What you need](installation.md#what-you-need).
+No, OrthoStreamXP does not make the mesh: it provides the photos. The mesh, the water masks and the coastlines come from scenery packages. Download a whole region's mesh in OrthoStreamXP itself (see [Packages](packages.md)), or use packages you already have, made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME. See [Installation › What you need](installation.md#what-you-need).
### Why can't it use the mesh from X-Plane's default scenery?
diff --git a/docs/first-setup.md b/docs/first-setup.md
index 7f257c0..e3591d6 100644
--- a/docs/first-setup.md
+++ b/docs/first-setup.md
@@ -25,6 +25,22 @@ If yours is not listed, click **Choose another folder…** and choose the folder
## 3. Scenery
+OrthoStreamXP adds the photos to **scenery packages**: the ground of an area, the shape of the terrain cut in 1° squares. There are two ways to get them.
+
+### Option 1: download a region in OrthoStreamXP
+
+The simplest way, if you have no scenery packages yet. Under **No scenery packages yet?**, click **Download a region…**. If you already chose a folder without packages, the hint turns red and **Download a region now** opens the same list.
+
+
+
+The list of regions opens: click **Install** next to the region you fly in. The progress shows under it, and you can stop and resume the download. When it finishes, the region's folder is chosen as your scenery packages folder and **Next** becomes available.
+
+
+
+See [Packages](packages.md) for the sizes, the roads, forests and buildings, and how to update or delete a region later. You can download more regions at any time on the **Packages** page.
+
+### Option 2: use your own scenery packages
+

Click **Choose a folder…** and pick the **parent folder** (in the example below it's "OrthoPackages") that holds your scenery packages, not one of the packages (not "Europe" or "Asia"). Below the folder, OrthoStreamXP lists the packages it found.
@@ -43,9 +59,10 @@ D:\OrthoPackages\
```
- Your packages stay unchanged on disk. While OrthoStreamXP runs, they appear in X-Plane's `Custom Scenery` folder with the photos added. When it stops, they disappear from there again.
-- If the folder has no packages, the hint turns red: *That folder has no scenery packages*. Check that each package has an `Earth nav data` folder inside it.
+- If the hint turns red, *That folder has no scenery packages*, you probably picked one package instead of the folder that holds them: check that each package has an `Earth nav data` folder inside it.
- If X-Plane's `Custom Scenery` already contains a folder with the same name as one of your packages, you get a warning. Usually another streaming tool still shows that package there; close that tool before you start OrthoStreamXP and check your scenery_packs.ini file.
+
## 4. Cache

diff --git a/docs/images/about.png b/docs/images/about.png
index a7d1c8c..1535697 100644
Binary files a/docs/images/about.png and b/docs/images/about.png differ
diff --git a/docs/images/packages-downloading.png b/docs/images/packages-downloading.png
new file mode 100644
index 0000000..ba56d10
Binary files /dev/null and b/docs/images/packages-downloading.png differ
diff --git a/docs/images/packages.png b/docs/images/packages.png
new file mode 100644
index 0000000..3146651
Binary files /dev/null and b/docs/images/packages.png differ
diff --git a/docs/images/settings-download.png b/docs/images/settings-download.png
index 9110e83..dc6f63a 100644
Binary files a/docs/images/settings-download.png and b/docs/images/settings-download.png differ
diff --git a/docs/images/settings-folders.png b/docs/images/settings-folders.png
index e060094..714391e 100644
Binary files a/docs/images/settings-folders.png and b/docs/images/settings-folders.png differ
diff --git a/docs/images/settings-other.png b/docs/images/settings-other.png
index 385e519..e16cd98 100644
Binary files a/docs/images/settings-other.png and b/docs/images/settings-other.png differ
diff --git a/docs/images/settings-quality.png b/docs/images/settings-quality.png
index 7440a79..cfd4d10 100644
Binary files a/docs/images/settings-quality.png and b/docs/images/settings-quality.png differ
diff --git a/docs/images/settings-tuning.png b/docs/images/settings-tuning.png
index 0bbbc47..33efb52 100644
Binary files a/docs/images/settings-tuning.png and b/docs/images/settings-tuning.png differ
diff --git a/docs/images/setup-scenery-download.png b/docs/images/setup-scenery-download.png
new file mode 100644
index 0000000..2c840ee
Binary files /dev/null and b/docs/images/setup-scenery-download.png differ
diff --git a/docs/images/setup-scenery-empty.png b/docs/images/setup-scenery-empty.png
new file mode 100644
index 0000000..7e79b63
Binary files /dev/null and b/docs/images/setup-scenery-empty.png differ
diff --git a/docs/images/setup-scenery.png b/docs/images/setup-scenery.png
index a729b64..5f91814 100644
Binary files a/docs/images/setup-scenery.png and b/docs/images/setup-scenery.png differ
diff --git a/docs/installation.md b/docs/installation.md
index d5984f0..ae1cc79 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -6,20 +6,22 @@
- **Windows 10 or 11 (64-bit).**
- **X-Plane 12.**
-- **Scenery packages of your own** for the regions you fly. OrthoStreamXP adds the photos to them, but it does not make the terrain (the mesh) itself. Packages made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME work. Each package is a folder that contains an `Earth nav data` folder. Put all of them in **one parent folder**, for example:
-
- ```
- D:\OrthoPackages\
- Europe\
- Earth nav data\
- terrain\
- ...
- Asia\
- Earth nav data\
- ...
- ```
-
- **In this example you have to specify the "OrthoPackages" folder as a Scenery packages folder in the application. Keep in mind the "OrthoPackages" here is just for example it can be whatever folder you have. Don't put your scenery packages to the "X-Plane 12/Custom Scenery" - it has to be out of it somewhere on you PC**
+- **Scenery packages** for the regions you fly: the terrain (the mesh) that OrthoStreamXP adds the photos to. Two ways to get them:
+ 1. **Download a region in OrthoStreamXP**, during the setup or later on the **Packages** page (Europe, North America...). Nothing to prepare beforehand; see [Packages](packages.md).
+ 2. **Use your own.** Packages made by Ortho4XP, OrthoStudio XP, AutoOrtho or XPME work. Each package is a folder that contains an `Earth nav data` folder. Put all of them in **one parent folder**, for example:
+
+ ```
+ D:\OrthoPackages\
+ Europe\
+ Earth nav data\
+ terrain\
+ ...
+ Asia\
+ Earth nav data\
+ ...
+ ```
+
+ **In this example you have to specify the "OrthoPackages" folder as a Scenery packages folder in the application. Keep in mind the "OrthoPackages" here is just for example it can be whatever folder you have. Don't put your scenery packages to the "X-Plane 12/Custom Scenery" - it has to be out of it somewhere on you PC**
- **WinFsp**, a free Windows driver that OrthoStreamXP uses to show the scenery to X-Plane.
- **An internet connection** for areas you have not flown yet.
diff --git a/docs/packages.md b/docs/packages.md
new file mode 100644
index 0000000..ff36f5f
--- /dev/null
+++ b/docs/packages.md
@@ -0,0 +1,44 @@
+# Packages
+
+[User guide](README.md) › Packages
+
+OrthoStreamXP streams photos onto scenery packages: the ground's shape (the mesh) for each area. The quickest way to get them is to download the mesh of a whole region, on the **Packages** page or in the **Scenery packages** step of the setup. You can also use packages of your own, or both together.
+
+
+
+The meshes are downloaded from a public collection on [GitHub](https://github.com/kubilus1/autoortho-scenery). Six regions are offered:
+
+| Region | Package | Download (with roads, forests and buildings) |
+|---|---|---|
+| Europe | `OSXP_Eur` | about 16 GB |
+| North America | `OSXP_NA` | about 25 GB |
+| South America | `OSXP_SA` | about 9 GB |
+| Africa | `OSXP_Afr` | about 12 GB |
+| Asia | `OSXP_Asi` | about 33 GB |
+| Australia and the Pacific | `OSXP_AusPac` | about 6 GB |
+
+The page shows the exact sizes of the current release.
+
+## Where they go
+
+Each region is installed as a package of its own in your **scenery packages folder**, beside your own packages. If you download one during the setup and have not chosen a folder yet, it goes to `OrthoStreamXP\Base Packages` in your user folder, and that folder becomes your scenery packages folder.
+
+Your own packages stay **above** the downloaded regions in X-Plane's scenery list. Where both cover the same area, X-Plane shows yours.
+
+Leave about 1.7 times the download size free on the drive: the files take more room once they are unpacked.
+
+## Roads, forests and buildings
+
+A square of a downloaded mesh no longer shows X-Plane's own roads, forests and buildings. **Roads, forests and buildings** downloads them with each region, into one shared package, `OSXP_Overlays`. Turn it off if another package of yours already brings them. The choice applies to the next region you install or update.
+
+## Downloading, stopping and resuming
+
+
+
+- **Install** starts the download. While one region downloads, the others wait: one install runs at a time. The progress and speed show under the region.
+- **Stop** stops it. What was downloaded is kept: **Resume** continues from there, even after OrthoStreamXP was closed.
+- A region is used from OrthoStreamXP's next start, and never half-installed: it appears in your packages folder only when all of it is there.
+- When a newer release comes out, the region says **Update available**. Updating downloads it again in full.
+- **Delete** removes the region and its roads, forests and buildings. Another region's stay.
+
+You can install a new region while OrthoStreamXP runs. Updating or deleting a region needs OrthoStreamXP stopped, because X-Plane has its files open while it runs.
diff --git a/mkdocs.yml b/mkdocs.yml
index 61ac979..2d70127 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -21,6 +21,7 @@ nav:
- flying.md
- settings.md
- cache.md
+ - packages.md
- troubleshooting.md
- Questions and answers: faq.md
- User guide (PDF): OrthoStreamXP-UserGuide.pdf
diff --git a/src/OrthoStreamXP/Gui/MainWindow.xaml b/src/OrthoStreamXP/Gui/MainWindow.xaml
index d27dd0e..419800c 100644
--- a/src/OrthoStreamXP/Gui/MainWindow.xaml
+++ b/src/OrthoStreamXP/Gui/MainWindow.xaml
@@ -10,6 +10,7 @@
+
diff --git a/src/OrthoStreamXP/Gui/Packages/InstallSlot.cs b/src/OrthoStreamXP/Gui/Packages/InstallSlot.cs
new file mode 100644
index 0000000..ce2efb2
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Packages/InstallSlot.cs
@@ -0,0 +1,18 @@
+namespace OrthoStreamXP.Gui;
+
+///
+/// One install or delete of a mesh region at a time in the whole window: the Packages page and the setup's list of regions
+/// each have their own rows, and both write into the same packages folder and the one shared overlays package.
+///
+internal sealed class InstallSlot
+{
+ private int _taken;
+
+ /// An install or a delete is running.
+ public bool Busy => Volatile.Read(ref _taken) == 1;
+
+ /// Takes the slot; false when another install or delete has it.
+ public bool TryTake() => Interlocked.CompareExchange(ref _taken, 1, 0) == 0;
+
+ public void Release() => Volatile.Write(ref _taken, 0);
+}
diff --git a/src/OrthoStreamXP/Gui/Packages/RegionRow.cs b/src/OrthoStreamXP/Gui/Packages/RegionRow.cs
new file mode 100644
index 0000000..d116041
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Packages/RegionRow.cs
@@ -0,0 +1,162 @@
+using System.Diagnostics;
+using System.Windows.Input;
+using OrthoStreamXP.Setup;
+
+namespace OrthoStreamXP.Gui;
+
+/// One region of the mesh on the Packages page: whether it is installed, its download, and its buttons.
+internal sealed class RegionRow : ObservableObject
+{
+ private readonly PackagesViewModel _owner;
+ private InstalledRegion? _installed;
+ private long _partial;
+ private bool _busy, _confirming, _deleting;
+ private InstallProgress? _progress;
+ private string? _error;
+ private (long At, long Bytes)? _rateFrom;
+ private double _bytesPerSecond;
+
+ public RegionRow(PackagesViewModel owner, MeshRegion region)
+ {
+ _owner = owner;
+ Region = region;
+ Install = new RelayCommand(() => _ = _owner.InstallAsync(this), () => _owner.CanInstall(this));
+ Stop = new RelayCommand(_owner.StopInstall, () => Busy);
+ AskDelete = new RelayCommand(() => Confirming = true, () => _owner.CanDelete(this));
+ CancelDelete = new RelayCommand(() => Confirming = false);
+ Delete = new RelayCommand(() => _ = _owner.DeleteAsync(this), () => _owner.CanDelete(this));
+ }
+
+ public MeshRegion Region { get; }
+ public ICommand Install { get; }
+ public ICommand Stop { get; }
+ public ICommand AskDelete { get; }
+ public ICommand CancelDelete { get; }
+ public ICommand Delete { get; }
+
+ public string Name => Region.Name;
+
+ public string Details =>
+ $"{CacheInventory.FormatBytes(Region.MeshSize)} mesh" +
+ (Region.OverlaysSize > 0 ? $" + {CacheInventory.FormatBytes(Region.OverlaysSize)} roads, forests and buildings" : "") +
+ $" · installed as {Region.PackageName}";
+
+ public InstalledRegion? Installed
+ {
+ get => _installed;
+ set
+ {
+ _installed = value;
+ RaiseState();
+ }
+ }
+
+ /// Bytes an earlier, stopped install already has.
+ public long Partial
+ {
+ get => _partial;
+ set
+ {
+ _partial = value;
+ RaiseState();
+ }
+ }
+
+ public bool IsInstalled => Installed is not null;
+
+ public bool UpToDate => Installed is { } i && i.Release == Region.Release;
+
+ public bool Busy
+ {
+ get => _busy;
+ set
+ {
+ if (!Set(ref _busy, value)) return;
+ _rateFrom = null;
+ _bytesPerSecond = 0;
+ if (!value) _progress = null;
+ RaiseState();
+ }
+ }
+
+ public bool Confirming
+ {
+ get => _confirming;
+ set => Set(ref _confirming, value);
+ }
+
+ public bool Deleting
+ {
+ get => _deleting;
+ set
+ {
+ if (Set(ref _deleting, value)) Confirming = false;
+ }
+ }
+
+ public string? Error
+ {
+ get => _error;
+ set
+ {
+ Set(ref _error, value);
+ Raise(nameof(HasError));
+ }
+ }
+
+ public bool HasError => Error is not null;
+
+ /// The install button's word: Install, Resume or Update; hidden when the region is up to date.
+ public string InstallLabel => IsInstalled ? "Update" : Partial > 0 ? "Resume" : "Install";
+
+ public bool ShowInstall => !Busy && !UpToDate;
+
+ public string DeleteQuestion =>
+ $"Delete the {Name} mesh ({Region.PackageName}) and its roads, forests and buildings? Downloading it again takes {CacheInventory.FormatBytes(Region.DownloadSize(true))}.";
+
+ public string Status
+ {
+ get
+ {
+ if (Busy)
+ {
+ if (_progress is not { } p) return "Starting…";
+ string rate = _bytesPerSecond > 0 ? $" · {_bytesPerSecond / (1 << 20):F1} MB/s" : "";
+ return $"{p.Stage}: {CacheInventory.FormatBytes(p.Done)} of {CacheInventory.FormatBytes(p.Total)}{rate}";
+ }
+ if (Installed is { } installed)
+ return UpToDate
+ ? $"Installed · release {installed.Release}{(installed.Overlays ? "" : ", without roads, forests and buildings")}"
+ : $"Update available: release {installed.Release} → {Region.Release}";
+ return Partial > 0 ? $"Stopped with {CacheInventory.FormatBytes(Partial)} downloaded: Resume goes on from there" : "Not installed";
+ }
+ }
+
+ /// 0 to 100.
+ public double Percent => _progress is { Total: > 0 } p ? 100.0 * p.Done / p.Total : 0;
+
+ public void Report(InstallProgress progress)
+ {
+ _progress = progress;
+ long now = Stopwatch.GetTimestamp();
+ if (_rateFrom is not { } from) _rateFrom = (now, progress.Done);
+ else if (Stopwatch.GetElapsedTime(from.At, now) is { TotalSeconds: >= 3 } span)
+ {
+ _bytesPerSecond = Math.Max(0, (progress.Done - from.Bytes) / span.TotalSeconds);
+ _rateFrom = (now, progress.Done);
+ }
+ Raise(nameof(Status));
+ Raise(nameof(Percent));
+ }
+
+ private void RaiseState()
+ {
+ Raise(nameof(IsInstalled));
+ Raise(nameof(UpToDate));
+ Raise(nameof(InstallLabel));
+ Raise(nameof(ShowInstall));
+ Raise(nameof(Status));
+ Raise(nameof(Percent));
+ RelayCommand.Refresh();
+ }
+}
diff --git a/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs
index 02d65d6..06d5873 100644
--- a/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs
+++ b/src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs
@@ -22,6 +22,9 @@ internal sealed class WindowPreferences
/// Start the mount as soon as the window opens.
public bool AutoStart { get; set; }
+ /// Download a mesh region's roads, forests and buildings (its overlays) with it.
+ public bool DownloadOverlays { get; set; } = true;
+
public static WindowPreferences Load()
{
try
@@ -61,12 +64,15 @@ public MainViewModel()
Home = new HomeViewModel(this);
Settings = new SettingsViewModel(this);
Cache = new CacheViewModel(this);
+ Packages = new PackagesViewModel(Mount, Preferences, MeshHttp, MeshInstalls,
+ () => SettingsDocument.Load(SettingsDocument.DefaultPath).Get("base-packages"));
About = new AboutViewModel();
Navigation =
[
new NavItem("Home", "", Home),
new NavItem("Settings", "", Settings),
new NavItem("Cache", "", Cache),
+ new NavItem("Packages", "", Packages),
new NavItem("Log", "", LogPage),
new NavItem("About", "", About),
];
@@ -84,6 +90,12 @@ public MainViewModel()
public HomeViewModel Home { get; }
public SettingsViewModel Settings { get; }
public CacheViewModel Cache { get; }
+ /// The regions of the mesh to download into the scenery packages folder.
+ public PackagesViewModel Packages { get; }
+ /// The client mesh downloads share, the setup's and the Packages page's.
+ public HttpClient MeshHttp { get; } = PackagesViewModel.CreateHttpClient();
+ /// One region install or delete at a time, from the Packages page or the setup.
+ public InstallSlot MeshInstalls { get; } = new();
public LogViewModel LogPage { get; }
public AboutViewModel About { get; }
public IReadOnlyList Navigation { get; }
diff --git a/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs
new file mode 100644
index 0000000..f2f0ef3
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs
@@ -0,0 +1,240 @@
+using System.Collections.ObjectModel;
+using System.Reflection;
+using System.Windows.Input;
+using OrthoStreamXP.Setup;
+
+namespace OrthoStreamXP.Gui;
+
+///
+/// The regions of AutoOrtho's scenery mesh, to download into the scenery packages folder: the Packages page, and the same
+/// list in the setup's Scenery step for someone who has no packages yet. One install runs at a time; a region is updated
+/// or deleted only while OrthoStreamXP is stopped (X-Plane has its files open while it runs).
+///
+internal sealed class PackagesViewModel : ObservableObject, IPage
+{
+ private readonly MountController _mount;
+ private readonly WindowPreferences _preferences;
+ private readonly HttpClient _http;
+ private readonly Func _folder;
+ private readonly Action? _installed;
+ private readonly InstallSlot _slot;
+ private CancellationTokenSource? _install;
+ private bool _loading;
+ private string? _message;
+
+ /// The scenery packages folder regions go into; null while there is none.
+ /// Called with the folder once a region is installed.
+ /// Shared with the window's other list of regions: one install or delete at a time.
+ public PackagesViewModel(MountController mount, WindowPreferences preferences, HttpClient http, InstallSlot slot, Func folder,
+ Action? installed = null)
+ {
+ _mount = mount;
+ _preferences = preferences;
+ _http = http;
+ _folder = folder;
+ _installed = installed;
+ _slot = slot;
+ Refresh = new RelayCommand(() => _ = LoadAsync(refresh: true), () => !Loading);
+ OpenFolder = new RelayCommand(() => Shell.Open(Folder!), () => Folder is { } f && Directory.Exists(f));
+ OpenProject = new RelayCommand(() => Shell.Open(MeshCatalog.ProjectPage));
+ _mount.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName != nameof(MountController.State)) return;
+ Raise(nameof(RunningNote));
+ RelayCommand.Refresh();
+ };
+ }
+
+ public ObservableCollection Rows { get; } = [];
+ public ICommand Refresh { get; }
+ public ICommand OpenFolder { get; }
+ public ICommand OpenProject { get; }
+
+ public string? Folder => _folder();
+
+ public string FolderText => Folder is { } folder
+ ? $"Regions are installed in your scenery packages folder, {folder}, beside your own packages. Your own packages stay above them in X-Plane, so where they overlap yours are shown."
+ : "Choose your scenery packages folder first (in the setup): the regions are installed there.";
+
+ public bool DownloadOverlays
+ {
+ get => _preferences.DownloadOverlays;
+ set
+ {
+ if (_preferences.DownloadOverlays == value) return;
+ _preferences.DownloadOverlays = value;
+ _preferences.Save();
+ Raise();
+ }
+ }
+
+ public bool Loading
+ {
+ get => _loading;
+ private set
+ {
+ Set(ref _loading, value);
+ RelayCommand.Refresh();
+ }
+ }
+
+ public string? Message
+ {
+ get => _message;
+ private set
+ {
+ Set(ref _message, value);
+ Raise(nameof(HasMessage));
+ }
+ }
+
+ public bool HasMessage => Message is not null;
+
+ public string? RunningNote => _mount.IsStopped ? null : "OrthoStreamXP is running: a region installed now is used from its next start. Stop it to update or delete a region.";
+
+ /// The folder regions go into may have changed (the setup's choices): what is installed there is read again.
+ public void FolderChanged()
+ {
+ Raise(nameof(FolderText));
+ UpdateInstalled();
+ RelayCommand.Refresh();
+ }
+
+ public void OnShown()
+ {
+ Raise(nameof(FolderText));
+ _ = LoadAsync(refresh: false);
+ }
+
+ /// The list from GitHub (or the one kept), and what is installed; rows of a running install are kept.
+ public async Task LoadAsync(bool refresh)
+ {
+ if (Loading) return;
+ Loading = true;
+ try
+ {
+ var catalog = new MeshCatalog(_http, Path.Join(AppPaths.Root, "mesh-regions.json"));
+ IReadOnlyList regions = await catalog.LoadAsync(refresh, CancellationToken.None).ConfigureAwait(true);
+ if (_install is null)
+ {
+ Rows.Clear();
+ foreach (MeshRegion region in regions) Rows.Add(new RegionRow(this, region));
+ }
+ Message = regions.Count == 0 ? "GitHub lists no mesh regions at the moment." : null;
+ UpdateInstalled();
+ }
+ catch (IOException ex)
+ {
+ Message = $"{ex.Message} Check the internet connection, then press Refresh.";
+ }
+ finally
+ {
+ Loading = false;
+ }
+ }
+
+ public bool CanInstall(RegionRow row) =>
+ !_slot.Busy && Folder is not null && !row.UpToDate && (!row.IsInstalled || _mount.IsStopped);
+
+ public bool CanDelete(RegionRow row) => !_slot.Busy && row.IsInstalled && !row.Deleting && _mount.IsStopped;
+
+ public void StopInstall() => _install?.Cancel();
+
+ public async Task InstallAsync(RegionRow row)
+ {
+ if (!CanInstall(row) || Folder is not { } folder || !_slot.TryTake()) return;
+ using var cancel = new CancellationTokenSource();
+ _install = cancel;
+ row.Error = null;
+ row.Busy = true;
+ Message = null;
+ RelayCommand.Refresh();
+ try
+ {
+ var progress = new Progress(row.Report);
+ await Installer(folder).InstallAsync(row.Region, DownloadOverlays, progress, cancel.Token).ConfigureAwait(true);
+ _installed?.Invoke(folder);
+ }
+ catch (OperationCanceledException)
+ {
+ Log.Info($"The install of the {row.Name} mesh was stopped; what was downloaded is kept for the next time");
+ }
+ catch (Exception ex) when (ex is InstallException or IOException or UnauthorizedAccessException)
+ {
+ row.Error = ex.Message;
+ Log.Warning(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ // The install runs unawaited from a button: nothing that goes wrong may vanish.
+ row.Error = $"The {row.Name} mesh could not be installed: {ex.Message}";
+ Log.Error(row.Error, ex);
+ }
+ finally
+ {
+ _install = null;
+ _slot.Release();
+ RelayCommand.Refresh();
+ row.Busy = false;
+ UpdateInstalled();
+ }
+ }
+
+ public async Task DeleteAsync(RegionRow row)
+ {
+ if (!CanDelete(row) || Folder is not { } folder || !_slot.TryTake()) return;
+ row.Deleting = true;
+ row.Error = null;
+ try
+ {
+ await Task.Run(() => Installer(folder).Delete(row.Region.Id)).ConfigureAwait(true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ row.Error = $"The {row.Name} mesh could not be deleted: {ex.Message}";
+ Log.Warning(row.Error);
+ }
+ catch (Exception ex)
+ {
+ row.Error = $"The {row.Name} mesh could not be deleted: {ex.Message}";
+ Log.Error(row.Error, ex);
+ }
+ finally
+ {
+ row.Deleting = false;
+ _slot.Release();
+ RelayCommand.Refresh();
+ UpdateInstalled();
+ }
+ }
+
+ private RegionInstaller Installer(string folder) => new(new AssetDownload(_http), folder);
+
+ private void UpdateInstalled()
+ {
+ if (Folder is not { } folder)
+ {
+ foreach (RegionRow row in Rows) (row.Installed, row.Partial) = (null, 0);
+ return;
+ }
+ RegionInstaller installer = Installer(folder);
+ IReadOnlyDictionary installed = installer.Installed();
+ foreach (RegionRow row in Rows)
+ {
+ row.Installed = installed.GetValueOrDefault(row.Region.Id);
+ row.Partial = row.Busy || row.UpToDate ? 0 : installer.AlreadyDone(row.Region, DownloadOverlays);
+ }
+ }
+
+ /// The client every mesh download shares: GitHub asks for a user agent, and a download may take hours.
+ public static HttpClient CreateHttpClient()
+ {
+ var http = new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(10) })
+ {
+ Timeout = Timeout.InfiniteTimeSpan, // each download watches for silence itself
+ };
+ string version = typeof(PackagesViewModel).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0";
+ http.DefaultRequestHeaders.UserAgent.ParseAdd($"orthostreamxp/{version}");
+ return http;
+ }
+}
diff --git a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs
index 0f50dd0..cabd626 100644
--- a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs
+++ b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs
@@ -54,6 +54,8 @@ public SetupViewModel(MainViewModel main)
_main = main;
SettingsDocument existing = SettingsDocument.Load(SettingsDocument.DefaultPath);
_winFsp = SystemCheck.WinFspInstalled();
+ // Before the folders are chosen below: each choice tells it where regions would go.
+ Downloads = new PackagesViewModel(_main.Mount, _main.Preferences, _main.MeshHttp, _main.MeshInstalls, () => DownloadFolder, RegionInstalled);
XPlaneOptions = [.. SystemCheck.FindXPlaneFolders().Select(f => new FolderOption(f, "X-Plane found here", ChooseXPlane))];
// Which packages to use is the person's own choice: nothing is searched for, only the folder chosen before is offered.
@@ -106,6 +108,39 @@ public SetupViewModel(MainViewModel main)
{
if (Shell.PickFolder("Choose a folder for the cache", CacheFolder) is { } folder) CacheFolder = folder;
});
+ ShowDownloads = new RelayCommand(() =>
+ {
+ DownloadsShown = true;
+ Downloads.OnShown();
+ }, () => !DownloadsShown);
+ }
+
+ /// The regions of the mesh, for someone without scenery packages: installed into the folder chosen, or a suggested one.
+ public PackagesViewModel Downloads { get; }
+
+ public ICommand ShowDownloads { get; }
+
+ public bool DownloadsShown
+ {
+ get => _downloadsShown;
+ private set
+ {
+ if (Set(ref _downloadsShown, value)) Raise(nameof(PackagesMissing));
+ }
+ }
+
+ private bool _downloadsShown;
+
+ /// The packages folder chosen, when it can hold packages; otherwise the one the setup suggests.
+ private string DownloadFolder =>
+ _packages is not null && XPlaneOk && !SystemCheck.IsInCustomScenery(_packages, _xplane) ? _packages : SystemCheck.SuggestedPackagesFolder();
+
+ /// A region is installed: its folder becomes the packages folder, its card telling the packages now in it.
+ private void RegionInstalled(string folder)
+ {
+ FolderOption? old = PackageOptions.FirstOrDefault(o => string.Equals(o.Path, folder, StringComparison.OrdinalIgnoreCase));
+ if (old is not null) PackageOptions.Remove(old);
+ Select(PackageOptions, folder, ChoosePackages, null);
}
public ObservableCollection XPlaneOptions { get; }
@@ -160,6 +195,13 @@ public int Step
/// A folder was chosen and it has no packages (shown in red; before a choice the hint is just a hint).
public bool PackagesWrong => _packages is not null && !PackagesOk;
+ ///
+ /// The folder chosen can be used but holds no packages: the download is offered right under the message, until the
+ /// list of regions is open.
+ ///
+ public bool PackagesMissing =>
+ !DownloadsShown && _packages is not null && XPlaneOk && !SystemCheck.IsInCustomScenery(_packages, _xplane) && SystemCheck.PackagesIn(_packages).Count == 0;
+
public string PackagesHint
{
get
@@ -171,7 +213,9 @@ public string PackagesHint
if (!XPlaneOk) return SystemCheck.XPlaneFirstExplanation;
if (SystemCheck.IsInCustomScenery(_packages, _xplane)) return SystemCheck.InCustomSceneryExplanation;
IReadOnlyList found = SystemCheck.PackagesIn(_packages);
- if (found.Count == 0) return "That folder has no scenery packages (folders with an Earth nav data folder in them).";
+ if (found.Count == 0)
+ return "That folder has no scenery packages: none of its folders has an Earth nav data folder. " +
+ "Choose the folder that holds your packages, not a package itself. No packages yet? Download a region below.";
IReadOnlyList taken = SystemCheck.TakenInCustomScenery(_xplane, found);
return taken.Count == 0
? "OrthoStreamXP adds photos to these packages while it runs. They stay unchanged on disk."
@@ -265,7 +309,9 @@ private void ChooseXPlane(FolderOption option)
Raise(nameof(PackagesOk));
Raise(nameof(PackagesWrong));
Raise(nameof(PackagesHint));
+ Raise(nameof(PackagesMissing));
Raise(nameof(Summary));
+ Downloads.FolderChanged();
RelayCommand.Refresh();
}
@@ -277,7 +323,9 @@ private void ChoosePackages(FolderOption option)
Raise(nameof(PackagesOk));
Raise(nameof(PackagesWrong));
Raise(nameof(PackagesHint));
+ Raise(nameof(PackagesMissing));
Raise(nameof(Summary));
+ Downloads.FolderChanged();
RelayCommand.Refresh();
}
diff --git a/src/OrthoStreamXP/Gui/Views/PackagesView.xaml b/src/OrthoStreamXP/Gui/Views/PackagesView.xaml
new file mode 100644
index 0000000..1f86d5c
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Views/PackagesView.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrthoStreamXP/Gui/Views/PackagesView.xaml.cs b/src/OrthoStreamXP/Gui/Views/PackagesView.xaml.cs
new file mode 100644
index 0000000..2da8a8b
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Views/PackagesView.xaml.cs
@@ -0,0 +1,6 @@
+namespace OrthoStreamXP.Gui;
+
+internal sealed partial class PackagesView
+{
+ public PackagesView() => InitializeComponent();
+}
diff --git a/src/OrthoStreamXP/Gui/Views/RegionListView.xaml b/src/OrthoStreamXP/Gui/Views/RegionListView.xaml
new file mode 100644
index 0000000..4fa0273
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Views/RegionListView.xaml
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Source
+
+
+
diff --git a/src/OrthoStreamXP/Gui/Views/RegionListView.xaml.cs b/src/OrthoStreamXP/Gui/Views/RegionListView.xaml.cs
new file mode 100644
index 0000000..84fd924
--- /dev/null
+++ b/src/OrthoStreamXP/Gui/Views/RegionListView.xaml.cs
@@ -0,0 +1,6 @@
+namespace OrthoStreamXP.Gui;
+
+internal sealed partial class RegionListView
+{
+ public RegionListView() => InitializeComponent();
+}
diff --git a/src/OrthoStreamXP/Gui/Views/SetupView.xaml b/src/OrthoStreamXP/Gui/Views/SetupView.xaml
index 873fa41..e61585f 100644
--- a/src/OrthoStreamXP/Gui/Views/SetupView.xaml
+++ b/src/OrthoStreamXP/Gui/Views/SetupView.xaml
@@ -1,6 +1,7 @@
@@ -170,8 +171,10 @@
+
+ Text="Choose the folder that holds your packages, not a package itself: for example D:\OrthoPackages, with the packages Europe and Asia in it. Have none yet? Download a region below." />
@@ -185,6 +188,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OrthoStreamXP/Gui/Views/SetupView.xaml.cs b/src/OrthoStreamXP/Gui/Views/SetupView.xaml.cs
index 6975ff8..734068d 100644
--- a/src/OrthoStreamXP/Gui/Views/SetupView.xaml.cs
+++ b/src/OrthoStreamXP/Gui/Views/SetupView.xaml.cs
@@ -1,6 +1,16 @@
+using System.Windows;
+using System.Windows.Threading;
+
namespace OrthoStreamXP.Gui;
internal sealed partial class SetupView
{
public SetupView() => InitializeComponent();
+
+ /// The list of regions opened (perhaps from the button under the red message): scrolled into sight once laid out.
+ private void OnDownloadsShown(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (e.NewValue is true && sender is FrameworkElement list)
+ Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => list.BringIntoView());
+ }
}
diff --git a/src/OrthoStreamXP/OrthoStreamXP.csproj b/src/OrthoStreamXP/OrthoStreamXP.csproj
index 3d0af77..c4d9434 100644
--- a/src/OrthoStreamXP/OrthoStreamXP.csproj
+++ b/src/OrthoStreamXP/OrthoStreamXP.csproj
@@ -7,7 +7,7 @@
falseorthostreamxpOrthoStreamXP
- 0.3.0
+ 1.1.0orthostreamxp.icotrue
diff --git a/src/OrthoStreamXP/Scenery/FileSystem/SceneryPacks.cs b/src/OrthoStreamXP/Scenery/FileSystem/SceneryPacks.cs
index 484c2de..725ef7b 100644
--- a/src/OrthoStreamXP/Scenery/FileSystem/SceneryPacks.cs
+++ b/src/OrthoStreamXP/Scenery/FileSystem/SceneryPacks.cs
@@ -10,6 +10,9 @@ namespace OrthoStreamXP.Scenery;
internal static class SceneryPacks
{
public const string FileName = "scenery_packs.ini";
+
+ /// The names of the packages OrthoStreamXP downloads itself (OSXP_Eur, OSXP_Overlays...).
+ public const string DownloadedPrefix = "OSXP_";
private const string Enabled = "SCENERY_PACK ", Disabled = "SCENERY_PACK_DISABLED ";
private static readonly string[] NewFileHeader = ["I", "1000 Version", "SCENERY", ""];
@@ -57,7 +60,8 @@ public static bool MoveToEnd(string customScenery, IReadOnlyList package
///
/// The lines without the packages' entries, then one entry per package at the end. Overlay packages
- /// (such as XPME_Overlays) come first among them, so their overlays draw on top of the other base packages.
+ /// (such as XPME_Overlays) come first among them, so their overlays draw on top of the other base packages;
+ /// the meshes OrthoStreamXP downloads (a whole region each) come last, so the person's own packages cover them.
///
internal static List Reorder(IReadOnlyList lines, IReadOnlyList packageNames)
{
@@ -70,7 +74,7 @@ internal static List Reorder(IReadOnlyList lines, IReadOnlyList<
else if (isDisabled) disabled.Add(package);
}
while (kept.Count > 0 && kept[^1].Trim().Length == 0) kept.RemoveAt(kept.Count - 1);
- foreach (string name in packageNames.OrderBy(name => IsOverlays(name) ? 0 : 1))
+ foreach (string name in packageNames.OrderBy(name => IsOverlays(name) ? 0 : IsDownloadedMesh(name) ? 2 : 1))
kept.Add($"{(disabled.Contains(name) ? Disabled : Enabled)}Custom Scenery/{name}/");
return kept;
}
@@ -91,5 +95,7 @@ internal static List Reorder(IReadOnlyList lines, IReadOnlyList<
private static bool IsOverlays(string packageName) => packageName.Contains("Overlays", StringComparison.OrdinalIgnoreCase);
+ private static bool IsDownloadedMesh(string packageName) => packageName.StartsWith(DownloadedPrefix, StringComparison.OrdinalIgnoreCase);
+
private static string Normalize(string path) => path.Trim().Replace('\\', '/').TrimEnd('/');
}
diff --git a/src/OrthoStreamXP/Setup/Installation/SystemCheck.cs b/src/OrthoStreamXP/Setup/Installation/SystemCheck.cs
index 3343943..4559b54 100644
--- a/src/OrthoStreamXP/Setup/Installation/SystemCheck.cs
+++ b/src/OrthoStreamXP/Setup/Installation/SystemCheck.cs
@@ -55,7 +55,7 @@ public static IReadOnlyList PackagesIn(string? folder)
/// Why a scenery packages folder in X-Plane's Custom Scenery is refused, and where the packages go instead.
public const string InCustomSceneryExplanation =
"Your scenery packages must not be in X-Plane's Custom Scenery folder. OrthoStreamXP puts each package into Custom Scenery " +
- "itself while it runs, so packages kept there (such as Custom Scenery\\z_autoortho\\scenery) would be in X-Plane twice. " +
+ "itself while it runs, so packages kept there would be in X-Plane twice." +
"Move the package folders out of X-Plane, for example to D:\\OrthoStreamXP\\Base Packages, and choose that folder as your scenery packages folder.";
/// Why the X-Plane folder is needed before the scenery packages folder.
@@ -137,6 +137,13 @@ public static string SuggestedCacheFolder(string? basePackages)
/// The name of the cache folder the setup suggests, beside "Base Packages".
public const string CacheFolderName = "Cache";
+ ///
+ /// Where the setup installs downloaded mesh regions for someone who has no scenery packages folder yet:
+ /// OrthoStreamXP\Base Packages in the user's home folder, beside the cache it suggests.
+ ///
+ public static string SuggestedPackagesFolder() =>
+ Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "OrthoStreamXP", "Base Packages");
+
private static IEnumerable FixedDrives()
{
foreach (DriveInfo drive in DriveInfo.GetDrives())
diff --git a/src/OrthoStreamXP/Setup/Packages/AssetDownload.cs b/src/OrthoStreamXP/Setup/Packages/AssetDownload.cs
new file mode 100644
index 0000000..a74f5af
--- /dev/null
+++ b/src/OrthoStreamXP/Setup/Packages/AssetDownload.cs
@@ -0,0 +1,127 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+
+namespace OrthoStreamXP.Setup;
+
+///
+/// Downloads one large file to the disk, resuming what is already there with an HTTP range: an install of many gigabytes
+/// survives a cut line, a stop and a restart of the program. Each answer is written as it comes. A file whose size or
+/// published SHA-256 does not match is deleted, so the next try starts it afresh.
+///
+internal sealed class AssetDownload(HttpClient http, int attempts = AssetDownload.DefaultAttempts, TimeSpan? stall = null)
+{
+ public const int DefaultAttempts = 5;
+ private const int BufferSize = 1 << 20;
+ private readonly TimeSpan _stall = stall ?? TimeSpan.FromSeconds(60);
+
+ ///
+ /// Makes the whole of , downloading what is missing.
+ /// gets the bytes added since the last call (so a resumed file reports only the new bytes).
+ ///
+ /// Every attempt failed, or the file does not match its size or SHA-256.
+ public async Task GetAsync(MeshAsset asset, string target, Action progress, CancellationToken cancel)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ Exception? last = null;
+ for (int attempt = 1; attempt <= attempts; attempt++)
+ {
+ try
+ {
+ if (Length(target) > asset.Size) File.Delete(target);
+ if (Length(target) < asset.Size) await DownloadRestAsync(asset, target, progress, cancel).ConfigureAwait(false);
+ if (Length(target) != asset.Size)
+ throw new IOException($"{asset.Name} ended at {Length(target)} bytes of {asset.Size}");
+ await CheckAsync(asset, target, cancel).ConfigureAwait(false);
+ return;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or TimeoutException && !cancel.IsCancellationRequested)
+ {
+ last = ex;
+ if (ex is ChecksumException || attempt == attempts) break;
+ Log.Warning($"Downloading {asset.Name} failed (attempt {attempt} of {attempts}): {ex.Message}; trying again");
+ await Task.Delay(TimeSpan.FromSeconds(Math.Min(30, 2 << attempt)), cancel).ConfigureAwait(false);
+ }
+ }
+ throw new IOException($"{asset.Name} could not be downloaded: {last?.Message}", last);
+ }
+
+ private async Task DownloadRestAsync(MeshAsset asset, string target, Action progress, CancellationToken cancel)
+ {
+ long have = Length(target);
+ using var request = new HttpRequestMessage(HttpMethod.Get, asset.Url);
+ if (have > 0) request.Headers.Range = new RangeHeaderValue(have, null);
+ using var stalled = CancellationTokenSource.CreateLinkedTokenSource(cancel);
+ stalled.CancelAfter(_stall);
+ HttpResponseMessage response;
+ try
+ {
+ response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, stalled.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
+ {
+ throw new TimeoutException($"{asset.Name}: no answer for {_stall.TotalSeconds:F0} s");
+ }
+ using (response)
+ {
+ response.EnsureSuccessStatusCode();
+ // A server that ignores the range sends the whole file again: it is written from the start.
+ bool resumed = have > 0 && response.StatusCode == HttpStatusCode.PartialContent;
+ if (have > 0 && !resumed)
+ {
+ progress(-have);
+ have = 0;
+ }
+ using var file = new FileStream(target, resumed ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.Read, BufferSize);
+ using Stream body = await response.Content.ReadAsStreamAsync(cancel).ConfigureAwait(false);
+ byte[] buffer = new byte[BufferSize];
+ while (true)
+ {
+ stalled.CancelAfter(_stall); // the limit is on a silence, not on the whole file
+ int read;
+ try
+ {
+ read = await body.ReadAsync(buffer, stalled.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
+ {
+ throw new TimeoutException($"{asset.Name}: nothing received for {_stall.TotalSeconds:F0} s");
+ }
+ if (read == 0) break;
+ await file.WriteAsync(buffer.AsMemory(0, read), cancel).ConfigureAwait(false);
+ progress(read);
+ }
+ }
+ }
+
+ /// Checks the file against its published SHA-256 (only some regions publish one); a mismatch deletes it.
+ private async Task CheckAsync(MeshAsset asset, string target, CancellationToken cancel)
+ {
+ if (asset.Sha256Url is null) return;
+ string published;
+ using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancel))
+ {
+ timeout.CancelAfter(_stall);
+ try
+ {
+ published = await http.GetStringAsync(new Uri(asset.Sha256Url), timeout.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
+ {
+ throw new TimeoutException($"{asset.Name}: its SHA-256 did not come within {_stall.TotalSeconds:F0} s");
+ }
+ }
+ string expected = published.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
+ string actual;
+ using (FileStream file = File.OpenRead(target))
+ actual = Convert.ToHexStringLower(await SHA256.HashDataAsync(file, cancel).ConfigureAwait(false));
+ if (string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) return;
+ File.Delete(target);
+ throw new ChecksumException($"{asset.Name} does not match its published SHA-256 (expected {expected}, got {actual}); it was deleted");
+ }
+
+ private static long Length(string path) => File.Exists(path) ? new FileInfo(path).Length : 0;
+
+ /// A file that arrived whole but differs from what was published: trying again at once would not help.
+ private sealed class ChecksumException(string message) : IOException(message);
+}
diff --git a/src/OrthoStreamXP/Setup/Packages/MeshCatalog.cs b/src/OrthoStreamXP/Setup/Packages/MeshCatalog.cs
new file mode 100644
index 0000000..a2fa4c8
--- /dev/null
+++ b/src/OrthoStreamXP/Setup/Packages/MeshCatalog.cs
@@ -0,0 +1,121 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace OrthoStreamXP.Setup;
+
+///
+/// The regions of AutoOrtho's scenery mesh, read from the GitHub releases of kubilus1/autoortho-scenery the way AutoOrtho's
+/// own installer reads them: a release belongs to a region when it carries that region's <id>_info.json, the
+/// newest one counts, and pre-releases and drafts do not. The list is kept beside the settings and asked for again at most
+/// every hour; it stands in when GitHub does not answer.
+///
+internal sealed partial class MeshCatalog(HttpClient http, string cacheFile)
+{
+ public const string Repository = "kubilus1/autoortho-scenery";
+ public const string ProjectPage = "https://github.com/" + Repository;
+ private const string ReleasesUrl = "https://api.github.com/repos/" + Repository + "/releases?per_page=100";
+ private static readonly TimeSpan MaxAge = TimeSpan.FromHours(1), RequestTimeout = TimeSpan.FromSeconds(30);
+ private static readonly JsonSerializerOptions CacheJson = new() { WriteIndented = true };
+
+ /// z_eur_03.zip (a mesh zip), y_eur_overlays.zip.02 (a part of the overlays zip) or y_afr_overlays.zip (in one piece).
+ [GeneratedRegex(@"^(?[yz])_(?.+?)\.zip(?:\.(?\d+))?$")]
+ private static partial Regex AssetPattern();
+
+ /// The regions, from the list kept when it is recent (unless ), else from GitHub.
+ /// GitHub did not answer and no list was kept before.
+ public async Task> LoadAsync(bool refresh, CancellationToken cancel)
+ {
+ bool fresh = !refresh && File.Exists(cacheFile) && DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile) < MaxAge;
+ if (fresh && ReadCache() is { } kept) return kept;
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancel);
+ timeout.CancelAfter(RequestTimeout);
+ using HttpResponseMessage response = await http.GetAsync(new Uri(ReleasesUrl), timeout.Token).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ string json = await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false);
+ IReadOnlyList regions = Parse(json);
+ if (regions.Count > 0) WriteCache(regions);
+ return regions;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or JsonException or IOException or OperationCanceledException && !cancel.IsCancellationRequested)
+ {
+ if (ReadCache() is { } older)
+ {
+ Log.Warning($"GitHub's list of mesh regions could not be read ({ex.Message}); the list kept from before is used");
+ return older;
+ }
+ throw new IOException($"The list of mesh regions could not be read from GitHub: {ex.Message}", ex);
+ }
+ }
+
+ /// The newest release of each known region in GitHub's list of releases (JSON, newest first).
+ internal static IReadOnlyList Parse(string releasesJson)
+ {
+ using JsonDocument doc = JsonDocument.Parse(releasesJson);
+ var regions = new Dictionary();
+ foreach (JsonElement release in doc.RootElement.EnumerateArray())
+ {
+ if (Bool(release, "prerelease") || Bool(release, "draft")) continue;
+ var assets = new List<(string Name, string Url, long Size)>();
+ if (release.TryGetProperty("assets", out JsonElement list) && list.ValueKind == JsonValueKind.Array)
+ foreach (JsonElement a in list.EnumerateArray())
+ if (Text(a, "name") is { } name && Text(a, "browser_download_url") is { } url && a.TryGetProperty("size", out JsonElement size))
+ assets.Add((name, url, size.GetInt64()));
+ string? id = assets.Select(a => a.Name).FirstOrDefault(n => n.EndsWith("_info.json", StringComparison.Ordinal))?[..^"_info.json".Length];
+ if (id is null || !MeshRegion.IsKnown(id) || regions.ContainsKey(id)) continue;
+ if (Of(id, Text(release, "tag_name") ?? "", assets) is { } region) regions[id] = region;
+ }
+ return [.. MeshRegion.Known.Where(k => regions.ContainsKey(k.Id)).Select(k => regions[k.Id])];
+ }
+
+ private static MeshRegion? Of(string id, string release, List<(string Name, string Url, long Size)> assets)
+ {
+ var urls = assets.ToDictionary(a => a.Name, a => a.Url, StringComparer.Ordinal);
+ var mesh = new List();
+ var overlays = new List<(int Part, MeshAsset Asset)>();
+ foreach (var (name, url, size) in assets)
+ {
+ Match m = AssetPattern().Match(name);
+ if (!m.Success) continue;
+ string? sha = urls.GetValueOrDefault(name + ".sha256");
+ if (m.Groups["kind"].Value == "z" && m.Groups["name"].Value.StartsWith(id + "_", StringComparison.Ordinal) && !m.Groups["part"].Success)
+ mesh.Add(new MeshAsset(name, url, size, sha));
+ else if (m.Groups["kind"].Value == "y" && m.Groups["name"].Value == id + "_overlays")
+ overlays.Add((m.Groups["part"].Success ? int.Parse(m.Groups["part"].Value, CultureInfo.InvariantCulture) : 0, new MeshAsset(name, url, size, sha)));
+ }
+ if (mesh.Count == 0) return null;
+ return new MeshRegion(id, release, [.. mesh.OrderBy(a => a.Name, StringComparer.Ordinal)], [.. overlays.OrderBy(o => o.Part).Select(o => o.Asset)]);
+ }
+
+ private static bool Bool(JsonElement e, string name) => e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.True;
+
+ private static string? Text(JsonElement e, string name) =>
+ e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ private List? ReadCache()
+ {
+ try
+ {
+ return File.Exists(cacheFile) ? JsonSerializer.Deserialize>(File.ReadAllText(cacheFile)) : null;
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException or NotSupportedException)
+ {
+ return null;
+ }
+ }
+
+ private void WriteCache(IReadOnlyList regions)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+ AtomicFile.Write(cacheFile, JsonSerializer.SerializeToUtf8Bytes(regions, CacheJson));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Log.Warning($"Could not keep the list of mesh regions: {ex.Message}");
+ }
+ }
+}
diff --git a/src/OrthoStreamXP/Setup/Packages/MeshRegion.cs b/src/OrthoStreamXP/Setup/Packages/MeshRegion.cs
new file mode 100644
index 0000000..98234c7
--- /dev/null
+++ b/src/OrthoStreamXP/Setup/Packages/MeshRegion.cs
@@ -0,0 +1,46 @@
+using OrthoStreamXP.Scenery;
+
+namespace OrthoStreamXP.Setup;
+
+/// One file of a region's release: its download address, size, and the address of its SHA-256 when one is published.
+internal sealed record MeshAsset(string Name, string Url, long Size, string? Sha256Url = null);
+
+///
+/// A region of AutoOrtho's scenery mesh (github.com/kubilus1/autoortho-scenery): the zips of its mesh, each holding one
+/// top folder with Earth nav data, terrain and textures, and the parts of one overlays zip (X-Plane's roads, forests and
+/// buildings for its squares). Installed as the package , its overlays into
+/// .
+///
+internal sealed record MeshRegion(string Id, string Release, IReadOnlyList Mesh, IReadOnlyList Overlays)
+{
+ /// The package every region's overlays go into.
+ public const string OverlaysPackageName = SceneryPacks.DownloadedPrefix + "Overlays";
+
+ /// The regions in the order they are listed, with their names and their packages' names.
+ public static readonly IReadOnlyList<(string Id, string Name, string Package)> Known =
+ [
+ ("eur", "Europe", "OSXP_Eur"),
+ ("na", "North America", "OSXP_NA"),
+ ("sa", "South America", "OSXP_SA"),
+ ("afr", "Africa", "OSXP_Afr"),
+ ("asi", "Asia", "OSXP_Asi"),
+ ("aus_pac", "Australia and the Pacific", "OSXP_AusPac"),
+ ];
+
+ public static bool IsKnown(string id) => Known.Any(k => k.Id == id);
+
+ public static string NameOf(string id) => Known.FirstOrDefault(k => k.Id == id).Name ?? id;
+
+ public static string PackageOf(string id) => Known.FirstOrDefault(k => k.Id == id).Package ?? SceneryPacks.DownloadedPrefix + id;
+
+ public string Name => NameOf(Id);
+
+ public string PackageName => PackageOf(Id);
+
+ public long MeshSize => Mesh.Sum(a => a.Size);
+
+ public long OverlaysSize => Overlays.Sum(a => a.Size);
+
+ /// What an install downloads, with or without the overlays.
+ public long DownloadSize(bool withOverlays) => MeshSize + (withOverlays ? OverlaysSize : 0);
+}
diff --git a/src/OrthoStreamXP/Setup/Packages/OverlayPackage.cs b/src/OrthoStreamXP/Setup/Packages/OverlayPackage.cs
new file mode 100644
index 0000000..297492d
--- /dev/null
+++ b/src/OrthoStreamXP/Setup/Packages/OverlayPackage.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+
+namespace OrthoStreamXP.Setup;
+
+///
+/// The one overlays package every downloaded region shares (). It records which
+/// region placed each file, so a region can be taken out again without touching the others'. A file another region placed
+/// already (a square on the border of two) stays that region's.
+///
+internal sealed class OverlayPackage(string packagesFolder)
+{
+ private const string RecordName = ".orthostreamxp-overlays.json";
+
+ public string Folder { get; } = Path.Join(packagesFolder, MeshRegion.OverlaysPackageName);
+
+ private string RecordFile => Path.Join(Folder, RecordName);
+
+ /// The regions whose overlays are in the package.
+ public IReadOnlyCollection Regions => Load().Keys;
+
+ /// Moves a region's unpacked overlays from into the package.
+ public void Add(string regionId, string staging, IReadOnlyList files)
+ {
+ Dictionary> record = Load();
+ var owner = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (region, owned) in record)
+ foreach (string file in owned)
+ owner[file] = region;
+ var placed = new List();
+ foreach (string file in files)
+ {
+ string target = Path.Join(Folder, file);
+ if (owner.TryGetValue(file, out string? other) ? other != regionId : File.Exists(target)) continue;
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.Move(Path.Join(staging, file), target, overwrite: true);
+ placed.Add(file);
+ }
+ record[regionId] = placed;
+ Save(record);
+ }
+
+ /// Deletes the files a region placed; the package goes with its last region.
+ public void Remove(string regionId)
+ {
+ Dictionary> record = Load();
+ if (!record.Remove(regionId, out List? owned)) return;
+ foreach (string file in owned)
+ {
+ string path = Path.Join(Folder, file);
+ if (File.Exists(path)) File.Delete(path);
+ }
+ if (record.Count == 0)
+ {
+ Directory.Delete(Folder, recursive: true);
+ return;
+ }
+ RemoveEmptyFolders(Folder);
+ Save(record);
+ }
+
+ private Dictionary> Load()
+ {
+ try
+ {
+ return File.Exists(RecordFile)
+ ? JsonSerializer.Deserialize>>(File.ReadAllText(RecordFile)) ?? []
+ : [];
+ }
+ catch (JsonException ex)
+ {
+ throw new IOException($"The record of {MeshRegion.OverlaysPackageName} could not be read: {ex.Message}", ex);
+ }
+ }
+
+ private void Save(Dictionary> record)
+ {
+ Directory.CreateDirectory(Folder);
+ AtomicFile.Write(RecordFile, JsonSerializer.SerializeToUtf8Bytes(record));
+ }
+
+ private static void RemoveEmptyFolders(string folder)
+ {
+ foreach (string sub in Directory.EnumerateDirectories(folder))
+ {
+ RemoveEmptyFolders(sub);
+ if (!Directory.EnumerateFileSystemEntries(sub).Any()) Directory.Delete(sub);
+ }
+ }
+}
diff --git a/src/OrthoStreamXP/Setup/Packages/RegionInstaller.cs b/src/OrthoStreamXP/Setup/Packages/RegionInstaller.cs
new file mode 100644
index 0000000..0814355
--- /dev/null
+++ b/src/OrthoStreamXP/Setup/Packages/RegionInstaller.cs
@@ -0,0 +1,308 @@
+using System.Runtime.ExceptionServices;
+using System.Text.Json;
+
+namespace OrthoStreamXP.Setup;
+
+/// A region installed in the packages folder: its release, and whether its overlays came with it.
+internal sealed record InstalledRegion(string Id, string Release, bool Overlays, DateTime InstalledUtc);
+
+/// How far an install is: what it does now, and the bytes downloaded of all it downloads.
+internal sealed record InstallProgress(string Stage, long Done, long Total);
+
+/// An install that cannot go on; the message says why, in the window's words.
+internal sealed class InstallException(string message, Exception? inner = null) : Exception(message, inner);
+
+///
+/// Installs, updates and deletes the regions of AutoOrtho's mesh in the scenery packages folder, each as a package of its
+/// own () beside the person's other packages, its overlays in the shared overlays package.
+///
+/// Everything is prepared in the hidden .osxp-download folder there, which is no package (it has no Earth nav data): a
+/// zip is unpacked as soon as it is whole, then deleted, and a record of the zips unpacked lets a stopped install go on
+/// where it was. The region takes its place only once all of it is there, with its marker written last, so X-Plane never
+/// sees half a region. An update is prepared the same way and swapped in at the end.
+///
+///
+internal sealed class RegionInstaller(AssetDownload download, string packagesFolder)
+{
+ public const string MarkerName = ".orthostreamxp-region.json";
+ public const string WorkFolderName = ".osxp-download";
+ /// Unpacked, a region takes about this much more than its zips (DSFs grow, each small .ter takes a whole disk block).
+ private const double UnpackedRatio = 1.7;
+ private const long SpareBytes = 2L << 30;
+
+ private sealed record State(List Unpacked, List? OverlayFiles);
+
+ public string PackagesFolder { get; } = packagesFolder;
+
+ private string WorkRoot => Path.Join(PackagesFolder, WorkFolderName);
+
+ private OverlayPackage Overlays => new(PackagesFolder);
+
+ /// The regions installed in the packages folder, by id.
+ public IReadOnlyDictionary Installed()
+ {
+ var found = new Dictionary();
+ foreach (var (id, _, package) in MeshRegion.Known)
+ {
+ string marker = Path.Join(PackagesFolder, package, MarkerName);
+ try
+ {
+ if (File.Exists(marker) && JsonSerializer.Deserialize(File.ReadAllText(marker)) is { } region)
+ found[id] = region;
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
+ {
+ Log.Warning($"Could not read {marker}: {ex.Message}");
+ }
+ }
+ return found;
+ }
+
+ /// Bytes of a region already downloaded or unpacked by an install that stopped: what it will not download again.
+ public long AlreadyDone(MeshRegion region, bool withOverlays)
+ {
+ string work = WorkOf(region);
+ State state = LoadState(work);
+ long done = region.Mesh.Where(a => state.Unpacked.Contains(a.Name)).Sum(a => a.Size);
+ if (withOverlays && state.OverlayFiles is not null) done += region.OverlaysSize;
+ foreach (MeshAsset asset in Pending(region, withOverlays, state))
+ done += Math.Min(asset.Size, Length(Path.Join(work, "zips", asset.Name)));
+ return done;
+ }
+
+ /// Installs or updates ; a stop leaves what was downloaded for the next time.
+ /// Not enough disk space, a download that failed, or a damaged zip.
+ public async Task InstallAsync(MeshRegion region, bool withOverlays, IProgress progress, CancellationToken cancel)
+ {
+ CheckRegion(region);
+ string work = WorkOf(region);
+ PrepareWork(region, work);
+ State state = LoadState(work);
+ long total = region.DownloadSize(withOverlays);
+ long done = AlreadyDone(region, withOverlays);
+ // A zip is downloaded while the one before is unpacked: two can be on the disk at once.
+ CheckSpace(total - done, region.Mesh.Count == 0 ? 0 : region.Mesh.Max(a => a.Size));
+ Log.Info($"Installing the {region.Name} mesh (release {region.Release}{(withOverlays ? ", with its overlays" : "")}) into {PackagesFolder}" +
+ (done > 0 ? $"; {CacheInventory.FormatBytes(done)} of {CacheInventory.FormatBytes(total)} done before" : ""));
+ void Downloaded(long bytes)
+ {
+ done += bytes;
+ progress.Report(new InstallProgress($"Downloading the {region.Name} mesh", done, total));
+ }
+
+ // Stopped by the person, or by a zip that cannot be unpacked while the next one downloads.
+ using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancel);
+ Task unpacking = Task.CompletedTask;
+ string unpackingName = "";
+ async Task UnpackMeshAsync(string name, string zip)
+ {
+ try
+ {
+ await Task.Run(() =>
+ {
+ using FileStream file = File.OpenRead(zip);
+ ZipUnpacker.Unpack(file, Path.Join(work, "mesh"), stop.Token);
+ }, stop.Token).ConfigureAwait(false);
+ // Recorded only once whole: a stop in the middle unpacks the zip again next time, over what is there.
+ state.Unpacked.Add(name);
+ SaveState(work, state);
+ File.Delete(zip);
+ }
+ catch
+ {
+ await stop.CancelAsync().ConfigureAwait(false); // no use downloading on: the download alongside stops
+ throw;
+ }
+ }
+
+ try
+ {
+ try
+ {
+ // Taken before the loop: the unpack running alongside adds to state.Unpacked.
+ MeshAsset[] todo = [.. region.Mesh.Where(a => !state.Unpacked.Contains(a.Name))];
+ foreach (MeshAsset asset in todo)
+ {
+ string zip = Path.Join(work, "zips", asset.Name);
+ await download.GetAsync(asset, zip, Downloaded, stop.Token).ConfigureAwait(false);
+ // One unpack at a time, in the list's order: the one before ends first.
+ if (!unpacking.IsCompleted) progress.Report(new InstallProgress($"Unpacking {unpackingName}", done, total));
+ await unpacking.ConfigureAwait(false);
+ (unpacking, unpackingName) = (UnpackMeshAsync(asset.Name, zip), asset.Name);
+ }
+
+ var parts = new List();
+ bool overlays = withOverlays && state.OverlayFiles is null && region.Overlays.Count > 0;
+ if (overlays)
+ foreach (MeshAsset part in region.Overlays) // while the last mesh zip is unpacked
+ {
+ string file = Path.Join(work, "zips", part.Name);
+ await download.GetAsync(part, file, Downloaded, stop.Token).ConfigureAwait(false);
+ parts.Add(file);
+ }
+ if (!unpacking.IsCompleted) progress.Report(new InstallProgress($"Unpacking {unpackingName}", done, total));
+ await unpacking.ConfigureAwait(false);
+ if (overlays)
+ {
+ progress.Report(new InstallProgress($"Unpacking the {region.Name} overlays", done, total));
+ IReadOnlyList files = await UnpackPartsAsync(parts, Path.Join(work, "overlays"), cancel).ConfigureAwait(false);
+ state = state with { OverlayFiles = [.. files] };
+ SaveState(work, state);
+ foreach (string part in parts) File.Delete(part);
+ }
+ }
+ catch (Exception error)
+ {
+ // Nothing may write once the install has ended: the unpack running alongside ends first.
+ Exception? unpackError = await EndAsync(unpacking).ConfigureAwait(false);
+ if (unpackError is not null && error is OperationCanceledException && !cancel.IsCancellationRequested)
+ ExceptionDispatchInfo.Throw(unpackError); // the download was stopped by that failure: it is the cause
+ if (unpackError is not null and not OperationCanceledException && !ReferenceEquals(unpackError, error))
+ Log.Warning($"Unpacking {unpackingName} failed as well: {unpackError.Message}");
+ throw;
+ }
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or HttpRequestException or UnauthorizedAccessException)
+ {
+ throw new InstallException($"The {region.Name} mesh could not be installed: {ex.Message}", ex);
+ }
+
+ cancel.ThrowIfCancellationRequested(); // a Stop just before the end: nothing is put in place
+ progress.Report(new InstallProgress($"Putting the {region.Name} mesh in place", total, total));
+ PutInPlace(region, work, withOverlays ? state.OverlayFiles : null);
+ Log.Info($"The {region.Name} mesh is installed as {region.PackageName}{(withOverlays ? $", its overlays in {MeshRegion.OverlaysPackageName}" : "")}");
+ }
+
+ /// The downloaded parts of the overlays zip, unpacked as the one zip they make; returns the files, relative.
+ private static Task> UnpackPartsAsync(IReadOnlyList parts, string destination, CancellationToken cancel) =>
+ Task.Run(() =>
+ {
+ using var zip = new ConcatenatedStream(parts);
+ return ZipUnpacker.Unpack(zip, destination, cancel);
+ }, cancel);
+
+ /// Waits for a task to end, whatever way it ends; returns its exception, or null.
+ private static async Task EndAsync(Task task)
+ {
+ try
+ {
+ await task.ConfigureAwait(false);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return ex;
+ }
+ }
+
+ /// Deletes an installed region and its overlays.
+ public void Delete(string regionId)
+ {
+ string package = Path.Join(PackagesFolder, MeshRegion.PackageOf(regionId));
+ // The marker first: a region half deleted is no longer taken for installed.
+ string marker = Path.Join(package, MarkerName);
+ if (File.Exists(marker)) File.Delete(marker);
+ if (Directory.Exists(package)) Directory.Delete(package, recursive: true);
+ Overlays.Remove(regionId);
+ Log.Info($"The {MeshRegion.NameOf(regionId)} mesh was deleted from {PackagesFolder}");
+ }
+
+ private void PutInPlace(MeshRegion region, string work, IReadOnlyList? overlayFiles)
+ {
+ string package = Path.Join(PackagesFolder, region.PackageName);
+ string mesh = Path.Join(work, "mesh");
+ string old = Path.Join(work, "old");
+ if (Directory.Exists(package)) Directory.Move(package, old); // an update: the new release takes its place
+ try
+ {
+ Directory.Move(mesh, package);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ if (Directory.Exists(old) && !Directory.Exists(package)) Directory.Move(old, package);
+ throw new InstallException($"The {region.Name} mesh could not be put in place: {ex.Message}", ex);
+ }
+ var marker = new InstalledRegion(region.Id, region.Release, overlayFiles is not null, DateTime.UtcNow);
+ AtomicFile.Write(Path.Join(package, MarkerName), JsonSerializer.SerializeToUtf8Bytes(marker));
+
+ OverlayPackage overlays = Overlays;
+ overlays.Remove(region.Id);
+ if (overlayFiles is not null) overlays.Add(region.Id, Path.Join(work, "overlays"), overlayFiles);
+
+ Directory.Delete(work, recursive: true);
+ DeleteIfEmpty(Path.GetDirectoryName(work)!);
+ DeleteIfEmpty(WorkRoot);
+ }
+
+ private static void DeleteIfEmpty(string folder)
+ {
+ if (Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any()) Directory.Delete(folder);
+ }
+
+ /// The region's work folder; one left by another release of it is deleted (a new release starts afresh).
+ private void PrepareWork(MeshRegion region, string work)
+ {
+ string regionWork = Path.GetDirectoryName(work)!;
+ if (Directory.Exists(regionWork))
+ foreach (string other in Directory.EnumerateDirectories(regionWork))
+ if (!string.Equals(Path.GetFullPath(other), Path.GetFullPath(work), StringComparison.OrdinalIgnoreCase))
+ Directory.Delete(other, recursive: true);
+ Directory.CreateDirectory(work);
+ File.SetAttributes(WorkRoot, File.GetAttributes(WorkRoot) | FileAttributes.Hidden);
+ }
+
+ ///
+ /// The region as the list of releases describes it is used for paths and downloads: a known region, plain file names
+ /// (never a path that leads elsewhere) and HTTPS addresses only, whether the list came from GitHub or from the copy kept.
+ ///
+ private static void CheckRegion(MeshRegion region)
+ {
+ if (!MeshRegion.IsKnown(region.Id)) throw new InstallException($"'{region.Id}' is not a region OrthoStreamXP knows.");
+ foreach (MeshAsset asset in region.Mesh.Concat(region.Overlays))
+ {
+ bool plainName = asset.Name.Length > 0 && asset.Name == Path.GetFileName(asset.Name)
+ && asset.Name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && asset.Name.Trim('.').Length > 0;
+ if (!plainName || !IsHttps(asset.Url) || (asset.Sha256Url is { } sha && !IsHttps(sha)))
+ throw new InstallException($"The list of the {region.Name} mesh's files has an entry that cannot be used ({asset.Name}). Press Refresh, then try again.");
+ }
+ }
+
+ private static bool IsHttps(string url) => Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) && uri.Scheme == Uri.UriSchemeHttps;
+
+ /// What is left to download.
+ /// A zip that may be on the disk beside the one being unpacked.
+ private void CheckSpace(long toDownload, long extraZip)
+ {
+ if (SystemCheck.FreeBytes(PackagesFolder) is not { } free) return;
+ long needed = (long)(toDownload * UnpackedRatio) + extraZip + SpareBytes;
+ if (free < needed)
+ throw new InstallException($"Not enough disk space: the install needs about {CacheInventory.FormatBytes(needed)} " +
+ $"and the drive of {PackagesFolder} has {CacheInventory.FormatBytes(free)} free.");
+ }
+
+ private static IEnumerable Pending(MeshRegion region, bool withOverlays, State state) =>
+ region.Mesh.Where(a => !state.Unpacked.Contains(a.Name))
+ .Concat(withOverlays && state.OverlayFiles is null ? region.Overlays : []);
+
+ private string WorkOf(MeshRegion region) => Path.Join(WorkRoot, region.Id, Safe(region.Release));
+
+ private static string Safe(string release) => string.Concat(release.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
+
+ private static State LoadState(string work)
+ {
+ string file = Path.Join(work, "state.json");
+ try
+ {
+ return File.Exists(file) ? JsonSerializer.Deserialize(File.ReadAllText(file)) ?? new State([], null) : new State([], null);
+ }
+ catch (JsonException)
+ {
+ return new State([], null); // unreadable: the zips are unpacked again, over what is there
+ }
+ }
+
+ private static void SaveState(string work, State state) =>
+ AtomicFile.Write(Path.Join(work, "state.json"), JsonSerializer.SerializeToUtf8Bytes(state));
+
+ private static long Length(string path) => File.Exists(path) ? new FileInfo(path).Length : 0;
+}
diff --git a/src/OrthoStreamXP/Setup/Packages/ZipUnpacker.cs b/src/OrthoStreamXP/Setup/Packages/ZipUnpacker.cs
new file mode 100644
index 0000000..3f76cc5
--- /dev/null
+++ b/src/OrthoStreamXP/Setup/Packages/ZipUnpacker.cs
@@ -0,0 +1,140 @@
+using System.IO.Compression;
+
+namespace OrthoStreamXP.Setup;
+
+///
+/// Unpacks a region's zip into a folder. The zips hold their package under a folder of their own (z_eur_03/Earth nav data/...,
+/// and the overlays one or two folders deeper), which is taken off: the files land where the package keeps them. Each file
+/// is written beside its place and renamed over it, so an interrupted unpack leaves no half file; an entry that would land
+/// outside the folder is refused.
+///
+internal static class ZipUnpacker
+{
+ private const string EarthNavData = "Earth nav data/";
+
+ /// Unpacks the files under the zip's package folder (the folder that holds Earth nav data); returns them, relative.
+ /// The zip holds no package, or an entry points outside the folder.
+ public static IReadOnlyList Unpack(Stream zip, string destination, CancellationToken cancel)
+ {
+ using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
+ string prefix = PackagePrefix(archive.Entries.Select(e => e.FullName.Replace('\\', '/')))
+ ?? throw new InvalidDataException("The zip holds no scenery package (no Earth nav data folder)");
+ string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destination)) + Path.DirectorySeparatorChar;
+ var written = new List();
+ foreach (ZipArchiveEntry entry in archive.Entries)
+ {
+ cancel.ThrowIfCancellationRequested();
+ string name = entry.FullName.Replace('\\', '/');
+ if (!name.StartsWith(prefix, StringComparison.Ordinal) || name.EndsWith('/')) continue;
+ string relative = name[prefix.Length..];
+ // A drive or an NTFS stream (a colon), or characters no path may hold: never a file of the package.
+ if (relative.Contains(':', StringComparison.Ordinal) || relative.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
+ throw new InvalidDataException($"The zip entry {entry.FullName} is not a usable file name");
+ string path;
+ try
+ {
+ path = Path.GetFullPath(Path.Join(root, relative));
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ throw new InvalidDataException($"The zip entry {entry.FullName} is not a usable file name", ex);
+ }
+ if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException($"The zip entry {entry.FullName} points outside the package");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ string part = path + ".part";
+ entry.ExtractToFile(part, overwrite: true);
+ File.Move(part, path, overwrite: true);
+ written.Add(relative.Replace('/', Path.DirectorySeparatorChar));
+ }
+ return written;
+ }
+
+ /// The path in the zip of the folder holding Earth nav data ("z_eur_03/", "" at the top), or null when there is none.
+ internal static string? PackagePrefix(IEnumerable entries)
+ {
+ foreach (string name in entries)
+ {
+ if (name.StartsWith(EarthNavData, StringComparison.Ordinal)) return "";
+ int at = name.IndexOf("/" + EarthNavData, StringComparison.Ordinal);
+ if (at >= 0) return name[..(at + 1)];
+ }
+ return null;
+ }
+}
+
+/// Files read one after another as one seekable stream: the parts of a zip cut in pieces (y_eur_overlays.zip.00, .01...).
+internal sealed class ConcatenatedStream : Stream
+{
+ private readonly FileStream[] _parts;
+ private readonly long[] _starts;
+ private long _position;
+
+ public ConcatenatedStream(IReadOnlyList files)
+ {
+ _parts = new FileStream[files.Count];
+ _starts = new long[files.Count + 1];
+ try
+ {
+ for (int i = 0; i < files.Count; i++)
+ {
+ _parts[i] = new FileStream(files[i], FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16);
+ _starts[i + 1] = _starts[i] + _parts[i].Length;
+ }
+ }
+ catch
+ {
+ Dispose(true);
+ throw;
+ }
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => true;
+ public override bool CanWrite => false;
+ public override long Length => _starts[^1];
+
+ public override long Position
+ {
+ get => _position;
+ set => _position = Math.Clamp(value, 0, Length);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
+
+ public override int Read(Span buffer)
+ {
+ if (_position >= Length || buffer.IsEmpty) return 0;
+ int part = Array.BinarySearch(_starts, _position);
+ part = part >= 0 ? part : ~part - 1;
+ while (part < _parts.Length - 1 && _starts[part + 1] <= _position) part++;
+ FileStream file = _parts[part];
+ file.Position = _position - _starts[part];
+ int read = file.Read(buffer[..(int)Math.Min(buffer.Length, _starts[part + 1] - _position)]);
+ _position += read;
+ return read;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) => Position = origin switch
+ {
+ SeekOrigin.Begin => offset,
+ SeekOrigin.Current => _position + offset,
+ _ => Length + offset,
+ };
+
+ public override void Flush()
+ {
+ }
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ foreach (FileStream? part in _parts)
+ part?.Dispose();
+ base.Dispose(disposing);
+ }
+}
diff --git a/tests/OrthoStreamXP.Tests/MeshPackagesTests.cs b/tests/OrthoStreamXP.Tests/MeshPackagesTests.cs
new file mode 100644
index 0000000..fbb1dbf
--- /dev/null
+++ b/tests/OrthoStreamXP.Tests/MeshPackagesTests.cs
@@ -0,0 +1,446 @@
+using System.IO.Compression;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using OrthoStreamXP.Scenery;
+using OrthoStreamXP.Setup;
+
+namespace OrthoStreamXP.Tests;
+
+public sealed class MeshPackagesTests : IDisposable
+{
+ private const string Host = "https://example.test/";
+ private readonly TempTree _tree = new();
+ private readonly FakeReleases _server = new();
+ private readonly HttpClient _http;
+
+ public MeshPackagesTests() => _http = new HttpClient(_server);
+
+ public void Dispose()
+ {
+ _http.Dispose();
+ _tree.Dispose();
+ }
+
+ private string Packages => _tree.PathOf("Base Packages");
+
+ private RegionInstaller Installer() => new(new AssetDownload(_http, attempts: 1), Packages);
+
+ private static readonly IProgress NoProgress = new Progress();
+
+ // ---------------------------------------------------------------- the catalog
+
+ [Fact]
+ public void The_catalog_takes_the_newest_full_release_of_each_region()
+ {
+ string json = """
+ [
+ { "tag_name": "0.0.54", "prerelease": false, "assets": [
+ { "name": "na_info.json", "browser_download_url": "u/na_info.json", "size": 1 },
+ { "name": "z_na_01.zip", "browser_download_url": "u/z_na_01.zip", "size": 20 },
+ { "name": "z_na_00.zip", "browser_download_url": "u/z_na_00.zip", "size": 10 },
+ { "name": "z_na_00.zip.sha256", "browser_download_url": "u/z_na_00.zip.sha256", "size": 64 },
+ { "name": "y_na_overlays.zip.00", "browser_download_url": "u/y0", "size": 5 } ] },
+ { "tag_name": "0.0.52", "prerelease": true, "assets": [
+ { "name": "eur_info.json", "browser_download_url": "u/x", "size": 1 },
+ { "name": "z_eur_00.zip", "browser_download_url": "u/pre", "size": 99 } ] },
+ { "tag_name": "0.0.51", "prerelease": false, "assets": [
+ { "name": "eur_info.json", "browser_download_url": "u/eur_info.json", "size": 1 },
+ { "name": "y_eur_overlays.zip.02", "browser_download_url": "u/e2", "size": 3 },
+ { "name": "y_eur_overlays.zip.00", "browser_download_url": "u/e0", "size": 1 },
+ { "name": "y_eur_overlays.zip.01", "browser_download_url": "u/e1", "size": 2 },
+ { "name": "z_eur_00.zip", "browser_download_url": "u/z_eur_00.zip", "size": 7 } ] },
+ { "tag_name": "0.0.50", "prerelease": false, "assets": [
+ { "name": "eur_info.json", "browser_download_url": "u/old", "size": 1 },
+ { "name": "z_eur_00.zip", "browser_download_url": "u/old.zip", "size": 7 } ] },
+ { "tag_name": "0.0.48", "prerelease": false, "assets": [
+ { "name": "z_afr_00.zip", "browser_download_url": "u/no-info", "size": 7 } ] }
+ ]
+ """;
+
+ IReadOnlyList regions = MeshCatalog.Parse(json);
+
+ Assert.Equal(["eur", "na"], regions.Select(r => r.Id)); // the window's order; Africa has no info.json
+ MeshRegion eur = regions[0];
+ Assert.Equal("0.0.51", eur.Release);
+ Assert.Equal(["z_eur_00.zip"], eur.Mesh.Select(a => a.Name));
+ Assert.Equal(["y_eur_overlays.zip.00", "y_eur_overlays.zip.01", "y_eur_overlays.zip.02"], eur.Overlays.Select(a => a.Name));
+ Assert.Equal("OSXP_Eur", eur.PackageName);
+ MeshRegion na = regions[1];
+ Assert.Equal(["z_na_00.zip", "z_na_01.zip"], na.Mesh.Select(a => a.Name));
+ Assert.Equal("u/z_na_00.zip.sha256", na.Mesh[0].Sha256Url);
+ Assert.Null(na.Mesh[1].Sha256Url);
+ Assert.Equal(30, na.MeshSize);
+ }
+
+ // ---------------------------------------------------------------- installing
+
+ [Fact]
+ public async Task A_region_becomes_one_package_and_its_overlays_go_to_the_shared_package()
+ {
+ MeshRegion eur = Region("eur", "0.0.51",
+ [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "dsf A"), ("z_eur_00/terrain/1_2_BI16.ter", "ter A")),
+ Zip(("z_eur_01/Earth nav data/+50+010/+51+011.dsf", "dsf B"))],
+ Zip(("y_eur/yOrtho4XP_Overlays/Earth nav data/+40+000/+45+005.dsf", "overlay A")), parts: 3);
+
+ await Installer().InstallAsync(eur, withOverlays: true, NoProgress, CancellationToken.None);
+
+ Assert.Equal("dsf A", File.ReadAllText(Path.Join(Packages, "OSXP_Eur", "Earth nav data", "+40+000", "+45+005.dsf")));
+ Assert.Equal("dsf B", File.ReadAllText(Path.Join(Packages, "OSXP_Eur", "Earth nav data", "+50+010", "+51+011.dsf")));
+ Assert.Equal("ter A", File.ReadAllText(Path.Join(Packages, "OSXP_Eur", "terrain", "1_2_BI16.ter")));
+ Assert.Equal("overlay A", File.ReadAllText(Path.Join(Packages, "OSXP_Overlays", "Earth nav data", "+40+000", "+45+005.dsf")));
+ Assert.False(Directory.Exists(Path.Join(Packages, RegionInstaller.WorkFolderName)));
+ InstalledRegion installed = Assert.Single(Installer().Installed()).Value;
+ Assert.Equal(("eur", "0.0.51", true), (installed.Id, installed.Release, installed.Overlays));
+ // Both are packages the mount picks up, the work folder never is.
+ Assert.Equal(["OSXP_Eur", "OSXP_Overlays"], SceneryPacks.FindPackages(Packages, out _).Select(Path.GetFileName));
+ }
+
+ [Fact]
+ public async Task Without_overlays_only_the_mesh_is_installed()
+ {
+ MeshRegion eur = Region("eur", "1", [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "dsf"))],
+ Zip(("y_eur/Earth nav data/+40+000/+45+005.dsf", "overlay")));
+
+ await Installer().InstallAsync(eur, withOverlays: false, NoProgress, CancellationToken.None);
+
+ Assert.False(Directory.Exists(Path.Join(Packages, "OSXP_Overlays")));
+ Assert.False(Installer().Installed()["eur"].Overlays);
+ Assert.DoesNotContain(_server.Requests, r => r.Url.Contains("overlays", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task A_partial_download_is_resumed_from_where_it_stopped()
+ {
+ // Text that does not compress, so the zip is well over the 1000 bytes already there.
+ string content = string.Concat(Enumerable.Range(0, 200).Select(_ => Guid.NewGuid().ToString("N")));
+ byte[] zip = Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", content));
+ MeshRegion eur = Region("eur", "1", [zip]);
+ _tree.WriteBytes($"Base Packages/{RegionInstaller.WorkFolderName}/eur/1/zips/z_eur_00.zip", zip[..1000]);
+
+ Assert.Equal(1000, Installer().AlreadyDone(eur, withOverlays: true));
+ await Installer().InstallAsync(eur, withOverlays: true, NoProgress, CancellationToken.None);
+
+ Assert.Equal(1000, Assert.Single(_server.Requests, r => r.Url.EndsWith("z_eur_00.zip", StringComparison.Ordinal)).From);
+ Assert.Equal(content, File.ReadAllText(Path.Join(Packages, "OSXP_Eur", "Earth nav data", "+40+000", "+45+005.dsf")));
+ }
+
+ [Fact]
+ public async Task A_stopped_install_goes_on_with_the_zips_it_has_not_unpacked()
+ {
+ MeshRegion eur = Region("eur", "1",
+ [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "A")), Zip(("z_eur_01/Earth nav data/+50+010/+51+011.dsf", "B"))]);
+ using var stop = new CancellationTokenSource();
+ // Stops as the second zip is about to be unpacked: the first is unpacked, the second downloaded.
+ var progress = new SyncProgress(p =>
+ {
+ if (p.Stage.Contains("z_eur_01", StringComparison.Ordinal)) stop.Cancel();
+ });
+
+ await Assert.ThrowsAnyAsync(() => Installer().InstallAsync(eur, true, progress, stop.Token));
+ Assert.Empty(Installer().Installed());
+ await Installer().InstallAsync(eur, withOverlays: true, NoProgress, CancellationToken.None);
+
+ Assert.Single(_server.Requests, r => r.Url.EndsWith("z_eur_00.zip", StringComparison.Ordinal));
+ Assert.Single(_server.Requests, r => r.Url.EndsWith("z_eur_01.zip", StringComparison.Ordinal));
+ Assert.Equal("B", File.ReadAllText(Path.Join(Packages, "OSXP_Eur", "Earth nav data", "+50+010", "+51+011.dsf")));
+ }
+
+ [Fact]
+ public async Task The_next_zip_downloads_while_the_one_before_is_unpacked()
+ {
+ // Many small files: unpacking the first zip takes a while.
+ byte[] first = Zip([("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "A"),
+ .. Enumerable.Range(0, 500).Select(i => ($"z_eur_00/terrain/{i}_0_BI16.ter", $"ter {i}"))]);
+ MeshRegion eur = Region("eur", "1", [first, Zip(("z_eur_01/Earth nav data/+50+010/+51+011.dsf", "B"))]);
+ string state = Path.Join(Packages, RegionInstaller.WorkFolderName, "eur", "1", "state.json");
+ bool? firstRecordedWhenSecondAsked = null;
+ _server.OnRequest = (url, _) =>
+ {
+ if (url.EndsWith("z_eur_01.zip", StringComparison.Ordinal))
+ firstRecordedWhenSecondAsked = File.Exists(state) && File.ReadAllText(state).Contains("z_eur_00.zip", StringComparison.Ordinal);
+ return Task.CompletedTask;
+ };
+
+ await Installer().InstallAsync(eur, withOverlays: true, NoProgress, CancellationToken.None);
+
+ Assert.False(firstRecordedWhenSecondAsked); // asked before the first zip was unpacked and recorded
+ Assert.Equal(500, Directory.GetFiles(Path.Join(Packages, "OSXP_Eur", "terrain")).Length);
+ Assert.Equal("B", File.ReadAllText(Path.Join(Packages, "OSXP_Eur", "Earth nav data", "+50+010", "+51+011.dsf")));
+ }
+
+ [Fact]
+ public async Task A_damaged_zip_stops_the_download_running_alongside_and_is_reported_as_the_cause()
+ {
+ byte[] damaged = Encoding.ASCII.GetBytes("this is not a zip at all, just bytes that end the download");
+ MeshRegion eur = Region("eur", "1", [damaged, Zip(("z_eur_01/Earth nav data/+50+010/+51+011.dsf", "B"))]);
+ // The second zip never comes unless the install stops asking for it.
+ _server.OnRequest = (url, cancel) =>
+ url.EndsWith("z_eur_01.zip", StringComparison.Ordinal) ? Task.Delay(Timeout.Infinite, cancel) : Task.CompletedTask;
+
+ var error = await Assert.ThrowsAsync(() => Installer().InstallAsync(eur, true, NoProgress, CancellationToken.None))
+ .WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+
+ Assert.IsType(error.InnerException);
+ Assert.Empty(Installer().Installed());
+ }
+
+ [Fact]
+ public async Task A_zip_that_does_not_match_its_published_sha256_is_refused_and_deleted()
+ {
+ byte[] zip = Zip(("z_na_00/Earth nav data/+40-080/+45-075.dsf", "dsf"));
+ _server.Files[Host + "z_na_00.zip.sha256"] = Encoding.ASCII.GetBytes(new string('0', 64) + " z_na_00.zip\n");
+ MeshRegion na = Region("na", "1", [zip]) with
+ {
+ Mesh = [new MeshAsset("z_na_00.zip", Host + "z_na_00.zip", zip.Length, Host + "z_na_00.zip.sha256")],
+ };
+
+ var error = await Assert.ThrowsAsync(() => Installer().InstallAsync(na, true, NoProgress, CancellationToken.None));
+
+ Assert.Contains("SHA-256", error.Message, StringComparison.Ordinal);
+ Assert.False(File.Exists(Path.Join(Packages, RegionInstaller.WorkFolderName, "na", "1", "zips", "z_na_00.zip")));
+ Assert.False(Directory.Exists(Path.Join(Packages, "OSXP_NA")));
+ }
+
+ [Fact]
+ public async Task A_matching_sha256_is_accepted()
+ {
+ byte[] zip = Zip(("z_na_00/Earth nav data/+40-080/+45-075.dsf", "dsf"));
+ _server.Files[Host + "z_na_00.zip.sha256"] = Encoding.ASCII.GetBytes(Convert.ToHexStringLower(SHA256.HashData(zip)) + " z_na_00.zip\n");
+ MeshRegion na = Region("na", "1", [zip]) with
+ {
+ Mesh = [new MeshAsset("z_na_00.zip", Host + "z_na_00.zip", zip.Length, Host + "z_na_00.zip.sha256")],
+ };
+
+ await Installer().InstallAsync(na, true, NoProgress, CancellationToken.None);
+
+ Assert.True(File.Exists(Path.Join(Packages, "OSXP_NA", "Earth nav data", "+40-080", "+45-075.dsf")));
+ }
+
+ [Fact]
+ public async Task An_entry_pointing_outside_the_package_is_refused()
+ {
+ MeshRegion eur = Region("eur", "1",
+ [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "dsf"), ("z_eur_00/../../../evil.txt", "evil"))]);
+
+ await Assert.ThrowsAsync(() => Installer().InstallAsync(eur, true, NoProgress, CancellationToken.None));
+
+ Assert.False(File.Exists(_tree.PathOf("evil.txt")));
+ Assert.False(File.Exists(Path.Join(Packages, "evil.txt")));
+ }
+
+ [Theory]
+ [InlineData("..\\..\\escaped.zip", Host + "z.zip")]
+ [InlineData("z_eur_00.zip", "http://example.test/z_eur_00.zip")]
+ [InlineData("..", Host + "z.zip")]
+ public async Task A_file_list_entry_that_could_write_elsewhere_or_download_insecurely_is_refused(string name, string url)
+ {
+ // As a tampered copy of the kept list could describe it: it never goes through GitHub's parser.
+ var region = new MeshRegion("eur", "1", [new MeshAsset(name, url, 10)], []);
+
+ await Assert.ThrowsAsync(() => Installer().InstallAsync(region, true, NoProgress, CancellationToken.None));
+
+ Assert.Empty(_server.Requests);
+ Assert.False(File.Exists(_tree.PathOf("escaped.zip")));
+ }
+
+ [Fact]
+ public async Task A_region_the_app_does_not_know_is_refused()
+ {
+ var region = new MeshRegion("..\\evil", "1", [new MeshAsset("z.zip", Host + "z.zip", 10)], []);
+
+ await Assert.ThrowsAsync(() => Installer().InstallAsync(region, true, NoProgress, CancellationToken.None));
+
+ Assert.Empty(_server.Requests);
+ }
+
+ [Fact]
+ public async Task An_entry_with_an_unusable_name_is_reported_not_lost()
+ {
+ MeshRegion eur = Region("eur", "1", [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "dsf"), ("z_eur_00/C:/x.txt", "x"))]);
+
+ var error = await Assert.ThrowsAsync(() => Installer().InstallAsync(eur, true, NoProgress, CancellationToken.None));
+
+ Assert.IsType(error.InnerException);
+ }
+
+ [Fact]
+ public async Task An_update_replaces_the_region_and_its_overlays()
+ {
+ await Installer().InstallAsync(Region("eur", "1", [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "old"), ("z_eur_00/terrain/gone.ter", "gone"))],
+ Zip(("y/Earth nav data/+40+000/+45+005.dsf", "old overlay"))), true, NoProgress, CancellationToken.None);
+ _server.Files.Clear();
+
+ await Installer().InstallAsync(Region("eur", "2", [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "new"))],
+ Zip(("y/Earth nav data/+40+000/+45+005.dsf", "new overlay"))), true, NoProgress, CancellationToken.None);
+
+ Assert.Equal("new", File.ReadAllText(Path.Join(Packages, "OSXP_Eur", "Earth nav data", "+40+000", "+45+005.dsf")));
+ Assert.False(File.Exists(Path.Join(Packages, "OSXP_Eur", "terrain", "gone.ter")));
+ Assert.Equal("new overlay", File.ReadAllText(Path.Join(Packages, "OSXP_Overlays", "Earth nav data", "+40+000", "+45+005.dsf")));
+ Assert.Equal("2", Installer().Installed()["eur"].Release);
+ }
+
+ [Fact]
+ public async Task Deleting_a_region_keeps_the_overlays_of_the_others()
+ {
+ await Installer().InstallAsync(Region("eur", "1", [Zip(("z_eur_00/Earth nav data/+30+000/+35+005.dsf", "eur"))],
+ Zip(("y/Earth nav data/+30+000/+35+005.dsf", "border, Europe's"), ("y/Earth nav data/+40+000/+45+005.dsf", "Europe only"))),
+ true, NoProgress, CancellationToken.None);
+ await Installer().InstallAsync(Region("afr", "1", [Zip(("z_afr_00/Earth nav data/+30+000/+31+001.dsf", "afr"))],
+ Zip(("y/Earth nav data/+30+000/+35+005.dsf", "border, Africa's"), ("y/Earth nav data/+00+010/+05+015.dsf", "Africa only"))),
+ true, NoProgress, CancellationToken.None);
+ string overlays = Path.Join(Packages, "OSXP_Overlays", "Earth nav data");
+ Assert.Equal("border, Europe's", File.ReadAllText(Path.Join(overlays, "+30+000", "+35+005.dsf"))); // placed first, kept
+
+ Installer().Delete("afr");
+
+ Assert.False(Directory.Exists(Path.Join(Packages, "OSXP_Afr")));
+ Assert.False(File.Exists(Path.Join(overlays, "+00+010", "+05+015.dsf")));
+ Assert.False(Directory.Exists(Path.Join(overlays, "+00+010")));
+ Assert.Equal("border, Europe's", File.ReadAllText(Path.Join(overlays, "+30+000", "+35+005.dsf")));
+ Assert.True(File.Exists(Path.Join(overlays, "+40+000", "+45+005.dsf")));
+
+ Installer().Delete("eur");
+
+ Assert.False(Directory.Exists(Path.Join(Packages, "OSXP_Overlays")));
+ Assert.Empty(Installer().Installed());
+ }
+
+ [Fact]
+ public async Task While_the_setup_installs_a_region_the_Packages_page_starts_no_other()
+ {
+ MeshRegion eur = Region("eur", "1", [Zip(("z_eur_00/Earth nav data/+40+000/+45+005.dsf", "A"))]);
+ MeshRegion afr = Region("afr", "1", [Zip(("z_afr_00/Earth nav data/+30+000/+31+001.dsf", "B"))]);
+ var asked = new TaskCompletionSource();
+ _server.OnRequest = async (url, cancel) =>
+ {
+ if (!url.Contains("z_eur_00", StringComparison.Ordinal)) return;
+ asked.TrySetResult();
+ await Task.Delay(Timeout.Infinite, cancel); // held until the install is stopped
+ };
+ var mount = new OrthoStreamXP.Gui.MountController();
+ var preferences = new OrthoStreamXP.Gui.WindowPreferences();
+ var slot = new OrthoStreamXP.Gui.InstallSlot();
+ var setup = new OrthoStreamXP.Gui.PackagesViewModel(mount, preferences, _http, slot, () => Packages);
+ var page = new OrthoStreamXP.Gui.PackagesViewModel(mount, preferences, _http, slot, () => Packages);
+ var setupRow = new OrthoStreamXP.Gui.RegionRow(setup, eur);
+ var pageRow = new OrthoStreamXP.Gui.RegionRow(page, afr);
+
+ Task installing = setup.InstallAsync(setupRow);
+ await asked.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+
+ Assert.False(page.CanInstall(pageRow));
+ await page.InstallAsync(pageRow); // refused: returns at once, nothing asked
+ Assert.DoesNotContain(_server.Requests, r => r.Url.Contains("z_afr_00", StringComparison.Ordinal));
+
+ setup.StopInstall();
+ await installing.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+ Assert.True(page.CanInstall(pageRow)); // the slot is free again
+ }
+
+ [Fact]
+ public void The_downloaded_meshes_are_listed_below_the_persons_own_packages()
+ {
+ List lines = SceneryPacks.Reorder(["I", "1000 Version", "SCENERY", ""],
+ ["OSXP_Eur", "OSXP_Overlays", "XPME_Europe", "yOrtho4XP_Overlays", "zOrtho4XP_+47+008"]);
+
+ Assert.Equal([
+ "I", "1000 Version", "SCENERY",
+ "SCENERY_PACK Custom Scenery/OSXP_Overlays/",
+ "SCENERY_PACK Custom Scenery/yOrtho4XP_Overlays/",
+ "SCENERY_PACK Custom Scenery/XPME_Europe/",
+ "SCENERY_PACK Custom Scenery/zOrtho4XP_+47+008/",
+ "SCENERY_PACK Custom Scenery/OSXP_Eur/",
+ ], lines);
+ }
+
+ [Fact]
+ public void The_parts_of_a_split_zip_read_as_one_stream()
+ {
+ byte[] whole = Encoding.ASCII.GetBytes("0123456789abcdefghij");
+ string[] parts = [_tree.WriteBytes("p.00", whole[..7]), _tree.WriteBytes("p.01", whole[7..8]), _tree.WriteBytes("p.02", whole[8..])];
+ using var stream = new ConcatenatedStream(parts);
+
+ Assert.Equal(whole.Length, stream.Length);
+ stream.Position = 5;
+ byte[] read = new byte[10];
+ stream.ReadExactly(read);
+ Assert.Equal("56789abcde", Encoding.ASCII.GetString(read));
+ stream.Seek(-3, SeekOrigin.End);
+ Assert.Equal('h', (char)stream.ReadByte());
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ /// A region whose files the fake server serves; its overlays zip cut in parts.
+ private MeshRegion Region(string id, string release, byte[][] meshZips, byte[]? overlaysZip = null, int parts = 1)
+ {
+ var mesh = new List();
+ for (int i = 0; i < meshZips.Length; i++)
+ {
+ string name = $"z_{id}_{i:00}.zip";
+ _server.Files[Host + name] = meshZips[i];
+ mesh.Add(new MeshAsset(name, Host + name, meshZips[i].Length));
+ }
+ var overlays = new List();
+ if (overlaysZip is not null)
+ {
+ int size = (overlaysZip.Length + parts - 1) / parts;
+ for (int p = 0; p < parts; p++)
+ {
+ byte[] part = overlaysZip[(p * size)..Math.Min(overlaysZip.Length, (p + 1) * size)];
+ string name = $"y_{id}_overlays.zip.{p:00}";
+ _server.Files[Host + name] = part;
+ overlays.Add(new MeshAsset(name, Host + name, part.Length));
+ }
+ }
+ return new MeshRegion(id, release, mesh, overlays);
+ }
+
+ private static byte[] Zip(params (string Name, string Text)[] entries)
+ {
+ using var memory = new MemoryStream();
+ using (var zip = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
+ foreach (var (name, text) in entries)
+ {
+ using Stream entry = zip.CreateEntry(name).Open();
+ entry.Write(Encoding.UTF8.GetBytes(text));
+ }
+ return memory.ToArray();
+ }
+
+ /// Progress reported on the installer's own thread, so a test can act on a stage at once.
+ private sealed class SyncProgress(Action report) : IProgress
+ {
+ public void Report(InstallProgress value) => report(value);
+ }
+
+ /// GitHub's release downloads, from memory: whole files, or the rest of one from a range's start.
+ private sealed class FakeReleases : HttpMessageHandler
+ {
+ public Dictionary Files { get; } = [];
+ public List<(string Url, long? From)> Requests { get; } = [];
+
+ /// Called as a request arrives, before it is answered: a test can look at the disk, or hold the answer.
+ public Func? OnRequest { get; set; }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ string url = request.RequestUri!.ToString();
+ long? from = request.Headers.Range?.Ranges.First().From;
+ lock (Requests) Requests.Add((url, from));
+ if (OnRequest is { } onRequest) await onRequest(url, cancellationToken);
+ return Answer(url, from);
+ }
+
+ private HttpResponseMessage Answer(string url, long? from)
+ {
+ if (!Files.TryGetValue(url, out byte[]? data)) return new HttpResponseMessage(HttpStatusCode.NotFound);
+ if (from is not long start) return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(data) };
+ var partial = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(data[(int)start..]) };
+ partial.Content.Headers.ContentRange = new ContentRangeHeaderValue(start, data.Length - 1, data.Length);
+ return partial;
+ }
+ }
+}
diff --git a/tests/OrthoStreamXP.Tests/ViewsTests.cs b/tests/OrthoStreamXP.Tests/ViewsTests.cs
index a1a429f..7242d44 100644
--- a/tests/OrthoStreamXP.Tests/ViewsTests.cs
+++ b/tests/OrthoStreamXP.Tests/ViewsTests.cs
@@ -18,7 +18,7 @@ public void Every_page_loads_with_the_theme()
app.ThemeMode = ThemeMode.Light;
#pragma warning restore WPF0001
app.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/orthostreamxp;component/Gui/Theme.xaml") });
- foreach (Type view in new[] { typeof(OrthoStreamXP.Gui.HomeView), typeof(OrthoStreamXP.Gui.SettingsView), typeof(OrthoStreamXP.Gui.CacheView), typeof(OrthoStreamXP.Gui.LogView) })
+ foreach (Type view in new[] { typeof(OrthoStreamXP.Gui.HomeView), typeof(OrthoStreamXP.Gui.SettingsView), typeof(OrthoStreamXP.Gui.CacheView), typeof(OrthoStreamXP.Gui.PackagesView), typeof(OrthoStreamXP.Gui.LogView) })
{
var page = (FrameworkElement)Activator.CreateInstance(view, nonPublic: true)!;
page.Measure(new Size(1200, 900));