diff --git a/Core/FormBrowser.cs b/Core/FormBrowser.cs index 26a009f7..a895c02b 100644 --- a/Core/FormBrowser.cs +++ b/Core/FormBrowser.cs @@ -78,8 +78,9 @@ public FormBrowser(UpdaterSettings updaterSettings){ Disposed += (sender, args) => { Config.MuteToggled -= Config_MuteToggled; Config.TrayBehaviorChanged -= Config_TrayBehaviorChanged; - + browser.Dispose(); + updates.Dispose(); contextMenu.Dispose(); notificationScreenshotManager?.Dispose(); @@ -96,6 +97,7 @@ public FormBrowser(UpdaterSettings updaterSettings){ UpdateTrayIcon(); this.updates = new UpdateHandler(browser, updaterSettings); + this.updates.CheckFinished += updates_CheckFinished; this.updates.UpdateAccepted += updates_UpdateAccepted; this.updates.UpdateDismissed += updates_UpdateDismissed; @@ -233,6 +235,18 @@ private static void plugins_Executed(object sender, PluginErrorEventArgs e){ } } + private void updates_CheckFinished(object sender, UpdateCheckEventArgs e){ + this.InvokeAsyncSafe(() => { + e.Result.Handle(update => { + if (!update.IsUpdateNew && !update.IsUpdateDismissed){ + updates.StartTimer(); + } + }, ex => { + // TODO show error and ask if the user wants to temporarily disable checks -- or maybe only do that for the first check of the day + }); + }); + } + private void updates_UpdateAccepted(object sender, UpdateEventArgs e){ this.InvokeAsyncSafe(() => { FormManager.CloseAllDialogs(); @@ -241,13 +255,21 @@ private void updates_UpdateAccepted(object sender, UpdateEventArgs e){ Config.DismissedUpdate = null; Config.Save(); } - - updates.BeginUpdateDownload(this, e.UpdateInfo, update => { - if (update.DownloadStatus == UpdateDownloadStatus.Done){ - UpdateInstallerPath = update.InstallerPath; - } - ForceClose(); + updates.BeginUpdateDownload(this, e.UpdateInfo, update => { + UpdateDownloadStatus status = update.DownloadStatus; + + if (status == UpdateDownloadStatus.Done){ + UpdateInstallerPath = update.InstallerPath; + ForceClose(); + } + else if (FormMessage.Error("Update Has Failed", "Could not automatically download the update: "+(update.DownloadError?.Message ?? "unknown error")+"\n\nWould you like to open the website and try downloading the update manually?", FormMessage.Yes, FormMessage.No)){ + BrowserUtils.OpenExternalBrowser(Program.Website); + ForceClose(); + } + else{ + Show(); + } }); }); } diff --git a/Core/Other/Settings/TabSettingsGeneral.cs b/Core/Other/Settings/TabSettingsGeneral.cs index 1d36ee8b..8dda7c37 100644 --- a/Core/Other/Settings/TabSettingsGeneral.cs +++ b/Core/Other/Settings/TabSettingsGeneral.cs @@ -222,9 +222,13 @@ private void updates_CheckFinished(object sender, UpdateCheckEventArgs e){ if (e.EventId == updateCheckEventId){ btnCheckUpdates.Enabled = true; - if (!e.IsUpdateAvailable){ - FormMessage.Information("No Updates Available", "Your version of TweetDuck is up to date.", FormMessage.OK); - } + e.Result.Handle(update => { + if (!update.IsUpdateNew){ + FormMessage.Information("No Updates Available", "Your version of TweetDuck is up to date.", FormMessage.OK); + } + }, ex => { + Program.Reporter.HandleException("Update Check", "Encountered an error while checking updates.", true, ex); + }); } }); } diff --git a/Resources/Scripts/update.js b/Resources/Scripts/update.js index 6d3ffa98..08444ef4 100644 --- a/Resources/Scripts/update.js +++ b/Resources/Scripts/update.js @@ -1,33 +1,9 @@ (function($, $TDU){ - // - // Variable: Current timeout ID for update checking. - // - var updateCheckTimeoutID; - - // - // Constant: Update exe file name. - // - const updateFileName = "TweetDuck.Update.exe"; - - // - // Constant: Url that returns JSON data about latest version. - // - const updateCheckUrlLatest = "https://api.github.com/repos/chylex/TweetDuck/releases/latest"; - - // - // Constant: Url that returns JSON data about all versions, including prereleases. - // - const updateCheckUrlAll = "https://api.github.com/repos/chylex/TweetDuck/releases"; - - // - // Constant: Fallback url in case the update installer file is missing. - // - const updateDownloadFallback = "https://tweetduck.chylex.com"; - // // Function: Creates the update notification element. Removes the old one if already exists. // - var displayNotification = function(version, download, changelog){ + var displayNotification = function(version, changelog){ + // styles var css = $("#tweetduck-update-css"); @@ -167,7 +143,7 @@ <div id='tweetduck-changelog'> <div id='tweetduck-changelog-box'> <h2>TweetDuck Update ${version}</h2> - ${changelog} + ${markdown(atob(changelog))} </div> </div> `).appendTo(document.body).css("display", "none"); @@ -219,17 +195,11 @@ buttonDiv.children(".tdu-btn-download").click(function(){ hide(); - - if (download){ - $TDU.onUpdateAccepted(); - } - else{ - $TDU.openBrowser(updateDownloadFallback); - } + $TDU.onUpdateAccepted(); }); buttonDiv.children(".tdu-btn-later").click(function(){ - clearTimeout(updateCheckTimeoutID); + $TDU.onUpdateDelayed(); slide(); }); @@ -245,15 +215,6 @@ return ele; }; - // - // Function: Returns milliseconds until the start of the next hour, with an extra offset in seconds that can skip an hour if the clock would roll over too soon. - // - var getTimeUntilNextHour = function(extra){ - var now = new Date(); - var offset = new Date(+now+extra*1000); - return new Date(offset.getFullYear(), offset.getMonth(), offset.getDate(), offset.getHours()+1, 0, 0)-now; - }; - // // Function: Ghetto-converts markdown to HTML. // @@ -273,33 +234,6 @@ .replace(/\n\r?\n\r?/g, "<br>"); }; - // - // Function: Runs an update check and updates all DOM elements appropriately. - // - var runUpdateCheck = function(eventID, versionTag, dismissedVersionTag, allowPre){ - clearTimeout(updateCheckTimeoutID); - updateCheckTimeoutID = setTimeout($TDU.triggerUpdateCheck, getTimeUntilNextHour(60*30)); // 30 minute offset - - $.getJSON(allowPre ? updateCheckUrlAll : updateCheckUrlLatest, function(response){ - var release = allowPre ? response[0] : response; - - var tagName = release.tag_name; - var hasUpdate = tagName !== versionTag && tagName !== dismissedVersionTag && release.assets.length > 0; - - if (hasUpdate){ - var obj = release.assets.find(asset => asset.name === updateFileName) || { browser_download_url: "" }; - displayNotification(tagName, obj.browser_download_url, markdown(release.body)); - - if (eventID){ // ignore undefined and 0 - $TDU.onUpdateCheckFinished(eventID, tagName, obj.browser_download_url); - } - } - else if (eventID){ // ignore undefined and 0 - $TDU.onUpdateCheckFinished(eventID, null, null); - } - }); - }; - // // Block: Check updates on startup. // @@ -310,5 +244,5 @@ // // Block: Setup global functions. // - window.TDUF_runUpdateCheck = runUpdateCheck; + window.TDUF_displayNotification = displayNotification; })($, $TDU); diff --git a/TweetDuck.csproj b/TweetDuck.csproj index fd09944d..9305dab0 100644 --- a/TweetDuck.csproj +++ b/TweetDuck.csproj @@ -69,6 +69,7 @@ <Reference Include="System.Core" /> <Reference Include="System.Drawing" /> <Reference Include="System.Management" /> + <Reference Include="System.Web.Extensions" /> <Reference Include="System.Windows.Forms" /> </ItemGroup> <ItemGroup> @@ -316,6 +317,7 @@ <Compile Include="Core\Management\BrowserCache.cs" /> <Compile Include="Core\Utils\BrowserUtils.cs" /> <Compile Include="Core\Utils\NativeMethods.cs" /> + <Compile Include="Updates\UpdateCheckClient.cs" /> <Compile Include="Updates\UpdateDownloadStatus.cs" /> <Compile Include="Updates\UpdateHandler.cs" /> <Compile Include="Updates\UpdateInfo.cs" /> diff --git a/Updates/Events/UpdateCheckEventArgs.cs b/Updates/Events/UpdateCheckEventArgs.cs index b7f58861..5da32326 100644 --- a/Updates/Events/UpdateCheckEventArgs.cs +++ b/Updates/Events/UpdateCheckEventArgs.cs @@ -1,13 +1,14 @@ using System; +using TweetDuck.Data; namespace TweetDuck.Updates.Events{ sealed class UpdateCheckEventArgs : EventArgs{ public int EventId { get; } - public bool IsUpdateAvailable { get; } + public Result<UpdateInfo> Result { get; } - public UpdateCheckEventArgs(int eventId, bool isUpdateAvailable){ + public UpdateCheckEventArgs(int eventId, Result<UpdateInfo> result){ this.EventId = eventId; - this.IsUpdateAvailable = isUpdateAvailable; + this.Result = result; } } } diff --git a/Updates/FormUpdateDownload.cs b/Updates/FormUpdateDownload.cs index ab8de653..9b85519f 100644 --- a/Updates/FormUpdateDownload.cs +++ b/Updates/FormUpdateDownload.cs @@ -1,7 +1,5 @@ using System; using System.Windows.Forms; -using TweetDuck.Core.Other; -using TweetDuck.Core.Utils; namespace TweetDuck.Updates{ sealed partial class FormUpdateDownload : Form{ @@ -22,19 +20,8 @@ private void btnCancel_Click(object sender, EventArgs e){ } private void timerDownloadCheck_Tick(object sender, EventArgs e){ - if (updateInfo.DownloadStatus == UpdateDownloadStatus.Done){ + if (updateInfo.DownloadStatus.IsFinished()){ timerDownloadCheck.Stop(); - DialogResult = DialogResult.OK; - Close(); - } - else if (updateInfo.DownloadStatus == UpdateDownloadStatus.Failed){ - timerDownloadCheck.Stop(); - - if (FormMessage.Error("Update Has Failed", "Could not download the update: "+(updateInfo.DownloadError?.Message ?? "unknown error")+"\n\nDo you want to open the website and try downloading the update manually?", FormMessage.Yes, FormMessage.No)){ - BrowserUtils.OpenExternalBrowser(Program.Website); - DialogResult = DialogResult.OK; - } - Close(); } } diff --git a/Updates/UpdateCheckClient.cs b/Updates/UpdateCheckClient.cs new file mode 100644 index 00000000..015eb50e --- /dev/null +++ b/Updates/UpdateCheckClient.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using System.Web.Script.Serialization; +using TweetDuck.Core.Utils; +using JsonObject = System.Collections.Generic.IDictionary<string, object>; + +namespace TweetDuck.Updates{ + sealed class UpdateCheckClient{ + private const string ApiLatestRelease = "https://api.github.com/repos/chylex/TweetDuck/releases/latest"; + private const string UpdaterAssetName = "TweetDuck.Update.exe"; + + private readonly UpdaterSettings settings; + + public UpdateCheckClient(UpdaterSettings settings){ + this.settings = settings; + } + + public Task<UpdateInfo> Check(){ + TaskCompletionSource<UpdateInfo> result = new TaskCompletionSource<UpdateInfo>(); + + WebClient client = BrowserUtils.CreateWebClient(); + client.Headers[HttpRequestHeader.Accept] = "application/vnd.github.v3+json"; // TODO could use .html to avoid custom markdown parsing + + client.DownloadStringTaskAsync(ApiLatestRelease).ContinueWith(task => { + if (task.IsCanceled){ + result.SetCanceled(); + } + else if (task.IsFaulted){ + result.SetException(task.Exception.InnerException); + } + else{ + try{ + result.SetResult(ParseFromJson(task.Result)); + }catch(Exception e){ + result.SetException(e); + } + } + }); + + return result.Task; + } + + private UpdateInfo ParseFromJson(string json){ + bool IsUpdaterAsset(JsonObject obj){ + return UpdaterAssetName == (string)obj["name"]; + } + + string AssetDownloadUrl(JsonObject obj){ + return (string)obj["browser_download_url"]; + } + + JsonObject root = (JsonObject)new JavaScriptSerializer().DeserializeObject(json); + + string versionTag = (string)root["tag_name"]; + string releaseNotes = (string)root["body"]; + string downloadUrl = ((Array)root["assets"]).Cast<JsonObject>().Where(IsUpdaterAsset).Select(AssetDownloadUrl).FirstOrDefault(); + + return new UpdateInfo(settings, versionTag, releaseNotes, downloadUrl); + } + } +} diff --git a/Updates/UpdateDownloadStatus.cs b/Updates/UpdateDownloadStatus.cs index 3a10f455..7a47aad9 100644 --- a/Updates/UpdateDownloadStatus.cs +++ b/Updates/UpdateDownloadStatus.cs @@ -2,7 +2,14 @@ public enum UpdateDownloadStatus{ None = 0, InProgress, + AssetMissing, Done, Failed } + + public static class UpdateDownloadStatusExtensions{ + public static bool IsFinished(this UpdateDownloadStatus status){ + return status == UpdateDownloadStatus.AssetMissing || status == UpdateDownloadStatus.Done || status == UpdateDownloadStatus.Failed; + } + } } diff --git a/Updates/UpdateHandler.cs b/Updates/UpdateHandler.cs index 6682ab5f..987ef2b6 100644 --- a/Updates/UpdateHandler.cs +++ b/Updates/UpdateHandler.cs @@ -1,21 +1,26 @@ using CefSharp; using System; +using System.Text; +using System.Threading.Tasks; using System.Windows.Forms; using TweetDuck.Core.Controls; using TweetDuck.Core.Other.Interfaces; -using TweetDuck.Core.Utils; +using TweetDuck.Data; using TweetDuck.Resources; using TweetDuck.Updates.Events; namespace TweetDuck.Updates{ - sealed class UpdateHandler{ + sealed class UpdateHandler : IDisposable{ public const int CheckCodeUpdatesDisabled = -1; public const int CheckCodeNotOnTweetDeck = -2; - - private readonly ITweetDeckBrowser browser; + private readonly UpdaterSettings settings; + private readonly UpdateCheckClient client; + private readonly ITweetDeckBrowser browser; + private readonly Timer timer; public event EventHandler<UpdateEventArgs> UpdateAccepted; + public event EventHandler<UpdateEventArgs> UpdateDelayed; public event EventHandler<UpdateEventArgs> UpdateDismissed; public event EventHandler<UpdateCheckEventArgs> CheckFinished; @@ -23,11 +28,44 @@ sealed class UpdateHandler{ private UpdateInfo lastUpdateInfo; public UpdateHandler(ITweetDeckBrowser browser, UpdaterSettings settings){ - this.browser = browser; this.settings = settings; + this.client = new UpdateCheckClient(settings); + + this.browser = browser; + this.browser.OnFrameLoaded(OnFrameLoaded); + this.browser.RegisterBridge("$TDU", new Bridge(this)); - browser.OnFrameLoaded(OnFrameLoaded); - browser.RegisterBridge("$TDU", new Bridge(this)); + this.timer = new Timer(); + this.timer.Tick += timer_Tick; + } + + public void Dispose(){ + timer.Dispose(); + } + + private void timer_Tick(object sender, EventArgs e){ + timer.Stop(); + Check(false); + } + + public void StartTimer(){ + if (timer.Enabled){ + return; + } + + timer.Stop(); + + if (Program.UserConfig.EnableUpdateCheck){ + DateTime now = DateTime.Now; + TimeSpan nextHour = now.AddSeconds(60*(60-now.Minute)-now.Second)-now; + + if (nextHour.TotalMinutes < 15){ + nextHour = nextHour.Add(TimeSpan.FromHours(1)); + } + + timer.Interval = (int)Math.Ceiling(nextHour.TotalMilliseconds); + timer.Start(); + } } private void OnFrameLoaded(IFrame frame){ @@ -43,17 +81,24 @@ public int Check(bool force){ if (!browser.IsTweetDeckWebsite){ return CheckCodeNotOnTweetDeck; } + + int nextEventId = unchecked(++lastEventId); + Task<UpdateInfo> checkTask = client.Check(); - browser.ExecuteFunction("TDUF_runUpdateCheck", (int)unchecked(++lastEventId), Program.VersionTag, settings.DismissedUpdate ?? string.Empty, settings.AllowPreReleases); - return lastEventId; + checkTask.ContinueWith(task => HandleUpdateCheckSuccessful(nextEventId, task.Result), TaskContinuationOptions.OnlyOnRanToCompletion); + checkTask.ContinueWith(task => HandleUpdateCheckFailed(nextEventId, task.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted); + + return nextEventId; } return CheckCodeUpdatesDisabled; } - public void BeginUpdateDownload(Form ownerForm, UpdateInfo updateInfo, Action<UpdateInfo> onSuccess){ - if (updateInfo.DownloadStatus == UpdateDownloadStatus.Done){ - onSuccess(updateInfo); + public void BeginUpdateDownload(Form ownerForm, UpdateInfo updateInfo, Action<UpdateInfo> onFinished){ + UpdateDownloadStatus status = updateInfo.DownloadStatus; + + if (status == UpdateDownloadStatus.Done || status == UpdateDownloadStatus.AssetMissing){ + onFinished(updateInfo); } else{ FormUpdateDownload downloadForm = new FormUpdateDownload(updateInfo); @@ -65,13 +110,7 @@ public void BeginUpdateDownload(Form ownerForm, UpdateInfo updateInfo, Action<Up downloadForm.FormClosed += (sender, args) => { downloadForm.Dispose(); - - if (downloadForm.DialogResult == DialogResult.OK){ // success or manual download - onSuccess(updateInfo); - } - else{ - ownerForm.Show(); - } + onFinished(updateInfo); }; downloadForm.Show(); @@ -85,17 +124,41 @@ public void CleanupDownload(){ } } - private void TriggerUpdateAcceptedEvent(UpdateEventArgs args){ - UpdateAccepted?.Invoke(this, args); + private void HandleUpdateCheckSuccessful(int eventId, UpdateInfo info){ + if (info.IsUpdateNew && !info.IsUpdateDismissed){ + CleanupDownload(); + lastUpdateInfo = info; + lastUpdateInfo.BeginSilentDownload(); + + browser.ExecuteFunction("TDUF_displayNotification", lastUpdateInfo.VersionTag, Convert.ToBase64String(Encoding.GetEncoding("iso-8859-1").GetBytes(lastUpdateInfo.ReleaseNotes))); // TODO move browser stuff outside + } + + CheckFinished?.Invoke(this, new UpdateCheckEventArgs(eventId, new Result<UpdateInfo>(info))); } - private void TriggerUpdateDismissedEvent(UpdateEventArgs args){ - settings.DismissedUpdate = args.UpdateInfo.VersionTag; - UpdateDismissed?.Invoke(this, args); + private void HandleUpdateCheckFailed(int eventId, Exception exception){ + CheckFinished?.Invoke(this, new UpdateCheckEventArgs(eventId, new Result<UpdateInfo>(exception))); } - private void TriggerCheckFinishedEvent(UpdateCheckEventArgs args){ - CheckFinished?.Invoke(this, args); + private void TriggerUpdateAcceptedEvent(){ + if (lastUpdateInfo != null){ + UpdateAccepted?.Invoke(this, new UpdateEventArgs(lastUpdateInfo)); + } + } + + private void TriggerUpdateDelayedEvent(){ + if (lastUpdateInfo != null){ + UpdateDelayed?.Invoke(this, new UpdateEventArgs(lastUpdateInfo)); + } + } + + private void TriggerUpdateDismissedEvent(){ + if (lastUpdateInfo != null){ + settings.DismissedUpdate = lastUpdateInfo.VersionTag; + UpdateDismissed?.Invoke(this, new UpdateEventArgs(lastUpdateInfo)); + + CleanupDownload(); + } } public sealed class Bridge{ @@ -109,31 +172,16 @@ public void TriggerUpdateCheck(){ owner.Check(false); } - public void OnUpdateCheckFinished(int eventId, string versionTag, string downloadUrl){ - if (versionTag != null && (owner.lastUpdateInfo == null || owner.lastUpdateInfo.VersionTag != versionTag)){ - owner.CleanupDownload(); - owner.lastUpdateInfo = new UpdateInfo(owner.settings, eventId, versionTag, downloadUrl); - owner.lastUpdateInfo.BeginSilentDownload(); - } - - owner.TriggerCheckFinishedEvent(new UpdateCheckEventArgs(eventId, owner.lastUpdateInfo != null)); + public void OnUpdateAccepted(){ + owner.TriggerUpdateAcceptedEvent(); } - public void OnUpdateAccepted(){ - if (owner.lastUpdateInfo != null){ - owner.TriggerUpdateAcceptedEvent(new UpdateEventArgs(owner.lastUpdateInfo)); - } + public void OnUpdateDelayed(){ + owner.TriggerUpdateDelayedEvent(); } public void OnUpdateDismissed(){ - if (owner.lastUpdateInfo != null){ - owner.TriggerUpdateDismissedEvent(new UpdateEventArgs(owner.lastUpdateInfo)); - owner.CleanupDownload(); - } - } - - public void OpenBrowser(string url){ - BrowserUtils.OpenExternalBrowser(url); + owner.TriggerUpdateDismissedEvent(); } } } diff --git a/Updates/UpdateInfo.cs b/Updates/UpdateInfo.cs index c3d7cbd8..c7d02c74 100644 --- a/Updates/UpdateInfo.cs +++ b/Updates/UpdateInfo.cs @@ -5,40 +5,43 @@ namespace TweetDuck.Updates{ sealed class UpdateInfo{ - public int EventId { get; } public string VersionTag { get; } + public string ReleaseNotes { get; } public string InstallerPath { get; } + public bool IsUpdateNew => VersionTag != Program.VersionTag; + public bool IsUpdateDismissed => VersionTag == settings.DismissedUpdate; + public UpdateDownloadStatus DownloadStatus { get; private set; } public Exception DownloadError { get; private set; } - private readonly string installerFolder; + private readonly UpdaterSettings settings; private readonly string downloadUrl; private WebClient currentDownload; - public UpdateInfo(UpdaterSettings settings, int eventId, string versionTag, string downloadUrl){ - this.installerFolder = settings.InstallerDownloadFolder; + public UpdateInfo(UpdaterSettings settings, string versionTag, string releaseNotes, string downloadUrl){ + this.settings = settings; this.downloadUrl = downloadUrl; - - this.EventId = eventId; + this.VersionTag = versionTag; - this.InstallerPath = Path.Combine(installerFolder, "TweetDuck."+versionTag+".exe"); + this.ReleaseNotes = releaseNotes; + this.InstallerPath = Path.Combine(settings.InstallerDownloadFolder, "TweetDuck."+versionTag+".exe"); } public void BeginSilentDownload(){ if (DownloadStatus == UpdateDownloadStatus.None || DownloadStatus == UpdateDownloadStatus.Failed){ DownloadStatus = UpdateDownloadStatus.InProgress; - try{ - Directory.CreateDirectory(installerFolder); - }catch(Exception e){ - DownloadError = e; - DownloadStatus = UpdateDownloadStatus.Failed; + if (string.IsNullOrEmpty(downloadUrl)){ + DownloadError = new InvalidDataException("Missing installer asset."); + DownloadStatus = UpdateDownloadStatus.AssetMissing; return; } - if (string.IsNullOrEmpty(downloadUrl)){ - DownloadError = new UriFormatException("Could not determine URL of the update installer"); + try{ + Directory.CreateDirectory(settings.InstallerDownloadFolder); + }catch(Exception e){ + DownloadError = e; DownloadStatus = UpdateDownloadStatus.Failed; return; } @@ -68,5 +71,13 @@ public void DeleteInstaller(){ // rip } } + + public override bool Equals(object obj){ + return obj is UpdateInfo info && VersionTag == info.VersionTag; + } + + public override int GetHashCode(){ + return VersionTag.GetHashCode(); + } } }