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);