Преглед изворни кода

UI: Add update channels (Windows)

derrod пре 3 година
родитељ
комит
f141b9c59b

+ 12 - 0
UI/data/locale/en-US.ini

@@ -268,6 +268,8 @@ Updater.Running.Title="Program currently active"
 Updater.Running.Text="Outputs are currently active, please shut down any active outputs before attempting to update"
 Updater.NoUpdatesAvailable.Title="No updates available"
 Updater.NoUpdatesAvailable.Text="No updates are currently available"
+Updater.BranchNotFound.Title="Update Channel Removed"
+Updater.BranchNotFound.Text="Your selected update channel is no longer available, OBS has been reset to the default."
 Updater.RepairButUpdatesAvailable.Title="Integrity Check Unavailable"
 Updater.RepairButUpdatesAvailable.Text="Checking file integrity is only possible for the latest version available. Use Help → Check For Updates to verify and update your OBS installation."
 Updater.RepairConfirm.Title="Confirm Integrity Check"
@@ -835,6 +837,10 @@ Basic.Settings.Confirm="You have unsaved changes. Save changes?"
 Basic.Settings.General="General"
 Basic.Settings.General.Theme="Theme"
 Basic.Settings.General.Language="Language"
+Basic.Settings.General.Updater="Updates"
+Basic.Settings.General.UpdateChannel="Update Channel"
+Basic.Settings.General.UpdateChannelDisabled="(Disabled)"
+Basic.Settings.General.UpdateChannelDefault="(Default)"
 Basic.Settings.General.EnableAutoUpdates="Automatically check for updates on startup"
 Basic.Settings.General.OpenStatsOnStartup="Open stats dialog on startup"
 Basic.Settings.General.HideOBSWindowsFromCapture="Hide OBS windows from screen capture"
@@ -886,6 +892,12 @@ Basic.Settings.General.MultiviewLayout.9Scene="Scenes only (9 Scenes)"
 Basic.Settings.General.MultiviewLayout.16Scene="Scenes only (16 Scenes)"
 Basic.Settings.General.MultiviewLayout.25Scene="Scenes only (25 Scenes)"
 
+# default channel name translations
+Basic.Settings.General.ChannelName.stable="Stable"
+Basic.Settings.General.ChannelDescription.stable="Latest stable release"
+Basic.Settings.General.ChannelName.beta="Betas / Release Candidates"
+Basic.Settings.General.ChannelDescription.beta="Potentially unstable pre-release versions"
+
 # basic mode 'stream' settings
 Basic.Settings.Stream="Stream"
 Basic.Settings.Stream.StreamType="Stream Type"

+ 60 - 11
UI/forms/OBSBasicSettings.ui

@@ -256,23 +256,13 @@
                     </spacer>
                    </item>
                    <item row="2" column="1">
-                    <widget class="QCheckBox" name="enableAutoUpdates">
-                     <property name="text">
-                      <string>Basic.Settings.General.EnableAutoUpdates</string>
-                     </property>
-                     <property name="checked">
-                      <bool>true</bool>
-                     </property>
-                    </widget>
-                   </item>
-                   <item row="3" column="1">
                     <widget class="QCheckBox" name="openStatsOnStartup">
                      <property name="text">
                       <string>Basic.Settings.General.OpenStatsOnStartup</string>
                      </property>
                     </widget>
                    </item>
-                   <item row="4" column="1">
+                   <item row="3" column="1">
                     <widget class="QCheckBox" name="hideOBSFromCapture">
                      <property name="toolTip">
                       <string>Basic.Settings.General.HideOBSWindowsFromCapture.Tooltip</string>
@@ -285,6 +275,64 @@
                   </layout>
                  </widget>
                 </item>
+                <item>
+                 <widget class="QGroupBox" name="updateSettingsGroupBox">
+                  <property name="title">
+                   <string>Basic.Settings.General.Updater</string>
+                  </property>
+                  <layout class="QFormLayout" name="formLayout_20">
+                   <property name="labelAlignment">
+                    <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+                   </property>
+                   <property name="fieldGrowthPolicy">
+                    <enum>QFormLayout::AllNonFixedFieldsGrow</enum>
+                   </property>
+                   <item row="0" column="0">
+                    <widget class="QLabel" name="label_10">
+                     <property name="text">
+                      <string>Basic.Settings.General.UpdateChannel</string>
+                     </property>
+                    </widget>
+                   </item>
+                   <item row="0" column="1">
+                    <widget class="QComboBox" name="updateChannelBox">
+                     <property name="editable">
+                      <bool>false</bool>
+                     </property>
+                     <property name="currentText">
+                      <string/>
+                     </property>
+                    </widget>
+                   </item>
+                   <item row="1" column="1">
+                    <widget class="QCheckBox" name="enableAutoUpdates">
+                     <property name="text">
+                      <string>Basic.Settings.General.EnableAutoUpdates</string>
+                     </property>
+                     <property name="checked">
+                      <bool>true</bool>
+                     </property>
+                    </widget>
+                   </item>
+                   <item row="1" column="0">
+                    <spacer name="horizontalSpacer_29">
+                     <property name="orientation">
+                      <enum>Qt::Horizontal</enum>
+                     </property>
+                     <property name="sizeType">
+                      <enum>QSizePolicy::Fixed</enum>
+                     </property>
+                     <property name="sizeHint" stdset="0">
+                      <size>
+                       <width>170</width>
+                       <height>20</height>
+                      </size>
+                     </property>
+                    </spacer>
+                   </item>
+                  </layout>
+                 </widget>
+                </item>
                 <item>
                  <widget class="QGroupBox" name="groupBox_16">
                   <property name="title">
@@ -7473,6 +7521,7 @@
   <tabstop>scrollArea_2</tabstop>
   <tabstop>language</tabstop>
   <tabstop>theme</tabstop>
+  <tabstop>updateChannelBox</tabstop>
   <tabstop>enableAutoUpdates</tabstop>
   <tabstop>openStatsOnStartup</tabstop>
   <tabstop>warnBeforeStreamStart</tabstop>

+ 107 - 0
UI/obs-app.cpp

@@ -55,6 +55,7 @@
 #include <curl/curl.h>
 
 #ifdef _WIN32
+#include <json11.hpp>
 #include <windows.h>
 #include <filesystem>
 #else
@@ -1264,6 +1265,112 @@ bool OBSApp::InitTheme()
 	return SetTheme("System");
 }
 
+#ifdef _WIN32
+void ParseBranchesJson(const std::string &jsonString, vector<UpdateBranch> &out,
+		       std::string &error)
+{
+	json11::Json root;
+	root = json11::Json::parse(jsonString, error);
+	if (!error.empty() || !root.is_array())
+		return;
+
+	for (const json11::Json &item : root.array_items()) {
+#ifdef _WIN32
+		if (!item["windows"].bool_value())
+			continue;
+#endif
+
+		UpdateBranch branch = {
+			QString::fromStdString(item["name"].string_value()),
+			QString::fromStdString(
+				item["display_name"].string_value()),
+			QString::fromStdString(
+				item["description"].string_value()),
+			item["enabled"].bool_value(),
+			item["visible"].bool_value(),
+		};
+		out.push_back(branch);
+	}
+}
+
+bool LoadBranchesFile(vector<UpdateBranch> &out)
+{
+	string error;
+	string branchesText;
+
+	BPtr<char> branchesFilePath =
+		GetConfigPathPtr("obs-studio/updates/branches.json");
+
+	QFile branchesFile(branchesFilePath.Get());
+	if (!branchesFile.open(QIODevice::ReadOnly)) {
+		error = "Opening file failed.";
+		goto fail;
+	}
+
+	branchesText = branchesFile.readAll();
+	if (branchesText.empty()) {
+		error = "File empty.";
+		goto fail;
+	}
+
+	ParseBranchesJson(branchesText, out, error);
+	if (error.empty())
+		return !out.empty();
+
+fail:
+	blog(LOG_WARNING, "Loading branches from file failed: %s",
+	     error.c_str());
+	return false;
+}
+#endif
+
+void OBSApp::SetBranchData(const string &data)
+{
+#ifdef _WIN32
+	string error;
+	vector<UpdateBranch> result;
+
+	ParseBranchesJson(data, result, error);
+
+	if (!error.empty()) {
+		blog(LOG_WARNING, "Reading branches JSON response failed: %s",
+		     error.c_str());
+		return;
+	}
+
+	if (!result.empty())
+		updateBranches = result;
+
+	branches_loaded = true;
+#else
+	UNUSED_PARAMETER(data);
+#endif
+}
+
+std::vector<UpdateBranch> OBSApp::GetBranches()
+{
+	vector<UpdateBranch> out;
+	/* Always ensure the default branch exists */
+	out.push_back(UpdateBranch{"stable", "", "", true, true});
+
+#ifdef _WIN32
+	if (!branches_loaded) {
+		vector<UpdateBranch> result;
+		if (LoadBranchesFile(result))
+			updateBranches = result;
+
+		branches_loaded = true;
+	}
+#endif
+
+	/* Copy additional branches to result (if any) */
+	if (!updateBranches.empty())
+		out.insert(out.end(), updateBranches.begin(),
+			   updateBranches.end());
+
+	return out;
+}
+
 OBSApp::OBSApp(int &argc, char **argv, profiler_name_store_t *store)
 	: QApplication(argc, argv), profilerNameStore(store)
 {

+ 13 - 0
UI/obs-app.hpp

@@ -74,6 +74,14 @@ struct OBSThemeMeta {
 	std::string author;
 };
 
+struct UpdateBranch {
+	QString name;
+	QString display_name;
+	QString description;
+	bool is_enabled;
+	bool is_visible;
+};
+
 class OBSApp : public QApplication {
 	Q_OBJECT
 
@@ -86,6 +94,8 @@ private:
 	TextLookup textLookup;
 	QPointer<OBSMainWindow> mainWindow;
 	profiler_name_store_t *profilerNameStore = nullptr;
+	std::vector<UpdateBranch> updateBranches;
+	bool branches_loaded = false;
 
 	bool libobs_initialized = false;
 
@@ -142,6 +152,9 @@ public:
 	bool SetTheme(std::string name, std::string path = "");
 	inline bool IsThemeDark() const { return themeDarkMode; };
 
+	void SetBranchData(const std::string &data);
+	std::vector<UpdateBranch> GetBranches();
+
 	inline lookup_t *GetTextLookup() const { return textLookup; }
 
 	inline const char *GetString(const char *lookupVal) const

+ 231 - 155
UI/win-update/win-update.cpp

@@ -36,6 +36,18 @@ extern QCef *cef;
 #define WIN_MANIFEST_URL "https://obsproject.com/update_studio/manifest.json"
 #endif
 
+#ifndef WIN_MANIFEST_BASE_URL
+#define WIN_MANIFEST_BASE_URL "https://obsproject.com/update_studio/"
+#endif
+
+#ifndef WIN_BRANCHES_URL
+#define WIN_BRANCHES_URL "https://obsproject.com/update_studio/branches.json"
+#endif
+
+#ifndef WIN_DEFAULT_BRANCH
+#define WIN_DEFAULT_BRANCH "stable"
+#endif
+
 #ifndef WIN_WHATSNEW_URL
 #define WIN_WHATSNEW_URL "https://obsproject.com/update_studio/whatsnew.json"
 #endif
@@ -399,8 +411,30 @@ try {
 
 /* ------------------------------------------------------------------------ */
 
+#if defined(OBS_RELEASE_CANDIDATE) && OBS_RELEASE_CANDIDATE > 0
+#define CUR_VER                                                               \
+	((uint64_t)OBS_RELEASE_CANDIDATE_VER << 16ULL | OBS_RELEASE_CANDIDATE \
+								<< 8ULL)
+#define PRE_RELEASE true
+#elif OBS_BETA > 0
+#define CUR_VER ((uint64_t)OBS_BETA_VER << 16ULL | OBS_BETA)
+#define PRE_RELEASE true
+#elif defined(OBS_COMMIT)
+#define CUR_VER 1 << 16ULL
+#define CUR_COMMIT OBS_COMMIT
+#define PRE_RELEASE true
+#else
+#define CUR_VER ((uint64_t)LIBOBS_API_VER << 16ULL)
+#define PRE_RELEASE false
+#endif
+
+#ifndef CUR_COMMIT
+#define CUR_COMMIT "00000000"
+#endif
+
 static bool ParseUpdateManifest(const char *manifest, bool *updatesAvailable,
-				string &notes_str, int &updateVer)
+				string &notes_str, uint64_t &updateVer,
+				string &branch)
 try {
 
 	string error;
@@ -415,8 +449,11 @@ try {
 	int major = root["version_major"].int_value();
 	int minor = root["version_minor"].int_value();
 	int patch = root["version_patch"].int_value();
+	int rc = root["rc"].int_value();
+	int beta = root["beta"].int_value();
+	string commit_hash = root["commit"].string_value();
 
-	if (major == 0)
+	if (major == 0 && commit_hash.empty())
 		throw strprintf("Invalid version number: %d.%d.%d", major,
 				minor, patch);
 
@@ -430,11 +467,35 @@ try {
 	if (!packages.is_array())
 		throw string("'packages' value invalid");
 
-	int cur_ver = LIBOBS_API_VER;
-	int new_ver = MAKE_SEMANTIC_VERSION(major, minor, patch);
+	uint64_t cur_ver;
+	uint64_t new_ver;
+
+	if (commit_hash.empty()) {
+		cur_ver = CUR_VER;
+		new_ver = MAKE_SEMANTIC_VERSION(
+			(uint64_t)major, (uint64_t)minor, (uint64_t)patch);
+		new_ver <<= 16;
+		/* RC builds are shifted so that rc1 and beta1 versions do not result
+		 * in the same new_ver. */
+		if (rc > 0)
+			new_ver |= (uint64_t)rc << 8;
+		else if (beta > 0)
+			new_ver |= (uint64_t)beta;
+	} else {
+		/* Test or nightly builds may not have a (valid) version number,
+		 * so compare commit hashes instead. */
+		cur_ver = stoul(CUR_COMMIT, nullptr, 16);
+		new_ver = stoul(commit_hash.substr(0, 8), nullptr, 16);
+	}
 
 	updateVer = new_ver;
-	*updatesAvailable = new_ver > cur_ver;
+
+	/* When using a pre-release build or non-default branch we only check if
+	 * the manifest version is different, so that it can be rolled-back. */
+	if (branch != WIN_DEFAULT_BRANCH || PRE_RELEASE)
+		*updatesAvailable = new_ver != cur_ver;
+	else
+		*updatesAvailable = new_ver > cur_ver;
 
 	return true;
 
@@ -443,6 +504,10 @@ try {
 	return false;
 }
 
+#undef CUR_COMMIT
+#undef CUR_VER
+#undef PRE_RELEASE
+
 /* ------------------------------------------------------------------------ */
 
 void GenerateGUID(string &guid)
@@ -481,6 +546,127 @@ string GetProgramGUID()
 	return guid;
 }
 
+/* ------------------------------------------------------------------------ */
+
+bool GetBranchAndUrl(string &selectedBranch, string &manifestUrl)
+{
+	const char *config_branch =
+		config_get_string(GetGlobalConfig(), "General", "UpdateBranch");
+	if (!config_branch)
+		return true;
+
+	bool found = false;
+	for (const UpdateBranch &branch : App()->GetBranches()) {
+		if (branch.name != config_branch)
+			continue;
+		/* A branch that is found but disabled will just silently fall back to
+		 * the default. But if the branch was removed entirely, the user should
+		 * be warned, so leave this false *only* if the branch was removed. */
+		found = true;
+
+		if (branch.is_enabled) {
+			selectedBranch = branch.name.toStdString();
+			if (branch.name != WIN_DEFAULT_BRANCH) {
+				manifestUrl = WIN_MANIFEST_BASE_URL;
+				manifestUrl += "manifest_" +
+					       branch.name.toStdString() +
+					       ".json";
+			}
+		}
+		break;
+	}
+
+	return found;
+}
+
+/* ------------------------------------------------------------------------ */
+
+static bool
+FetchAndVerifyFile(const char *name, const char *file, const char *url,
+		   string &text,
+		   const vector<string> &extraHeaders = vector<string>())
+{
+	long responseCode;
+	vector<string> headers;
+	string error;
+	string signature;
+	BYTE fileHash[BLAKE2_HASH_LENGTH];
+	bool success;
+
+	BPtr<char> filePath = GetConfigPathPtr(file);
+
+	if (!extraHeaders.empty()) {
+		headers.insert(headers.end(), extraHeaders.begin(),
+			       extraHeaders.end());
+	}
+
+	/* ----------------------------------- *
+	 * avoid downloading json again        */
+
+	if (CalculateFileHash(filePath, fileHash)) {
+		char hashString[BLAKE2_HASH_STR_LENGTH];
+		HashToString(fileHash, hashString);
+
+		string header = "If-None-Match: ";
+		header += hashString;
+		headers.push_back(move(header));
+	}
+
+	/* ----------------------------------- *
+	 * get current install GUID            */
+
+	string guid = GetProgramGUID();
+
+	if (!guid.empty()) {
+		string header = "X-OBS2-GUID: ";
+		header += guid;
+		headers.push_back(move(header));
+	}
+
+	/* ----------------------------------- *
+	 * get json from server                */
+
+	success = GetRemoteFile(url, text, error, &responseCode, nullptr, "",
+				nullptr, headers, &signature);
+
+	if (!success || (responseCode != 200 && responseCode != 304)) {
+		if (responseCode == 404)
+			return false;
+
+		throw strprintf("Failed to fetch %s file: %s", name,
+				error.c_str());
+	}
+
+	/* ----------------------------------- *
+	 * verify file signature               */
+
+	if (responseCode == 200) {
+		success = CheckDataSignature(text, name, signature.data(),
+					     signature.size());
+		if (!success)
+			throw strprintf("Invalid %s signature", name);
+	}
+
+	/* ----------------------------------- *
+	 * write or load json                  */
+
+	if (responseCode == 200) {
+		if (!QuickWriteFile(filePath, text.data(), text.size()))
+			throw strprintf("Could not write file '%s'",
+					filePath.Get());
+	} else {
+		if (!QuickReadFile(filePath, text))
+			throw strprintf("Could not read file '%s'",
+					filePath.Get());
+	}
+
+	/* ----------------------------------- *
+	 * success                             */
+	return true;
+}
+
+/* ------------------------------------------------------------------------ */
+
 void AutoUpdateThread::infoMsg(const QString &title, const QString &text)
 {
 	OBSMessageBox::information(App()->GetMainWindow(), title, text);
@@ -532,15 +718,12 @@ bool AutoUpdateThread::queryRepair()
 
 void AutoUpdateThread::run()
 try {
-	long responseCode;
-	vector<string> extraHeaders;
 	string text;
-	string error;
-	string signature;
-	CryptProvider localProvider;
-	BYTE manifestHash[BLAKE2_HASH_LENGTH];
+	string branch = WIN_DEFAULT_BRANCH;
+	string manifestUrl = WIN_MANIFEST_URL;
+	vector<string> extraHeaders;
 	bool updatesAvailable = false;
-	bool success;
+	CryptProvider localProvider;
 
 	struct FinishedTrigger {
 		inline ~FinishedTrigger()
@@ -550,9 +733,6 @@ try {
 		}
 	} finishedTrigger;
 
-	BPtr<char> manifestPath =
-		GetConfigPathPtr("obs-studio\\updates\\manifest.json");
-
 	/* ----------------------------------- *
 	 * create signature provider           */
 
@@ -564,25 +744,20 @@ try {
 	provider = localProvider;
 
 	/* ----------------------------------- *
-	 * avoid downloading manifest again    */
+	 * get branches from server            */
 
-	if (CalculateFileHash(manifestPath, manifestHash)) {
-		char hashString[BLAKE2_HASH_STR_LENGTH];
-		HashToString(manifestHash, hashString);
-
-		string header = "If-None-Match: ";
-		header += hashString;
-		extraHeaders.push_back(move(header));
-	}
+	if (FetchAndVerifyFile("branches", "obs-studio\\updates\\branches.json",
+			       WIN_BRANCHES_URL, text))
+		App()->SetBranchData(text);
 
 	/* ----------------------------------- *
-	 * get current install GUID            */
+	 * get branches from server            */
 
-	string guid = GetProgramGUID();
-	if (!guid.empty()) {
-		string header = "X-OBS2-GUID: ";
-		header += guid;
-		extraHeaders.push_back(move(header));
+	if (!GetBranchAndUrl(branch, manifestUrl)) {
+		config_set_string(GetGlobalConfig(), "General", "UpdateBranch",
+				  WIN_DEFAULT_BRANCH);
+		info(QTStr("Updater.BranchNotFound.Title"),
+		     QTStr("Updater.BranchNotFound.Text"));
 	}
 
 	/* allow server to know if this was a manual update check in case
@@ -593,50 +768,20 @@ try {
 	/* ----------------------------------- *
 	 * get manifest from server            */
 
-	success = GetRemoteFile(WIN_MANIFEST_URL, text, error, &responseCode,
-				nullptr, "", nullptr, extraHeaders, &signature);
-
-	if (!success || (responseCode != 200 && responseCode != 304)) {
-		if (responseCode == 404)
-			return;
-
-		throw strprintf("Failed to fetch manifest file: %s",
-				error.c_str());
-	}
-
-	/* ----------------------------------- *
-	 * verify file signature               */
-
-	/* a new file must be digitally signed */
-	if (responseCode == 200) {
-		success = CheckDataSignature(text, "manifest", signature.data(),
-					     signature.size());
-		if (!success)
-			throw string("Invalid manifest signature");
-	}
-
-	/* ----------------------------------- *
-	 * write or load manifest              */
-
-	if (responseCode == 200) {
-		if (!QuickWriteFile(manifestPath, text.data(), text.size()))
-			throw strprintf("Could not write file '%s'",
-					manifestPath.Get());
-	} else {
-		if (!QuickReadFile(manifestPath, text))
-			throw strprintf("Could not read file '%s'",
-					manifestPath.Get());
-	}
+	text.clear();
+	if (!FetchAndVerifyFile("manifest",
+				"obs-studio\\updates\\manifest.json",
+				manifestUrl.c_str(), text, extraHeaders))
+		return;
 
 	/* ----------------------------------- *
 	 * check manifest for update           */
 
 	string notes;
-	int updateVer = 0;
+	uint64_t updateVer = 0;
 
-	success = ParseUpdateManifest(text.c_str(), &updatesAvailable, notes,
-				      updateVer);
-	if (!success)
+	if (!ParseUpdateManifest(text.c_str(), &updatesAvailable, notes,
+				 updateVer, branch))
 		throw string("Failed to parse manifest");
 
 	if (!updatesAvailable && !repairMode) {
@@ -653,8 +798,8 @@ try {
 	/* ----------------------------------- *
 	 * skip this version if set to skip    */
 
-	int skipUpdateVer = config_get_int(GetGlobalConfig(), "General",
-					   "SkipUpdateVersion");
+	uint64_t skipUpdateVer = config_get_uint(GetGlobalConfig(), "General",
+						 "SkipUpdateVersion");
 	if (!manualUpdate && updateVer == skipUpdateVer && !repairMode)
 		return;
 
@@ -682,8 +827,8 @@ try {
 			return;
 
 		} else if (queryResult == OBSUpdate::Skip) {
-			config_set_int(GetGlobalConfig(), "General",
-				       "SkipUpdateVersion", updateVer);
+			config_set_uint(GetGlobalConfig(), "General",
+					"SkipUpdateVersion", updateVer);
 			return;
 		}
 	}
@@ -713,16 +858,19 @@ try {
 
 	execInfo.cbSize = sizeof(execInfo);
 	execInfo.lpFile = wUpdateFilePath;
-#ifndef UPDATE_CHANNEL
-#define UPDATE_ARG_SUFFIX L""
-#else
-#define UPDATE_ARG_SUFFIX UPDATE_CHANNEL
-#endif
+
+	string parameters = "";
 	if (App()->IsPortableMode())
-		execInfo.lpParameters = UPDATE_ARG_SUFFIX L" Portable";
-	else
-		execInfo.lpParameters = UPDATE_ARG_SUFFIX;
+		parameters += "--portable";
+	if (branch != WIN_DEFAULT_BRANCH)
+		parameters += "--branch=" + branch;
+
+	BPtr<wchar_t> lpParameters;
+	size = os_utf8_to_wcs_ptr(parameters.c_str(), 0, &lpParameters);
+	if (!size && !parameters.empty())
+		throw string("Could not convert parameters to wide");
 
+	execInfo.lpParameters = lpParameters;
 	execInfo.lpDirectory = cwd;
 	execInfo.nShow = SW_SHOWNORMAL;
 
@@ -737,8 +885,6 @@ try {
 	 * in case of issues with the new version */
 	config_set_int(GetGlobalConfig(), "General", "LastUpdateCheck", 0);
 	config_set_int(GetGlobalConfig(), "General", "SkipUpdateVersion", 0);
-	config_set_string(GetGlobalConfig(), "General", "InstallGUID",
-			  guid.c_str());
 
 	QMetaObject::invokeMethod(App()->GetMainWindow(), "close");
 
@@ -750,18 +896,8 @@ try {
 
 void WhatsNewInfoThread::run()
 try {
-	long responseCode;
-	vector<string> extraHeaders;
 	string text;
-	string error;
-	string signature;
 	CryptProvider localProvider;
-	BYTE whatsnewHash[BLAKE2_HASH_LENGTH];
-	bool success;
-
-	BPtr<char> whatsnewPath =
-		GetConfigPathPtr("obs-studio\\updates\\whatsnew.json");
-
 	/* ----------------------------------- *
 	 * create signature provider           */
 
@@ -772,71 +908,11 @@ try {
 
 	provider = localProvider;
 
-	/* ----------------------------------- *
-	 * avoid downloading json again        */
-
-	if (CalculateFileHash(whatsnewPath, whatsnewHash)) {
-		char hashString[BLAKE2_HASH_STR_LENGTH];
-		HashToString(whatsnewHash, hashString);
-
-		string header = "If-None-Match: ";
-		header += hashString;
-		extraHeaders.push_back(move(header));
+	if (FetchAndVerifyFile("whatsnew", "obs-studio\\updates\\whatsnew.json",
+			       WIN_WHATSNEW_URL, text)) {
+		emit Result(QString::fromStdString(text));
 	}
 
-	/* ----------------------------------- *
-	 * get current install GUID            */
-
-	string guid = GetProgramGUID();
-
-	if (!guid.empty()) {
-		string header = "X-OBS2-GUID: ";
-		header += guid;
-		extraHeaders.push_back(move(header));
-	}
-
-	/* ----------------------------------- *
-	 * get json from server                */
-
-	success = GetRemoteFile(WIN_WHATSNEW_URL, text, error, &responseCode,
-				nullptr, "", nullptr, extraHeaders, &signature);
-
-	if (!success || (responseCode != 200 && responseCode != 304)) {
-		if (responseCode == 404)
-			return;
-
-		throw strprintf("Failed to fetch whatsnew file: %s",
-				error.c_str());
-	}
-
-	/* ----------------------------------- *
-	 * verify file signature               */
-
-	if (responseCode == 200) {
-		success = CheckDataSignature(text, "whatsnew", signature.data(),
-					     signature.size());
-		if (!success)
-			throw string("Invalid whatsnew signature");
-	}
-
-	/* ----------------------------------- *
-	 * write or load json                  */
-
-	if (responseCode == 200) {
-		if (!QuickWriteFile(whatsnewPath, text.data(), text.size()))
-			throw strprintf("Could not write file '%s'",
-					whatsnewPath.Get());
-	} else {
-		if (!QuickReadFile(whatsnewPath, text))
-			throw strprintf("Could not read file '%s'",
-					whatsnewPath.Get());
-	}
-
-	/* ----------------------------------- *
-	 * success                             */
-
-	emit Result(QString::fromUtf8(text.c_str()));
-
 } catch (string &text) {
 	blog(LOG_WARNING, "%s: %s", __FUNCTION__, text.c_str());
 }

+ 98 - 0
UI/window-basic-settings.cpp

@@ -373,6 +373,7 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 	/* clang-format off */
 	HookWidget(ui->language,             COMBO_CHANGED,  GENERAL_CHANGED);
 	HookWidget(ui->theme, 		     COMBO_CHANGED,  GENERAL_CHANGED);
+	HookWidget(ui->updateChannelBox,     COMBO_CHANGED,  GENERAL_CHANGED);
 	HookWidget(ui->enableAutoUpdates,    CHECK_CHANGED,  GENERAL_CHANGED);
 	HookWidget(ui->openStatsOnStartup,   CHECK_CHANGED,  GENERAL_CHANGED);
 	HookWidget(ui->hideOBSFromCapture,   CHECK_CHANGED,  GENERAL_CHANGED);
@@ -580,6 +581,17 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 #if !defined(_WIN32) && !defined(__APPLE__)
 	delete ui->enableAutoUpdates;
 	ui->enableAutoUpdates = nullptr;
+	delete ui->updateChannelBox;
+	ui->updateChannelBox = nullptr;
+	delete ui->updateSettingsGroupBox;
+	ui->updateSettingsGroupBox = nullptr;
+#elif defined(__APPLE__)
+	delete ui->updateChannelBox;
+	ui->updateChannelBox = nullptr;
+#else
+	// Hide update section if disabled
+	if (App()->IsUpdaterDisabled())
+		ui->updateSettingsGroupBox->hide();
 #endif
 
 	// Remove the Advanced Audio section if monitoring is not supported, as the monitoring device selection is the only item in the group box.
@@ -1192,6 +1204,73 @@ void OBSBasicSettings::LoadThemeList()
 		ui->theme->setCurrentIndex(idx);
 }
 
+#ifdef _WIN32
+void TranslateBranchInfo(const QString &name, QString &displayName,
+			 QString &description)
+{
+	QString translatedName =
+		QTStr("Basic.Settings.General.ChannelName." + name.toUtf8());
+	QString translatedDesc = QTStr(
+		"Basic.Settings.General.ChannelDescription." + name.toUtf8());
+
+	if (!translatedName.startsWith("Basic.Settings."))
+		displayName = translatedName;
+	if (!translatedDesc.startsWith("Basic.Settings."))
+		description = translatedDesc;
+}
+#endif
+
+void OBSBasicSettings::LoadBranchesList()
+{
+#ifdef _WIN32
+	bool configBranchRemoved = true;
+	QString configBranch =
+		config_get_string(GetGlobalConfig(), "General", "UpdateBranch");
+
+	for (const UpdateBranch &branch : App()->GetBranches()) {
+		if (branch.name == configBranch)
+			configBranchRemoved = false;
+		if (!branch.is_visible && branch.name != configBranch)
+			continue;
+
+		QString displayName = branch.display_name;
+		QString description = branch.description;
+
+		TranslateBranchInfo(branch.name, displayName, description);
+		QString itemDesc = displayName + " - " + description;
+
+		if (!branch.is_enabled) {
+			itemDesc.prepend(" ");
+			itemDesc.prepend(QTStr(
+				"Basic.Settings.General.UpdateChannelDisabled"));
+		} else if (branch.name == "stable") {
+			itemDesc.append(" ");
+			itemDesc.append(QTStr(
+				"Basic.Settings.General.UpdateChannelDefault"));
+		}
+
+		ui->updateChannelBox->addItem(itemDesc, branch.name);
+
+		// Disable item if branch is disabled
+		if (!branch.is_enabled) {
+			QStandardItemModel *model =
+				dynamic_cast<QStandardItemModel *>(
+					ui->updateChannelBox->model());
+			QStandardItem *item =
+				model->item(ui->updateChannelBox->count() - 1);
+			item->setFlags(Qt::NoItemFlags);
+		}
+	}
+
+	// Fall back to default if not yet set or user-selected branch has been removed
+	if (configBranch.isEmpty() || configBranchRemoved)
+		configBranch = "stable";
+
+	int idx = ui->updateChannelBox->findData(configBranch);
+	ui->updateChannelBox->setCurrentIndex(idx);
+#endif
+}
+
 void OBSBasicSettings::LoadGeneralSettings()
 {
 	loading = true;
@@ -1203,6 +1282,10 @@ void OBSBasicSettings::LoadGeneralSettings()
 	bool enableAutoUpdates = config_get_bool(GetGlobalConfig(), "General",
 						 "EnableAutoUpdates");
 	ui->enableAutoUpdates->setChecked(enableAutoUpdates);
+
+#ifdef _WIN32
+	LoadBranchesList();
+#endif
 #endif
 	bool openStatsOnStartup = config_get_bool(main->Config(), "General",
 						  "OpenStatsOnStartup");
@@ -3036,6 +3119,16 @@ void OBSBasicSettings::SaveGeneralSettings()
 				ui->enableAutoUpdates->isChecked());
 #endif
 #ifdef _WIN32
+	int branchIdx = ui->updateChannelBox->currentIndex();
+	QString branchName =
+		ui->updateChannelBox->itemData(branchIdx).toString();
+
+	if (WidgetChanged(ui->updateChannelBox)) {
+		config_set_string(GetGlobalConfig(), "General", "UpdateBranch",
+				  QT_TO_UTF8(branchName));
+		forceUpdateCheck = true;
+	}
+
 	if (ui->hideOBSFromCapture && WidgetChanged(ui->hideOBSFromCapture)) {
 		bool hide_window = ui->hideOBSFromCapture->isChecked();
 		config_set_bool(GetGlobalConfig(), "BasicWindow",
@@ -4142,6 +4235,11 @@ bool OBSBasicSettings::AskIfCanCloseSettings()
 		forceAuthReload = false;
 	}
 
+	if (forceUpdateCheck) {
+		main->CheckForUpdates(false);
+		forceUpdateCheck = false;
+	}
+
 	return canCloseSettings;
 }
 

+ 2 - 0
UI/window-basic-settings.hpp

@@ -120,6 +120,7 @@ private:
 	int pageIndex = 0;
 	bool loading = true;
 	bool forceAuthReload = false;
+	bool forceUpdateCheck = false;
 	std::string savedTheme;
 	int sampleRateIndex = 0;
 	int channelIndex = 0;
@@ -250,6 +251,7 @@ private:
 	/* general */
 	void LoadLanguageList();
 	void LoadThemeList();
+	void LoadBranchesList();
 
 	/* stream */
 	void InitStreamPage();