xenadmin/XenAdmin/Wizards/ImportWizard/ImportSourcePage.cs
Konstantina Chremmou 749518e94c CA-303195: Provide the user the option to reconsider wizard cancellation
(either when cancelling the wizard itself or exiting the application).
Implemented in the first instance for compiling the server status report.

Signed-off-by: Konstantina Chremmou <konstantina.chremmou@citrix.com>
2019-01-15 17:12:09 +00:00

895 lines
28 KiB
C#

/* Copyright (c) Citrix Systems, Inc.
* All rights reserved.
*
* Redistribution and use in source and binary forms,
* with or without modification, are permitted provided
* that the following conditions are met:
*
* * Redistributions of source code must retain the above
* copyright notice, this list of conditions and the
* following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the
* following disclaimer in the documentation and/or other
* materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.IO;
using System.Net;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using System.Xml;
using System.Xml.XPath;
using DiscUtils;
using DiscUtils.Wim;
using XenAdmin.Controls;
using XenCenterLib;
using XenCenterLib.Compression;
using XenAdmin.Dialogs;
using XenAdmin.Controls.Common;
using XenCenterLib.Archive;
using XenOvf;
using XenOvf.Definitions;
using XenOvf.Definitions.VMC;
using XenOvf.Utilities;
namespace XenAdmin.Wizards.ImportWizard
{
internal partial class ImportSourcePage : XenTabPage
{
#region Private fields
private static readonly log4net.ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
private readonly string[] m_supportedImageTypes = { ".vhd", ".vmdk" };//CA-61385: remove ".vdi", ".wim" support for Boston
private readonly string[] m_supportedApplianceTypes = { ".ovf", ".ova", ".ova.gz" };
private readonly string[] m_supportedXvaTypes = {".xva", "ova.xml"};
/// <summary>
/// Stores the last valid selected appliance
/// </summary>
private string m_lastValidAppliance;
private EnvelopeType m_selectedOvfEnvelope;
private bool m_buttonNextEnabled;
private string _unzipFileIn;
private string _unzipFileOut;
private Uri _uri;
private string _downloadFolder;
private WebClient _webClient;
private Queue<ApplianceFile> _filesToDownload;
private bool longProcessInProgress;
#endregion
public ImportSourcePage()
{
InitializeComponent();
m_ctrlError.HideError();
m_tlpEncryption.Visible = false;
m_tlpError.Visible = false;
progressBar1.Visible = false;
labelProgress.Visible = false;
}
#region Base class (XenTabPage) overrides
/// <summary>
/// Gets the page's title (headline)
/// </summary>
public override string PageTitle { get { return Messages.IMPORT_SOURCE_PAGE_TITLE; } }
/// <summary>
/// Gets the page's label in the (left hand side) wizard progress panel
/// </summary>
public override string Text { get { return Messages.IMPORT_SOURCE_PAGE_TEXT; } }
/// <summary>
/// Gets the value by which the help files section for this page is identified
/// </summary>
public override string HelpID { get { return "Source"; } }
protected override bool ImplementsIsDirty()
{
return true;
}
protected override void PageLeaveCore(PageLoadedDirection direction, ref bool cancel)
{
if (direction == PageLoadedDirection.Forward && IsDirty)
{
if (IsUri() && !Download())
{
cancel = true;
return;
}
if (!PerformCheck(CheckIsSupportedType, CheckPathExists))
{
cancel = true;
return;
}
string extension = Path.GetExtension(FilePath).ToLower();
if (extension == ".gz")
{
if (!PerformCheck(CheckSourceIsWritable) || !Uncompress())
{
cancel = true;
return;
}
}
CheckDelegate check = null;
switch (TypeOfImport)
{
case ImportWizard.ImportType.Xva:
check = GetDiskCapacityXva;
break;
case ImportWizard.ImportType.Ovf:
check = LoadAppliance;
break;
case ImportWizard.ImportType.Vhd:
check = GetDiskCapacityImage;
break;
}
if (!PerformCheck(check))
{
cancel = true;
return;
}
if (TypeOfImport == ImportWizard.ImportType.Ovf && OVF.HasEncryption(SelectedOvfEnvelope))
{
cancel = true;
m_tlpEncryption.Visible = true;
m_buttonNextEnabled = false;
OnPageUpdated();
}
}
}
public override void PageCancelled(ref bool cancel)
{
if (_webClient != null && _webClient.IsBusy)
_webClient.CancelAsync();
if (_unzipWorker.IsBusy)
_unzipWorker.CancelAsync();
}
public override void PopulatePage()
{
lblIntro.Text = OvfModeOnly ? Messages.IMPORT_SOURCE_PAGE_INTRO_OVF_ONLY : Messages.IMPORT_SOURCE_PAGE_INTRO;
}
public override bool EnableNext()
{
return m_buttonNextEnabled;
}
#endregion
#region Accessors
public bool OvfModeOnly { private get; set; }
public ImportWizard.ImportType TypeOfImport { get; private set; }
public string FilePath { get { return m_textBoxFile.Text.Trim(); } }
/// <summary>
/// Maybe null if no valid ovf has been found
/// </summary>
public EnvelopeType SelectedOvfEnvelope
{
get { return m_selectedOvfEnvelope; }
private set
{
m_selectedOvfEnvelope = value;
if (m_selectedOvfEnvelope == null)
_SelectedOvfPackage = null;
else
_SelectedOvfPackage = XenOvf.Package.Create(FilePath);
}
}
/// <summary>
/// XenOvf.Package is the proper abstraction for the wizard to use for most cases instead of just OVF.
/// Maintain it along side SelectedOvf until it can be phased out.
/// </summary>
public XenOvf.Package SelectedOvfPackage
{
get
{
if (m_selectedOvfEnvelope == null)
return null;
return _SelectedOvfPackage;
}
}
XenOvf.Package _SelectedOvfPackage;
public ulong ImageLength { get; private set; }
public bool IsWIM { get; private set; }
public bool IsXvaVersion1 { get; private set; }
public ulong DiskCapacity { get; private set; }
#endregion
public void SetFileName(string fileName)
{
m_textBoxFile.Text = fileName;
}
#region Private methods
/// <summary>
/// Performs certain checks on the pages's input data and shows/hides an error accordingly
/// </summary>
/// <param name="checks">The checks to perform</param>
private bool PerformCheck(params CheckDelegate[] checks)
{
m_buttonNextEnabled = m_ctrlError.PerformCheck(checks);
OnPageUpdated();
return m_buttonNextEnabled;
}
private bool GetDiskCapacityXva(out string error)
{
error = string.Empty;
try
{
FileInfo info = new FileInfo(FilePath);
ImageLength = info.Length > 0 ? (ulong)info.Length : 0;
DiskCapacity = IsXvaVersion1
? GetTotalSizeFromXmlGeneva() //Geneva style
: GetTotalSizeFromXmlXva(GetXmlStringFromTarXVA()); //xva style
}
catch (Exception)
{
DiskCapacity = ImageLength;
}
return true;
}
private ulong GetTotalSizeFromXmlGeneva()
{
ulong totalSize = 0;
XmlDocument xmlMetadata = new XmlDocument();
xmlMetadata.Load(FilePath);
XPathNavigator nav = xmlMetadata.CreateNavigator();
XPathNodeIterator nodeIterator = nav.Select(".//vdi");
while (nodeIterator.MoveNext())
totalSize += UInt64.Parse(nodeIterator.Current.GetAttribute("size", ""));
return totalSize;
}
private string GetXmlStringFromTarXVA()
{
using (Stream stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
{
ArchiveIterator iterator = ArchiveFactory.Reader(ArchiveFactory.Type.Tar, stream);
if( iterator.HasNext() )
{
Stream ofs = new MemoryStream();
iterator.ExtractCurrentFile(ofs);
return new StreamReader(ofs).ReadToEnd();
}
return String.Empty;
}
}
private ulong GetTotalSizeFromXmlXva(string xmlString)
{
ulong totalSize = 0;
XmlDocument xmlMetadata = new XmlDocument();
xmlMetadata.LoadXml(xmlString);
XPathNavigator nav = xmlMetadata.CreateNavigator();
XPathNodeIterator nodeIterator = nav.Select(".//name[. = \"virtual_size\"]");
while (nodeIterator.MoveNext())
{
XPathNavigator vdiNavigator = nodeIterator.Current;
if (vdiNavigator.MoveToNext())
totalSize += UInt64.Parse(vdiNavigator.Value);
}
return totalSize;
}
private bool GetDiskCapacityImage(out string error)
{
error = string.Empty;
string filename = FilePath;
FileInfo info = new FileInfo(filename);
ImageLength = info.Length > 0 ? (ulong)info.Length : 0;
if (IsWIM)
{
try
{
using (FileStream wimstream = new FileStream(filename, FileMode.Open, FileAccess.Read))
{
WimFile wimDisk = new WimFile(wimstream);
string manifest = wimDisk.Manifest;
Wim_Manifest wimManifest = (Wim_Manifest)Tools.Deserialize(manifest, typeof(Wim_Manifest));
DiskCapacity = wimManifest.Image[wimDisk.BootImage].TotalBytes; //image data size
return true;
}
}
catch (Exception)
{
error = Messages.IMAGE_SELECTION_PAGE_ERROR_CORRUPT_FILE;
return false;
}
}
try
{
using (VirtualDisk vd = VirtualDisk.OpenDisk(filename, FileAccess.Read))
{
DiskCapacity = (ulong)vd.Capacity;
return true;
}
}
catch (IOException ioe)
{
error = ioe.Message.Contains("Invalid VMDK descriptor file")
? Messages.IMAGE_SELECTION_PAGE_ERROR_INVALID_VMDK_DESCRIPTOR
: Messages.IMAGE_SELECTION_PAGE_ERROR_INVALID_FILE_TYPE;
return false;
}
catch (Exception)
{
error = Messages.IMAGE_SELECTION_PAGE_ERROR_CORRUPT_FILE;
return false;
}
}
private bool LoadAppliance(out string error)
{
error = string.Empty;
if (m_lastValidAppliance == FilePath)
return true;
SelectedOvfEnvelope = GetOvfEnvelope(out error);
if (SelectedOvfEnvelope != null)
{
m_lastValidAppliance = FilePath;
return true;
}
return false;
}
private EnvelopeType GetOvfEnvelope(out string error)
{
error = string.Empty;
string path = FilePath;
string extension = Path.GetExtension(path).ToLower();
if (extension == ".ovf")
{
EnvelopeType env = null;
try
{
env = OVF.Load(path);
}
catch(OutOfMemoryException ex)
{
log.ErrorFormat("Failed to load OVF {0} as we ran out of memory: {1}", path, ex.Message);
env = null;
}
if (env == null)
{
error = Messages.INVALID_OVF;
return null;
}
return env;
}
if (extension == ".ova")
{
if (!CheckSourceIsWritable(out error))
return null;
try
{
string x = OVF.ExtractOVFXml(path);
var env = Tools.DeserializeOvfXml(x);
if (env == null)
{
error = Messages.IMPORT_SELECT_APPLIANCE_PAGE_ERROR_CORRUPT_OVA;
return null;
}
return env;
}
catch (Exception)
{
error = Messages.IMPORT_SELECT_APPLIANCE_PAGE_ERROR_CORRUPT_OVA;
return null;
}
}
return null;
}
private bool IsUri()
{
Regex regex = new Regex(Messages.URI_REGEX, RegexOptions.IgnoreCase);
return regex.Match(FilePath).Success;
}
/// <summary>
/// Check on the fly
/// </summary>
private bool CheckPathValid(out string error)
{
error = string.Empty;
if (String.IsNullOrEmpty(FilePath))
return false;
if (IsUri())
return CheckDownloadFromUri(out error);
if (!PathValidator.IsPathValid(FilePath))
{
error = Messages.IMPORT_SELECT_APPLIANCE_PAGE_ERROR_INVALID_PATH;
return false;
}
return true;
}
/// <summary>
/// Check when we leave the page
/// </summary>
private bool CheckIsSupportedType(out string error)
{
error = string.Empty;
IsWIM = false;
string filepath = FilePath;
foreach (var ext in m_supportedXvaTypes)
{
if (filepath.ToLower().EndsWith(ext))
{
if (OvfModeOnly)
{
error = Messages.IMPORT_SOURCE_PAGE_ERROR_OVF_ONLY;
return false;
}
if (ext == "ova.xml")
IsXvaVersion1 = true;
TypeOfImport = ImportWizard.ImportType.Xva;
return true;
}
}
foreach (var ext in m_supportedApplianceTypes)
{
if (filepath.ToLower().EndsWith(ext))
{
TypeOfImport = ImportWizard.ImportType.Ovf;
return true;
}
}
foreach (var ext in m_supportedImageTypes)
{
if (filepath.ToLower().EndsWith(ext))
{
if (OvfModeOnly)
{
error = Messages.IMPORT_SOURCE_PAGE_ERROR_OVF_ONLY;
return false;
}
if (ext == ".wim")
IsWIM = true;
TypeOfImport = ImportWizard.ImportType.Vhd;
return true;
}
}
error = Messages.ERROR_UNSUPPORTED_FILE_TYPE;
return false;
}
/// <summary>
/// Check when we leave the page
/// </summary>
private bool CheckPathExists(out string error)
{
error = string.Empty;
if (File.Exists(FilePath))
return true;
error = Messages.IMPORT_SELECT_APPLIANCE_PAGE_ERROR_NONE_EXIST_PATH;
return false;
}
/// <summary>
/// Check the source folder is writable
/// </summary>
private bool CheckSourceIsWritable(out string error)
{
error = string.Empty;
var directory = Path.GetDirectoryName(FilePath);
var touchFile = Path.Combine(directory, "_");
try
{
//attempt writing in the destination directory
using (FileStream fs = File.OpenWrite(touchFile))
{
//it works
}
File.Delete(touchFile);
return true;
}
catch (UnauthorizedAccessException)
{
error = Messages.IMPORT_SELECT_APPLIANCE_PAGE_ERROR_SOURCE_IS_READONLY;
return false;
}
}
private bool CheckDownloadFromUri(out string error)
{
error = string.Empty;
try
{
_uri = new Uri(FilePath);
return true;
}
catch (ArgumentNullException)
{
error = Messages.IMPORT_SELECT_APPLIANCE_PAGE_ERROR_INVALID_URI;
return false;
}
catch (UriFormatException)
{
error = Messages.IMPORT_SELECT_APPLIANCE_PAGE_ERROR_INVALID_URI;
return false;
}
}
#endregion
#region Download and Uncompression
private bool Download()
{
using (var dlog = new FolderBrowserDialog { Description = Messages.FOLDER_BROWSER_DOWNLOAD_APPLIANCE })
{
if (dlog.ShowDialog() == DialogResult.OK)
{
if (_webClient == null)
{
_webClient = new WebClient { Proxy = XenAdminConfigManager.Provider.GetProxyFromSettings(null, false) };
_webClient.DownloadFileCompleted += webclient_DownloadFileCompleted;
_webClient.DownloadProgressChanged += webclient_DownloadProgressChanged;
}
_downloadFolder = dlog.SelectedPath;
var downloadedPath = Path.Combine(_downloadFolder, Path.GetFileName(_uri.AbsolutePath));
if (string.IsNullOrEmpty(Path.GetExtension(downloadedPath))) //CA-41747
downloadedPath += ".ovf";
var file = new ApplianceFile(_uri, downloadedPath);
_filesToDownload = new Queue<ApplianceFile>();
_filesToDownload.Enqueue(file);
LongProcessWrapper(() => _webClient.DownloadFileAsync(_uri, downloadedPath, file),
string.Format(Messages.IMPORT_WIZARD_DOWNLOADING, Path.GetFileName(downloadedPath).Ellipsise(50)));
return m_textBoxFile.Text == downloadedPath;
}
return false;
}
}
private bool Uncompress()
{
_unzipFileIn = FilePath;
if (string.IsNullOrEmpty(_unzipFileIn))
return false;
_unzipFileOut = Path.Combine(Path.GetDirectoryName(_unzipFileIn), Path.GetFileNameWithoutExtension(_unzipFileIn));
var msg = string.Format(Messages.UNCOMPRESS_APPLIANCE_DESCRIPTION,
Path.GetFileName(_unzipFileOut), Path.GetFileName(_unzipFileIn));
using (var dlog = new ThreeButtonDialog(new ThreeButtonDialog.Details(SystemIcons.Exclamation, msg, Messages.XENCENTER),
new ThreeButtonDialog.TBDButton(Messages.YES, DialogResult.Yes),
new ThreeButtonDialog.TBDButton(Messages.NO, DialogResult.No)))
{
if (dlog.ShowDialog(this) == DialogResult.Yes)
{
LongProcessWrapper(() => _unzipWorker.RunWorkerAsync(),
string.Format(Messages.IMPORT_WIZARD_UNCOMPRESSING, Path.GetFileName(_unzipFileIn).Ellipsise(50)));
return m_textBoxFile.Text == _unzipFileOut;
}
return false;
}
}
private void LongProcessWrapper(Action process, string processMessage)
{
m_textBoxFile.Enabled = false;
m_buttonBrowse.Enabled = false;
m_buttonNextEnabled = false;
OnPageUpdated();
m_tlpError.Visible = false;
labelProgress.Text = processMessage;
labelProgress.Visible = true;
progressBar1.Visible = true;
progressBar1.Value = 0;
longProcessInProgress = true;
process.Invoke();
while (longProcessInProgress)
Application.DoEvents();
m_textBoxFile.Enabled = true;
m_buttonBrowse.Enabled = true;
}
#endregion
#region Event handlers
private void m_buttonBrowse_Click(object sender, EventArgs e)
{
using (FileDialog ofd = new OpenFileDialog
{
CheckFileExists = true,
CheckPathExists = true,
DereferenceLinks = true,
Filter = OvfModeOnly ? Messages.IMPORT_SOURCE_PAGE_FILETYPES_OVF_ONLY : Messages.IMPORT_SOURCE_PAGE_FILETYPES,
RestoreDirectory = true,
Multiselect = false,
})
{
if (ofd.ShowDialog() == DialogResult.OK)
m_textBoxFile.Text = ofd.FileName;
}
}
private void m_textBoxImage_TextChanged(object sender, EventArgs e)
{
m_tlpEncryption.Visible = false;
m_tlpError.Visible = false;
PerformCheck(CheckPathValid);
IsDirty = true;
}
private void _unzipWorker_DoWork(object sender, DoWorkEventArgs e)
{
FileInfo info = new FileInfo(_unzipFileIn);
long length = info.Length;
using (Stream inStream = File.Open(_unzipFileIn, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
using (Stream outStream = File.Open(_unzipFileOut, FileMode.Create, FileAccess.ReadWrite, FileShare.None))
{
try
{
using (CompressionStream bzis = CompressionFactory.Reader(CompressionFactory.Type.Gz, inStream))
{
byte[] buffer = new byte[4 * 1024];
int bytesRead;
while ((bytesRead = bzis.Read(buffer, 0, buffer.Length)) > 0)
{
outStream.Write(buffer, 0, bytesRead);
int percentage = (int)Math.Floor((double)bzis.Position * 100 / length);
if (percentage < 0)
_unzipWorker.ReportProgress(0);
else if (percentage > 100)
_unzipWorker.ReportProgress(100);
else
_unzipWorker.ReportProgress(percentage);
if (_unzipWorker.CancellationPending)
{
e.Cancel = true;
return;
}
}
}
e.Result = _unzipFileOut;
}
catch (Exception ex)
{
log.Error(string.Format("Error while uncompressing {0} to {1}", _unzipFileIn, _unzipFileOut), ex);
throw;
}
finally
{
outStream.Flush();
}
}
}
private void _unzipWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
progressBar1.Value = e.ProgressPercentage;
}
private void _unzipWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
progressBar1.Visible = false;
labelProgress.Visible = false;
if (e.Cancelled || e.Error != null)
{
if (e.Error != null)
{
_labelError.Text = string.Format(Messages.IMPORT_WIZARD_FAILED_UNCOMPRESS,
Path.GetFileName(_unzipFileIn).Ellipsise(50));
m_tlpError.Visible = true;
}
try
{
File.Delete(_unzipFileOut);
}
catch (Exception ex)
{
log.Error(string.Format("Failed to delete uncompressed file {0}.", _unzipFileOut), ex);
}
longProcessInProgress = false;
return;
}
var uncompressedFile = e.Result as string;
progressBar1.Value = 100;
m_textBoxFile.Text = uncompressedFile;
try
{
File.Delete(_unzipFileIn);
}
catch (Exception ex)
{
log.Error(string.Format("Failed to delete compressed file {0}.", _unzipFileIn), ex);
}
longProcessInProgress = false;
}
private void webclient_DownloadProgressChanged(object sender, DownloadProgressChangedEventArgs e)
{
Program.Invoke(this, () => progressBar1.Value = e.ProgressPercentage);
}
private void webclient_DownloadFileCompleted(object sender, AsyncCompletedEventArgs e)
{
Program.Invoke(this, () =>
{
var appfile = (ApplianceFile)e.UserState;
if (e.Cancelled)
{
longProcessInProgress = false;
}
else if (e.Error != null) // failure
{
progressBar1.Visible = false;
labelProgress.Visible = false;
_labelError.Text = string.Format(Messages.IMPORT_WIZARD_FAILED_DOWNLOAD,
Path.GetFileName(appfile.LocalPath));
m_tlpError.Visible = true;
log.Error(string.Format("Failed to download file {0}.", appfile), e.Error);
longProcessInProgress = false;
}
else // success
{
if (_filesToDownload.Peek() == appfile)
_filesToDownload.Dequeue();
if (appfile.LocalPath.ToLower().EndsWith(".ovf"))
{
var envType = OVF.Load(appfile.LocalPath);
if (envType != null)
{
int index = _uri.OriginalString.LastIndexOf('/') + 1;
var remoteDir = _uri.OriginalString.Substring(0, index);
foreach (var file in envType.References.File)
{
var remoteUri = new Uri(remoteDir + file.href);
var localPath = Path.Combine(_downloadFolder, file.href);
_filesToDownload.Enqueue(new ApplianceFile(remoteUri, localPath));
}
}
}
if (_filesToDownload.Count == 0)
{
progressBar1.Value = 100;
progressBar1.Visible = false;
labelProgress.Visible = false;
m_textBoxFile.Text = Path.Combine(_downloadFolder, Path.GetFileName(_uri.AbsolutePath));
longProcessInProgress = false;
}
else
{
progressBar1.Value = 0;
var file = _filesToDownload.Peek();
labelProgress.Text = string.Format(Messages.IMPORT_WIZARD_DOWNLOADING,
Path.GetFileName(file.LocalPath).Ellipsise(50));
_webClient.DownloadFileAsync(file.RemoteUri, file.LocalPath, file);
}
}
});
}
#endregion
private class ApplianceFile
{
public ApplianceFile(Uri remoteUri, string localPath)
{
RemoteUri = remoteUri;
LocalPath = localPath;
}
public Uri RemoteUri { get; private set; }
public string LocalPath { get; private set; }
}
}
}