mirror of
https://github.com/velopack/velopack.git
synced 2025-10-25 15:19:22 +00:00
Rewrite UpdateManager
* Remove Filesystem Abstractions * Rx => async/await * Nuke the AppDomain stuff
This commit is contained in:
30
src/Squirrel/FileDownloader.cs
Normal file
30
src/Squirrel/FileDownloader.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Squirrel
|
||||
{
|
||||
public interface IFileDownloader
|
||||
{
|
||||
Task DownloadFile(string url, string targetFile);
|
||||
Task<byte[]> DownloadUrl(string url);
|
||||
}
|
||||
|
||||
class FileDownloader : IFileDownloader
|
||||
{
|
||||
public async Task DownloadFile(string url, string targetFile)
|
||||
{
|
||||
var wc = new WebClient();
|
||||
await wc.DownloadFileTaskAsync(url, targetFile);
|
||||
}
|
||||
|
||||
public Task<byte[]> DownloadUrl(string url)
|
||||
{
|
||||
var wc = new WebClient();
|
||||
return wc.DownloadDataTaskAsync(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ namespace Squirrel
|
||||
/// will return values from 0-100 and Complete, or Throw</param>
|
||||
/// <returns>An UpdateInfo object representing the updates to install.
|
||||
/// </returns>
|
||||
Task<UpdateInfo> CheckForUpdate(bool ignoreDeltaUpdates, Action<int> progress);
|
||||
Task<UpdateInfo> CheckForUpdate(bool ignoreDeltaUpdates, Action<int> progress = null);
|
||||
|
||||
/// <summary>
|
||||
/// Download a list of releases into the local package directory.
|
||||
@@ -31,7 +31,7 @@ namespace Squirrel
|
||||
/// will return values from 0-100 and Complete, or Throw</param>
|
||||
/// <returns>A completion Observable - either returns a single
|
||||
/// Unit.Default then Complete, or Throw</returns>
|
||||
Task DownloadReleases(IEnumerable<ReleaseEntry> releasesToDownload, Action<int> progress);
|
||||
Task DownloadReleases(IEnumerable<ReleaseEntry> releasesToDownload, Action<int> progress = null);
|
||||
|
||||
/// <summary>
|
||||
/// Take an already downloaded set of releases and apply them,
|
||||
@@ -42,9 +42,7 @@ namespace Squirrel
|
||||
/// CheckForUpdate</param>
|
||||
/// <param name="progress">A Observer which can be used to report Progress -
|
||||
/// will return values from 0-100 and Complete, or Throw</param>
|
||||
/// <returns>A list of EXEs that should be started if this is a new
|
||||
/// installation.</returns>
|
||||
Task<List<string>> ApplyReleases(UpdateInfo updateInfo, Action<int> progress);
|
||||
Task ApplyReleases(UpdateInfo updateInfo, Action<int> progress = null);
|
||||
}
|
||||
|
||||
public static class EasyModeMixin
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Drawing" />
|
||||
<Reference Include="System.Runtime.Serialization" />
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Data.DataSetExtensions" />
|
||||
<Reference Include="Microsoft.CSharp" />
|
||||
@@ -57,12 +59,18 @@
|
||||
<Compile Include="ContentType.cs" />
|
||||
<Compile Include="DeltaPackage.cs" />
|
||||
<Compile Include="EnumerableExtensions.cs" />
|
||||
<Compile Include="FileDownloader.cs" />
|
||||
<Compile Include="IUpdateManager.cs" />
|
||||
<Compile Include="MarkdownSharp.cs" />
|
||||
<Compile Include="PackageExtensions.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
<Compile Include="ReleaseEntry.cs" />
|
||||
<Compile Include="ReleaseExtensions.cs" />
|
||||
<Compile Include="ReleasePackage.cs" />
|
||||
<Compile Include="ShellFile.cs" />
|
||||
<Compile Include="TaskbarHelper.cs" />
|
||||
<Compile Include="UpdateInfo.cs" />
|
||||
<Compile Include="UpdateManager.cs" />
|
||||
<Compile Include="Utility.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -76,4 +84,4 @@
|
||||
<Target Name="AfterBuild">
|
||||
</Target>
|
||||
-->
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -2,40 +2,23 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Contracts;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Reactive;
|
||||
using System.Reactive.Disposables;
|
||||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Reactive.Threading.Tasks;
|
||||
using NuGet;
|
||||
using ReactiveUIMicro;
|
||||
using Squirrel.Core;
|
||||
|
||||
// NB: These are whitelisted types from System.IO, so that we always end up
|
||||
// using fileSystem instead.
|
||||
using Squirrel.Core.Extensions;
|
||||
using FileAccess = System.IO.FileAccess;
|
||||
using FileMode = System.IO.FileMode;
|
||||
using MemoryStream = System.IO.MemoryStream;
|
||||
using Path = System.IO.Path;
|
||||
using StreamReader = System.IO.StreamReader;
|
||||
using Splat;
|
||||
|
||||
namespace Squirrel
|
||||
{
|
||||
public sealed class UpdateManager : IUpdateManager, IEnableLogger
|
||||
{
|
||||
readonly IRxUIFullLogger log;
|
||||
readonly IFileSystemFactory fileSystem;
|
||||
readonly IFullLogger log;
|
||||
readonly string rootAppDirectory;
|
||||
readonly string applicationName;
|
||||
readonly IUrlDownloader urlDownloader;
|
||||
readonly IFileDownloader urlDownloader;
|
||||
readonly string updateUrlOrPath;
|
||||
readonly FrameworkVersion appFrameworkVersion;
|
||||
|
||||
@@ -45,23 +28,18 @@ namespace Squirrel
|
||||
string applicationName,
|
||||
FrameworkVersion appFrameworkVersion,
|
||||
string rootDirectory = null,
|
||||
IFileSystemFactory fileSystem = null,
|
||||
IUrlDownloader urlDownloader = null)
|
||||
IFileDownloader urlDownloader = null)
|
||||
{
|
||||
Contract.Requires(!String.IsNullOrEmpty(urlOrPath));
|
||||
Contract.Requires(!String.IsNullOrEmpty(applicationName));
|
||||
|
||||
// XXX: ALWAYS BE LOGGING
|
||||
log = new WrappingFullLogger(new FileLogger(applicationName), typeof(UpdateManager));
|
||||
|
||||
updateUrlOrPath = urlOrPath;
|
||||
this.applicationName = applicationName;
|
||||
this.appFrameworkVersion = appFrameworkVersion;
|
||||
|
||||
this.rootAppDirectory = Path.Combine(rootDirectory ?? getLocalAppDataDirectory(), applicationName);
|
||||
this.fileSystem = fileSystem ?? AnonFileSystem.Default;
|
||||
|
||||
this.urlDownloader = urlDownloader ?? new DirectUrlDownloader(fileSystem);
|
||||
this.urlDownloader = urlDownloader ?? new FileDownloader();
|
||||
}
|
||||
|
||||
public string PackageDirectory {
|
||||
@@ -72,18 +50,15 @@ namespace Squirrel
|
||||
get { return Path.Combine(PackageDirectory, "RELEASES"); }
|
||||
}
|
||||
|
||||
public IObservable<UpdateInfo> CheckForUpdate(bool ignoreDeltaUpdates = false, IObserver<int> progress = null)
|
||||
public async Task<UpdateInfo> CheckForUpdate(bool ignoreDeltaUpdates = false, Action<int> progress = null)
|
||||
{
|
||||
return acquireUpdateLock().SelectMany(_ => checkForUpdate(ignoreDeltaUpdates, progress));
|
||||
}
|
||||
await acquireUpdateLock();
|
||||
|
||||
IObservable<UpdateInfo> checkForUpdate(bool ignoreDeltaUpdates = false, IObserver<int> progress = null)
|
||||
{
|
||||
var localReleases = Enumerable.Empty<ReleaseEntry>();
|
||||
progress = progress ?? new Subject<int>();
|
||||
|
||||
bool shouldInitialize = false;
|
||||
try {
|
||||
var file = fileSystem.GetFileInfo(LocalReleaseFile).OpenRead();
|
||||
var file = File.OpenRead(LocalReleaseFile);
|
||||
|
||||
// NB: sr disposes file
|
||||
using (var sr = new StreamReader(file, Encoding.UTF8)) {
|
||||
@@ -92,207 +67,161 @@ namespace Squirrel
|
||||
} catch (Exception ex) {
|
||||
// Something has gone wrong, we'll start from scratch.
|
||||
log.WarnException("Failed to load local release list", ex);
|
||||
initializeClientAppDirectory();
|
||||
shouldInitialize = true;
|
||||
}
|
||||
|
||||
IObservable<string> releaseFile;
|
||||
if (shouldInitialize) await initializeClientAppDirectory();
|
||||
|
||||
string releaseFile;
|
||||
|
||||
// Fetch the remote RELEASES file, whether it's a local dir or an
|
||||
// HTTP URL
|
||||
try {
|
||||
if (isHttpUrl(updateUrlOrPath)) {
|
||||
log.Info("Downloading RELEASES file from {0}", updateUrlOrPath);
|
||||
releaseFile = urlDownloader.DownloadUrl(String.Format("{0}/{1}", updateUrlOrPath, "RELEASES"), progress)
|
||||
.Catch<string, TimeoutException>(ex => {
|
||||
log.Info("Download timed out (returning blank release list)");
|
||||
return Observable.Return(String.Empty);
|
||||
})
|
||||
.Catch<string, WebException>(ex => {
|
||||
log.InfoException("Download resulted in WebException (returning blank release list)", ex);
|
||||
return Observable.Return(String.Empty);
|
||||
});
|
||||
} else {
|
||||
log.Info("Reading RELEASES file from {0}", updateUrlOrPath);
|
||||
if (isHttpUrl(updateUrlOrPath)) {
|
||||
log.Info("Downloading RELEASES file from {0}", updateUrlOrPath);
|
||||
|
||||
if (!fileSystem.GetDirectoryInfo(updateUrlOrPath).Exists) {
|
||||
var message =
|
||||
String.Format(
|
||||
"The directory {0} does not exist, something is probably broken with your application", updateUrlOrPath);
|
||||
var ex = new SquirrelConfigurationException(message);
|
||||
return Observable.Throw<UpdateInfo>(ex);
|
||||
try {
|
||||
var data = await urlDownloader.DownloadUrl(String.Format("{0}/{1}", updateUrlOrPath, "RELEASES"));
|
||||
releaseFile = Encoding.UTF8.GetString(data);
|
||||
} catch (WebException ex) {
|
||||
log.InfoException("Download resulted in WebException (returning blank release list)", ex);
|
||||
releaseFile = String.Empty;
|
||||
}
|
||||
|
||||
progress(33);
|
||||
} else {
|
||||
log.Info("Reading RELEASES file from {0}", updateUrlOrPath);
|
||||
|
||||
if (!Directory.Exists(updateUrlOrPath)) {
|
||||
var message = String.Format(
|
||||
"The directory {0} does not exist, something is probably broken with your application",
|
||||
updateUrlOrPath);
|
||||
|
||||
throw new Exception(message);
|
||||
}
|
||||
|
||||
var fi = new FileInfo(Path.Combine(updateUrlOrPath, "RELEASES"));
|
||||
if (!fi.Exists) {
|
||||
var message = String.Format(
|
||||
"The file {0} does not exist, something is probably broken with your application",
|
||||
fi.FullName);
|
||||
|
||||
log.Warn(message);
|
||||
|
||||
var packages = (new DirectoryInfo(updateUrlOrPath)).GetFiles("*.nupkg");
|
||||
if (packages.Length == 0) {
|
||||
throw new Exception(message);
|
||||
}
|
||||
|
||||
var fi = fileSystem.GetFileInfo(Path.Combine(updateUrlOrPath, "RELEASES"));
|
||||
if (!fi.Exists) {
|
||||
var message = String.Format(
|
||||
"The file {0} does not exist, something is probably broken with your application", fi.FullName);
|
||||
// NB: Create a new RELEASES file since we've got a directory of packages
|
||||
ReleaseEntry.WriteReleaseFile(
|
||||
packages.Select(x => ReleaseEntry.GenerateFromFile(x.FullName)), fi.FullName);
|
||||
}
|
||||
|
||||
log.Warn(message);
|
||||
|
||||
var packages = fileSystem.GetDirectoryInfo(updateUrlOrPath).GetFiles("*.nupkg");
|
||||
if (packages.Length == 0) {
|
||||
var ex = new SquirrelConfigurationException(message);
|
||||
return Observable.Throw<UpdateInfo>(ex);
|
||||
}
|
||||
|
||||
// NB: Create a new RELEASES file since we've got a directory of packages
|
||||
ReleaseEntry.WriteReleaseFile(
|
||||
packages.Select(x => ReleaseEntry.GenerateFromFile(x.FullName)), fi.FullName);
|
||||
}
|
||||
|
||||
using (var sr = new StreamReader(fi.OpenRead(), Encoding.UTF8)) {
|
||||
var text = sr.ReadToEnd();
|
||||
releaseFile = Observable.Return(text);
|
||||
}
|
||||
|
||||
progress.OnNext(100);
|
||||
progress.OnCompleted();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
progress.OnCompleted();
|
||||
return Observable.Throw<UpdateInfo>(ex);
|
||||
releaseFile = File.ReadAllText(fi.FullName, Encoding.UTF8);
|
||||
progress(33);
|
||||
}
|
||||
|
||||
// Return null if no updates found
|
||||
var ret = releaseFile
|
||||
.Select(ReleaseEntry.ParseReleaseFile)
|
||||
.SelectMany(releases =>
|
||||
releases.Any() ? determineUpdateInfo(localReleases, releases, ignoreDeltaUpdates)
|
||||
: Observable.Return<UpdateInfo>(null))
|
||||
.PublishLast();
|
||||
var ret = default(UpdateInfo);
|
||||
var remoteReleases = ReleaseEntry.ParseReleaseFile(releaseFile);
|
||||
progress(66);
|
||||
|
||||
ret.Connect();
|
||||
if (!remoteReleases.IsEmpty()) {
|
||||
ret = determineUpdateInfo(localReleases, remoteReleases, ignoreDeltaUpdates);
|
||||
}
|
||||
|
||||
progress(100);
|
||||
return ret;
|
||||
}
|
||||
|
||||
public IObservable<Unit> DownloadReleases(IEnumerable<ReleaseEntry> releasesToDownload, IObserver<int> progress = null)
|
||||
public async Task DownloadReleases(IEnumerable<ReleaseEntry> releasesToDownload, Action<int> progress = null)
|
||||
{
|
||||
return acquireUpdateLock().SelectMany(_ => downloadReleases(releasesToDownload, progress));
|
||||
}
|
||||
progress = progress ?? (_ => { });
|
||||
int current = 0;
|
||||
int toIncrement = (int)(100.0 / releasesToDownload.Count());
|
||||
|
||||
IObservable<Unit> downloadReleases(IEnumerable<ReleaseEntry> releasesToDownload, IObserver<int> progress = null)
|
||||
{
|
||||
progress = progress ?? new Subject<int>();
|
||||
IObservable<Unit> downloadResult = null;
|
||||
await acquireUpdateLock();
|
||||
|
||||
if (isHttpUrl(updateUrlOrPath)) {
|
||||
var urls = releasesToDownload.Select(x => String.Format("{0}/{1}", updateUrlOrPath, x.Filename));
|
||||
var paths = releasesToDownload.Select(x => Path.Combine(rootAppDirectory, "packages", x.Filename));
|
||||
|
||||
downloadResult = urlDownloader.QueueBackgroundDownloads(urls, paths, progress);
|
||||
await releasesToDownload.ForEachAsync(async x => {
|
||||
await urlDownloader.DownloadFile(
|
||||
String.Format("{0}/{1}", updateUrlOrPath, x.Filename),
|
||||
Path.Combine(rootAppDirectory, "packages", x.Filename));
|
||||
lock (progress) progress(current += toIncrement);
|
||||
});
|
||||
} else {
|
||||
var toIncrement = 100.0 / releasesToDownload.Count();
|
||||
|
||||
// Do a parallel copy from the remote directory to the local
|
||||
var downloads = releasesToDownload.ToObservable()
|
||||
.Select(x => fileSystem.CopyAsync(
|
||||
await releasesToDownload.ForEachAsync(x => {
|
||||
File.Copy(
|
||||
Path.Combine(updateUrlOrPath, x.Filename),
|
||||
Path.Combine(rootAppDirectory, "packages", x.Filename)))
|
||||
.Merge(4)
|
||||
.Publish();
|
||||
|
||||
downloads
|
||||
.Scan(0.0, (acc, _) => acc + toIncrement)
|
||||
.Select(x => (int) x)
|
||||
.Subscribe(progress);
|
||||
|
||||
downloadResult = downloads.TakeLast(1);
|
||||
downloads.Connect();
|
||||
Path.Combine(rootAppDirectory, "packages", x.Filename));
|
||||
lock (progress) progress(current += toIncrement);
|
||||
});
|
||||
}
|
||||
|
||||
return downloadResult.SelectMany(_ => checksumAllPackages(releasesToDownload));
|
||||
}
|
||||
|
||||
public IObservable<List<string>> ApplyReleases(UpdateInfo updateInfo, IObserver<int> progress = null)
|
||||
public async Task ApplyReleases(UpdateInfo updateInfo, Action<int> progress = null)
|
||||
{
|
||||
progress = progress ?? new Subject<int>();
|
||||
progress = progress ?? (_ => { });
|
||||
|
||||
// NB: It's important that we update the local releases file *only*
|
||||
// once the entire operation has completed, even though we technically
|
||||
// could do it after DownloadUpdates finishes. We do this so that if
|
||||
// we get interrupted / killed during this operation, we'll start over
|
||||
return Observable.Using(_ => acquireUpdateLock().ToTask(), (dontcare, ct) => {
|
||||
var obs = cleanDeadVersions(updateInfo.CurrentlyInstalledVersion != null ? updateInfo.CurrentlyInstalledVersion.Version : null)
|
||||
.Do(_ => progress.OnNext(10))
|
||||
.SelectMany(_ => createFullPackagesFromDeltas(updateInfo.ReleasesToApply, updateInfo.CurrentlyInstalledVersion))
|
||||
.Do(_ => progress.OnNext(50))
|
||||
.Select(release => installPackageToAppDir(updateInfo, release))
|
||||
.Do(_ => progress.OnNext(95))
|
||||
.SelectMany(ret => UpdateLocalReleasesFile().Select(_ => ret))
|
||||
.Finally(() => progress.OnCompleted())
|
||||
.PublishLast();
|
||||
await acquireUpdateLock();
|
||||
|
||||
obs.Connect();
|
||||
await cleanDeadVersions(updateInfo.CurrentlyInstalledVersion != null ? updateInfo.CurrentlyInstalledVersion.Version : null);
|
||||
progress(10);
|
||||
|
||||
// NB: This overload of Using is high as a kite.
|
||||
var tcs = new TaskCompletionSource<IObservable<List<string>>>();
|
||||
tcs.SetResult(obs);
|
||||
return tcs.Task;
|
||||
});
|
||||
var release = await createFullPackagesFromDeltas(updateInfo.ReleasesToApply, updateInfo.CurrentlyInstalledVersion);
|
||||
progress(50);
|
||||
|
||||
await installPackageToAppDir(updateInfo, release);
|
||||
progress(95);
|
||||
|
||||
await UpdateLocalReleasesFile();
|
||||
progress(100);
|
||||
}
|
||||
|
||||
public IObservable<Unit> UpdateLocalReleasesFile()
|
||||
public async Task UpdateLocalReleasesFile()
|
||||
{
|
||||
return acquireUpdateLock().SelectMany(_ => Observable.Start(() =>
|
||||
ReleaseEntry.BuildReleasesFile(PackageDirectory, fileSystem), RxApp.TaskpoolScheduler));
|
||||
await acquireUpdateLock();
|
||||
await Task.Run(() => ReleaseEntry.BuildReleasesFile(PackageDirectory));
|
||||
}
|
||||
|
||||
public IObservable<Unit> FullUninstall(Version version = null)
|
||||
public async Task FullUninstall(Version version = null)
|
||||
{
|
||||
version = version ?? new Version(255, 255, 255, 255);
|
||||
log.Info("Uninstalling version '{0}'", version);
|
||||
return acquireUpdateLock().SelectMany(_ => fullUninstall(version));
|
||||
|
||||
await acquireUpdateLock();
|
||||
await fullUninstall(version);
|
||||
}
|
||||
|
||||
IEnumerable<DirectoryInfoBase> getReleases()
|
||||
IEnumerable<DirectoryInfo> getReleases()
|
||||
{
|
||||
var rootDirectory = fileSystem.GetDirectoryInfo(rootAppDirectory);
|
||||
var rootDirectory = new DirectoryInfo(rootAppDirectory);
|
||||
|
||||
if (!rootDirectory.Exists)
|
||||
return Enumerable.Empty<DirectoryInfoBase>();
|
||||
if (!rootDirectory.Exists) return Enumerable.Empty<DirectoryInfo>();
|
||||
|
||||
return rootDirectory.GetDirectories()
|
||||
.Where(x => x.Name.StartsWith("app-", StringComparison.InvariantCultureIgnoreCase));
|
||||
.Where(x => x.Name.StartsWith("app-", StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
IEnumerable<DirectoryInfoBase> getOldReleases(Version version)
|
||||
IEnumerable<DirectoryInfo> getOldReleases(Version version)
|
||||
{
|
||||
return getReleases()
|
||||
.Where(x => x.Name.ToVersion() < version)
|
||||
.ToArray();
|
||||
.Where(x => x.Name.ToVersion() < version)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
IObservable<Unit> fullUninstall(Version version)
|
||||
async Task fullUninstall(Version version)
|
||||
{
|
||||
// find all the old releases (and this one)
|
||||
return getOldReleases(version)
|
||||
.Concat(new [] { getDirectoryForRelease(version) })
|
||||
.Where(d => d.Exists)
|
||||
.OrderBy(d => d.Name)
|
||||
.Select(d => d.FullName)
|
||||
.ToObservable()
|
||||
.SelectMany(dir => {
|
||||
// cleanup each version
|
||||
runAppCleanup(dir);
|
||||
runAppUninstall(dir);
|
||||
// and then force a delete on each folder
|
||||
return Utility.DeleteDirectory(dir)
|
||||
.Catch<Unit, Exception>(ex => {
|
||||
var message = String.Format("Uninstall failed to delete dir '{0}', punting to next reboot", dir);
|
||||
log.WarnException(message, ex);
|
||||
return Observable.Start(
|
||||
() => Utility.DeleteDirectoryAtNextReboot(rootAppDirectory));
|
||||
});
|
||||
})
|
||||
.Aggregate(Unit.Default, (acc, x) => acc)
|
||||
.SelectMany(_ => {
|
||||
// if there are no other relases found
|
||||
// delete the root directory too
|
||||
if (!getReleases().Any()) {
|
||||
return Utility.DeleteDirectory(rootAppDirectory);
|
||||
}
|
||||
return Observable.Return(Unit.Default);
|
||||
});
|
||||
// find all the old releases (and this one)
|
||||
var directoriesToDelete = getOldReleases(version)
|
||||
.Concat(new [] { getDirectoryForRelease(version) })
|
||||
.Where(d => d.Exists)
|
||||
.Select(d => d.FullName);
|
||||
|
||||
await directoriesToDelete.ForEachAsync(x => deleteDirectoryWithFallbackToNextReboot(x));
|
||||
|
||||
if (!getReleases().Any()) {
|
||||
await deleteDirectoryWithFallbackToNextReboot(rootAppDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -310,13 +239,16 @@ namespace Squirrel
|
||||
}
|
||||
}
|
||||
|
||||
IObservable<IDisposable> acquireUpdateLock()
|
||||
Task<IDisposable> acquireUpdateLock()
|
||||
{
|
||||
if (updateLock != null) return Observable.Return(updateLock);
|
||||
if (updateLock != null) return Task.FromResult(updateLock);
|
||||
|
||||
return Observable.Start(() => {
|
||||
return Task.Run(() => {
|
||||
// TODO: We'll bring this back later
|
||||
var key = Utility.CalculateStreamSHA1(new MemoryStream(Encoding.UTF8.GetBytes(rootAppDirectory)));
|
||||
var theLock = Disposable.Create(() => { });
|
||||
|
||||
/*
|
||||
IDisposable theLock;
|
||||
try {
|
||||
theLock = RxApp.InUnitTestRunner() ?
|
||||
@@ -324,6 +256,7 @@ namespace Squirrel
|
||||
} catch (TimeoutException) {
|
||||
throw new TimeoutException("Couldn't acquire update lock, another instance may be running updates");
|
||||
}
|
||||
*/
|
||||
|
||||
var ret = Disposable.Create(() => {
|
||||
theLock.Dispose();
|
||||
@@ -340,33 +273,46 @@ namespace Squirrel
|
||||
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
}
|
||||
|
||||
DirectoryInfoBase getDirectoryForRelease(Version releaseVersion)
|
||||
DirectoryInfo getDirectoryForRelease(Version releaseVersion)
|
||||
{
|
||||
return fileSystem.GetDirectoryInfo(Path.Combine(rootAppDirectory, "app-" + releaseVersion));
|
||||
return new DirectoryInfo(Path.Combine(rootAppDirectory, "app-" + releaseVersion));
|
||||
}
|
||||
|
||||
async Task deleteDirectoryWithFallbackToNextReboot(string dir)
|
||||
{
|
||||
try {
|
||||
await Utility.DeleteDirectory(dir);
|
||||
} catch (UnauthorizedAccessException ex) {
|
||||
var message = String.Format("Uninstall failed to delete dir '{0}', punting to next reboot", dir);
|
||||
log.WarnException(message, ex);
|
||||
|
||||
Utility.DeleteDirectoryAtNextReboot(dir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// CheckForUpdate methods
|
||||
//
|
||||
|
||||
void initializeClientAppDirectory()
|
||||
async Task initializeClientAppDirectory()
|
||||
{
|
||||
// On bootstrap, we won't have any of our directories, create them
|
||||
var pkgDir = Path.Combine(rootAppDirectory, "packages");
|
||||
if (fileSystem.GetDirectoryInfo(pkgDir).Exists) {
|
||||
fileSystem.DeleteDirectoryRecursive(pkgDir);
|
||||
if (Directory.Exists(pkgDir)) {
|
||||
await Utility.DeleteDirectory(pkgDir);
|
||||
}
|
||||
|
||||
fileSystem.CreateDirectoryRecursive(pkgDir);
|
||||
Directory.CreateDirectory(pkgDir);
|
||||
}
|
||||
|
||||
IObservable<UpdateInfo> determineUpdateInfo(IEnumerable<ReleaseEntry> localReleases, IEnumerable<ReleaseEntry> remoteReleases, bool ignoreDeltaUpdates)
|
||||
UpdateInfo determineUpdateInfo(IEnumerable<ReleaseEntry> localReleases, IEnumerable<ReleaseEntry> remoteReleases, bool ignoreDeltaUpdates)
|
||||
{
|
||||
localReleases = localReleases ?? Enumerable.Empty<ReleaseEntry>();
|
||||
|
||||
if (remoteReleases == null) {
|
||||
log.Warn("Release information couldn't be determined due to remote corrupt RELEASES file");
|
||||
return Observable.Throw<UpdateInfo>(new Exception("Corrupt remote RELEASES file"));
|
||||
throw new Exception("Corrupt remote RELEASES file");
|
||||
}
|
||||
|
||||
if (localReleases.Count() == remoteReleases.Count()) {
|
||||
@@ -376,7 +322,7 @@ namespace Squirrel
|
||||
var currentRelease = findCurrentVersion(localReleases);
|
||||
|
||||
var info = UpdateInfo.Create(currentRelease, new[] {latestFullRelease}, PackageDirectory,appFrameworkVersion);
|
||||
return Observable.Return(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
if (ignoreDeltaUpdates) {
|
||||
@@ -387,17 +333,17 @@ namespace Squirrel
|
||||
log.Warn("First run or local directory is corrupt, starting from scratch");
|
||||
|
||||
var latestFullRelease = findCurrentVersion(remoteReleases);
|
||||
return Observable.Return(UpdateInfo.Create(findCurrentVersion(localReleases), new[] {latestFullRelease}, PackageDirectory, appFrameworkVersion));
|
||||
return UpdateInfo.Create(findCurrentVersion(localReleases), new[] {latestFullRelease}, PackageDirectory, appFrameworkVersion);
|
||||
}
|
||||
|
||||
if (localReleases.Max(x => x.Version) > remoteReleases.Max(x => x.Version)) {
|
||||
log.Warn("hwhat, local version is greater than remote version");
|
||||
|
||||
var latestFullRelease = findCurrentVersion(remoteReleases);
|
||||
return Observable.Return(UpdateInfo.Create(findCurrentVersion(localReleases), new[] {latestFullRelease}, PackageDirectory, appFrameworkVersion));
|
||||
return UpdateInfo.Create(findCurrentVersion(localReleases), new[] {latestFullRelease}, PackageDirectory, appFrameworkVersion);
|
||||
}
|
||||
|
||||
return Observable.Return(UpdateInfo.Create(findCurrentVersion(localReleases), remoteReleases, PackageDirectory, appFrameworkVersion));
|
||||
return UpdateInfo.Create(findCurrentVersion(localReleases), remoteReleases, PackageDirectory, appFrameworkVersion);
|
||||
}
|
||||
|
||||
static ReleaseEntry findCurrentVersion(IEnumerable<ReleaseEntry> localReleases)
|
||||
@@ -409,6 +355,7 @@ namespace Squirrel
|
||||
return localReleases.MaxBy(x => x.Version).SingleOrDefault(x => !x.IsDelta);
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// DownloadReleases methods
|
||||
//
|
||||
@@ -423,31 +370,32 @@ namespace Squirrel
|
||||
}
|
||||
}
|
||||
|
||||
IObservable<Unit> checksumAllPackages(IEnumerable<ReleaseEntry> releasesDownloaded)
|
||||
Task checksumAllPackages(IEnumerable<ReleaseEntry> releasesDownloaded)
|
||||
{
|
||||
return releasesDownloaded
|
||||
.MapReduce(x => Observable.Start(() => checksumPackage(x)))
|
||||
.Select(_ => Unit.Default);
|
||||
return releasesDownloaded.ForEachAsync(x => checksumPackage(x));
|
||||
}
|
||||
|
||||
void checksumPackage(ReleaseEntry downloadedRelease)
|
||||
{
|
||||
var targetPackage = fileSystem.GetFileInfo(
|
||||
var targetPackage = new FileInfo(
|
||||
Path.Combine(rootAppDirectory, "packages", downloadedRelease.Filename));
|
||||
|
||||
if (!targetPackage.Exists) {
|
||||
log.Error("File {0} should exist but doesn't", targetPackage.FullName);
|
||||
|
||||
throw new Exception("Checksummed file doesn't exist: " + targetPackage.FullName);
|
||||
}
|
||||
|
||||
if (targetPackage.Length != downloadedRelease.Filesize) {
|
||||
log.Error("File Length should be {0}, is {1}", downloadedRelease.Filesize, targetPackage.Length);
|
||||
targetPackage.Delete();
|
||||
|
||||
throw new Exception("Checksummed file size doesn't match: " + targetPackage.FullName);
|
||||
}
|
||||
|
||||
using (var file = targetPackage.OpenRead()) {
|
||||
var hash = Utility.CalculateStreamSHA1(file);
|
||||
|
||||
if (!hash.Equals(downloadedRelease.SHA1,StringComparison.OrdinalIgnoreCase)) {
|
||||
log.Error("File SHA1 should be {0}, is {1}", downloadedRelease.SHA1, hash);
|
||||
targetPackage.Delete();
|
||||
@@ -460,15 +408,16 @@ namespace Squirrel
|
||||
// ApplyReleases methods
|
||||
//
|
||||
|
||||
List<string> installPackageToAppDir(UpdateInfo updateInfo, ReleaseEntry release)
|
||||
async Task installPackageToAppDir(UpdateInfo updateInfo, ReleaseEntry release)
|
||||
{
|
||||
var pkg = new ZipPackage(Path.Combine(updateInfo.PackageDirectory, release.Filename));
|
||||
var target = getDirectoryForRelease(release.Version);
|
||||
|
||||
// NB: This might happen if we got killed partially through applying the release
|
||||
if (target.Exists) {
|
||||
Utility.DeleteDirectory(target.FullName).Wait();
|
||||
await Utility.DeleteDirectory(target.FullName);
|
||||
}
|
||||
|
||||
target.Create();
|
||||
|
||||
// Copy all of the files out of the lib/ dirs in the NuGet package
|
||||
@@ -479,28 +428,28 @@ namespace Squirrel
|
||||
// with the 4.0 version.
|
||||
log.Info("Writing files to app directory: {0}", target.FullName);
|
||||
|
||||
pkg.GetLibFiles().Where(x => pathIsInFrameworkProfile(x, appFrameworkVersion))
|
||||
.OrderBy(x => x.Path)
|
||||
.ForEach(x => CopyFileToLocation(target, x));
|
||||
await pkg.GetLibFiles().Where(x => pathIsInFrameworkProfile(x, appFrameworkVersion))
|
||||
.OrderBy(x => x.Path)
|
||||
.ForEachAsync(x => CopyFileToLocation(target, x));
|
||||
|
||||
pkg.GetContentFiles().ForEach(x => CopyFileToLocation(target, x));
|
||||
await pkg.GetContentFiles().ForEachAsync(x => CopyFileToLocation(target, x));
|
||||
|
||||
var newCurrentVersion = updateInfo.FutureReleaseEntry.Version;
|
||||
|
||||
// Perform post-install; clean up the previous version by asking it
|
||||
// which shortcuts to install, and nuking them. Then, run the app's
|
||||
// post install and set up shortcuts.
|
||||
return runPostInstallAndCleanup(newCurrentVersion, updateInfo.IsBootstrapping);
|
||||
runPostInstallAndCleanup(newCurrentVersion, updateInfo.IsBootstrapping);
|
||||
}
|
||||
|
||||
void CopyFileToLocation(FileSystemInfoBase target, IPackageFile x)
|
||||
void CopyFileToLocation(FileSystemInfo target, IPackageFile x)
|
||||
{
|
||||
var targetPath = Path.Combine(target.FullName, x.EffectivePath);
|
||||
|
||||
var fi = fileSystem.GetFileInfo(targetPath);
|
||||
var fi = new FileInfo(targetPath);
|
||||
if (fi.Exists) fi.Delete();
|
||||
|
||||
var dir = fileSystem.GetDirectoryInfo(Path.GetDirectoryName(targetPath));
|
||||
var dir = new DirectoryInfo(Path.GetDirectoryName(targetPath));
|
||||
if (!dir.Exists) dir.Create();
|
||||
|
||||
using (var inf = x.GetStream())
|
||||
@@ -509,31 +458,12 @@ namespace Squirrel
|
||||
}
|
||||
}
|
||||
|
||||
List<string> runPostInstallAndCleanup(Version newCurrentVersion, bool isBootstrapping)
|
||||
void runPostInstallAndCleanup(Version newCurrentVersion, bool isBootstrapping)
|
||||
{
|
||||
log.Debug("AppDomain ID: {0}", AppDomain.CurrentDomain.Id);
|
||||
|
||||
fixPinnedExecutables(newCurrentVersion);
|
||||
|
||||
log.Info("runPostInstallAndCleanup: finished fixPinnedExecutables");
|
||||
|
||||
var shortcutsToIgnore = cleanUpOldVersions(newCurrentVersion);
|
||||
var targetPath = getDirectoryForRelease(newCurrentVersion);
|
||||
|
||||
return runPostInstallOnDirectory(targetPath.FullName, isBootstrapping, newCurrentVersion, shortcutsToIgnore);
|
||||
}
|
||||
|
||||
List<string> runPostInstallOnDirectory(string newAppDirectoryRoot, bool isFirstInstall, Version newCurrentVersion, IEnumerable<ShortcutCreationRequest> shortcutRequestsToIgnore)
|
||||
{
|
||||
var postInstallInfo = new PostInstallInfo {
|
||||
NewAppDirectoryRoot = newAppDirectoryRoot,
|
||||
IsFirstInstall = isFirstInstall,
|
||||
NewCurrentVersion = newCurrentVersion,
|
||||
ShortcutRequestsToIgnore = shortcutRequestsToIgnore.ToArray()
|
||||
};
|
||||
|
||||
var installerHooks = new InstallerHookOperations(fileSystem, applicationName);
|
||||
return AppDomainHelper.ExecuteInNewAppDomain(postInstallInfo, installerHooks.RunAppSetupInstallers).ToList();
|
||||
cleanUpOldVersions(newCurrentVersion);
|
||||
}
|
||||
|
||||
static bool pathIsInFrameworkProfile(IPackageFile packageFile, FrameworkVersion appFrameworkVersion)
|
||||
@@ -550,21 +480,21 @@ namespace Squirrel
|
||||
return true;
|
||||
}
|
||||
|
||||
IObservable<ReleaseEntry> createFullPackagesFromDeltas(IEnumerable<ReleaseEntry> releasesToApply, ReleaseEntry currentVersion)
|
||||
async Task<ReleaseEntry> createFullPackagesFromDeltas(IEnumerable<ReleaseEntry> releasesToApply, ReleaseEntry currentVersion)
|
||||
{
|
||||
Contract.Requires(releasesToApply != null);
|
||||
|
||||
// If there are no deltas in our list, we're already done
|
||||
if (!releasesToApply.Any() || releasesToApply.All(x => !x.IsDelta)) {
|
||||
return Observable.Return(releasesToApply.MaxBy(x => x.Version).First());
|
||||
return releasesToApply.MaxBy(x => x.Version).First();
|
||||
}
|
||||
|
||||
if (!releasesToApply.All(x => x.IsDelta)) {
|
||||
return Observable.Throw<ReleaseEntry>(new Exception("Cannot apply combinations of delta and full packages"));
|
||||
throw new Exception("Cannot apply combinations of delta and full packages");
|
||||
}
|
||||
|
||||
// Smash together our base full package and the nearest delta
|
||||
var ret = Observable.Start(() => {
|
||||
var ret = await Task.Run(() => {
|
||||
var basePkg = new ReleasePackage(Path.Combine(rootAppDirectory, "packages", currentVersion.Filename));
|
||||
var deltaPkg = new ReleasePackage(Path.Combine(rootAppDirectory, "packages", releasesToApply.First().Filename));
|
||||
|
||||
@@ -572,67 +502,32 @@ namespace Squirrel
|
||||
|
||||
return deltaBuilder.ApplyDeltaPackage(basePkg, deltaPkg,
|
||||
Regex.Replace(deltaPkg.InputPackageFile, @"-delta.nupkg$", ".nupkg", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
|
||||
}, RxApp.TaskpoolScheduler);
|
||||
});
|
||||
|
||||
if (releasesToApply.Count() == 1) {
|
||||
return ret.Select(x => ReleaseEntry.GenerateFromFile(x.InputPackageFile));
|
||||
return ReleaseEntry.GenerateFromFile(ret.InputPackageFile);
|
||||
}
|
||||
|
||||
return ret.SelectMany(x => {
|
||||
var fi = fileSystem.GetFileInfo(x.InputPackageFile);
|
||||
var entry = ReleaseEntry.GenerateFromFile(fi.OpenRead(), fi.Name);
|
||||
var fi = new FileInfo(ret.InputPackageFile);
|
||||
var entry = ReleaseEntry.GenerateFromFile(fi.OpenRead(), fi.Name);
|
||||
|
||||
// Recursively combine the rest of them
|
||||
return createFullPackagesFromDeltas(releasesToApply.Skip(1), entry);
|
||||
});
|
||||
// Recursively combine the rest of them
|
||||
return await createFullPackagesFromDeltas(releasesToApply.Skip(1), entry);
|
||||
}
|
||||
|
||||
IEnumerable<ShortcutCreationRequest> cleanUpOldVersions(Version newCurrentVersion)
|
||||
void cleanUpOldVersions(Version newCurrentVersion)
|
||||
{
|
||||
var directory = fileSystem.GetDirectoryInfo(rootAppDirectory);
|
||||
var directory = new DirectoryInfo(rootAppDirectory);
|
||||
if (!directory.Exists) {
|
||||
log.Warn("cleanUpOldVersions: the directory '{0}' does not exist", rootAppDirectory);
|
||||
return Enumerable.Empty<ShortcutCreationRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
return getOldReleases(newCurrentVersion)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(d => d.FullName)
|
||||
.SelectMany(runAppCleanup);
|
||||
}
|
||||
|
||||
IEnumerable<ShortcutCreationRequest> runAppCleanup(string path)
|
||||
{
|
||||
var installerHooks = new InstallerHookOperations(fileSystem, applicationName);
|
||||
|
||||
var ret = AppDomainHelper.ExecuteInNewAppDomain(path, installerHooks.RunAppSetupCleanups);
|
||||
|
||||
try {
|
||||
Utility.DeleteDirectoryAtNextReboot(path);
|
||||
foreach (var v in getOldReleases(newCurrentVersion)) {
|
||||
Utility.DeleteDirectoryAtNextReboot(v.FullName);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
var message = String.Format("Couldn't delete old app directory on next reboot {0}", path);
|
||||
log.WarnException(message, ex);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
IEnumerable<ShortcutCreationRequest> runAppUninstall(string path)
|
||||
{
|
||||
var installerHooks = new InstallerHookOperations(fileSystem, applicationName);
|
||||
|
||||
var ret = AppDomainHelper.ExecuteInNewAppDomain(path, installerHooks.RunAppUninstall);
|
||||
|
||||
try {
|
||||
Utility.DeleteDirectoryAtNextReboot(path);
|
||||
} catch (Exception ex) {
|
||||
var message = String.Format("Couldn't delete old app directory on next reboot {0}", path);
|
||||
log.WarnException(message, ex);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
void fixPinnedExecutables(Version newCurrentVersion)
|
||||
{
|
||||
if (Environment.OSVersion.Version < new Version(6, 1)) {
|
||||
@@ -641,7 +536,7 @@ namespace Squirrel
|
||||
}
|
||||
|
||||
var newCurrentFolder = "app-" + newCurrentVersion;
|
||||
var oldAppDirectories = fileSystem.GetDirectoryInfo(rootAppDirectory).GetDirectories()
|
||||
var oldAppDirectories = (new DirectoryInfo(rootAppDirectory)).GetDirectories()
|
||||
.Where(x => x.Name.StartsWith("app-", StringComparison.InvariantCultureIgnoreCase))
|
||||
.Where(x => x.Name != newCurrentFolder)
|
||||
.Select(x => x.FullName)
|
||||
@@ -658,7 +553,7 @@ namespace Squirrel
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Microsoft\\Internet Explorer\\Quick Launch\\User Pinned\\TaskBar");
|
||||
|
||||
Func<FileInfoBase, ShellLink> resolveLink = file => {
|
||||
Func<FileInfo, ShellLink> resolveLink = file => {
|
||||
try {
|
||||
return new ShellLink(file.FullName);
|
||||
} catch (Exception ex) {
|
||||
@@ -668,11 +563,10 @@ namespace Squirrel
|
||||
}
|
||||
};
|
||||
|
||||
var shellLinks = fileSystem.GetDirectoryInfo(taskbarPath)
|
||||
.GetFiles("*.lnk")
|
||||
.Select(resolveLink)
|
||||
.Where(x => x != null)
|
||||
.ToArray();
|
||||
var shellLinks = (new DirectoryInfo(taskbarPath)).GetFiles("*.lnk")
|
||||
.Select(resolveLink)
|
||||
.Where(x => x != null)
|
||||
.ToArray();
|
||||
|
||||
foreach (var shortcut in shellLinks) {
|
||||
try {
|
||||
@@ -687,6 +581,7 @@ namespace Squirrel
|
||||
void updateLink(ShellLink shortcut, string[] oldAppDirectories, string newAppPath)
|
||||
{
|
||||
log.Info("Processing shortcut '{0}'", shortcut.Target);
|
||||
|
||||
foreach (var oldAppDirectory in oldAppDirectories) {
|
||||
if (!shortcut.Target.StartsWith(oldAppDirectory, StringComparison.OrdinalIgnoreCase)) {
|
||||
log.Info("Does not match '{0}', continuing to next directory", oldAppDirectory);
|
||||
@@ -696,7 +591,7 @@ namespace Squirrel
|
||||
// replace old app path with new app path and check, if executable still exists
|
||||
var newTarget = Path.Combine(newAppPath, shortcut.Target.Substring(oldAppDirectory.Length + 1));
|
||||
|
||||
if (fileSystem.GetFileInfo(newTarget).Exists) {
|
||||
if (File.Exists(newTarget)) {
|
||||
shortcut.Target = newTarget;
|
||||
|
||||
// replace working directory too if appropriate
|
||||
@@ -724,12 +619,12 @@ namespace Squirrel
|
||||
// directory are "dead" (i.e. already uninstalled, but not deleted), and
|
||||
// we blow them away. This is to make sure that we don't attempt to run
|
||||
// an uninstaller on an already-uninstalled version.
|
||||
IObservable<Unit> cleanDeadVersions(Version currentVersion)
|
||||
async Task cleanDeadVersions(Version currentVersion)
|
||||
{
|
||||
if (currentVersion == null) return Observable.Return(Unit.Default);
|
||||
if (currentVersion == null) return;
|
||||
|
||||
var di = fileSystem.GetDirectoryInfo(rootAppDirectory);
|
||||
if (!di.Exists) return Observable.Return(Unit.Default);
|
||||
var di = new DirectoryInfo(rootAppDirectory);
|
||||
if (!di.Exists) return;
|
||||
|
||||
log.Info("cleanDeadVersions: for version {0}", currentVersion);
|
||||
|
||||
@@ -743,12 +638,17 @@ namespace Squirrel
|
||||
// scheduled for deletion by MoveFileEx it throws what seems like
|
||||
// NT's only error code, ERROR_ACCESS_DENIED. Squelch errors that
|
||||
// come from here.
|
||||
return di.GetDirectories().ToObservable()
|
||||
var toCleanup = di.GetDirectories()
|
||||
.Where(x => x.Name.ToLowerInvariant().Contains("app-"))
|
||||
.Where(x => x.Name != currentVersionFolder)
|
||||
.SelectMany(x => Utility.DeleteDirectory(x.FullName, RxApp.TaskpoolScheduler))
|
||||
.LoggedCatch<Unit, UpdateManager, UnauthorizedAccessException>(this, _ => Observable.Return(Unit.Default))
|
||||
.Aggregate(Unit.Default, (acc, x) => acc);
|
||||
.Where(x => x.Name != currentVersionFolder);
|
||||
|
||||
await toCleanup.ForEachAsync(async x => {
|
||||
try {
|
||||
await Utility.DeleteDirectory(x.FullName);
|
||||
} catch (UnauthorizedAccessException ex) {
|
||||
this.Log().WarnException("Couldn't delete directory: " + x.FullName, ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user