From a953af8e0ce26cea1faca46edc80fcc4fc993a37 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Thu, 19 Feb 2026 07:45:54 -0500 Subject: [PATCH 01/12] prefs: add wireframe updates/authorize screen --- src/ui/preferences.cpp | 89 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/ui/preferences.cpp b/src/ui/preferences.cpp index 4509eb64e..9533a4a00 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -1138,6 +1138,90 @@ class MidiSettingsPage : public SettingsPage, } }; +//============================================================================== +class UpdatesSettingsPage : public SettingsPage +{ +public: + UpdatesSettingsPage (Context& w) + : world (w) + { + 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); + releaseChannelBox.setSelectedId (1, dontSendNotification); + + addAndMakeVisible (authorizationLabel); + authorizationLabel.setText ("Authorization", dontSendNotification); + authorizationLabel.setFont (Font (FontOptions (15.0f).withStyle ("Bold"))); + + addAndMakeVisible (usernameLabel); + usernameLabel.setText ("Username", dontSendNotification); + usernameLabel.setFont (Font (FontOptions (12.0, Font::bold))); + + addAndMakeVisible (usernameField); + usernameField.setTextToShowWhenEmpty ("Enter username", Colours::grey); + + addAndMakeVisible (passwordLabel); + passwordLabel.setText ("Password", dontSendNotification); + passwordLabel.setFont (Font (FontOptions (12.0, Font::bold))); + + addAndMakeVisible (passwordField); + passwordField.setPasswordCharacter ('*'); + passwordField.setTextToShowWhenEmpty ("Enter password", Colours::grey); + + addAndMakeVisible (authorizeButton); + authorizeButton.setButtonText ("Authorize"); + } + + ~UpdatesSettingsPage() {} + + 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); + + // Username + layoutSetting (r, usernameLabel, usernameField, getWidth() / 2); + + // Password + layoutSetting (r, passwordLabel, passwordField, getWidth() / 2); + + // Authorize button + r.removeFromTop (spacingBetweenSections); + authorizeButton.setBounds (r.removeFromTop (settingHeight).removeFromLeft (100)); + } + +private: + Context& world; + + Label releaseChannelLabel; + ComboBox releaseChannelBox; + + Label authorizationLabel; + Label usernameLabel; + TextEditor usernameField; + Label passwordLabel; + TextEditor passwordField; + TextButton authorizeButton; +}; + //============================================================================== Preferences::Preferences (GuiService& ui) : _context (ui.context()), _ui (ui) @@ -1208,6 +1292,10 @@ Component* Preferences::createPageForName (const String& name) { return new OSCSettingsPage (_context, _ui); } + else if (name == EL_REPOSITORY_PREFERENCE_NAME) + { + return new UpdatesSettingsPage (_context); + } return nullptr; } @@ -1223,6 +1311,7 @@ void Preferences::addDefaultPages() #if ! ELEMENT_SE addPage (EL_OSC_SETTINGS_NAME); #endif + addPage (EL_REPOSITORY_PREFERENCE_NAME); setPage (EL_GENERAL_SETTINGS_NAME); } From 6037de53afef64fa9e05c43d48cec0306901262f Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Thu, 19 Feb 2026 08:03:37 -0500 Subject: [PATCH 02/12] refactor: reorganize includes and clean up whitespace in preferences.cpp --- src/ui/preferences.cpp | 62 +++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/src/ui/preferences.cpp b/src/ui/preferences.cpp index 9533a4a00..eae92bea1 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -1,22 +1,23 @@ // 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 "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 "services/oscservice.hpp" +#include "ui/buttons.hpp" +#include "ui/viewhelpers.hpp" namespace element { @@ -1139,7 +1140,8 @@ class MidiSettingsPage : public SettingsPage, }; //============================================================================== -class UpdatesSettingsPage : public SettingsPage +#if ELEMENT_UPDATER +class UpdatesSettingsPage : public SettingsPage { public: UpdatesSettingsPage (Context& w) @@ -1148,72 +1150,72 @@ class UpdatesSettingsPage : public SettingsPage 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); releaseChannelBox.setSelectedId (1, dontSendNotification); - + addAndMakeVisible (authorizationLabel); authorizationLabel.setText ("Authorization", dontSendNotification); authorizationLabel.setFont (Font (FontOptions (15.0f).withStyle ("Bold"))); - + addAndMakeVisible (usernameLabel); usernameLabel.setText ("Username", dontSendNotification); usernameLabel.setFont (Font (FontOptions (12.0, Font::bold))); - + addAndMakeVisible (usernameField); usernameField.setTextToShowWhenEmpty ("Enter username", Colours::grey); - + addAndMakeVisible (passwordLabel); passwordLabel.setText ("Password", dontSendNotification); passwordLabel.setFont (Font (FontOptions (12.0, Font::bold))); - + addAndMakeVisible (passwordField); passwordField.setPasswordCharacter ('*'); passwordField.setTextToShowWhenEmpty ("Enter password", Colours::grey); - + addAndMakeVisible (authorizeButton); authorizeButton.setButtonText ("Authorize"); } - + ~UpdatesSettingsPage() {} - + 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); - + // Username layoutSetting (r, usernameLabel, usernameField, getWidth() / 2); - + // Password layoutSetting (r, passwordLabel, passwordField, getWidth() / 2); - + // Authorize button r.removeFromTop (spacingBetweenSections); authorizeButton.setBounds (r.removeFromTop (settingHeight).removeFromLeft (100)); } - + private: Context& world; - + Label releaseChannelLabel; ComboBox releaseChannelBox; - + Label authorizationLabel; Label usernameLabel; TextEditor usernameField; @@ -1221,6 +1223,7 @@ class UpdatesSettingsPage : public SettingsPage TextEditor passwordField; TextButton authorizeButton; }; +#endif //============================================================================== Preferences::Preferences (GuiService& ui) @@ -1292,11 +1295,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; } @@ -1311,7 +1315,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); } From ec5e701b8ed1ec6a529549db8364d42f5bb29241 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Thu, 19 Feb 2026 08:16:18 -0500 Subject: [PATCH 03/12] feat: implement OAuth authorization flow in UpdatesSettingsPage --- src/ui/preferences.cpp | 154 +++++++++++++++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 27 deletions(-) diff --git a/src/ui/preferences.cpp b/src/ui/preferences.cpp index eae92bea1..8ba095328 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -1141,7 +1141,8 @@ class MidiSettingsPage : public SettingsPage, //============================================================================== #if ELEMENT_UPDATER -class UpdatesSettingsPage : public SettingsPage +class UpdatesSettingsPage : public SettingsPage, + public Button::Listener { public: UpdatesSettingsPage (Context& w) @@ -1157,29 +1158,43 @@ class UpdatesSettingsPage : public SettingsPage releaseChannelBox.setSelectedId (1, dontSendNotification); addAndMakeVisible (authorizationLabel); - authorizationLabel.setText ("Authorization", dontSendNotification); + authorizationLabel.setText ("Preview Access", dontSendNotification); authorizationLabel.setFont (Font (FontOptions (15.0f).withStyle ("Bold"))); - addAndMakeVisible (usernameLabel); - usernameLabel.setText ("Username", dontSendNotification); - usernameLabel.setFont (Font (FontOptions (12.0, Font::bold))); + addAndMakeVisible (statusLabel); + statusLabel.setText ("Not authorized", dontSendNotification); + statusLabel.setFont (Font (FontOptions (12.0))); + statusLabel.setColour (Label::textColourId, Colours::grey); - addAndMakeVisible (usernameField); - usernameField.setTextToShowWhenEmpty ("Enter username", Colours::grey); + addAndMakeVisible (authorizeButton); + authorizeButton.setButtonText ("Sign in with Kushview.net"); + authorizeButton.addListener (this); - addAndMakeVisible (passwordLabel); - passwordLabel.setText ("Password", dontSendNotification); - passwordLabel.setFont (Font (FontOptions (12.0, Font::bold))); + addAndMakeVisible (signOutButton); + signOutButton.setButtonText ("Sign Out"); + signOutButton.addListener (this); + signOutButton.setVisible (false); - addAndMakeVisible (passwordField); - passwordField.setPasswordCharacter ('*'); - passwordField.setTextToShowWhenEmpty ("Enter password", Colours::grey); + updateAuthorizationState(); + } - addAndMakeVisible (authorizeButton); - authorizeButton.setButtonText ("Authorize"); + ~UpdatesSettingsPage() + { + authorizeButton.removeListener (this); + signOutButton.removeListener (this); } - ~UpdatesSettingsPage() {} + void buttonClicked (Button* button) override + { + if (button == &authorizeButton) + { + startOAuthFlow(); + } + else if (button == &signOutButton) + { + signOut(); + } + } void resized() override { @@ -1199,15 +1214,15 @@ class UpdatesSettingsPage : public SettingsPage r.removeFromTop (spacingBetweenSections); - // Username - layoutSetting (r, usernameLabel, usernameField, getWidth() / 2); + // Status label + statusLabel.setBounds (r.removeFromTop (settingHeight)); - // Password - layoutSetting (r, passwordLabel, passwordField, getWidth() / 2); - - // Authorize button + // Buttons r.removeFromTop (spacingBetweenSections); - authorizeButton.setBounds (r.removeFromTop (settingHeight).removeFromLeft (100)); + auto buttonRow = r.removeFromTop (settingHeight); + authorizeButton.setBounds (buttonRow.removeFromLeft (180)); + buttonRow.removeFromLeft (spacingBetweenSections); + signOutButton.setBounds (buttonRow.removeFromLeft (100)); } private: @@ -1217,11 +1232,96 @@ class UpdatesSettingsPage : public SettingsPage ComboBox releaseChannelBox; Label authorizationLabel; - Label usernameLabel; - TextEditor usernameField; - Label passwordLabel; - TextEditor passwordField; + Label statusLabel; TextButton authorizeButton; + TextButton signOutButton; + + /** 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() + { + // TODO: Implement OAuth flow + // 1. Generate state parameter for CSRF protection + // 2. Build authorization URL with client_id, redirect_uri, scope + // 3. Launch in default browser + // 4. Register URL handler to receive callback + + const String authUrl = "https://kushview.net/oauth/authorize?" + "client_id=element-app&" + "redirect_uri=element://auth/callback&" + "response_type=code&" + "scope=updates"; + + URL (authUrl).launchInDefaultBrowser(); + + Logger::writeToLog ("OAuth: Authorization flow started"); + } + + /** Handles the OAuth callback with authorization code. + + Exchanges the authorization code for an access token and + refresh token, then stores them securely. + + @param code The authorization code received from the OAuth callback + */ + void handleOAuthCallback (const String& code) + { + // TODO: Implement token exchange + // 1. POST to token endpoint with code + // 2. Parse response to get access_token and refresh_token + // 3. Store tokens securely (keychain/credential manager) + // 4. Update UI and configure updater + + Logger::writeToLog ("OAuth: Received authorization code: " + code); + + // For now, just update UI as if authorized + updateAuthorizationState (true, "user@example.com"); + } + + /** Signs out the user and clears stored tokens. */ + void signOut() + { + // TODO: Implement sign out + // 1. Clear stored tokens from keychain/credential manager + // 2. Reset updater to use stable channel URL + // 3. Update UI + + Logger::writeToLog ("OAuth: Signing out"); + updateAuthorizationState (false); + } + + /** Updates the UI to reflect current authorization state. + + @param authorized Whether the user is currently authorized + @param email Optional email address to display when authorized + */ + void updateAuthorizationState (bool authorized = false, const String& email = String()) + { + if (authorized) + { + statusLabel.setText ("Authorized as: " + email, dontSendNotification); + statusLabel.setColour (Label::textColourId, Colours::green); + authorizeButton.setVisible (false); + signOutButton.setVisible (true); + releaseChannelBox.setItemEnabled (2, true); // Enable Preview + } + else + { + statusLabel.setText ("Not authorized", dontSendNotification); + statusLabel.setColour (Label::textColourId, Colours::grey); + authorizeButton.setVisible (true); + signOutButton.setVisible (false); + releaseChannelBox.setItemEnabled (2, false); // Disable Preview + + // Switch to Stable if Preview was selected + if (releaseChannelBox.getSelectedId() == 2) + releaseChannelBox.setSelectedId (1, dontSendNotification); + } + } }; #endif From cf4d6c0929150d85f7a4017bf6383bc19d8d68a1 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Fri, 20 Feb 2026 03:37:09 -0500 Subject: [PATCH 04/12] feat: implement OAuth URL handling and registration for custom URL scheme --- cmake/FindSparkle.cmake | 11 ++++ include/element/application.hpp | 19 +++++- src/CMakeLists.txt | 5 +- src/application.cpp | 100 ++++++++++++++++++++++++++++++-- src/services/sessionservice.cpp | 10 ++-- src/ui/preferences.cpp | 20 ++++--- src/urlhandler.mm | 80 +++++++++++++++++++++++++ 7 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 src/urlhandler.mm 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 535da035e..a6a1855fb 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; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a272bfc2a..95e93527d 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 95171d07d..6daa115e7 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -221,7 +221,21 @@ void Application::initialise (const String& commandLine) initializeModulePath(); printCopyNotice(); + + Logger::writeToLog ("=== Command line: " + commandLine); + +#if JUCE_MAC + registerURLSchemeHandler(); +#endif + launchApplication(); + + // Handle URL scheme if passed on command line + if (commandLine.startsWith ("element://")) + { + Logger::writeToLog ("=== Handling URL from command line: " + commandLine); + handleURLSchemeCallback (commandLine); + } } void Application::actionListenerCallback (const String& message) @@ -232,16 +246,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; @@ -251,6 +266,11 @@ void Application::shutdown() { if (! world) return; + +#if JUCE_MAC + unregisterURLSchemeHandler(); +#endif + #if JUCE_LINUX applyMidiSettings.reset(); #endif @@ -349,9 +369,79 @@ void Application::anotherInstanceStarted (const String& commandLine) if (! world) return; + // Handle custom URL scheme callbacks (e.g., OAuth) + if (commandLine.startsWith ("element://")) + { + handleURLSchemeCallback (commandLine); + return; + } + maybeOpenCommandLineFile (commandLine); } +void Application::handleURLSchemeCallback (const String& urlString) +{ + Logger::setCurrentLogger (nullptr); + Logger::writeToLog ("=== handleURLSchemeCallback called with: " + urlString); + + URL url (urlString); + + // Handle OAuth callback: element://auth/callback?code=... + // Note: getSubPath() returns "callback" without leading slash + if (url.getDomain() == "auth" && url.getSubPath() == "callback") + { + Logger::writeToLog ("=== Detected OAuth callback"); + + // Bring main window to front + if (world) + { + if (auto* gui = world->services().find()) + { + if (auto* mainWindow = gui->getMainWindow()) + { + mainWindow->toFront (true); + Logger::writeToLog ("=== Brought main window to front"); + } + } + } + + // Parse query parameters from URL + StringPairArray parameters; + String query = url.toString (true).fromFirstOccurrenceOf ("?", false, false); + + // Parse key=value pairs + StringArray pairs = StringArray::fromTokens (query, "&", ""); + for (const auto& pair : pairs) + { + int equalsPos = pair.indexOf ("="); + if (equalsPos > 0) + { + String key = pair.substring (0, equalsPos); + String value = pair.substring (equalsPos + 1); + parameters.set (key, URL::removeEscapeChars (value)); + } + } + + String authCode = parameters["code"]; + if (authCode.isNotEmpty()) + { + Logger::writeToLog ("=== Authorization code: " + authCode); + std::cout << "OAuth: Received authorization code: " << authCode << std::endl; + // TODO: Notify the preferences page or handle token exchange here + } + else + { + String error = parameters["error"]; + Logger::writeToLog ("=== OAuth error: " + error); + std::cout << "OAuth error: " << error << std::endl; + } + } + else + { + Logger::writeToLog ("=== Not an OAuth callback, domain=" + url.getDomain() + " path=" + url.getSubPath()); + } +} + void Application::suspended() {} void Application::resumed() @@ -368,7 +458,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(); 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/ui/preferences.cpp b/src/ui/preferences.cpp index 8ba095328..9d78a12d5 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -1249,15 +1249,17 @@ class UpdatesSettingsPage : public SettingsPage, // 2. Build authorization URL with client_id, redirect_uri, scope // 3. Launch in default browser // 4. Register URL handler to receive callback - + + // WP OAuth Server endpoint - may need adjustment based on plugin configuration + // Could be /oauth/authorize or /wp-json/oauth/authorize const String authUrl = "https://kushview.net/oauth/authorize?" - "client_id=element-app&" + "client_id=wX6ESifSO3MpHmnSwqevJYYMT9oTVVxi3oYteiUF&" "redirect_uri=element://auth/callback&" "response_type=code&" - "scope=updates"; - + "scope=basic"; // WP OAuth Server default scope + URL (authUrl).launchInDefaultBrowser(); - + Logger::writeToLog ("OAuth: Authorization flow started"); } @@ -1275,9 +1277,9 @@ class UpdatesSettingsPage : public SettingsPage, // 2. Parse response to get access_token and refresh_token // 3. Store tokens securely (keychain/credential manager) // 4. Update UI and configure updater - + Logger::writeToLog ("OAuth: Received authorization code: " + code); - + // For now, just update UI as if authorized updateAuthorizationState (true, "user@example.com"); } @@ -1289,7 +1291,7 @@ class UpdatesSettingsPage : public SettingsPage, // 1. Clear stored tokens from keychain/credential manager // 2. Reset updater to use stable channel URL // 3. Update UI - + Logger::writeToLog ("OAuth: Signing out"); updateAuthorizationState (false); } @@ -1316,7 +1318,7 @@ class UpdatesSettingsPage : public SettingsPage, authorizeButton.setVisible (true); signOutButton.setVisible (false); releaseChannelBox.setItemEnabled (2, false); // Disable Preview - + // Switch to Stable if Preview was selected if (releaseChannelBox.getSelectedId() == 2) releaseChannelBox.setSelectedId (1, dontSendNotification); diff --git a/src/urlhandler.mm b/src/urlhandler.mm new file mode 100644 index 000000000..70525a1cd --- /dev/null +++ b/src/urlhandler.mm @@ -0,0 +1,80 @@ +// 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]; + + NSLog(@"*** ELEMENT: URL Event Handler called with: %@ ***", urlString); + + if (urlString && _app) + { + String juceURL = String::fromUTF8([urlString UTF8String]); + auto* appPtr = _app; + + MessageManager::callAsync([appPtr, juceURL]() + { + if (appPtr) + { + NSLog(@"*** ELEMENT: Dispatching to handleURLSchemeCallback ***"); + 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 From 76fcd8d6fd6c06a92b83604983e1865d05ae3c17 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Fri, 20 Feb 2026 03:44:39 -0500 Subject: [PATCH 05/12] refactor: remove unnecessary logging in URL handling --- src/application.cpp | 25 ------------------------- src/urlhandler.mm | 7 +------ 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/src/application.cpp b/src/application.cpp index 6daa115e7..03e5a4eed 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -222,8 +222,6 @@ void Application::initialise (const String& commandLine) initializeModulePath(); printCopyNotice(); - Logger::writeToLog ("=== Command line: " + commandLine); - #if JUCE_MAC registerURLSchemeHandler(); #endif @@ -232,10 +230,7 @@ void Application::initialise (const String& commandLine) // Handle URL scheme if passed on command line if (commandLine.startsWith ("element://")) - { - Logger::writeToLog ("=== Handling URL from command line: " + commandLine); handleURLSchemeCallback (commandLine); - } } void Application::actionListenerCallback (const String& message) @@ -381,27 +376,19 @@ void Application::anotherInstanceStarted (const String& commandLine) void Application::handleURLSchemeCallback (const String& urlString) { - Logger::setCurrentLogger (nullptr); - Logger::writeToLog ("=== handleURLSchemeCallback called with: " + urlString); - URL url (urlString); // Handle OAuth callback: element://auth/callback?code=... // Note: getSubPath() returns "callback" without leading slash if (url.getDomain() == "auth" && url.getSubPath() == "callback") { - Logger::writeToLog ("=== Detected OAuth callback"); - // Bring main window to front if (world) { if (auto* gui = world->services().find()) { if (auto* mainWindow = gui->getMainWindow()) - { mainWindow->toFront (true); - Logger::writeToLog ("=== Brought main window to front"); - } } } @@ -425,20 +412,8 @@ void Application::handleURLSchemeCallback (const String& urlString) String authCode = parameters["code"]; if (authCode.isNotEmpty()) { - Logger::writeToLog ("=== Authorization code: " + authCode); - std::cout << "OAuth: Received authorization code: " << authCode << std::endl; // TODO: Notify the preferences page or handle token exchange here } - else - { - String error = parameters["error"]; - Logger::writeToLog ("=== OAuth error: " + error); - std::cout << "OAuth error: " << error << std::endl; - } - } - else - { - Logger::writeToLog ("=== Not an OAuth callback, domain=" + url.getDomain() + " path=" + url.getSubPath()); } } diff --git a/src/urlhandler.mm b/src/urlhandler.mm index 70525a1cd..083f5727f 100644 --- a/src/urlhandler.mm +++ b/src/urlhandler.mm @@ -22,8 +22,6 @@ - (void)handleURLEvent:(NSAppleEventDescriptor*)event withReplyEvent:(NSAppleEve { NSString* urlString = [[event paramDescriptorForKeyword:keyDirectObject] stringValue]; - NSLog(@"*** ELEMENT: URL Event Handler called with: %@ ***", urlString); - if (urlString && _app) { String juceURL = String::fromUTF8([urlString UTF8String]); @@ -32,10 +30,7 @@ - (void)handleURLEvent:(NSAppleEventDescriptor*)event withReplyEvent:(NSAppleEve MessageManager::callAsync([appPtr, juceURL]() { if (appPtr) - { - NSLog(@"*** ELEMENT: Dispatching to handleURLSchemeCallback ***"); - appPtr->handleURLSchemeCallback(juceURL); - } }); + appPtr->handleURLSchemeCallback(juceURL); }); } } From 0a8cd70b1eefb6f2cb912e82e91cf5e75bc2f357 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Sat, 21 Feb 2026 23:51:40 -0500 Subject: [PATCH 06/12] auth: implement simple token exchange. --- include/element/settings.hpp | 3 +- src/application.cpp | 49 ++++-- src/auth.cpp | 293 +++++++++++++++++++++++++++++++++++ src/auth.hpp | 112 +++++++++++++ src/settings.cpp | 1 + src/ui/preferences.cpp | 97 ++++++------ 6 files changed, 490 insertions(+), 65 deletions(-) create mode 100644 src/auth.cpp create mode 100644 src/auth.hpp diff --git a/include/element/settings.hpp b/include/element/settings.hpp index 680328c00..1ddc7d34f 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(); diff --git a/src/application.cpp b/src/application.cpp index 03e5a4eed..2ad69e507 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. @@ -364,7 +365,7 @@ void Application::anotherInstanceStarted (const String& commandLine) if (! world) return; - // Handle custom URL scheme callbacks (e.g., OAuth) + // Handle custom URL scheme callbacks (e.g., auth) if (commandLine.startsWith ("element://")) { handleURLSchemeCallback (commandLine); @@ -378,7 +379,7 @@ void Application::handleURLSchemeCallback (const String& urlString) { URL url (urlString); - // Handle OAuth callback: element://auth/callback?code=... + // Handle auth callback: element://auth/callback?code=... // Note: getSubPath() returns "callback" without leading slash if (url.getDomain() == "auth" && url.getSubPath() == "callback") { @@ -392,27 +393,43 @@ void Application::handleURLSchemeCallback (const String& urlString) } } - // Parse query parameters from URL - StringPairArray parameters; - String query = url.toString (true).fromFirstOccurrenceOf ("?", false, false); + const auto parameters = auth::parseQueryParameters (url.toString (true)); - // Parse key=value pairs - StringArray pairs = StringArray::fromTokens (query, "&", ""); - for (const auto& pair : pairs) + const auto authError = parameters["error"]; + if (authError.isNotEmpty()) { - int equalsPos = pair.indexOf ("="); - if (equalsPos > 0) - { - String key = pair.substring (0, equalsPos); - String value = pair.substring (equalsPos + 1); - parameters.set (key, URL::removeEscapeChars (value)); - } + Logger::writeToLog ("Auth callback error: " + authError); + return; } String authCode = parameters["code"]; if (authCode.isNotEmpty()) { - // TODO: Notify the preferences page or handle token exchange here + 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"); } } } diff --git a/src/auth.cpp b/src/auth.cpp new file mode 100644 index 000000000..a3721bc33 --- /dev/null +++ b/src/auth.cpp @@ -0,0 +1,293 @@ +// 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; + } + + 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); + + if (auto* props = settings.getUserSettings()) + props->setValue (refreshTokenKey, tokenResponse.refreshToken); +} + +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; +} + +} // namespace element::auth diff --git a/src/auth.hpp b/src/auth.hpp new file mode 100644 index 000000000..40581b391 --- /dev/null +++ b/src/auth.hpp @@ -0,0 +1,112 @@ +// 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"; + +/** 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; +}; + +/** 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); + +} // namespace element::auth diff --git a/src/settings.cpp b/src/settings.cpp index 02030e29d..18c7cd87d 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -480,6 +480,7 @@ void Settings::setUpdateKey (const String& slug) { if (auto p = getProps()) p->setValue (updateKeyKey, slug.trim()); + sendChangeMessage(); } juce::String Settings::getUpdateChannel() const diff --git a/src/ui/preferences.cpp b/src/ui/preferences.cpp index 9d78a12d5..3164109be 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -15,6 +15,7 @@ #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" @@ -1142,12 +1143,14 @@ class MidiSettingsPage : public SettingsPage, //============================================================================== #if ELEMENT_UPDATER class UpdatesSettingsPage : public SettingsPage, - public Button::Listener + public Button::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))); @@ -1180,10 +1183,16 @@ class UpdatesSettingsPage : public SettingsPage, ~UpdatesSettingsPage() { + world.settings().removeChangeListener (this); authorizeButton.removeListener (this); signOutButton.removeListener (this); } + void changeListenerCallback (juce::ChangeBroadcaster*) override + { + updateAuthorizationState(); + } + void buttonClicked (Button* button) override { if (button == &authorizeButton) @@ -1244,68 +1253,60 @@ class UpdatesSettingsPage : public SettingsPage, */ void startOAuthFlow() { - // TODO: Implement OAuth flow - // 1. Generate state parameter for CSRF protection - // 2. Build authorization URL with client_id, redirect_uri, scope - // 3. Launch in default browser - // 4. Register URL handler to receive callback - - // WP OAuth Server endpoint - may need adjustment based on plugin configuration - // Could be /oauth/authorize or /wp-json/oauth/authorize - const String authUrl = "https://kushview.net/oauth/authorize?" - "client_id=wX6ESifSO3MpHmnSwqevJYYMT9oTVVxi3oYteiUF&" - "redirect_uri=element://auth/callback&" - "response_type=code&" - "scope=basic"; // WP OAuth Server default scope + auto* props = world.settings().getUserSettings(); + if (props == nullptr) + { + Logger::writeToLog ("Auth: Unable to start authorization flow (missing user settings)"); + return; + } - URL (authUrl).launchInDefaultBrowser(); + 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; + } - Logger::writeToLog ("OAuth: Authorization flow started"); - } + const auto authUrl = auth::buildAuthorizationURL (state, codeVerifier); - /** Handles the OAuth callback with authorization code. - - Exchanges the authorization code for an access token and - refresh token, then stores them securely. - - @param code The authorization code received from the OAuth callback - */ - void handleOAuthCallback (const String& code) - { - // TODO: Implement token exchange - // 1. POST to token endpoint with code - // 2. Parse response to get access_token and refresh_token - // 3. Store tokens securely (keychain/credential manager) - // 4. Update UI and configure updater - - Logger::writeToLog ("OAuth: Received authorization code: " + code); + std::cout << "authUrl=" << authUrl << std::endl; + URL (authUrl).launchInDefaultBrowser(); - // For now, just update UI as if authorized - updateAuthorizationState (true, "user@example.com"); + Logger::writeToLog ("Auth: Authorization flow started (PKCE)"); } /** Signs out the user and clears stored tokens. */ void signOut() { - // TODO: Implement sign out - // 1. Clear stored tokens from keychain/credential manager - // 2. Reset updater to use stable channel URL - // 3. Update UI + Logger::writeToLog ("Auth: Signing out"); + + auto& settings = world.settings(); + settings.setUpdateKey ({}); + settings.setUpdateKeyUser ({}); + settings.setUpdateKeyType ("element-v1"); - Logger::writeToLog ("OAuth: Signing out"); - updateAuthorizationState (false); + 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. - - @param authorized Whether the user is currently authorized - @param email Optional email address to display when authorized - */ - void updateAuthorizationState (bool authorized = false, const String& email = String()) + /** 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(); + if (authorized) { - statusLabel.setText ("Authorized as: " + email, dontSendNotification); + 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); From c0c5b2cd65dd7cf7ef6b31efa10ff2fc528087ed Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Sun, 22 Feb 2026 20:36:00 -0500 Subject: [PATCH 07/12] auth: wip: retrieve signed appcast URL. --- include/element/application.hpp | 19 ++++-- include/element/settings.hpp | 14 ++++ include/element/ui.hpp | 4 ++ src/application.cpp | 13 ++++ src/auth.cpp | 111 +++++++++++++++++++++++++++++++- src/auth.hpp | 60 +++++++++++++---- src/services/guiservice.cpp | 23 +++++++ src/settings.cpp | 30 +++++++++ src/ui/preferences.cpp | 33 ++++++++-- 9 files changed, 286 insertions(+), 21 deletions(-) diff --git a/include/element/application.hpp b/include/element/application.hpp index a6a1855fb..b7b0fc5d9 100644 --- a/include/element/application.hpp +++ b/include/element/application.hpp @@ -141,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 1ddc7d34f..d4c56f151 100644 --- a/include/element/settings.hpp +++ b/include/element/settings.hpp @@ -50,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; @@ -154,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..c821b8c30 100644 --- a/include/element/ui.hpp +++ b/include/element/ui.hpp @@ -43,6 +43,10 @@ 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); + Services& services() const { return controller; } juce::KeyListener* getKeyListener() const; diff --git a/src/application.cpp b/src/application.cpp index 2ad69e507..299563c2b 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -274,6 +274,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(); @@ -459,12 +463,21 @@ 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()); +} + void Application::printCopyNotice() { String appName = Util::appName(); diff --git a/src/auth.cpp b/src/auth.cpp index a3721bc33..f28e6f64a 100644 --- a/src/auth.cpp +++ b/src/auth.cpp @@ -7,7 +7,7 @@ #include #if JUCE_MAC - #include +#include #endif namespace element::auth { @@ -86,6 +86,10 @@ static TokenResponse parseTokenPayload (const String& payload, const String& err 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; } @@ -271,9 +275,15 @@ void persistTokens (element::Settings& settings, const TokenResponse& tokenRespo 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) @@ -290,4 +300,103 @@ bool consumePendingPKCE (element::Settings& settings, String& state, String& ver return false; } +String fetchSignedAppcastUrl (const String& accessToken) +{ + if (accessToken.isEmpty()) + return {}; + + const String endpoint = String (apiBaseEndpoint) + "/appcast-url"; + 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(); + std::cout << "URL: " << appcastUrl << std::endl; + 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 index 40581b391..a9ae81cf5 100644 --- a/src/auth.hpp +++ b/src/auth.hpp @@ -48,15 +48,22 @@ 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; +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. */ @@ -67,12 +74,12 @@ juce::String generateCodeVerifier(); /** Builds the browser authorization URL for sign-in. */ juce::String buildAuthorizationURL (const juce::String& state, - const juce::String& codeVerifier); + 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); + const juce::String& state, + const juce::String& verifier); /** Parses URL query key/value pairs from a callback URL string. @@ -88,7 +95,7 @@ juce::StringPairArray parseQueryParameters (const juce::String& urlString); @return Parsed token response and error details */ TokenResponse exchangeAuthorizationCode (const juce::String& authCode, - const juce::String& codeVerifier); + const juce::String& codeVerifier); /** Refreshes an access token using refresh token rotation semantics. */ TokenResponse refreshAccessToken (const juce::String& refreshToken); @@ -109,4 +116,33 @@ void persistTokens (element::Settings& settings, const TokenResponse& tokenRespo */ 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 1d3e9357f..07ee71897 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,12 @@ void GuiService::activate() SystemTray::init (*this); context().devices().addChangeListener (this); impl->restoreRecents(); + + // Apply any cached signed appcast URL so the updater points at the + // correct channel (stable vs preview) immediately on launch. + const auto storedAppcastUrl = settings().getAuthAppcastUrl(); + if (storedAppcastUrl.isNotEmpty()) + setUpdaterFeedUrl (storedAppcastUrl); } void GuiService::deactivate() @@ -398,6 +416,11 @@ void GuiService::checkUpdates (bool background) #endif } +void GuiService::setUpdaterFeedUrl (const juce::String& url) +{ + updates->setFeedUrl (url); +} + void GuiService::showPreferencesDialog (const String& section) { if (auto* const dialog = windowManager->findDialogByName ("Preferences")) diff --git a/src/settings.cpp b/src/settings.cpp index 18c7cd87d..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"; //============================================================================= @@ -496,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 3164109be..346bb4b13 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -1,6 +1,8 @@ // Copyright 2023 Kushview, LLC // SPDX-License-Identifier: GPL-3.0-or-later +#include + #include #include #include @@ -1270,7 +1272,6 @@ class UpdatesSettingsPage : public SettingsPage, const auto authUrl = auth::buildAuthorizationURL (state, codeVerifier); - std::cout << "authUrl=" << authUrl << std::endl; URL (authUrl).launchInDefaultBrowser(); Logger::writeToLog ("Auth: Authorization flow started (PKCE)"); @@ -1282,9 +1283,32 @@ class UpdatesSettingsPage : public SettingsPage, 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 ({}); + + // Revert the updater to the compiled-in default feed URL. + if (auto* g = world.services().find()) + g->setUpdaterFeedUrl ({}); if (auto* props = settings.getUserSettings()) { @@ -1298,10 +1322,11 @@ class UpdatesSettingsPage : public SettingsPage, /** Updates the UI to reflect current authorization state from Settings. */ void updateAuthorizationState() { - const auto& settings = world.settings(); + const auto& settings = world.settings(); const auto accessToken = settings.getUpdateKey().trim(); const auto userDisplay = settings.getUpdateKeyUser().trim(); - const bool authorized = accessToken.isNotEmpty(); + const bool authorized = accessToken.isNotEmpty(); + const bool canPreview = authorized && settings.getAuthPreviewUpdates(); if (authorized) { @@ -1310,7 +1335,7 @@ class UpdatesSettingsPage : public SettingsPage, statusLabel.setColour (Label::textColourId, Colours::green); authorizeButton.setVisible (false); signOutButton.setVisible (true); - releaseChannelBox.setItemEnabled (2, true); // Enable Preview + releaseChannelBox.setItemEnabled (2, canPreview); // Preview requires entitlement } else { From 1bc566249668ff7e8c54e8b493c9a48760c93fdd Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Sun, 22 Feb 2026 21:32:35 -0500 Subject: [PATCH 08/12] feat: implement applyStoredChannelToUpdater method and integrate it into the application flow --- include/element/ui.hpp | 5 ++++ src/application.cpp | 7 +++++ src/auth.cpp | 1 - src/services/guiservice.cpp | 17 +++++++---- src/ui/preferences.cpp | 60 +++++++++++++++++++++++++++++++++---- src/winsparkle.cc | 9 ++++++ 6 files changed, 88 insertions(+), 11 deletions(-) diff --git a/include/element/ui.hpp b/include/element/ui.hpp index c821b8c30..50d8a6fd2 100644 --- a/include/element/ui.hpp +++ b/include/element/ui.hpp @@ -47,6 +47,11 @@ class GuiService : public Service, 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/application.cpp b/src/application.cpp index 299563c2b..619a135fe 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -476,6 +476,13 @@ void Application::finishLaunching() 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() diff --git a/src/auth.cpp b/src/auth.cpp index f28e6f64a..050004aa9 100644 --- a/src/auth.cpp +++ b/src/auth.cpp @@ -326,7 +326,6 @@ String fetchSignedAppcastUrl (const String& accessToken) const auto body = stream->readEntireStreamAsString(); auto json = JSON::parse (body); const auto appcastUrl = json["url"].toString(); - std::cout << "URL: " << appcastUrl << std::endl; if (appcastUrl.isNotEmpty()) Logger::writeToLog ("Auth: fetched signed appcast URL"); else diff --git a/src/services/guiservice.cpp b/src/services/guiservice.cpp index 07ee71897..d23246fa1 100644 --- a/src/services/guiservice.cpp +++ b/src/services/guiservice.cpp @@ -357,11 +357,8 @@ void GuiService::activate() context().devices().addChangeListener (this); impl->restoreRecents(); - // Apply any cached signed appcast URL so the updater points at the - // correct channel (stable vs preview) immediately on launch. - const auto storedAppcastUrl = settings().getAuthAppcastUrl(); - if (storedAppcastUrl.isNotEmpty()) - setUpdaterFeedUrl (storedAppcastUrl); + // Apply the saved release channel to the updater on launch. + applyStoredChannelToUpdater(); } void GuiService::deactivate() @@ -421,6 +418,16 @@ 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/ui/preferences.cpp b/src/ui/preferences.cpp index 346bb4b13..a994fd903 100644 --- a/src/ui/preferences.cpp +++ b/src/ui/preferences.cpp @@ -1146,6 +1146,7 @@ class MidiSettingsPage : public SettingsPage, #if ELEMENT_UPDATER class UpdatesSettingsPage : public SettingsPage, public Button::Listener, + public ComboBox::Listener, public juce::ChangeListener { public: @@ -1160,7 +1161,12 @@ class UpdatesSettingsPage : public SettingsPage, addAndMakeVisible (releaseChannelBox); releaseChannelBox.addItem ("Stable", 1); releaseChannelBox.addItem ("Preview", 2); - releaseChannelBox.setSelectedId (1, dontSendNotification); + // 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); @@ -1186,6 +1192,7 @@ class UpdatesSettingsPage : public SettingsPage, ~UpdatesSettingsPage() { world.settings().removeChangeListener (this); + releaseChannelBox.removeListener (this); authorizeButton.removeListener (this); signOutButton.removeListener (this); } @@ -1195,6 +1202,18 @@ class UpdatesSettingsPage : public SettingsPage, 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) @@ -1247,6 +1266,31 @@ class UpdatesSettingsPage : public SettingsPage, 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. @@ -1305,10 +1349,11 @@ class UpdatesSettingsPage : public SettingsPage, settings.setUpdateKeyType ("element-v1"); settings.setAuthPreviewUpdates (false); settings.setAuthAppcastUrl ({}); + settings.setUpdateChannel ("stable"); - // Revert the updater to the compiled-in default feed URL. + // Revert the updater to the public stable feed. if (auto* g = world.services().find()) - g->setUpdaterFeedUrl ({}); + g->applyStoredChannelToUpdater(); if (auto* props = settings.getUserSettings()) { @@ -1345,9 +1390,14 @@ class UpdatesSettingsPage : public SettingsPage, signOutButton.setVisible (false); releaseChannelBox.setItemEnabled (2, false); // Disable Preview - // Switch to Stable if Preview was selected - if (releaseChannelBox.getSelectedId() == 2) + // 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(); + } } } }; 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); From 34ab414a43a1fe459a766701fb34b2027ce6ad77 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Mon, 23 Feb 2026 06:01:07 -0500 Subject: [PATCH 09/12] fix: update Windows platform version to 2022 in build configuration --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bdc3cbc4..c396cbf36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: build-and-test: strategy: matrix: - os: [ubuntu-22.04, macos-latest, windows-2019] + os: [ubuntu-22.04, macos-latest, windows-2022] fail-fast: false runs-on: ${{ matrix.os }} @@ -122,7 +122,7 @@ jobs: uses: MarkusJx/install-boost@v2.4.5 with: boost_version: 1.83.0 - platform_version: 2019 + platform_version: 2022 toolset: msvc link: static From 0eede79454ef3768a654bfb0777c2606e0930464 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Mon, 23 Feb 2026 06:30:20 -0500 Subject: [PATCH 10/12] fix: revert Windows platform version from 2022 to 2019 in build configuration --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c396cbf36..1bdc3cbc4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: build-and-test: strategy: matrix: - os: [ubuntu-22.04, macos-latest, windows-2022] + os: [ubuntu-22.04, macos-latest, windows-2019] fail-fast: false runs-on: ${{ matrix.os }} @@ -122,7 +122,7 @@ jobs: uses: MarkusJx/install-boost@v2.4.5 with: boost_version: 1.83.0 - platform_version: 2022 + platform_version: 2019 toolset: msvc link: static From bed072579c950090233e7a00a5fbc63e9ca4f8af Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Mon, 23 Feb 2026 10:05:56 -0500 Subject: [PATCH 11/12] fix: update Windows platform version to 2022 in build configuration --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bdc3cbc4..c396cbf36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: build-and-test: strategy: matrix: - os: [ubuntu-22.04, macos-latest, windows-2019] + os: [ubuntu-22.04, macos-latest, windows-2022] fail-fast: false runs-on: ${{ matrix.os }} @@ -122,7 +122,7 @@ jobs: uses: MarkusJx/install-boost@v2.4.5 with: boost_version: 1.83.0 - platform_version: 2019 + platform_version: 2022 toolset: msvc link: static From b6f6aea1da2d7d75d7377ed0413b62eb69b6aa70 Mon Sep 17 00:00:00 2001 From: Michael Fisher Date: Sat, 28 Feb 2026 07:33:51 -0500 Subject: [PATCH 12/12] feat: include platform information in appcast URL generation --- src/auth.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/auth.cpp b/src/auth.cpp index 050004aa9..19240ed9a 100644 --- a/src/auth.cpp +++ b/src/auth.cpp @@ -305,7 +305,15 @@ String fetchSignedAppcastUrl (const String& accessToken) if (accessToken.isEmpty()) return {}; - const String endpoint = String (apiBaseEndpoint) + "/appcast-url"; +#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;