diff --git a/CHANGELOG.md b/CHANGELOG.md index 13fed6f27..dec82d6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ * Added popout diff visualizer for table properties like Attributes and Tags ([#834]) * Updated Theme to use Studio colors ([#838]) * Improved patch visualizer UX ([#883]) +* Added update notifications for newer compatible versions in the Studio plugin. ([#832]) * Added experimental setting for Auto Connect in playtests ([#840]) * Improved settings UI ([#886]) * `Open Scripts Externally` option can now be changed while syncing ([#911]) @@ -75,6 +76,7 @@ **All** sync rules are reset between project files, so they must be specified in each one when nesting them. This is to ensure that nothing can break other projects by changing how files are synced! [#813]: https://github.com/rojo-rbx/rojo/pull/813 +[#832]: https://github.com/rojo-rbx/rojo/pull/832 [#834]: https://github.com/rojo-rbx/rojo/pull/834 [#838]: https://github.com/rojo-rbx/rojo/pull/838 [#840]: https://github.com/rojo-rbx/rojo/pull/840 diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index 3f84d15f4..b0bf33d9a 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -4,6 +4,7 @@ local Packages = Rojo.Packages local Roact = require(Packages.Roact) +local timeUtil = require(Plugin.timeUtil) local Theme = require(Plugin.App.Theme) local Assets = require(Plugin.Assets) local PatchSet = require(Plugin.PatchSet) @@ -20,28 +21,6 @@ local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer) local e = Roact.createElement -local AGE_UNITS = { - { 31556909, "y" }, - { 2629743, "mon" }, - { 604800, "w" }, - { 86400, "d" }, - { 3600, "h" }, - { 60, "m" }, -} -function timeSinceText(elapsed: number): string - local ageText = string.format("%ds", elapsed) - - for _, UnitData in ipairs(AGE_UNITS) do - local UnitSeconds, UnitName = UnitData[1], UnitData[2] - if elapsed > UnitSeconds then - ageText = elapsed // UnitSeconds .. UnitName - break - end - end - - return ageText -end - local ChangesViewer = Roact.Component:extend("ChangesViewer") function ChangesViewer:init() @@ -287,7 +266,7 @@ function ConnectedPage:getChangeInfoText() if patchData == nil then return "" end - return timeSinceText(DateTime.now().UnixTimestamp - patchData.timestamp) + return timeUtil.elapsedToText(DateTime.now().UnixTimestamp - patchData.timestamp) end function ConnectedPage:startChangeInfoTextUpdater() @@ -303,7 +282,7 @@ function ConnectedPage:startChangeInfoTextUpdater() local updateInterval = 1 -- Update timestamp text as frequently as currently needed - for _, UnitData in ipairs(AGE_UNITS) do + for _, UnitData in ipairs(timeUtil.AGE_UNITS) do local UnitSeconds = UnitData[1] if elapsed > UnitSeconds then updateInterval = UnitSeconds diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index 70f835084..122a9f281 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -162,6 +162,25 @@ function SettingsPage:render() layoutOrder = layoutIncrement(), }), + CheckForUpdates = e(Setting, { + id = "checkForUpdates", + name = "Check For Updates", + description = "Notify about newer compatible Rojo releases", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + }), + + CheckForPreleases = e(Setting, { + id = "checkForPrereleases", + name = "Include Prerelease Updates", + description = "Include prereleases when checking for updates", + transparency = self.props.transparency, + layoutOrder = layoutIncrement(), + visible = if string.find(debug.traceback(), "\n[^\n]-user_.-$") == nil + then false -- Must be a local install to allow prerelease checks + else Settings:getBinding("checkForUpdates"), + }), + AutoConnectPlaytestServer = e(Setting, { id = "autoConnectPlaytestServer", name = "Auto Connect Playtest Server", diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index bd21765d0..e3f98725e 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -23,6 +23,7 @@ local PatchTree = require(Plugin.PatchTree) local preloadAssets = require(Plugin.preloadAssets) local soundPlayer = require(Plugin.soundPlayer) local ignorePlaceIds = require(Plugin.ignorePlaceIds) +local timeUtil = require(Plugin.timeUtil) local Theme = require(script.Theme) local Page = require(script.Page) @@ -118,6 +119,13 @@ function App:init() end) end) + self.disconnectUpdatesCheckChanged = Settings:onChanged("checkForUpdates", function() + self:checkForUpdates() + end) + self.disconnectPrereleasesCheckChanged = Settings:onChanged("checkForPrereleases", function() + self:checkForUpdates() + end) + self:setState({ appStatus = AppStatus.NotConnected, guiEnabled = false, @@ -131,32 +139,35 @@ function App:init() toolbarIcon = Assets.Images.PluginButton, }) - if - RunService:IsEdit() - and self.serveSession == nil - and Settings:get("syncReminder") - and self:getLastSyncTimestamp() - and (self:isSyncLockAvailable()) - then - self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, { - Connect = { - text = "Connect", - style = "Solid", - layoutOrder = 1, - onClick = function(notification) - notification:dismiss() - self:startSession() - end, - }, - Dismiss = { - text = "Dismiss", - style = "Bordered", - layoutOrder = 2, - onClick = function(notification) - notification:dismiss() - end, - }, - }) + if RunService:IsEdit() then + self:checkForUpdates() + + if + Settings:get("syncReminder") + and self.serveSession == nil + and self:getLastSyncTimestamp() + and (self:isSyncLockAvailable()) + then + self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, { + Connect = { + text = "Connect", + style = "Solid", + layoutOrder = 1, + onClick = function(notification) + notification:dismiss() + self:startSession() + end, + }, + Dismiss = { + text = "Dismiss", + style = "Bordered", + layoutOrder = 2, + onClick = function(notification) + notification:dismiss() + end, + }, + }) + end end if self:isAutoConnectPlaytestServerAvailable() then @@ -179,6 +190,10 @@ end function App:willUnmount() self.waypointConnection:Disconnect() self.confirmationBindable:Destroy() + + self.disconnectUpdatesCheckChanged() + self.disconnectPrereleasesCheckChanged() + self.autoConnectPlaytestServerListener() self:clearRunningConnectionInfo() end @@ -225,6 +240,40 @@ function App:closeNotification(id: number) }) end +function App:checkForUpdates() + if not Settings:get("checkForUpdates") then + return + end + + local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil + local latestCompatibleVersion = Version.retrieveLatestCompatible({ + version = Config.version, + includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"), + }) + if not latestCompatibleVersion then + return + end + + self:addNotification( + string.format( + "A newer compatible version of Rojo, %s, was published %s! Go to the Rojo releases page to learn more.", + Version.display(latestCompatibleVersion.version), + timeUtil.elapsedToText(DateTime.now().UnixTimestamp - latestCompatibleVersion.publishedUnixTimestamp) + ), + 500, + { + Dismiss = { + text = "Dismiss", + style = "Bordered", + layoutOrder = 2, + onClick = function(notification) + notification:dismiss() + end, + }, + } + ) +end + function App:getPriorEndpoint() local priorEndpoints = Settings:get("priorEndpoints") if not priorEndpoints then diff --git a/plugin/src/Settings.lua b/plugin/src/Settings.lua index 67f13ba21..7810635b3 100644 --- a/plugin/src/Settings.lua +++ b/plugin/src/Settings.lua @@ -14,6 +14,8 @@ local defaultSettings = { twoWaySync = false, showNotifications = true, syncReminder = true, + checkForUpdates = true, + checkForPrereleases = false, autoConnectPlaytestServer = false, confirmationBehavior = "Initial", largeChangesConfirmationThreshold = 5, diff --git a/plugin/src/Version.lua b/plugin/src/Version.lua index 6c93a14e1..d95702e89 100644 --- a/plugin/src/Version.lua +++ b/plugin/src/Version.lua @@ -1,3 +1,7 @@ +local Packages = script.Parent.Parent.Packages +local Http = require(Packages.Http) +local Promise = require(Packages.Promise) + local function compare(a, b) if a > b then return 1 @@ -30,7 +34,48 @@ function Version.compare(a, b) return minor end - return revision + if revision ~= 0 then + return revision + end + + local aPrerelease = if a[4] == "" then nil else a[4] + local bPrerelease = if b[4] == "" then nil else b[4] + + -- If neither are prerelease, they are the same + if aPrerelease == nil and bPrerelease == nil then + return 0 + end + + -- If one is prerelease it is older + if aPrerelease ~= nil and bPrerelease == nil then + return -1 + end + if aPrerelease == nil and bPrerelease ~= nil then + return 1 + end + + -- If they are both prereleases, compare those based on number + local aPrereleaseNumeric = string.match(aPrerelease, "(%d+).*$") + local bPrereleaseNumeric = string.match(bPrerelease, "(%d+).*$") + + if aPrereleaseNumeric == nil or bPrereleaseNumeric == nil then + -- If one or both lack a number, comparing isn't meaningful + return 0 + end + return compare(tonumber(aPrereleaseNumeric) or 0, tonumber(bPrereleaseNumeric) or 0) +end + +function Version.parse(versionString: string) + local version = { string.match(versionString, "^v?(%d+)%.(%d+)%.(%d+)(.*)$") } + for i, v in version do + version[i] = tonumber(v) or v + end + + if version[4] == "" then + version[4] = nil + end + + return version end function Version.display(version) @@ -43,4 +88,64 @@ function Version.display(version) return output end +function Version.retrieveLatestCompatible(options: { + version: { number }, + includePrereleases: boolean?, +}): { + version: { number }, + prerelease: boolean, + publishedUnixTimestamp: number, +}? + local success, releases = Http.get("https://api.github.com/repos/rojo-rbx/rojo/releases?per_page=10") + :andThen(function(response) + if response.code >= 400 then + local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body) + + return Promise.reject(message) + end + + return response + end) + :andThen(Http.Response.json) + :await() + + if success == false or type(releases) ~= "table" or next(releases) ~= 1 then + return nil + end + + -- Iterate through releases, looking for the latest compatible version + local latestCompatible = nil + for _, release in releases do + -- Skip prereleases if they are not requested + if (not options.includePrereleases) and release.prerelease then + continue + end + + local releaseVersion = Version.parse(release.tag_name) + + -- Skip releases that are potentially incompatible + if releaseVersion[1] > options.version[1] then + continue + end + + -- Skip releases that are older than the latest compatible version + if latestCompatible ~= nil and Version.compare(releaseVersion, latestCompatible.version) <= 0 then + continue + end + + latestCompatible = { + version = releaseVersion, + prerelease = release.prerelease, + publishedUnixTimestamp = DateTime.fromIsoDate(release.published_at).UnixTimestamp, + } + end + + -- Don't return anything if the latest found is not newer than the current version + if latestCompatible == nil or Version.compare(latestCompatible.version, options.version) <= 0 then + return nil + end + + return latestCompatible +end + return Version diff --git a/plugin/src/Version.spec.lua b/plugin/src/Version.spec.lua index cf9938d9c..f1e9eba22 100644 --- a/plugin/src/Version.spec.lua +++ b/plugin/src/Version.spec.lua @@ -3,6 +3,7 @@ return function() it("should compare equal versions", function() expect(Version.compare({ 1, 2, 3 }, { 1, 2, 3 })).to.equal(0) + expect(Version.compare({ 1, 2, 3, "rc1" }, { 1, 2, 3, "rc1" })).to.equal(0) expect(Version.compare({ 0, 4, 0 }, { 0, 4 })).to.equal(0) expect(Version.compare({ 0, 0, 123 }, { 0, 0, 123 })).to.equal(0) expect(Version.compare({ 26 }, { 26 })).to.equal(0) @@ -13,6 +14,7 @@ return function() it("should compare newer, older versions", function() expect(Version.compare({ 1 }, { 0 })).to.equal(1) expect(Version.compare({ 1, 1 }, { 1, 0 })).to.equal(1) + expect(Version.compare({ 1, 2, 3 }, { 1, 2, 0 })).to.equal(1) end) it("should compare different major versions", function() @@ -25,4 +27,37 @@ return function() expect(Version.compare({ 1, 2, 3 }, { 1, 3, 2 })).to.equal(-1) expect(Version.compare({ 50, 1 }, { 50, 2 })).to.equal(-1) end) + + it("should compare different patch versions", function() + expect(Version.compare({ 1, 1, 3 }, { 1, 1, 2 })).to.equal(1) + expect(Version.compare({ 1, 1, 2 }, { 1, 1, 3 })).to.equal(-1) + expect(Version.compare({ 1, 1, 3, "-rc1" }, { 1, 1, 2, "-rc2" })).to.equal(1) + expect(Version.compare({ 1, 1, 2, "-rc5" }, { 1, 1, 3, "-alpha" })).to.equal(-1) + end) + + it("should compare prerelease tags", function() + expect(Version.compare({ 1, 0, 0, "-alpha" }, { 1, 0, 0 })).to.equal(-1) + expect(Version.compare({ 1, 0, 0 }, { 1, 0, 0, "-alpha" })).to.equal(1) + expect(Version.compare({ 1, 0, 0, "-rc1" }, { 1, 0, 0, "-rc2" })).to.equal(-1) + expect(Version.compare({ 1, 0, 0, "-rc2" }, { 1, 0, 0, "-rc1" })).to.equal(1) + + -- Non number prereleases are not compared since that isn't meaningful + expect(Version.compare({ 1, 0, 0, "-alpha" }, { 1, 0, 0, "-beta" })).to.equal(0) + end) + + it("should parse version from strings", function() + local a = Version.parse("v1.0.0") + expect(a).to.be.ok() + expect(a[1]).to.equal(1) + expect(a[2]).to.equal(0) + expect(a[3]).to.equal(0) + expect(a[4]).to.equal(nil) + + local b = Version.parse("7.3.1-rc1") + expect(b).to.be.ok() + expect(b[1]).to.equal(7) + expect(b[2]).to.equal(3) + expect(b[3]).to.equal(1) + expect(b[4]).to.equal("-rc1") + end) end diff --git a/plugin/src/timeUtil.lua b/plugin/src/timeUtil.lua new file mode 100644 index 000000000..280f72ef4 --- /dev/null +++ b/plugin/src/timeUtil.lua @@ -0,0 +1,31 @@ +local timeUtil = {} + +timeUtil.AGE_UNITS = table.freeze({ + { 31556909, "year" }, + { 2629743, "month" }, + { 604800, "week" }, + { 86400, "day" }, + { 3600, "hour" }, + { 60, "minute" }, +}) + +function timeUtil.elapsedToText(elapsed: number): string + if elapsed < 3 then + return "just now" + end + + local ageText = string.format("%d seconds ago", elapsed) + + for _, UnitData in timeUtil.AGE_UNITS do + local UnitSeconds, UnitName = UnitData[1], UnitData[2] + if elapsed > UnitSeconds then + local c = math.floor(elapsed / UnitSeconds) + ageText = string.format("%d %s%s ago", c, UnitName, c > 1 and "s" or "") + break + end + end + + return ageText +end + +return timeUtil