diff --git a/cmake/FindSparkle.cmake b/cmake/FindSparkle.cmake
index 8ab2e12c6..c302efd77 100644
--- a/cmake/FindSparkle.cmake
+++ b/cmake/FindSparkle.cmake
@@ -38,4 +38,15 @@ string(APPEND ELEMENT_APP_PLIST_TO_MERGE "SUEnableAutomaticChecks")
string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
string(APPEND ELEMENT_APP_PLIST_TO_MERGE "SUScheduledCheckInterval")
string(APPEND ELEMENT_APP_PLIST_TO_MERGE "0")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "CFBundleURLTypes")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "CFBundleURLName")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "net.kushview.Element")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "CFBundleURLSchemes")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "element")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
+string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
string(APPEND ELEMENT_APP_PLIST_TO_MERGE "")
diff --git a/include/element/application.hpp b/include/element/application.hpp
index a3c477b80..41548c3d4 100644
--- a/include/element/application.hpp
+++ b/include/element/application.hpp
@@ -88,12 +88,29 @@ class Application : public juce::JUCEApplication,
/** Called when another instance of the application is started.
- Attempts to open any file specified in the command line of the new instance.
+ Attempts to open any file specified in the command line of the new instance,
+ or handles custom URL scheme callbacks (e.g., OAuth).
@param commandLine The command line from the new instance
*/
void anotherInstanceStarted (const juce::String& commandLine) override;
+ /** Handles custom URL scheme callbacks.
+
+ Processes URLs with the element:// scheme, such as OAuth authorization callbacks.
+
+ @param urlString The complete URL string (e.g., element://auth/callback?code=...)
+ */
+ void handleURLSchemeCallback (const juce::String& urlString);
+
+#if JUCE_MAC
+ /** Registers the URL scheme handler for macOS Apple Events. */
+ void registerURLSchemeHandler();
+
+ /** Unregisters the URL scheme handler. */
+ void unregisterURLSchemeHandler();
+#endif
+
/** Called when the application is suspended (mobile platforms). */
void suspended() override;
@@ -124,10 +141,21 @@ class Application : public juce::JUCEApplication,
virtual std::unique_ptr createContentFactory() { return nullptr; }
private:
- juce::String launchCommandLine; ///< The command line used to launch the app
- std::unique_ptr world; ///< The application context and services
- std::unique_ptr startup; ///< Handles startup initialization
- juce::OwnedArray workers; ///< Worker processes (e.g., plugin scanner)
+ /** Background thread that attempts a token refresh on startup. */
+ class AuthStartupThread : public juce::Thread {
+ public:
+ explicit AuthStartupThread (Context& c) : juce::Thread ("AuthStartup"), ctx (c) {}
+ void run() override;
+
+ private:
+ Context& ctx;
+ };
+
+ juce::String launchCommandLine; ///< The command line used to launch the app
+ std::unique_ptr world; ///< The application context and services
+ std::unique_ptr startup; ///< Handles startup initialization
+ std::unique_ptr authStartupThread; ///< Startup auth refresh thread
+ juce::OwnedArray workers; ///< Worker processes (e.g., plugin scanner)
#if JUCE_LINUX
class MidiSettingsApply {
public:
diff --git a/include/element/settings.hpp b/include/element/settings.hpp
index 680328c00..d4c56f151 100644
--- a/include/element/settings.hpp
+++ b/include/element/settings.hpp
@@ -13,7 +13,8 @@ class Context;
struct MidiPanicParams;
-class Settings : public juce::ApplicationProperties {
+class Settings : public juce::ApplicationProperties,
+ public juce::ChangeBroadcaster {
public:
Settings();
~Settings();
@@ -49,6 +50,8 @@ class Settings : public juce::ApplicationProperties {
static const char* updateKeyTypeKey;
static const char* updateKeyKey;
static const char* updateKeyUserKey;
+ static const char* authPreviewUpdatesKey;
+ static const char* authAppcastUrlKey;
static const char* transportStartStopContinue;
bool getBool (std::string_view key, bool fallback = false) const noexcept;
@@ -153,6 +156,18 @@ class Settings : public juce::ApplicationProperties {
/** Change the update channgel. stable or nightly. */
void setUpdateChannel (const String& key);
+ /** Returns true if the authenticated user has preview update access. */
+ bool getAuthPreviewUpdates() const;
+
+ /** Set whether the authenticated user has preview update access. */
+ void setAuthPreviewUpdates (bool enabled);
+
+ /** Returns the cached signed appcast URL, or empty if not set. */
+ juce::String getAuthAppcastUrl() const;
+
+ /** Stores the signed appcast URL returned by the server. */
+ void setAuthAppcastUrl (const juce::String& url);
+
/** Set global midi panic settings. */
void setMidiPanicParams (MidiPanicParams);
diff --git a/include/element/ui.hpp b/include/element/ui.hpp
index 77896a845..50d8a6fd2 100644
--- a/include/element/ui.hpp
+++ b/include/element/ui.hpp
@@ -43,6 +43,15 @@ class GuiService : public Service,
/** Check for a newer version and show alert, if available. */
void checkUpdates (bool background);
+ /** Override the updater's feed URL at runtime.
+ Pass an empty string to revert to the compiled-in default. */
+ void setUpdaterFeedUrl (const juce::String& url);
+
+ /** Re-reads the saved release channel from Settings and applies the
+ appropriate feed URL to the updater. Safe to call from the message
+ thread at any time (e.g. after a token refresh or sign-out). */
+ void applyStoredChannelToUpdater();
+
Services& services() const { return controller; }
juce::KeyListener* getKeyListener() const;
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 32600c978..6cdce17ef 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -31,7 +31,10 @@ file(GLOB_RECURSE ELEMENT_SOURCES
target_sources(element PRIVATE ${ELEMENT_SOURCES})
if(APPLE)
- target_sources(element PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/ui/nsviewwithparent.mm")
+ target_sources(element PRIVATE
+ "${CMAKE_CURRENT_SOURCE_DIR}/ui/nsviewwithparent.mm"
+ "${CMAKE_CURRENT_SOURCE_DIR}/urlhandler.mm"
+ )
endif()
target_compile_definitions(element
diff --git a/src/application.cpp b/src/application.cpp
index 10ffdd849..8aab499e8 100644
--- a/src/application.cpp
+++ b/src/application.cpp
@@ -22,6 +22,7 @@
#include "services/sessionservice.hpp"
#include "log.hpp"
#include "messages.hpp"
+#include "auth.hpp"
#include "utils.hpp"
/** Define to force the application to behave as if running for the first time.
@@ -223,7 +224,16 @@ void Application::initialise (const String& commandLine)
initializeModulePath();
printCopyNotice();
+
+#if JUCE_MAC
+ registerURLSchemeHandler();
+#endif
+
launchApplication();
+
+ // Handle URL scheme if passed on command line
+ if (commandLine.startsWith ("element://"))
+ handleURLSchemeCallback (commandLine);
}
void Application::actionListenerCallback (const String& message)
@@ -234,16 +244,17 @@ void Application::actionListenerCallback (const String& message)
bool Application::canShutdown()
{
- if (! MessageManager::getInstance()->isThisTheMessageThread())
+ if (! MessageManager::getInstance()->isThisTheMessageThread())
{
auto result = MessageManager::getInstance()->callSync (Application::canShutdown);
- return result.has_value() ? * result : true;
+ return result.has_value() ? *result : true;
}
- if (auto app = dynamic_cast (getInstance())) {
+ if (auto app = dynamic_cast (getInstance()))
+ {
auto& services = app->world->services();
auto ssvc = services.find();
- return !ssvc->hasSessionChanged();
+ return ! ssvc->hasSessionChanged();
}
return true;
@@ -253,6 +264,11 @@ void Application::shutdown()
{
if (! world)
return;
+
+#if JUCE_MAC
+ unregisterURLSchemeHandler();
+#endif
+
#if JUCE_LINUX
applyMidiSettings.reset();
#endif
@@ -260,6 +276,10 @@ void Application::shutdown()
auto& srvs = world->services();
srvs.saveSettings();
+ if (authStartupThread && authStartupThread->isThreadRunning())
+ authStartupThread->stopThread (3000);
+ authStartupThread.reset();
+
auto& devices (world->devices());
devices.closeAudioDevice();
@@ -351,9 +371,75 @@ void Application::anotherInstanceStarted (const String& commandLine)
if (! world)
return;
+ // Handle custom URL scheme callbacks (e.g., auth)
+ if (commandLine.startsWith ("element://"))
+ {
+ handleURLSchemeCallback (commandLine);
+ return;
+ }
+
maybeOpenCommandLineFile (commandLine);
}
+void Application::handleURLSchemeCallback (const String& urlString)
+{
+ URL url (urlString);
+
+ // Handle auth callback: element://auth/callback?code=...
+ // Note: getSubPath() returns "callback" without leading slash
+ if (url.getDomain() == "auth" && url.getSubPath() == "callback")
+ {
+ // Bring main window to front
+ if (world)
+ {
+ if (auto* gui = world->services().find())
+ {
+ if (auto* mainWindow = gui->getMainWindow())
+ mainWindow->toFront (true);
+ }
+ }
+
+ const auto parameters = auth::parseQueryParameters (url.toString (true));
+
+ const auto authError = parameters["error"];
+ if (authError.isNotEmpty())
+ {
+ Logger::writeToLog ("Auth callback error: " + authError);
+ return;
+ }
+
+ String authCode = parameters["code"];
+ if (authCode.isNotEmpty())
+ {
+ auto& settings = world->settings();
+ String expectedState;
+ String codeVerifier;
+ if (! auth::consumePendingPKCE (settings, expectedState, codeVerifier))
+ {
+ Logger::writeToLog ("Auth token exchange failed: missing PKCE session; restart sign-in");
+ return;
+ }
+
+ const auto callbackState = parameters["state"].trim();
+ if (callbackState.isEmpty() || callbackState != expectedState)
+ {
+ Logger::writeToLog ("Auth callback rejected: invalid state");
+ return;
+ }
+
+ auto tokenResponse = auth::exchangeAuthorizationCode (authCode, codeVerifier);
+ if (! tokenResponse.success)
+ {
+ Logger::writeToLog (tokenResponse.error);
+ return;
+ }
+
+ auth::persistTokens (settings, tokenResponse);
+ Logger::writeToLog ("Auth token exchange completed successfully");
+ }
+ }
+}
+
void Application::suspended() {}
void Application::resumed()
@@ -370,7 +456,7 @@ void Application::finishLaunching()
if (world->settings().scanForPluginsOnStartup())
world->plugins().scanAudioPlugins();
- const bool isFirstRun =startup->isFirstRun;
+ const bool isFirstRun = startup->isFirstRun;
startup.reset();
auto& ui = *world->services().find();
@@ -379,12 +465,28 @@ void Application::finishLaunching()
world->services().run();
+ // Attempt to restore auth state from a stored refresh token in the background.
+ authStartupThread = std::make_unique (*world);
+ authStartupThread->startThread (Thread::Priority::low);
+
if (! isFirstRun && world->settings().checkForUpdates())
startTimer (10 * 1000);
maybeOpenCommandLineFile (getCommandLineParameters());
}
+void Application::AuthStartupThread::run()
+{
+ auth::maybeRefreshOnStartup (ctx.settings());
+
+ // Re-apply the channel preference to the updater from the message thread:
+ // the startup refresh may have fetched a new signed appcast URL.
+ juce::MessageManager::callAsync ([&ctx = ctx]() {
+ if (auto* gui = ctx.services().find())
+ gui->applyStoredChannelToUpdater();
+ });
+}
+
void Application::printCopyNotice()
{
String appName = Util::appName();
diff --git a/src/auth.cpp b/src/auth.cpp
new file mode 100644
index 000000000..19240ed9a
--- /dev/null
+++ b/src/auth.cpp
@@ -0,0 +1,409 @@
+// Copyright 2026 Kushview, LLC
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "auth.hpp"
+
+#include
+#include
+
+#if JUCE_MAC
+#include
+#endif
+
+namespace element::auth {
+using namespace juce;
+
+static String base64UrlEncode (const void* data, size_t numBytes)
+{
+ MemoryOutputStream output;
+ Base64::convertToBase64 (output, data, numBytes);
+ auto encoded = output.toString().trim();
+ encoded = encoded.replaceCharacter ('+', '-')
+ .replaceCharacter ('/', '_')
+ .upToLastOccurrenceOf ("=", false, false);
+ while (encoded.endsWithChar ('='))
+ encoded = encoded.dropLastCharacters (1);
+ return encoded;
+}
+
+static String createCodeChallenge (const String& verifier, String& method)
+{
+#if JUCE_MAC
+ const auto utf8 = verifier.toUTF8();
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH] = { 0 };
+ CC_SHA256 (reinterpret_cast (utf8.getAddress()),
+ static_cast (std::strlen (utf8.getAddress())),
+ digest);
+ method = "S256";
+ return base64UrlEncode (digest, sizeof (digest));
+#else
+ method = "plain";
+ return verifier;
+#endif
+}
+
+static TokenResponse parseTokenPayload (const String& payload, const String& errorPrefix)
+{
+ TokenResponse response;
+
+ const auto parsed = JSON::parse (payload);
+ if (parsed.isVoid() || ! parsed.isObject())
+ {
+ response.error = errorPrefix + ": invalid JSON response";
+ return response;
+ }
+
+ auto* object = parsed.getDynamicObject();
+ if (object == nullptr)
+ {
+ response.error = errorPrefix + ": malformed response object";
+ return response;
+ }
+
+ response.accessToken = object->getProperty ("access_token").toString().trim();
+ response.refreshToken = object->getProperty ("refresh_token").toString().trim();
+ response.expiresInSeconds = static_cast (object->getProperty ("expires_in"));
+
+ if (auto user = object->getProperty ("user"); user.isObject())
+ {
+ if (auto* userObj = user.getDynamicObject())
+ {
+ response.userEmail = userObj->getProperty ("email").toString().trim();
+ response.userDisplay = userObj->getProperty ("display_name").toString().trim();
+ }
+ }
+
+ if (response.userEmail.isEmpty())
+ response.userEmail = object->getProperty ("email").toString().trim();
+ if (response.userDisplay.isEmpty())
+ response.userDisplay = object->getProperty ("display_name").toString().trim();
+ if (response.userDisplay.isEmpty())
+ response.userDisplay = response.userEmail;
+
+ if (response.accessToken.isEmpty())
+ {
+ response.error = errorPrefix + ": access token missing in response";
+ return response;
+ }
+
+ if (auto entitlements = object->getProperty ("entitlements"); entitlements.isObject())
+ if (auto* ent = entitlements.getDynamicObject())
+ response.previewUpdates = static_cast (ent->getProperty ("preview_updates"));
+
+ response.success = true;
+ return response;
+}
+
+String generateState()
+{
+ return Uuid().toString().replaceCharacters ("{}-", "");
+}
+
+String generateCodeVerifier()
+{
+ static constexpr auto alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
+ static constexpr int alphabetLen = 66;
+ static constexpr int verifierLength = 64;
+
+ String verifier;
+ verifier.preallocateBytes (verifierLength);
+
+ auto& random = Random::getSystemRandom();
+ for (int i = 0; i < verifierLength; ++i)
+ verifier << String::charToString (alphabet[random.nextInt (alphabetLen)]);
+
+ return verifier;
+}
+
+String buildAuthorizationURL (const String& state,
+ const String& codeVerifier)
+{
+ String challengeMethod;
+ const auto codeChallenge = createCodeChallenge (codeVerifier, challengeMethod);
+
+ String authUrl (authorizeEndpoint);
+ authUrl << "?response_type=code"
+ << "&client_id=" << URL::addEscapeChars (clientId, true)
+ << "&redirect_uri=" << URL::addEscapeChars (redirectUri, true)
+ << "&scope=" << URL::addEscapeChars ("basic", true)
+ << "&state=" << URL::addEscapeChars (state, true)
+ << "&code_challenge=" << URL::addEscapeChars (codeChallenge, true)
+ << "&code_challenge_method=" << URL::addEscapeChars (challengeMethod, true);
+
+ return authUrl;
+}
+
+bool storePendingPKCE (element::Settings& settings,
+ const String& state,
+ const String& verifier)
+{
+ if (auto* props = settings.getUserSettings())
+ {
+ props->setValue (pkceStateKey, state.trim());
+ props->setValue (pkceVerifierKey, verifier.trim());
+ return true;
+ }
+
+ return false;
+}
+
+StringPairArray parseQueryParameters (const String& urlString)
+{
+ StringPairArray parameters;
+ const String query = urlString.fromFirstOccurrenceOf ("?", false, false);
+ const StringArray pairs = StringArray::fromTokens (query, "&", "");
+ for (const auto& pair : pairs)
+ {
+ const int equalsPos = pair.indexOfChar ('=');
+ if (equalsPos <= 0)
+ continue;
+
+ const auto key = pair.substring (0, equalsPos);
+ const auto value = URL::removeEscapeChars (pair.substring (equalsPos + 1));
+ parameters.set (key, value);
+ }
+
+ return parameters;
+}
+
+TokenResponse exchangeAuthorizationCode (const String& authCode,
+ const String& codeVerifier)
+{
+ TokenResponse response;
+ if (authCode.isEmpty())
+ {
+ response.error = "Auth token exchange failed: missing authorization code";
+ return response;
+ }
+
+ if (codeVerifier.isEmpty())
+ {
+ response.error = "Auth token exchange failed: missing PKCE code_verifier";
+ return response;
+ }
+
+ auto body = String ("grant_type=authorization_code&code=");
+ body << URL::addEscapeChars (authCode, true)
+ << "&client_id=" << URL::addEscapeChars (clientId, true)
+ << "&redirect_uri=" << URL::addEscapeChars (redirectUri, true)
+ << "&code_verifier=" << URL::addEscapeChars (codeVerifier, true);
+
+ URL tokenUrl (tokenEndpoint);
+ tokenUrl = tokenUrl.withPOSTData (body);
+
+ int statusCode = -1;
+ StringPairArray responseHeaders;
+ auto options = URL::InputStreamOptions (URL::ParameterHandling::inAddress)
+ .withHttpRequestCmd ("POST")
+ .withExtraHeaders ("Content-Type: application/x-www-form-urlencoded\r\n"
+ "Accept: application/json\r\n")
+ .withConnectionTimeoutMs (15000)
+ .withStatusCode (&statusCode)
+ .withResponseHeaders (&responseHeaders);
+
+ std::unique_ptr stream (tokenUrl.createInputStream (options));
+ if (statusCode == -1 || stream == nullptr)
+ {
+ response.error = "Auth token exchange failed: unable to contact token endpoint";
+ return response;
+ }
+
+ const auto payload = stream->readEntireStreamAsString().trim();
+
+ if (statusCode < 200 || statusCode >= 300)
+ {
+ response.error = String ("Auth token exchange failed: HTTP ")
+ + String (statusCode)
+ + (payload.isNotEmpty() ? " " + payload : String());
+ return response;
+ }
+
+ return parseTokenPayload (payload, "Auth token exchange failed");
+}
+
+TokenResponse refreshAccessToken (const String& refreshToken)
+{
+ TokenResponse response;
+ if (refreshToken.isEmpty())
+ {
+ response.error = "Auth token refresh failed: missing refresh token";
+ return response;
+ }
+
+ auto body = String ("grant_type=refresh_token&refresh_token=");
+ body << URL::addEscapeChars (refreshToken, true)
+ << "&client_id=" << URL::addEscapeChars (clientId, true);
+
+ URL tokenUrl (refreshEndpoint);
+ tokenUrl = tokenUrl.withPOSTData (body);
+
+ int statusCode = -1;
+ StringPairArray responseHeaders;
+ auto options = URL::InputStreamOptions (URL::ParameterHandling::inAddress)
+ .withHttpRequestCmd ("POST")
+ .withExtraHeaders ("Content-Type: application/x-www-form-urlencoded\r\n"
+ "Accept: application/json\r\n")
+ .withConnectionTimeoutMs (15000)
+ .withStatusCode (&statusCode)
+ .withResponseHeaders (&responseHeaders);
+
+ std::unique_ptr stream (tokenUrl.createInputStream (options));
+ if (statusCode == -1 || stream == nullptr)
+ {
+ response.error = "Auth token refresh failed: unable to contact token endpoint";
+ return response;
+ }
+
+ const auto payload = stream->readEntireStreamAsString().trim();
+
+ if (statusCode < 200 || statusCode >= 300)
+ {
+ response.error = String ("Auth token refresh failed: HTTP ")
+ + String (statusCode)
+ + (payload.isNotEmpty() ? " " + payload : String());
+ return response;
+ }
+
+ return parseTokenPayload (payload, "Auth token refresh failed");
+}
+
+void persistTokens (element::Settings& settings, const TokenResponse& tokenResponse)
+{
+ if (! tokenResponse.success)
+ return;
+
+ settings.setUpdateKeyType ("member");
+ settings.setUpdateKey (tokenResponse.accessToken);
+ settings.setUpdateKeyUser (tokenResponse.userDisplay);
+ settings.setAuthPreviewUpdates (tokenResponse.previewUpdates);
+
+ if (auto* props = settings.getUserSettings())
+ props->setValue (refreshTokenKey, tokenResponse.refreshToken);
+
+ // Fetch a short-lived signed appcast URL and cache it so the updater
+ // can point at the correct channel without needing a token at feed time.
+ const auto appcastUrl = fetchSignedAppcastUrl (tokenResponse.accessToken);
+ settings.setAuthAppcastUrl (appcastUrl);
+}
+
+bool consumePendingPKCE (element::Settings& settings, String& state, String& verifier)
+{
+ if (auto* props = settings.getUserSettings())
+ {
+ state = props->getValue (pkceStateKey).trim();
+ verifier = props->getValue (pkceVerifierKey).trim();
+ props->setValue (pkceStateKey, "");
+ props->setValue (pkceVerifierKey, "");
+ return state.isNotEmpty() && verifier.isNotEmpty();
+ }
+
+ return false;
+}
+
+String fetchSignedAppcastUrl (const String& accessToken)
+{
+ if (accessToken.isEmpty())
+ return {};
+
+#if JUCE_MAC
+ constexpr const char* plat = "macos";
+#elif JUCE_WINDOWS
+ constexpr const char* plat = "windows";
+#else
+ constexpr const char* plat = "unknown";
+#endif
+
+ const String endpoint = String (apiBaseEndpoint) + "/appcast-url?plat=" + plat;
+ URL url (endpoint);
+
+ int statusCode = -1;
+ auto options = URL::InputStreamOptions (URL::ParameterHandling::inAddress)
+ .withHttpRequestCmd ("GET")
+ .withExtraHeaders ("Authorization: Bearer " + accessToken + "\r\n"
+ "Accept: application/json\r\n")
+ .withConnectionTimeoutMs (10000)
+ .withStatusCode (&statusCode);
+
+ std::unique_ptr stream (url.createInputStream (options));
+ if (statusCode != 200 || stream == nullptr)
+ {
+ Logger::writeToLog ("Auth: appcast-url request failed (HTTP " + String (statusCode) + ")");
+ return {};
+ }
+
+ const auto body = stream->readEntireStreamAsString();
+ auto json = JSON::parse (body);
+ const auto appcastUrl = json["url"].toString();
+ if (appcastUrl.isNotEmpty())
+ Logger::writeToLog ("Auth: fetched signed appcast URL");
+ else
+ Logger::writeToLog ("Auth: appcast-url response missing 'url' field");
+
+ return appcastUrl;
+}
+
+void revokeRefreshToken (const String& refreshToken)
+{
+ if (refreshToken.isEmpty())
+ return;
+
+ const String revokeEndpoint = String (apiBaseEndpoint) + "/token/revoke";
+ auto body = String ("refresh_token=") + URL::addEscapeChars (refreshToken, true);
+
+ URL url (revokeEndpoint);
+ url = url.withPOSTData (body);
+
+ int statusCode = -1;
+ auto options = URL::InputStreamOptions (URL::ParameterHandling::inAddress)
+ .withHttpRequestCmd ("POST")
+ .withExtraHeaders ("Content-Type: application/x-www-form-urlencoded\r\n"
+ "Accept: application/json\r\n")
+ .withConnectionTimeoutMs (10000)
+ .withStatusCode (&statusCode);
+
+ std::unique_ptr stream (url.createInputStream (options));
+ if (statusCode >= 200 && statusCode < 300)
+ Logger::writeToLog ("Auth: refresh token revoked successfully");
+ else
+ Logger::writeToLog ("Auth: revoke request failed (HTTP " + String (statusCode) + ")");
+}
+
+void maybeRefreshOnStartup (element::Settings& settings)
+{
+ const auto storedRefreshToken = [&]() -> String {
+ if (auto* props = settings.getUserSettings())
+ return props->getValue (refreshTokenKey).trim();
+ return {};
+ }();
+
+ if (storedRefreshToken.isEmpty())
+ {
+ Logger::writeToLog ("Auth: no stored refresh token, skipping startup refresh");
+ return;
+ }
+
+ Logger::writeToLog ("Auth: attempting token refresh on startup");
+ const auto response = refreshAccessToken (storedRefreshToken);
+
+ if (! response.success)
+ {
+ Logger::writeToLog ("Auth: startup refresh failed: " + response.error);
+ // Clear stale credentials so the UI reflects signed-out state.
+ settings.setUpdateKey ({});
+ settings.setUpdateKeyUser ({});
+ settings.setUpdateKeyType ("element-v1");
+ settings.setAuthPreviewUpdates (false);
+ settings.setAuthAppcastUrl ({});
+ if (auto* props = settings.getUserSettings())
+ {
+ props->setValue (refreshTokenKey, String());
+ props->save();
+ }
+ return;
+ }
+
+ persistTokens (settings, response);
+ Logger::writeToLog ("Auth: startup token refresh succeeded");
+}
+
+} // namespace element::auth
diff --git a/src/auth.hpp b/src/auth.hpp
new file mode 100644
index 000000000..a9ae81cf5
--- /dev/null
+++ b/src/auth.hpp
@@ -0,0 +1,148 @@
+// Copyright 2026 Kushview, LLC
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include
+#include
+
+namespace element {
+class Settings;
+}
+
+namespace element::auth {
+
+/** Public client ID used for desktop authorization flow. */
+inline constexpr const char* clientId = "element-desktop";
+
+/** Redirect URI registered for the desktop app callback. */
+inline constexpr const char* redirectUri = "element://auth/callback";
+
+#define ELEMENT_LOCAL_AUTH 1
+#if ELEMENT_LOCAL_AUTH
+inline constexpr const char* apiBaseEndpoint = "https://scratch-woo.local/wp-json/element-auth/v1";
+inline constexpr const char* authorizeEndpoint = "https://scratch-woo.local/auth/authorize";
+inline constexpr const char* tokenEndpoint = "https://scratch-woo.local/wp-json/element-auth/v1/token";
+inline constexpr const char* refreshEndpoint = "https://scratch-woo.local/wp-json/element-auth/v1/token/refresh";
+#else
+/** Custom auth API base endpoint. */
+inline constexpr const char* apiBaseEndpoint = "https://kushview.net/wp-json/element-auth/v1";
+
+/** Browser authorization endpoint. */
+inline constexpr const char* authorizeEndpoint = "https://kushview.net/auth/authorize";
+
+/** Token exchange endpoint. */
+inline constexpr const char* tokenEndpoint = "https://kushview.net/wp-json/element-auth/v1/token";
+
+/** Refresh endpoint. */
+inline constexpr const char* refreshEndpoint = "https://kushview.net/wp-json/element-auth/v1/token/refresh";
+
+#endif
+
+/** User settings key for persisted refresh token. */
+inline constexpr const char* refreshTokenKey = "authRefreshToken";
+
+/** User settings key for PKCE state. */
+inline constexpr const char* pkceStateKey = "authPkceState";
+
+/** User settings key for PKCE code verifier. */
+inline constexpr const char* pkceVerifierKey = "authPkceVerifier";
+
+/** User settings key for the cached signed appcast URL. */
+inline constexpr const char* appcastUrlKey = "authAppcastUrl";
+
+/** Token response parsed from auth server payload. */
+struct TokenResponse
+{
+ bool success { false };
+ juce::String accessToken;
+ juce::String refreshToken;
+ int expiresInSeconds { 0 };
+ juce::String userDisplay;
+ juce::String userEmail;
+ juce::String error;
+
+ /** Entitlement: whether the user has access to preview update builds. */
+ bool previewUpdates { false };
+};
+
+/** Generates a random CSRF state value. */
+juce::String generateState();
+
+/** Generates a PKCE code verifier. */
+juce::String generateCodeVerifier();
+
+/** Builds the browser authorization URL for sign-in. */
+juce::String buildAuthorizationURL (const juce::String& state,
+ const juce::String& codeVerifier);
+
+/** Stores pending PKCE values used by callback validation and token exchange. */
+bool storePendingPKCE (element::Settings& settings,
+ const juce::String& state,
+ const juce::String& verifier);
+
+/** Parses URL query key/value pairs from a callback URL string.
+
+ @param urlString Full callback URL including query params
+ @return Parsed key/value parameter array
+ */
+juce::StringPairArray parseQueryParameters (const juce::String& urlString);
+
+/** Exchanges authorization code for access and refresh tokens.
+
+ @param authCode Authorization code returned by OAuth callback
+ @param codeVerifier PKCE verifier generated at authorization start
+ @return Parsed token response and error details
+ */
+TokenResponse exchangeAuthorizationCode (const juce::String& authCode,
+ const juce::String& codeVerifier);
+
+/** Refreshes an access token using refresh token rotation semantics. */
+TokenResponse refreshAccessToken (const juce::String& refreshToken);
+
+/** Persists exchanged auth credentials into user settings.
+
+ @param settings Application settings to persist credentials into
+ @param tokenResponse Parsed OAuth token response data
+ */
+void persistTokens (element::Settings& settings, const TokenResponse& tokenResponse);
+
+/** Gets and clears pending OAuth PKCE state and verifier values.
+
+ @param settings Application settings where PKCE values were stored
+ @param state Out value for state
+ @param verifier Out value for code verifier
+ @return true when both values were available
+ */
+bool consumePendingPKCE (element::Settings& settings, juce::String& state, juce::String& verifier);
+
+/** Sends a best-effort revocation request for the given refresh token.
+
+ This is a fire-and-forget call — failures are logged but not returned.
+ Must be called from a background thread.
+
+ @param refreshToken The opaque refresh token to revoke
+ */
+void revokeRefreshToken (const juce::String& refreshToken);
+
+/** Fetches a short-lived signed appcast URL from the server.
+
+ Uses the access token to authenticate. The returned URL is suitable for
+ passing directly to Sparkle — no token header needed at fetch time.
+ Must be called from a background thread.
+
+ @param accessToken The current JWT access token
+ @return The signed URL string, or empty on failure
+ */
+juce::String fetchSignedAppcastUrl (const juce::String& accessToken);
+
+/** Attempts to restore auth state on startup using a stored refresh token.
+
+ If a refresh token is present in settings, exchanges it for a fresh token pair
+ and persists the result. Must be called from a background thread.
+
+ @param settings Application settings containing the stored refresh token
+ */
+void maybeRefreshOnStartup (element::Settings& settings);
+
+} // namespace element::auth
diff --git a/src/services/guiservice.cpp b/src/services/guiservice.cpp
index 681c1d45a..e225b2498 100644
--- a/src/services/guiservice.cpp
+++ b/src/services/guiservice.cpp
@@ -180,6 +180,18 @@ class GuiService::UpdateManager
// is implemented
}
+ void setFeedUrl (const juce::String& url)
+ {
+#if ELEMENT_UPDATER
+ if (url.isNotEmpty())
+ updater->setFeedUrl (url.toStdString());
+ else
+ updater->setFeedUrl ({});
+#else
+ juce::ignoreUnused (url);
+#endif
+ }
+
bool launchUpdaterOnExit { false };
bool showAlertWhenNoUpdatesReady = false;
std::unique_ptr updater;
@@ -344,6 +356,9 @@ void GuiService::activate()
SystemTray::init (*this);
context().devices().addChangeListener (this);
impl->restoreRecents();
+
+ // Apply the saved release channel to the updater on launch.
+ applyStoredChannelToUpdater();
}
void GuiService::deactivate()
@@ -398,6 +413,21 @@ void GuiService::checkUpdates (bool background)
#endif
}
+void GuiService::setUpdaterFeedUrl (const juce::String& url)
+{
+ updates->setFeedUrl (url);
+}
+
+void GuiService::applyStoredChannelToUpdater()
+{
+ const auto& s = settings();
+ const auto channel = s.getUpdateChannel();
+ if (channel == "preview" && s.getAuthPreviewUpdates())
+ updates->setFeedUrl (s.getAuthAppcastUrl());
+ else
+ updates->setFeedUrl ({});
+}
+
void GuiService::showPreferencesDialog (const String& section)
{
if (auto* const dialog = windowManager->findDialogByName ("Preferences"))
diff --git a/src/services/sessionservice.cpp b/src/services/sessionservice.cpp
index edc253891..60004f27f 100644
--- a/src/services/sessionservice.cpp
+++ b/src/services/sessionservice.cpp
@@ -153,8 +153,9 @@ void SessionService::openFile (const File& file)
}
}
-const File SessionService::getSessionFile() const {
- return document != nullptr ? document->getFile() : File();
+const File SessionService::getSessionFile() const
+{
+ return document != nullptr ? document->getFile() : File();
}
void SessionService::exportGraph (const Node& node, const File& targetFile)
@@ -180,8 +181,9 @@ void SessionService::closeSession()
DBG ("[SC] close session");
}
-bool SessionService::hasSessionChanged() {
- return (document) ? document->hasChangedSinceSaved() : false;
+bool SessionService::hasSessionChanged()
+{
+ return (document) ? document->hasChangedSinceSaved() : false;
}
void SessionService::resetChanges (const bool resetDocumentFile)
diff --git a/src/settings.cpp b/src/settings.cpp
index 02030e29d..14a15b4fd 100644
--- a/src/settings.cpp
+++ b/src/settings.cpp
@@ -40,6 +40,8 @@ const char* Settings::updateChannelKey = "updateChannel";
const char* Settings::updateKeyTypeKey = "updateKeyType";
const char* Settings::updateKeyKey = "updateKey";
const char* Settings::updateKeyUserKey = "updateKeyUserKey";
+const char* Settings::authPreviewUpdatesKey = "authPreviewUpdates";
+const char* Settings::authAppcastUrlKey = "authAppcastUrl";
const char* Settings::transportStartStopContinue = "transportStartStopContinueKey";
//=============================================================================
@@ -480,6 +482,7 @@ void Settings::setUpdateKey (const String& slug)
{
if (auto p = getProps())
p->setValue (updateKeyKey, slug.trim());
+ sendChangeMessage();
}
juce::String Settings::getUpdateChannel() const
@@ -495,6 +498,34 @@ void Settings::setUpdateChannel (const String& channel)
p->setValue (updateChannelKey, channel.trim());
}
+bool Settings::getAuthPreviewUpdates() const
+{
+ if (auto* p = getProps())
+ return p->getBoolValue (authPreviewUpdatesKey, false);
+ return false;
+}
+
+void Settings::setAuthPreviewUpdates (bool enabled)
+{
+ if (auto* p = getProps())
+ p->setValue (authPreviewUpdatesKey, enabled);
+ sendChangeMessage();
+}
+
+juce::String Settings::getAuthAppcastUrl() const
+{
+ if (auto* p = getProps())
+ return p->getValue (authAppcastUrlKey);
+ return {};
+}
+
+void Settings::setAuthAppcastUrl (const juce::String& url)
+{
+ if (auto* p = getProps())
+ p->setValue (authAppcastUrlKey, url);
+ sendChangeMessage();
+}
+
void Settings::setMidiPanicParams (MidiPanicParams params)
{
if (auto p = getProps())
diff --git a/src/ui/preferences.cpp b/src/ui/preferences.cpp
index 4509eb64e..a994fd903 100644
--- a/src/ui/preferences.cpp
+++ b/src/ui/preferences.cpp
@@ -1,22 +1,26 @@
// Copyright 2023 Kushview, LLC
// SPDX-License-Identifier: GPL-3.0-or-later
+#include
+
+#include
#include
#include
-#include
#include
-#include
+#include
#include
#include
-#include
#include
+#include
+#include
-#include "ui/audiodeviceselector.hpp"
-#include "ui/guicommon.hpp"
-#include "ui/viewhelpers.hpp"
-#include "services/oscservice.hpp"
#include "engine/midiengine.hpp"
#include "engine/midipanic.hpp"
+#include "messages.hpp"
+#include "auth.hpp"
+#include "services/oscservice.hpp"
+#include "ui/buttons.hpp"
+#include "ui/viewhelpers.hpp"
namespace element {
@@ -1138,6 +1142,267 @@ class MidiSettingsPage : public SettingsPage,
}
};
+//==============================================================================
+#if ELEMENT_UPDATER
+class UpdatesSettingsPage : public SettingsPage,
+ public Button::Listener,
+ public ComboBox::Listener,
+ public juce::ChangeListener
+{
+public:
+ UpdatesSettingsPage (Context& w)
+ : world (w)
+ {
+ world.settings().addChangeListener (this);
+ addAndMakeVisible (releaseChannelLabel);
+ releaseChannelLabel.setText ("Release Channel", dontSendNotification);
+ releaseChannelLabel.setFont (Font (FontOptions (12.0, Font::bold)));
+
+ addAndMakeVisible (releaseChannelBox);
+ releaseChannelBox.addItem ("Stable", 1);
+ releaseChannelBox.addItem ("Preview", 2);
+ // ID 3 is reserved for a future "Testing" channel.
+
+ // Restore the persisted channel selection (defaults to stable).
+ const auto savedSlug = world.settings().getUpdateChannel();
+ releaseChannelBox.setSelectedId (channelIdForSlug (savedSlug), dontSendNotification);
+ releaseChannelBox.addListener (this);
+
+ addAndMakeVisible (authorizationLabel);
+ authorizationLabel.setText ("Preview Access", dontSendNotification);
+ authorizationLabel.setFont (Font (FontOptions (15.0f).withStyle ("Bold")));
+
+ addAndMakeVisible (statusLabel);
+ statusLabel.setText ("Not authorized", dontSendNotification);
+ statusLabel.setFont (Font (FontOptions (12.0)));
+ statusLabel.setColour (Label::textColourId, Colours::grey);
+
+ addAndMakeVisible (authorizeButton);
+ authorizeButton.setButtonText ("Sign in with Kushview.net");
+ authorizeButton.addListener (this);
+
+ addAndMakeVisible (signOutButton);
+ signOutButton.setButtonText ("Sign Out");
+ signOutButton.addListener (this);
+ signOutButton.setVisible (false);
+
+ updateAuthorizationState();
+ }
+
+ ~UpdatesSettingsPage()
+ {
+ world.settings().removeChangeListener (this);
+ releaseChannelBox.removeListener (this);
+ authorizeButton.removeListener (this);
+ signOutButton.removeListener (this);
+ }
+
+ void changeListenerCallback (juce::ChangeBroadcaster*) override
+ {
+ updateAuthorizationState();
+ }
+
+ void comboBoxChanged (ComboBox* box) override
+ {
+ if (box != &releaseChannelBox)
+ return;
+
+ const auto slug = channelSlugForId (releaseChannelBox.getSelectedId());
+ world.settings().setUpdateChannel (slug);
+
+ if (auto* g = world.services().find())
+ g->applyStoredChannelToUpdater();
+ }
+
+ void buttonClicked (Button* button) override
+ {
+ if (button == &authorizeButton)
+ {
+ startOAuthFlow();
+ }
+ else if (button == &signOutButton)
+ {
+ signOut();
+ }
+ }
+
+ void resized() override
+ {
+ const int spacingBetweenSections = 6;
+ const int settingHeight = 22;
+
+ Rectangle r (getLocalBounds());
+
+ // Release channel selector
+ auto r2 = r.removeFromTop (settingHeight);
+ releaseChannelLabel.setBounds (r2.removeFromLeft (getWidth() / 2));
+ releaseChannelBox.setBounds (r2.withSizeKeepingCentre (r2.getWidth(), settingHeight));
+
+ // Authorization section
+ r.removeFromTop (spacingBetweenSections * 2);
+ authorizationLabel.setBounds (r.removeFromTop (24));
+
+ r.removeFromTop (spacingBetweenSections);
+
+ // Status label
+ statusLabel.setBounds (r.removeFromTop (settingHeight));
+
+ // Buttons
+ r.removeFromTop (spacingBetweenSections);
+ auto buttonRow = r.removeFromTop (settingHeight);
+ authorizeButton.setBounds (buttonRow.removeFromLeft (180));
+ buttonRow.removeFromLeft (spacingBetweenSections);
+ signOutButton.setBounds (buttonRow.removeFromLeft (100));
+ }
+
+private:
+ Context& world;
+
+ Label releaseChannelLabel;
+ ComboBox releaseChannelBox;
+
+ Label authorizationLabel;
+ Label statusLabel;
+ TextButton authorizeButton;
+ TextButton signOutButton;
+
+ /** Converts a settings channel slug to its combo box item ID.
+ Add new channels both here and in the combo box initialiser.
+ @param slug "stable", "preview", or a future slug
+ @return Combo box ID (1 = Stable if slug is unrecognised) */
+ static int channelIdForSlug (const juce::String& slug)
+ {
+ if (slug == "preview")
+ return 2;
+ // if (slug == "testing") return 3; // reserved for future use
+ return 1;
+ }
+
+ /** Returns the settings slug stored for a given combo box item ID. */
+ static juce::String channelSlugForId (int id)
+ {
+ switch (id)
+ {
+ case 2:
+ return "preview";
+ // case 3: return "testing"; // reserved for future use
+ default:
+ return "stable";
+ }
+ }
+
+ /** Initiates the OAuth authorization flow.
+
+ Opens the user's default browser to kushview.net OAuth page.
+ The website will redirect back to the app via custom URL scheme
+ after successful authentication.
+ */
+ void startOAuthFlow()
+ {
+ auto* props = world.settings().getUserSettings();
+ if (props == nullptr)
+ {
+ Logger::writeToLog ("Auth: Unable to start authorization flow (missing user settings)");
+ return;
+ }
+
+ const auto state = auth::generateState();
+ const auto codeVerifier = auth::generateCodeVerifier();
+ if (! auth::storePendingPKCE (world.settings(), state, codeVerifier))
+ {
+ Logger::writeToLog ("Auth: Unable to start authorization flow (failed to store PKCE state)");
+ return;
+ }
+
+ const auto authUrl = auth::buildAuthorizationURL (state, codeVerifier);
+
+ URL (authUrl).launchInDefaultBrowser();
+
+ Logger::writeToLog ("Auth: Authorization flow started (PKCE)");
+ }
+
+ /** Signs out the user and clears stored tokens. */
+ void signOut()
+ {
+ Logger::writeToLog ("Auth: Signing out");
+
+ auto& settings = world.settings();
+
+ // Grab the refresh token before clearing it, then revoke server-side
+ // on a background thread (fire-and-forget).
+ const auto refreshToken = [&]() -> juce::String {
+ if (auto* props = settings.getUserSettings())
+ return props->getValue (auth::refreshTokenKey).trim();
+ return {};
+ }();
+
+ if (refreshToken.isNotEmpty())
+ {
+ juce::String tokenCopy = refreshToken;
+ std::thread ([tokenCopy]() {
+ auth::revokeRefreshToken (tokenCopy);
+ }).detach();
+ }
+
+ settings.setUpdateKey ({});
+ settings.setUpdateKeyUser ({});
+ settings.setUpdateKeyType ("element-v1");
+ settings.setAuthPreviewUpdates (false);
+ settings.setAuthAppcastUrl ({});
+ settings.setUpdateChannel ("stable");
+
+ // Revert the updater to the public stable feed.
+ if (auto* g = world.services().find())
+ g->applyStoredChannelToUpdater();
+
+ if (auto* props = settings.getUserSettings())
+ {
+ props->setValue (auth::refreshTokenKey, juce::String());
+ props->save();
+ }
+
+ // updateAuthorizationState() is triggered automatically via changeListenerCallback.
+ }
+
+ /** Updates the UI to reflect current authorization state from Settings. */
+ void updateAuthorizationState()
+ {
+ const auto& settings = world.settings();
+ const auto accessToken = settings.getUpdateKey().trim();
+ const auto userDisplay = settings.getUpdateKeyUser().trim();
+ const bool authorized = accessToken.isNotEmpty();
+ const bool canPreview = authorized && settings.getAuthPreviewUpdates();
+
+ if (authorized)
+ {
+ const auto label = userDisplay.isNotEmpty() ? userDisplay : accessToken.substring (0, 12) + "...";
+ statusLabel.setText ("Authorized as: " + label, dontSendNotification);
+ statusLabel.setColour (Label::textColourId, Colours::green);
+ authorizeButton.setVisible (false);
+ signOutButton.setVisible (true);
+ releaseChannelBox.setItemEnabled (2, canPreview); // Preview requires entitlement
+ }
+ else
+ {
+ statusLabel.setText ("Not authorized", dontSendNotification);
+ statusLabel.setColour (Label::textColourId, Colours::grey);
+ authorizeButton.setVisible (true);
+ signOutButton.setVisible (false);
+ releaseChannelBox.setItemEnabled (2, false); // Disable Preview
+
+ // Revert to Stable if a non-public channel was selected.
+ if (releaseChannelBox.getSelectedId() != 1)
+ {
+ releaseChannelBox.setSelectedId (1, dontSendNotification);
+ world.settings().setUpdateChannel ("stable");
+ if (auto* g = world.services().find())
+ g->applyStoredChannelToUpdater();
+ }
+ }
+ }
+};
+#endif
+
//==============================================================================
Preferences::Preferences (GuiService& ui)
: _context (ui.context()), _ui (ui)
@@ -1208,7 +1473,12 @@ Component* Preferences::createPageForName (const String& name)
{
return new OSCSettingsPage (_context, _ui);
}
-
+#if ELEMENT_UPDATER
+ else if (name == EL_REPOSITORY_PREFERENCE_NAME)
+ {
+ return new UpdatesSettingsPage (_context);
+ }
+#endif
return nullptr;
}
@@ -1223,6 +1493,9 @@ void Preferences::addDefaultPages()
#if ! ELEMENT_SE
addPage (EL_OSC_SETTINGS_NAME);
#endif
+#if ELEMENT_UPDATER
+ addPage (EL_REPOSITORY_PREFERENCE_NAME);
+#endif
setPage (EL_GENERAL_SETTINGS_NAME);
}
diff --git a/src/urlhandler.mm b/src/urlhandler.mm
new file mode 100644
index 000000000..083f5727f
--- /dev/null
+++ b/src/urlhandler.mm
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: 2026 Kushview, LLC
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#import
+#import
+#import
+
+#include
+#include
+
+using namespace juce;
+
+/** URL event handler that doesn't interfere with JUCE's delegate */
+@interface ElementURLHandler : NSObject
+@property (nonatomic, assign) element::Application* app;
+- (void)handleURLEvent:(NSAppleEventDescriptor*)event withReplyEvent:(NSAppleEventDescriptor*)replyEvent;
+@end
+
+@implementation ElementURLHandler
+
+- (void)handleURLEvent:(NSAppleEventDescriptor*)event withReplyEvent:(NSAppleEventDescriptor*)replyEvent
+{
+ NSString* urlString = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
+
+ if (urlString && _app)
+ {
+ String juceURL = String::fromUTF8([urlString UTF8String]);
+ auto* appPtr = _app;
+
+ MessageManager::callAsync([appPtr, juceURL]()
+ {
+ if (appPtr)
+ appPtr->handleURLSchemeCallback(juceURL); });
+ }
+}
+
+@end
+
+namespace element
+{
+
+static ElementURLHandler* urlHandler = nil;
+
+void Application::registerURLSchemeHandler()
+{
+ if (urlHandler == nil)
+ {
+ urlHandler = [[ElementURLHandler alloc] init];
+ urlHandler.app = this;
+
+ // Register with Apple Event Manager (doesn't interfere with JUCE's delegate)
+ NSAppleEventManager* appleEventManager = [NSAppleEventManager sharedAppleEventManager];
+ [appleEventManager setEventHandler:urlHandler
+ andSelector:@selector(handleURLEvent:withReplyEvent:)
+ forEventClass:kInternetEventClass
+ andEventID:kAEGetURL];
+
+ Logger::writeToLog("URL scheme handler (Apple Event Manager) registered");
+ }
+}
+
+void Application::unregisterURLSchemeHandler()
+{
+ if (urlHandler != nil)
+ {
+ NSAppleEventManager* appleEventManager = [NSAppleEventManager sharedAppleEventManager];
+ [appleEventManager removeEventHandlerForEventClass:kInternetEventClass
+ andEventID:kAEGetURL];
+
+ [urlHandler release];
+ urlHandler = nil;
+ }
+}
+
+} // namespace element
diff --git a/src/winsparkle.cc b/src/winsparkle.cc
index 8b7a5efc9..351e8ea9e 100644
--- a/src/winsparkle.cc
+++ b/src/winsparkle.cc
@@ -45,6 +45,15 @@ class WinSparkleUpdater : public Updater
#endif
}
+ void setFeedUrl (const std::string& url) override
+ {
+ if (url.empty())
+ win_sparkle_set_appcast_url (ELEMENT_APPCAST_URL);
+ else
+ win_sparkle_set_appcast_url (url.c_str());
+ ;
+ }
+
void check (bool async) override
{
juce::ignoreUnused (async);