Update improvements

Include NzbDrone.Update in mono/osx package
Do not ignore certificate warnings for services
Check hash before extracting update
New: Update support for Linux/OS X - see the wiki for more information
pull/4/head
Mark McDowall 11 years ago
parent 5c2f77339d
commit ef3777fccf

@ -6,6 +6,7 @@ $testPackageFolder = '.\_tests\'
$testSearchPattern = '*.Test\bin\x86\Release'
$sourceFolder = '.\src'
$updateFolder = $outputFolder + '\NzbDrone.Update'
$updateFolderMono = $outputFolderMono + '\NzbDrone.Update'
Function Build()
{
@ -73,9 +74,6 @@ Function PackageMono()
Copy-Item $outputFolder $outputFolderMono -recurse
Write-Host Removing Update Client
Remove-Item -Recurse -Force "$outputFolderMono\NzbDrone.Update"
Write-Host Creating MDBs
get-childitem $outputFolderMono -File -Include @("*.exe", "*.dll") -Exclude @("MediaInfo.dll", "sqlite3.dll") -Recurse | foreach ($_) {
Write-Host "Creating .mdb for $_"
@ -110,6 +108,9 @@ Function PackageMono()
Remove-Item "$outputFolderMono\NzbDrone.Console.vshost.exe"
Write-Host Adding NzbDrone.Mono to UpdatePackage
Copy-Item $outputFolderMono\* $updateFolderMono -Filter NzbDrone.Mono.*
Write-Host "##teamcity[progressFinish 'Creating Mono Package']"
}

@ -3,7 +3,9 @@ using System.Reflection;
using FluentValidation;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using Omu.ValueInjecter;
namespace NzbDrone.Api.Config
@ -12,7 +14,7 @@ namespace NzbDrone.Api.Config
{
private readonly IConfigFileProvider _configFileProvider;
public HostConfigModule(ConfigFileProvider configFileProvider)
public HostConfigModule(IConfigFileProvider configFileProvider)
: base("/config/host")
{
_configFileProvider = configFileProvider;
@ -29,6 +31,8 @@ namespace NzbDrone.Api.Config
SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl);
SharedValidator.RuleFor(c => c.SslCertHash).NotEmpty().When(c => c.EnableSsl && OsInfo.IsWindows);
SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script);
}
private HostConfigResource GetHostConfig()

@ -1,5 +1,6 @@
using System;
using NzbDrone.Api.REST;
using NzbDrone.Core.Update;
namespace NzbDrone.Api.Config
{
@ -14,10 +15,12 @@ namespace NzbDrone.Api.Config
public String Password { get; set; }
public String LogLevel { get; set; }
public String Branch { get; set; }
public Boolean AutoUpdate { get; set; }
public String ApiKey { get; set; }
public Boolean Torrent { get; set; }
public String SslCertHash { get; set; }
public String UrlBase { get; set; }
public Boolean UpdateAutomatically { get; set; }
public UpdateMechanism UpdateMechanism { get; set; }
public String UpdateScriptPath { get; set; }
}
}

@ -162,6 +162,7 @@
<Compile Include="Mapping\MappingValidation.cs" />
<Compile Include="Mapping\ResourceMappingException.cs" />
<Compile Include="Mapping\ValueInjectorExtensions.cs" />
<Compile Include="Update\UpdateResource.cs" />
<Compile Include="Wanted\CutoffModule.cs" />
<Compile Include="Wanted\LegacyMissingModule.cs" />
<Compile Include="Wanted\MissingModule.cs" />

@ -1,8 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using NzbDrone.Api.REST;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Update;
using NzbDrone.Api.Mapping;
@ -44,18 +41,4 @@ namespace NzbDrone.Api.Update
return resources;
}
}
public class UpdateResource : RestResource
{
[JsonConverter(typeof(Newtonsoft.Json.Converters.VersionConverter))]
public Version Version { get; set; }
public String Branch { get; set; }
public DateTime ReleaseDate { get; set; }
public String FileName { get; set; }
public String Url { get; set; }
public Boolean IsUpgrade { get; set; }
public Boolean Installed { get; set; }
public UpdateChanges Changes { get; set; }
}
}

@ -0,0 +1,22 @@
using System;
using Newtonsoft.Json;
using NzbDrone.Api.REST;
using NzbDrone.Core.Update;
namespace NzbDrone.Api.Update
{
public class UpdateResource : RestResource
{
[JsonConverter(typeof(Newtonsoft.Json.Converters.VersionConverter))]
public Version Version { get; set; }
public String Branch { get; set; }
public DateTime ReleaseDate { get; set; }
public String FileName { get; set; }
public String Url { get; set; }
public Boolean IsUpgrade { get; set; }
public Boolean Installed { get; set; }
public UpdateChanges Changes { get; set; }
public String Hash { get; set; }
}
}

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Model;
@ -18,18 +19,26 @@ namespace NzbDrone.App.Test
{
Mocker.GetMock<IProcessProvider>().Setup(c => c.GetCurrentProcess())
.Returns(new ProcessInfo() { Id = CURRENT_PROCESS_ID });
Mocker.GetMock<IProcessProvider>()
.Setup(s => s.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME))
.Returns(new List<ProcessInfo>());
Mocker.GetMock<IProcessProvider>()
.Setup(s => s.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME))
.Returns(new List<ProcessInfo>());
}
[Test]
public void should_continue_if_only_instance()
{
Mocker.GetMock<INzbDroneProcessProvider>().Setup(c => c.FindNzbDroneProcesses())
Mocker.GetMock<IProcessProvider>()
.Setup(c => c.FindProcessByName(It.Is<String>(f => f.Contains("NzbDrone"))))
.Returns(new List<ProcessInfo>
{
new ProcessInfo {Id = CURRENT_PROCESS_ID}
});
Subject.PreventStartIfAlreadyRunning();
Mocker.GetMock<IBrowserService>().Verify(c => c.LaunchWebUI(), Times.Never());
@ -38,8 +47,8 @@ namespace NzbDrone.App.Test
[Test]
public void should_enforce_if_another_console_is_running()
{
Mocker.GetMock<INzbDroneProcessProvider>()
.Setup(c => c.FindNzbDroneProcesses())
Mocker.GetMock<IProcessProvider>()
.Setup(c => c.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME))
.Returns(new List<ProcessInfo>
{
new ProcessInfo {Id = 10},
@ -54,8 +63,8 @@ namespace NzbDrone.App.Test
[Test]
public void should_return_false_if_another_gui_is_running()
{
Mocker.GetMock<INzbDroneProcessProvider>()
.Setup(c => c.FindNzbDroneProcesses())
Mocker.GetMock<IProcessProvider>()
.Setup(c => c.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME))
.Returns(new List<ProcessInfo>
{
new ProcessInfo {Id = CURRENT_PROCESS_ID},

@ -168,25 +168,25 @@ namespace NzbDrone.Common.Test
[Test]
public void Sanbox()
{
GetIAppDirectoryInfo().GetUpdateSandboxFolder().Should().BeEquivalentTo(@"C:\Temp\Nzbdrone_update\".AsOsAgnostic());
GetIAppDirectoryInfo().GetUpdateSandboxFolder().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\".AsOsAgnostic());
}
[Test]
public void GetUpdatePackageFolder()
{
GetIAppDirectoryInfo().GetUpdatePackageFolder().Should().BeEquivalentTo(@"C:\Temp\Nzbdrone_update\NzbDrone\".AsOsAgnostic());
GetIAppDirectoryInfo().GetUpdatePackageFolder().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\NzbDrone\".AsOsAgnostic());
}
[Test]
public void GetUpdateClientFolder()
{
GetIAppDirectoryInfo().GetUpdateClientFolder().Should().BeEquivalentTo(@"C:\Temp\Nzbdrone_update\NzbDrone\NzbDrone.Update\".AsOsAgnostic());
GetIAppDirectoryInfo().GetUpdateClientFolder().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\NzbDrone\NzbDrone.Update\".AsOsAgnostic());
}
[Test]
public void GetUpdateClientExePath()
{
GetIAppDirectoryInfo().GetUpdateClientExePath().Should().BeEquivalentTo(@"C:\Temp\Nzbdrone_update\NzbDrone.Update.exe".AsOsAgnostic());
GetIAppDirectoryInfo().GetUpdateClientExePath().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\NzbDrone.Update.exe".AsOsAgnostic());
}
[Test]

@ -447,5 +447,15 @@ namespace NzbDrone.Common.Disk
return driveInfo.VolumeLabel;
}
public FileStream StreamFile(string path)
{
if (!FileExists(path))
{
throw new FileNotFoundException("Unable to find file: " + path, path);
}
return new FileStream(path, FileMode.Open);
}
}
}

@ -11,7 +11,6 @@ namespace NzbDrone.Common.Disk
void InheritFolderPermissions(string filename);
void SetPermissions(string path, string mask, string user, string group);
long? GetTotalSize(string path);
DateTime FolderGetLastWrite(string path);
DateTime FileGetLastWrite(string path);
DateTime FileGetLastWriteUtc(string path);
@ -44,5 +43,6 @@ namespace NzbDrone.Common.Disk
void EmptyFolder(string path);
string[] GetFixedDrives();
string GetVolumeLabel(string path);
FileStream StreamFile(string path);
}
}

@ -1,4 +1,6 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Text;
namespace NzbDrone.Common.EnvironmentInfo
{

@ -105,7 +105,6 @@
<Compile Include="Messaging\IEvent.cs" />
<Compile Include="Messaging\IMessage.cs" />
<Compile Include="PathEqualityComparer.cs" />
<Compile Include="Processes\INzbDroneProcessProvider.cs" />
<Compile Include="Processes\PidFileProvider.cs" />
<Compile Include="Processes\ProcessOutput.cs" />
<Compile Include="RateGate.cs" />

@ -13,10 +13,10 @@ namespace NzbDrone.Common
private const string NZBDRONE_LOG_DB = "logs.db";
private const string BACKUP_ZIP_FILE = "NzbDrone_Backup.zip";
private const string NLOG_CONFIG_FILE = "nlog.config";
private const string UPDATE_CLIENT_EXE = "nzbdrone.update.exe";
private const string UPDATE_CLIENT_EXE = "NzbDrone.Update.exe";
private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "nzbdrone_update" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "nzbdrone" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "NzbDrone" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_BACKUP_FOLDER_NAME = "nzbdrone_backup" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_BACKUP_APPDATA_FOLDER_NAME = "nzbdrone_appdata_backup" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_CLIENT_FOLDER_NAME = "NzbDrone.Update" + Path.DirectorySeparatorChar;

@ -1,10 +0,0 @@
using System.Collections.Generic;
using NzbDrone.Common.Model;
namespace NzbDrone.Common.Processes
{
public interface INzbDroneProcessProvider
{
List<ProcessInfo> FindNzbDroneProcesses();
}
}

@ -21,7 +21,7 @@ namespace NzbDrone.Common.Processes
void SetPriority(int processId, ProcessPriorityClass priority);
void KillAll(string processName);
void Kill(int processId);
bool Exists(string processName);
Boolean Exists(string processName);
ProcessPriorityClass GetCurrentProcessPriority();
Process Start(string path, string args = null, Action<string> onOutputDataReceived = null, Action<string> onErrorDataReceived = null);
Process SpawnNewProcess(string path, string args = null);
@ -35,20 +35,12 @@ namespace NzbDrone.Common.Processes
public const string NZB_DRONE_PROCESS_NAME = "NzbDrone";
public const string NZB_DRONE_CONSOLE_PROCESS_NAME = "NzbDrone.Console";
private static List<Process> GetProcessesByName(string name)
{
var monoProcesses = Process.GetProcessesByName("mono")
.Where(process => process.Modules.Cast<ProcessModule>().Any(module => module.ModuleName.ToLower() == name.ToLower() + ".exe"));
return Process.GetProcessesByName(name)
.Union(monoProcesses).ToList();
}
public ProcessInfo GetCurrentProcess()
{
return ConvertToProcessInfo(Process.GetCurrentProcess());
}
public bool Exists(string processName)
public Boolean Exists(string processName)
{
return GetProcessesByName(processName).Any();
}
@ -78,7 +70,7 @@ namespace NzbDrone.Common.Processes
public List<ProcessInfo> FindProcessByName(string name)
{
return Process.GetProcessesByName(name).Select(ConvertToProcessInfo).Where(c => c != null).ToList();
return GetProcessesByName(name).Select(ConvertToProcessInfo).Where(c => c != null).ToList();
}
public void OpenDefaultBrowser(string url)
@ -203,12 +195,40 @@ namespace NzbDrone.Common.Processes
process.PriorityClass = priority;
}
public void Kill(int processId)
{
var process = Process.GetProcesses().FirstOrDefault(p => p.Id == processId);
if (process == null)
{
Logger.Warn("Cannot find process with id: {0}", processId);
return;
}
process.Refresh();
if (process.HasExited)
{
Logger.Debug("Process has already exited");
return;
}
Logger.Info("[{0}]: Killing process", process.Id);
process.Kill();
Logger.Info("[{0}]: Waiting for exit", process.Id);
process.WaitForExit();
Logger.Info("[{0}]: Process terminated successfully", process.Id);
}
public void KillAll(string processName)
{
var processToKill = GetProcessesByName(processName);
var processes = GetProcessesByName(processName);
foreach (var processInfo in processToKill)
Logger.Debug("Found {0} processes to kill", processes.Count);
foreach (var processInfo in processes)
{
Logger.Debug("Killing process: {0} [{1}]", processInfo.Id, processInfo.ProcessName);
Kill(processInfo.Id);
}
}
@ -254,29 +274,23 @@ namespace NzbDrone.Common.Processes
return process.Modules.Cast<ProcessModule>().FirstOrDefault(module => module.ModuleName.ToLower().EndsWith(".exe")).FileName;
}
public void Kill(int processId)
private static List<Process> GetProcessesByName(string name)
{
var process = Process.GetProcesses().FirstOrDefault(p => p.Id == processId);
//TODO: move this to an OS specific class
if (process == null)
{
Logger.Warn("Cannot find process with id: {0}", processId);
return;
}
var monoProcesses = Process.GetProcessesByName("mono")
.Union(Process.GetProcessesByName("mono-sgen"))
.Where(process =>
process.Modules.Cast<ProcessModule>()
.Any(module =>
module.ModuleName.ToLower() == name.ToLower() + ".exe"));
process.Refresh();
var processes = Process.GetProcessesByName(name)
.Union(monoProcesses).ToList();
if (process.HasExited)
{
Logger.Debug("Process has already exited");
return;
}
Logger.Debug("Found {0} processes with the name: {1}", processes.Count, name);
Logger.Info("[{0}]: Killing process", process.Id);
process.Kill();
Logger.Info("[{0}]: Waiting for exit", process.Id);
process.WaitForExit();
Logger.Info("[{0}]: Process terminated successfully", process.Id);
return processes;
}
}
}

@ -12,5 +12,5 @@ using System.Runtime.InteropServices;
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
[assembly: AssemblyVersion("10.0.0.*")]
[assembly: AssemblyVersion("2.0.0.1")]
[assembly: AssemblyFileVersion("10.0.0.*")]

@ -13,6 +13,15 @@ namespace NzbDrone.Common.Security
private static bool ValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslpolicyerrors)
{
var request = sender as HttpWebRequest;
if (request != null &&
request.Address.OriginalString.ContainsIgnoreCase("nzbdrone.com") &&
sslpolicyerrors != SslPolicyErrors.None)
{
return false;
}
return true;
}
}

@ -3,6 +3,7 @@ using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using ICSharpCode.SharpZipLib.Zip;
namespace NzbDrone.Common
{
@ -65,5 +66,20 @@ namespace NzbDrone.Common
{
return String.IsNullOrWhiteSpace(text);
}
public static bool ContainsIgnoreCase(this string text, string contains)
{
return text.IndexOf(contains, StringComparison.InvariantCultureIgnoreCase) > -1;
}
public static string WrapInQuotes(this string text)
{
if (!text.Contains(" "))
{
return text;
}
return "\"" + text + "\"";
}
}
}

@ -9,6 +9,7 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Http;
using NzbDrone.Common.Model;
using NzbDrone.Common.Processes;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Update;
using NzbDrone.Core.Update.Commands;
@ -49,12 +50,28 @@ namespace NzbDrone.Core.Test.UpdateTests
Mocker.GetMock<IAppFolderInfo>().SetupGet(c => c.TempFolder).Returns(TempFolder);
Mocker.GetMock<ICheckUpdateService>().Setup(c => c.AvailableUpdate()).Returns(_updatePackage);
Mocker.GetMock<IVerifyUpdates>().Setup(c => c.Verify(It.IsAny<UpdatePackage>(), It.IsAny<String>())).Returns(true);
Mocker.GetMock<IProcessProvider>().Setup(c => c.GetCurrentProcess()).Returns(new ProcessInfo { Id = 12 });
Mocker.GetMock<IRuntimeInfo>().Setup(c => c.ExecutingApplication).Returns(@"C:\Test\NzbDrone.exe");
_sandboxFolder = Mocker.GetMock<IAppFolderInfo>().Object.GetUpdateSandboxFolder();
}
private void GivenInstallScript(string path)
{
Mocker.GetMock<IConfigFileProvider>()
.SetupGet(s => s.UpdateMechanism)
.Returns(UpdateMechanism.Script);
Mocker.GetMock<IConfigFileProvider>()
.SetupGet(s => s.UpdateScriptPath)
.Returns(path);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.FileExists(path, true))
.Returns(true);
}
[Test]
public void should_delete_sandbox_before_update_if_folder_exists()
@ -77,7 +94,6 @@ namespace NzbDrone.Core.Test.UpdateTests
Mocker.GetMock<IDiskProvider>().Verify(c => c.DeleteFolder(_sandboxFolder, true), Times.Never());
}
[Test]
public void Should_download_update_package()
{
@ -118,11 +134,11 @@ namespace NzbDrone.Core.Test.UpdateTests
Subject.Execute(new ApplicationUpdateCommand());
Mocker.GetMock<IProcessProvider>()
.Verify(c => c.Start(It.IsAny<string>(), "12", null, null), Times.Once());
.Verify(c => c.Start(It.IsAny<string>(), It.Is<String>(s => s.StartsWith("12")), null, null), Times.Once());
}
[Test]
public void when_no_updates_are_available_should_return_without_error_or_warnings()
public void should_return_without_error_or_warnings_when_no_updates_are_available()
{
Mocker.GetMock<ICheckUpdateService>().Setup(c => c.AvailableUpdate()).Returns<UpdatePackage>(null);
@ -132,6 +148,75 @@ namespace NzbDrone.Core.Test.UpdateTests
ExceptionVerification.AssertNoUnexpectedLogs();
}
[Test]
public void should_not_extract_if_verification_fails()
{
Mocker.GetMock<IVerifyUpdates>().Setup(c => c.Verify(It.IsAny<UpdatePackage>(), It.IsAny<String>())).Returns(false);
Subject.Execute(new ApplicationUpdateCommand());
Mocker.GetMock<IArchiveService>().Verify(v => v.Extract(It.IsAny<String>(), It.IsAny<String>()), Times.Never());
}
[Test]
[Platform("Mono")]
public void should_run_script_if_configured()
{
const string scriptPath = "/tmp/nzbdrone/update.sh";
GivenInstallScript(scriptPath);
Subject.Execute(new ApplicationUpdateCommand());
Mocker.GetMock<IProcessProvider>().Verify(v => v.Start(scriptPath, It.IsAny<String>(), null, null), Times.Once());
}
[Test]
[Platform("Mono")]
public void should_throw_if_script_is_not_set()
{
const string scriptPath = "/tmp/nzbdrone/update.sh";
GivenInstallScript("");
Subject.Execute(new ApplicationUpdateCommand());
ExceptionVerification.ExpectedErrors(1);
Mocker.GetMock<IProcessProvider>().Verify(v => v.Start(scriptPath, It.IsAny<String>(), null, null), Times.Never());
}
[Test]
[Platform("Mono")]
public void should_throw_if_script_is_null()
{
const string scriptPath = "/tmp/nzbdrone/update.sh";
GivenInstallScript(null);
Subject.Execute(new ApplicationUpdateCommand());
ExceptionVerification.ExpectedErrors(1);
Mocker.GetMock<IProcessProvider>().Verify(v => v.Start(scriptPath, It.IsAny<String>(), null, null), Times.Never());
}
[Test]
[Platform("Mono")]
public void should_throw_if_script_path_does_not_exist()
{
const string scriptPath = "/tmp/nzbdrone/update.sh";
GivenInstallScript(scriptPath);
Mocker.GetMock<IDiskProvider>()
.Setup(s => s.FileExists(scriptPath, true))
.Returns(false);
Subject.Execute(new ApplicationUpdateCommand());
ExceptionVerification.ExpectedErrors(1);
Mocker.GetMock<IProcessProvider>().Verify(v => v.Start(scriptPath, It.IsAny<String>(), null, null), Times.Never());
}
[Test]
[IntegrationTest]
public void Should_download_and_extract_to_temp_folder()

@ -11,6 +11,7 @@ using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Update;
namespace NzbDrone.Core.Configuration
@ -30,11 +31,13 @@ namespace NzbDrone.Core.Configuration
string Password { get; }
string LogLevel { get; }
string Branch { get; }
bool AutoUpdate { get; }
string ApiKey { get; }
bool Torrent { get; }
string SslCertHash { get; }
string UrlBase { get; }
Boolean UpdateAutomatically { get; }
UpdateMechanism UpdateMechanism { get; }
String UpdateScriptPath { get; }
}
public class ConfigFileProvider : IConfigFileProvider
@ -141,11 +144,6 @@ namespace NzbDrone.Core.Configuration
get { return GetValue("Branch", "master").ToLowerInvariant(); }
}
public bool AutoUpdate
{
get { return GetValueBoolean("AutoUpdate", false, persist: false); }
}
public string Username
{
get { return GetValue("Username", ""); }
@ -181,6 +179,21 @@ namespace NzbDrone.Core.Configuration
}
}
public bool UpdateAutomatically
{
get { return GetValueBoolean("UpdateAutomatically", false, false); }
}
public UpdateMechanism UpdateMechanism
{
get { return GetValueEnum("UpdateMechanism", UpdateMechanism.BuiltIn, false); }
}
public string UpdateScriptPath
{
get { return GetValue("UpdateScriptPath", "", false ); }
}
public int GetValueInt(string key, int defaultValue)
{
return Convert.ToInt32(GetValue(key, defaultValue));
@ -191,9 +204,9 @@ namespace NzbDrone.Core.Configuration
return Convert.ToBoolean(GetValue(key, defaultValue, persist));
}
public T GetValueEnum<T>(string key, T defaultValue)
public T GetValueEnum<T>(string key, T defaultValue, bool persist = true)
{
return (T)Enum.Parse(typeof(T), GetValue(key, defaultValue), true);
return (T)Enum.Parse(typeof(T), GetValue(key, defaultValue), persist);
}
public string GetValue(string key, object defaultValue, bool persist = true)
@ -210,7 +223,9 @@ namespace NzbDrone.Core.Configuration
var valueHolder = parentContainer.Descendants(key).ToList();
if (valueHolder.Count() == 1)
{
return valueHolder.First().Value.Trim();
}
//Save the value
if (persist)

@ -6,6 +6,7 @@ using NzbDrone.Common.EnsureThat;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Update;
namespace NzbDrone.Core.Configuration

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Update;
namespace NzbDrone.Core.Configuration
{
@ -23,7 +24,6 @@ namespace NzbDrone.Core.Configuration
Int32 BlacklistRetryInterval { get; set; }
Int32 BlacklistRetryLimit { get; set; }
//Media Management
Boolean AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; }
String RecycleBin { get; set; }

@ -693,10 +693,13 @@
<Compile Include="Update\InstallUpdateService.cs" />
<Compile Include="Update\RecentUpdateProvider.cs" />
<Compile Include="Update\UpdateChanges.cs" />
<Compile Include="Update\UpdateMechanism.cs" />
<Compile Include="Update\UpdatePackageAvailable.cs" />
<Compile Include="Update\UpdatePackageProvider.cs" />
<Compile Include="Update\UpdatePackage.cs" />
<Compile Include="Update\UpdateCheckService.cs" />
<Compile Include="Update\UpdateVerification.cs" />
<Compile Include="Update\UpdateVerificationFailedException.cs" />
<Compile Include="Validation\Paths\SeriesExistsValidator.cs" />
<Compile Include="Validation\Paths\RootFolderValidator.cs" />
<Compile Include="Validation\Paths\DroneFactoryValidator.cs" />

@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace NzbDrone.Core
@ -7,19 +8,30 @@ namespace NzbDrone.Core
{
public static string SHA256Hash(this string input)
{
var stringBuilder = new StringBuilder();
using (var hash = SHA256Managed.Create())
{
var enc = Encoding.UTF8;
var result = hash.ComputeHash(enc.GetBytes(input));
return GetHash(hash.ComputeHash(enc.GetBytes(input)));
}
}
foreach (var b in result)
public static string SHA256Hash(this Stream input)
{
stringBuilder.Append(b.ToString("x2"));
using (var hash = SHA256Managed.Create())
{
return GetHash(hash.ComputeHash(input));
}
}
private static string GetHash(byte[] bytes)
{
var stringBuilder = new StringBuilder();
foreach (var b in bytes)
{
stringBuilder.Append(b.ToString("x2"));
}
return stringBuilder.ToString();
}
}

@ -6,6 +6,7 @@ using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Http;
using NzbDrone.Common.Processes;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Instrumentation.Extensions;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Update.Commands;
@ -27,18 +28,31 @@ namespace NzbDrone.Core.Update
private readonly IHttpProvider _httpProvider;
private readonly IArchiveService _archiveService;
private readonly IProcessProvider _processProvider;
private readonly IVerifyUpdates _updateVerifier;
private readonly IConfigFileProvider _configFileProvider;
private readonly IRuntimeInfo _runtimeInfo;
public InstallUpdateService(ICheckUpdateService checkUpdateService, IAppFolderInfo appFolderInfo,
IDiskProvider diskProvider, IHttpProvider httpProvider,
IArchiveService archiveService, IProcessProvider processProvider, Logger logger)
IArchiveService archiveService, IProcessProvider processProvider,
IVerifyUpdates updateVerifier,
IConfigFileProvider configFileProvider,
IRuntimeInfo runtimeInfo, Logger logger)
{
if (configFileProvider == null)
{
throw new ArgumentNullException("configFileProvider");
}
_checkUpdateService = checkUpdateService;
_appFolderInfo = appFolderInfo;
_diskProvider = diskProvider;
_httpProvider = httpProvider;
_archiveService = archiveService;
_processProvider = processProvider;
_updateVerifier = updateVerifier;
_configFileProvider = configFileProvider;
_runtimeInfo = runtimeInfo;
_logger = logger;
}
@ -60,19 +74,34 @@ namespace NzbDrone.Core.Update
_logger.Debug("Downloading update package from [{0}] to [{1}]", updatePackage.Url, packageDestination);
_httpProvider.DownloadFile(updatePackage.Url, packageDestination);
_logger.ProgressInfo("Verifying update package");
if (!_updateVerifier.Verify(updatePackage, packageDestination))
{
_logger.Error("Update package is invalid");
throw new UpdateVerificationFailedException("Update file '{0}' is invalid", packageDestination);
}
_logger.Info("Update package verified successfully");
_logger.ProgressInfo("Extracting Update package");
_archiveService.Extract(packageDestination, updateSandboxFolder);
_logger.Info("Update package extracted successfully");
if (OsInfo.IsMono && _configFileProvider.UpdateMechanism == UpdateMechanism.Script)
{
InstallUpdateWithScript(updateSandboxFolder);
return;
}
_logger.Info("Preparing client");
_diskProvider.MoveFolder(_appFolderInfo.GetUpdateClientFolder(),
updateSandboxFolder);
_logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath());
_logger.ProgressInfo("NzbDrone will restart shortly.");
_processProvider.Start(_appFolderInfo.GetUpdateClientExePath(), _processProvider.GetCurrentProcess().Id.ToString());
_processProvider.Start(_appFolderInfo.GetUpdateClientExePath(), GetUpdaterArgs(updateSandboxFolder));
}
catch (Exception ex)
{
@ -80,6 +109,36 @@ namespace NzbDrone.Core.Update
}
}
private void InstallUpdateWithScript(String updateSandboxFolder)
{
var scriptPath = _configFileProvider.UpdateScriptPath;
if (scriptPath.IsNullOrWhiteSpace())
{
throw new ArgumentException("Update Script has not been defined");
}
if (!_diskProvider.FileExists(scriptPath, true))
{
var message = String.Format("Update Script: '{0}' does not exist", scriptPath);
throw new FileNotFoundException(message, scriptPath);
}
_logger.Info("Removing NzbDrone.Update");
_diskProvider.DeleteFolder(_appFolderInfo.GetUpdateClientFolder(), true);
_logger.ProgressInfo("Starting update script: {0}", _configFileProvider.UpdateScriptPath);
_processProvider.Start(scriptPath, GetUpdaterArgs(updateSandboxFolder.WrapInQuotes()));
}
private string GetUpdaterArgs(string updateSandboxFolder)
{
var processId = _processProvider.GetCurrentProcess().Id.ToString();
var executingApplication = _runtimeInfo.ExecutingApplication;
return String.Join(" ", processId, updateSandboxFolder.WrapInQuotes(), executingApplication.WrapInQuotes());
}
public void Execute(ApplicationUpdateCommand message)
{
_logger.ProgressDebug("Checking for updates");

@ -18,7 +18,9 @@ namespace NzbDrone.Core.Update
private readonly Logger _logger;
public CheckUpdateService(IUpdatePackageProvider updatePackageProvider, IConfigFileProvider configFileProvider, Logger logger)
public CheckUpdateService(IUpdatePackageProvider updatePackageProvider,
IConfigFileProvider configFileProvider,
Logger logger)
{
_updatePackageProvider = updatePackageProvider;
_configFileProvider = configFileProvider;
@ -27,7 +29,10 @@ namespace NzbDrone.Core.Update
public UpdatePackage AvailableUpdate()
{
if (OsInfo.IsMono) return null;
if (OsInfo.IsMono && !_configFileProvider.UpdateAutomatically)
{
return null;
}
var latestAvailable = _updatePackageProvider.GetLatestUpdate(_configFileProvider.Branch, BuildInfo.Version);

@ -0,0 +1,8 @@
namespace NzbDrone.Core.Update
{
public enum UpdateMechanism
{
BuiltIn = 0,
Script = 1
}
}

@ -11,5 +11,6 @@ namespace NzbDrone.Core.Update
public String FileName { get; set; }
public String Url { get; set; }
public UpdateChanges Changes { get; set; }
public String Hash { get; set; }
}
}

@ -0,0 +1,30 @@
using System;
using NzbDrone.Common.Disk;
namespace NzbDrone.Core.Update
{
public interface IVerifyUpdates
{
Boolean Verify(UpdatePackage updatePackage, String packagePath);
}
public class UpdateVerification : IVerifyUpdates
{
private readonly IDiskProvider _diskProvider;
public UpdateVerification(IDiskProvider diskProvider)
{
_diskProvider = diskProvider;
}
public Boolean Verify(UpdatePackage updatePackage, String packagePath)
{
using (var fileStream = _diskProvider.StreamFile(packagePath))
{
var hash = fileStream.SHA256Hash();
return hash.Equals(updatePackage.Hash, StringComparison.CurrentCultureIgnoreCase);
}
}
}
}

@ -0,0 +1,15 @@
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Core.Update
{
public class UpdateVerificationFailedException : NzbDroneException
{
public UpdateVerificationFailedException(string message, params object[] args) : base(message, args)
{
}
public UpdateVerificationFailedException(string message) : base(message)
{
}
}
}

@ -16,17 +16,14 @@ namespace NzbDrone.Host
{
private readonly IProcessProvider _processProvider;
private readonly IBrowserService _browserService;
private readonly INzbDroneProcessProvider _nzbDroneProcessProvider;
private readonly Logger _logger;
public SingleInstancePolicy(IProcessProvider processProvider,
IBrowserService browserService,
INzbDroneProcessProvider nzbDroneProcessProvider,
Logger logger)
{
_processProvider = processProvider;
_browserService = browserService;
_nzbDroneProcessProvider = nzbDroneProcessProvider;
_logger = logger;
}
@ -56,7 +53,8 @@ namespace NzbDrone.Host
private List<int> GetOtherNzbDroneProcessIds()
{
var currentId = _processProvider.GetCurrentProcess().Id;
var otherProcesses = _nzbDroneProcessProvider.FindNzbDroneProcesses()
var otherProcesses = _processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME)
.Union(_processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME))
.Select(c => c.Id)
.Except(new[] {currentId})
.ToList();

@ -70,7 +70,6 @@
<ItemGroup>
<Compile Include="DiskProvider.cs" />
<Compile Include="LinuxPermissionsException.cs" />
<Compile Include="NzbDroneProcessProvider.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>

@ -1,43 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Model;
using NzbDrone.Common.Processes;
namespace NzbDrone.Mono
{
public class NzbDroneProcessProvider : INzbDroneProcessProvider
{
private readonly IProcessProvider _processProvider;
private readonly Logger _logger;
public NzbDroneProcessProvider(IProcessProvider processProvider, Logger logger)
{
_processProvider = processProvider;
_logger = logger;
}
public List<ProcessInfo> FindNzbDroneProcesses()
{
var monoProcesses = _processProvider.FindProcessByName("mono");
return monoProcesses.Where(c =>
{
try
{
var processArgs = _processProvider.StartAndCapture("ps", String.Format("-p {0} -o args=", c.Id));
return processArgs.Standard.Any(p => p.Contains(ProcessProvider.NZB_DRONE_PROCESS_NAME + ".exe") ||
p.Contains(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME + ".exe"));
}
catch (InvalidOperationException ex)
{
_logger.WarnException("Error getting process arguments", ex);
return false;
}
}).ToList();
}
}
}

@ -21,7 +21,7 @@ namespace NzbDrone.Test.Common
LogManager.Configuration = new LoggingConfiguration();
var consoleTarget = new ConsoleTarget { Layout = "${level}: ${message} ${exception}" };
LogManager.Configuration.AddTarget(consoleTarget.GetType().Name, consoleTarget);
LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Info, consoleTarget));
LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", LogLevel.Debug, consoleTarget));
RegisterExceptionVerification();
}

@ -9,7 +9,9 @@ namespace NzbDrone.Test.Dummy
static void Main(string[] args)
{
Console.WriteLine("Dummy process. ID:{0} Path:{1}", Process.GetCurrentProcess().Id, Process.GetCurrentProcess().MainModule.FileName);
var process = Process.GetCurrentProcess();
Console.WriteLine("Dummy process. ID:{0} Name:{1} Path:{2}", process.Id, process.ProcessName, process.MainModule.FileName);
Console.ReadLine();
}
}

@ -51,6 +51,7 @@
<Link>Properties\SharedAssemblyInfo.cs</Link>
</Compile>
<Compile Include="AppType.cs" />
<Compile Include="UpdateStartupContext.cs" />
<Compile Include="UpdateApp.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="UpdateContainerBuilder.cs" />

@ -1,6 +1,8 @@
using System;
using System.IO;
using System.Linq;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.Composition;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation;
@ -50,24 +52,60 @@ namespace NzbDrone.Update
public void Start(string[] args)
{
var processId = ParseProcessId(args);
var startupContext = ParseArgs(args);
string targetFolder;
var exeFileInfo = new FileInfo(_processProvider.GetProcessById(processId).StartPath);
var targetFolder = exeFileInfo.Directory.FullName;
if (startupContext.ExecutingApplication.IsNullOrWhiteSpace())
{
var exeFileInfo = new FileInfo(_processProvider.GetProcessById(startupContext.ProcessId).StartPath);
targetFolder = exeFileInfo.Directory.FullName;
}
else
{
var exeFileInfo = new FileInfo(startupContext.ExecutingApplication);
targetFolder = exeFileInfo.Directory.FullName;
}
logger.Info("Starting update process. Target Path:{0}", targetFolder);
_installUpdateService.Start(targetFolder);
}
private int ParseProcessId(string[] args)
private UpdateStartupContext ParseArgs(string[] args)
{
if (args == null || !args.Any())
{
throw new ArgumentOutOfRangeException("args", "args must be specified");
}
var startupContext = new UpdateStartupContext
{
ProcessId = ParseProcessId(args[0])
};
if (args.Count() == 1)
{
return startupContext;
}
if (args.Count() >= 3)
{
startupContext.UpdateLocation = args[1];
startupContext.ExecutingApplication = args[2];
}
return startupContext;
}
private int ParseProcessId(string arg)
{
int id;
if (args == null || !Int32.TryParse(args[0], out id) || id <= 0)
if (!Int32.TryParse(arg, out id) || id <= 0)
{
throw new ArgumentOutOfRangeException("args", "Invalid process ID");
throw new ArgumentOutOfRangeException("arg", "Invalid process ID");
}
logger.Debug("NzbDrone processId:{0}", id);
logger.Debug("NzbDrone process ID: {0}", id);
return id;
}
}

@ -1,4 +1,5 @@
using NzbDrone.Common;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Processes;
namespace NzbDrone.Update.UpdateEngine
@ -21,6 +22,12 @@ namespace NzbDrone.Update.UpdateEngine
public AppType GetAppType()
{
if (OsInfo.IsMono)
{
//Tehcnically its the console, but its been renamed for mono (Linux/OS X)
return AppType.Normal;
}
if (_serviceProvider.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)
&& _serviceProvider.IsServiceRunning(ServiceProvider.NZBDRONE_SERVICE_NAME))
{

@ -82,7 +82,6 @@ namespace NzbDrone.Update.UpdateEngine
_backupAndRestore.Restore(installationFolder);
_logger.FatalException("Failed to copy upgrade package to target folder.", e);
}
}
finally
{

@ -1,6 +1,7 @@
using System;
using NLog;
using NzbDrone.Common;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Processes;
using IServiceProvider = NzbDrone.Common.IServiceProvider;
@ -26,6 +27,15 @@ namespace NzbDrone.Update.UpdateEngine
public void Terminate()
{
if (OsInfo.IsMono)
{
_logger.Info("Stopping all instances");
_processProvider.KillAll(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME);
_processProvider.KillAll(ProcessProvider.NZB_DRONE_PROCESS_NAME);
return;
}
_logger.Info("Stopping all running services");
if (_serviceProvider.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)
@ -35,7 +45,6 @@ namespace NzbDrone.Update.UpdateEngine
{
_logger.Info("NzbDrone Service is installed and running");
_serviceProvider.Stop(ServiceProvider.NZBDRONE_SERVICE_NAME);
}
catch (Exception e)
{

@ -0,0 +1,11 @@
using System;
namespace NzbDrone.Update
{
public class UpdateStartupContext
{
public Int32 ProcessId { get; set; }
public String ExecutingApplication { get; set; }
public String UpdateLocation { get; set; }
}
}

@ -63,7 +63,6 @@
</ItemGroup>
<ItemGroup>
<Compile Include="DiskProvider.cs" />
<Compile Include="NzbDroneProcessProvider.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>

@ -1,25 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Model;
using NzbDrone.Common.Processes;
namespace NzbDrone.Windows
{
public class NzbDroneProcessProvider : INzbDroneProcessProvider
{
private readonly IProcessProvider _processProvider;
public NzbDroneProcessProvider(IProcessProvider processProvider)
{
_processProvider = processProvider;
}
public List<ProcessInfo> FindNzbDroneProcesses()
{
var consoleProcesses = _processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME);
var winformProcesses = _processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME);
return consoleProcesses.Concat(winformProcesses).ToList();
}
}
}

@ -14,7 +14,8 @@ define(
events: {
'change .x-auth' : '_setAuthOptionsVisibility',
'change .x-ssl' : '_setSslOptionsVisibility',
'click .x-reset-api-key' : '_resetApiKey'
'click .x-reset-api-key' : '_resetApiKey',
'change .x-update-mechanism' : '_setScriptGroupVisibility'
},
ui: {
@ -24,7 +25,9 @@ define(
sslOptions : '.x-ssl-options',
resetApiKey : '.x-reset-api-key',
copyApiKey : '.x-copy-api-key',
apiKeyInput : '.x-api-key'
apiKeyInput : '.x-api-key',
updateMechanism : '.x-update-mechanism',
scriptGroup : '.x-script-group'
},
initialize: function () {
@ -40,6 +43,10 @@ define(
this.ui.sslOptions.hide();
}
if (!this._showScriptGroup()) {
this.ui.scriptGroup.hide();
}
CommandController.bindToCommand({
element: this.ui.resetApiKey,
command: {
@ -79,7 +86,7 @@ define(
},
_resetApiKey: function () {
if (window.confirm("Reset API Key?")) {
if (window.confirm('Reset API Key?')) {
CommandController.Execute('resetApiKey', {
name : 'resetApiKey'
});
@ -90,6 +97,21 @@ define(
if (options.command.get('name') === 'resetapikey') {
this.model.fetch();
}
},
_setScriptGroupVisibility: function () {
if (this._showScriptGroup()) {
this.ui.scriptGroup.slideDown();
}
else {
this.ui.scriptGroup.slideUp();
}
},
_showScriptGroup: function () {
return this.ui.updateMechanism.val() === 'script';
}
});

@ -102,7 +102,7 @@
<div class="col-sm-8">
<div class="input-group">
<label class="checkbox toggle well">
<input type="checkbox" class='x-auth' name="authenticationEnabled"/>
<input type="checkbox" class="x-auth" name="authenticationEnabled"/>
<p>
<span>On</span>
<span>Off</span>
@ -117,7 +117,7 @@
</div>
</div>
<div class='x-auth-options'>
<div class="x-auth-options">
<div class="form-group">
<label class="col-sm-3 control-label">Username</label>
@ -174,9 +174,8 @@
</div>
</fieldset>
{{#if_windows}}
<fieldset class="advanced-setting">
<legend>Development</legend>
<legend>Updating</legend>
<div class="form-group">
<label class="col-sm-3 control-label">Branch</label>
@ -186,28 +185,56 @@
</div>
</div>
<!--{{#if_mono}}-->
<!--<div class="form-group">-->
<!--<label class="control-label">Auto Update</label>-->
{{#if_mono}}
<div class="alert alert-warning">Please see: <a href="https://github.com/NzbDrone/NzbDrone/wiki/Updating">the wiki</a> for more information</div>
<div class="form-group">
<label class="col-sm-3 control-label">Automatic</label>
<div class="col-sm-8">
<div class="input-group">
<label class="checkbox toggle well">
<input type="checkbox" name="updateAutomatically"/>
<p>
<span>On</span>
<span>Off</span>
</p>
<div class="btn btn-primary slide-button"/>
</label>
<span class="help-inline-checkbox">
<i class="icon-nd-form-info" title="Automatically download and install updates. You will still be able to install from System: Updates"/>
</span>
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Mechanism</label>
<!--<div class="controls">-->
<!--<label class="checkbox toggle well">-->
<!--<input type="checkbox" name="autoUpdate"/>-->
<div class="col-sm-1 col-sm-push-4 help-inline">
<i class="icon-nd-form-info" title="Use built-in updater or external script"/>
</div>
<!--<p>-->
<!--<span>Yes</span>-->
<!--<span>No</span>-->
<!--</p>-->
<div class="col-sm-4 col-sm-pull-1">
<select name="updateMechanism" class="form-control x-update-mechanism">
<option value="builtIn">Built-in</option>
<option value="script">Script</option>
</select>
</div>
</div>
<div class="form-group x-script-group">
<label class="col-sm-3 control-label">Script Path</label>
<!--<div class="btn btn-primary slide-button"/>-->
<!--</label>-->
<div class="col-sm-1 col-sm-push-4 help-inline">
<i class="icon-nd-form-info" title="Path to a custom script that take an extracted update package and handle the remainder of the update process"/>
</div>
<!--<span class="help-inline-checkbox">-->
<!--<i class="icon-nd-form-info" title="Use drone's built in auto update instead of package manager/manual updating"/>-->
<!--</span>-->
<!--</div>-->
<!--</div>-->
<!--{{/if_mono}}-->
<div class="col-sm-4 col-sm-pull-1">
<input type="text" name="updateScriptPath" class="form-control"/>
</div>
</div>
{{/if_mono}}
</fieldset>
{{/if_windows}}
</div>

@ -5,17 +5,9 @@
- {{ShortDate releaseDate}}
{{#if installed}}<i class="icon-ok" title="Installed"></i>{{/if}}
{{#if_windows}}
{{#if isUpgrade}}
<span class="label label-default install-update x-install-update">Install</span>
{{/if}}
{{else}}
{{#if isUpgrade}}
<span class="label label-default install-update">
<a href="https://github.com/NzbDrone/NzbDrone/wiki/Installation#linux">Install</a>
</span>
{{/if}}
{{/if_windows}}
</span>
</legend>

Loading…
Cancel
Save