-
-
Notifications
You must be signed in to change notification settings - Fork 188
feat(notifications): add push proxy registration for remote delivery #536
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| # Push Notifications | ||
|
|
||
| Conduit supports optional remote push wakeups through a Conduit-owned push proxy | ||
| and Open WebUI's per-user notification webhooks. | ||
|
|
||
| The app still uses the existing socket/local notification path for foreground | ||
| and connected background behavior. Remote push is only activated when the app is | ||
| built with push proxy and Firebase configuration. | ||
|
|
||
| ## Build Configuration | ||
|
|
||
| Set these Dart defines for a build that should register remote push: | ||
|
|
||
| ```bash | ||
| --dart-define=CONDUIT_PUSH_PROXY_BASE_URL=https://push.example.com | ||
| --dart-define=CONDUIT_FIREBASE_API_KEY=... | ||
| --dart-define=CONDUIT_FIREBASE_PROJECT_ID=... | ||
| --dart-define=CONDUIT_FIREBASE_MESSAGING_SENDER_ID=... | ||
| --dart-define=CONDUIT_FIREBASE_APP_ID_ANDROID=... | ||
| --dart-define=CONDUIT_FIREBASE_APP_ID_IOS=... | ||
| ``` | ||
|
|
||
| Optional: | ||
|
|
||
| ```bash | ||
| --dart-define=CONDUIT_FIREBASE_IOS_BUNDLE_ID=app.cogwheel.conduit | ||
| --dart-define=CONDUIT_FIREBASE_ANDROID_CLIENT_ID=... | ||
| ``` | ||
|
|
||
| If these are omitted, the notification settings screen still works, but remote | ||
| push registration is disabled and the app will not write webhook URLs. | ||
|
|
||
| ## Admin Flow | ||
|
|
||
| Admins see an **Auto-configure server** switch in notification settings. Turning | ||
| it on shows a disclosure dialog and then sets Open WebUI's | ||
| `ENABLE_USER_WEBHOOKS` admin config flag through: | ||
|
|
||
| ```text | ||
| GET /api/v1/auths/admin/config | ||
| POST /api/v1/auths/admin/config | ||
| ``` | ||
|
|
||
| Conduit posts the full admin config payload back with only | ||
| `ENABLE_USER_WEBHOOKS` changed, matching Open WebUI's admin-config update | ||
| contract. | ||
|
|
||
| ## User Flow | ||
|
|
||
| When a user enables system notifications, Conduit: | ||
|
|
||
| 1. Requests OS notification permission. | ||
| 2. Checks `/api/config` for `features.enable_user_webhooks`. | ||
| 3. Reads the device push token: | ||
| - Android: FCM registration token. | ||
| - iOS: APNs token exposed by Firebase Messaging. | ||
| 4. Registers the token with `CONDUIT_PUSH_PROXY_BASE_URL`. | ||
| 5. Writes the returned webhook URL to | ||
| `ui.notifications.webhook_url` in `/api/v1/users/user/settings/update`. | ||
|
|
||
| Conduit refuses to overwrite an existing webhook URL unless it matches the URL | ||
| that Conduit previously stored for that server. This prevents silently replacing | ||
| a user's ntfy, Nextcloud, or custom Open WebUI webhook integration. | ||
|
|
||
| ## Push Proxy Protocol | ||
|
|
||
| Registration request: | ||
|
|
||
| ```http | ||
| POST /v1/installations | ||
| content-type: application/json | ||
| ``` | ||
|
|
||
| ```json | ||
| { | ||
| "protocol_version": 1, | ||
| "app": "conduit", | ||
| "server_id": "server-id", | ||
| "server_url": "https://openwebui.example.com", | ||
| "user_id": "user-id", | ||
| "installation_id": "stable-device-installation-id", | ||
| "platform": "ios", | ||
| "token_type": "apns", | ||
| "push_token": "native-token" | ||
| } | ||
| ``` | ||
|
|
||
| Registration response: | ||
|
|
||
| ```json | ||
| { | ||
| "subscription_id": "subscription-id", | ||
| "webhook_url": "https://push.example.com/v1/openwebui/webhooks/..." | ||
| } | ||
| ``` | ||
|
|
||
| Unregister: | ||
|
|
||
| ```http | ||
| DELETE /v1/installations/{subscription_id} | ||
| ``` | ||
|
|
||
| Notification tap payloads should include: | ||
|
|
||
| ```json | ||
| { | ||
| "conduit_kind": "chat_completion", | ||
| "conduit_source_id": "chat-id" | ||
| } | ||
| ``` | ||
|
|
||
| Supported `conduit_kind` values are `chat_completion` and `channel_message`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -123,6 +123,7 @@ class BackendConfig { | |
| this.oauthProviders = const OAuthProviders(), | ||
| this.enableLdap = false, | ||
| this.enableLoginForm = true, | ||
| this.enableUserWebhooks = false, | ||
| }); | ||
|
|
||
| /// Mirrors `features.enable_websocket` from OpenWebUI. | ||
|
|
@@ -152,6 +153,9 @@ class BackendConfig { | |
| /// Whether the standard login form (email/password) is enabled. | ||
| final bool enableLoginForm; | ||
|
|
||
| /// Whether Open WebUI allows per-user webhook URLs for notifications. | ||
| final bool enableUserWebhooks; | ||
|
|
||
| /// Whether SSO (OAuth) login is available. | ||
| bool get hasSsoEnabled => oauthProviders.hasAnyProvider; | ||
|
|
||
|
|
@@ -173,6 +177,7 @@ class BackendConfig { | |
| OAuthProviders? oauthProviders, | ||
| bool? enableLdap, | ||
| bool? enableLoginForm, | ||
| bool? enableUserWebhooks, | ||
| }) { | ||
| return BackendConfig( | ||
| enableWebsocket: enableWebsocket ?? this.enableWebsocket, | ||
|
|
@@ -191,6 +196,7 @@ class BackendConfig { | |
| oauthProviders: oauthProviders ?? this.oauthProviders, | ||
| enableLdap: enableLdap ?? this.enableLdap, | ||
| enableLoginForm: enableLoginForm ?? this.enableLoginForm, | ||
| enableUserWebhooks: enableUserWebhooks ?? this.enableUserWebhooks, | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -231,6 +237,7 @@ class BackendConfig { | |
| 'oauth': {'providers': oauthProviders.toJson()}, | ||
| 'enable_ldap': enableLdap, | ||
| 'enable_login_form': enableLoginForm, | ||
| 'enable_user_webhooks': enableUserWebhooks, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -251,6 +258,7 @@ class BackendConfig { | |
| OAuthProviders oauthProviders = const OAuthProviders(); | ||
| bool enableLdap = false; | ||
| bool enableLoginForm = true; | ||
| bool enableUserWebhooks = false; | ||
|
|
||
| // Try canonical format first | ||
| final value = json['enable_websocket']; | ||
|
|
@@ -311,6 +319,8 @@ class BackendConfig { | |
| if (ldapValue is bool) enableLdap = ldapValue; | ||
| final loginFormValue = json['enable_login_form']; | ||
| if (loginFormValue is bool) enableLoginForm = loginFormValue; | ||
| final userWebhooksValue = json['enable_user_webhooks']; | ||
| if (userWebhooksValue is bool) enableUserWebhooks = userWebhooksValue; | ||
|
|
||
| // Fallback to nested format for backwards compatibility | ||
| final features = json['features']; | ||
|
|
@@ -379,6 +389,10 @@ class BackendConfig { | |
| if (nestedLdap is bool) enableLdap = nestedLdap; | ||
| final nestedLoginForm = features['enable_login_form']; | ||
| if (nestedLoginForm is bool) enableLoginForm = nestedLoginForm; | ||
| final nestedUserWebhooks = features['enable_user_webhooks']; | ||
| if (nestedUserWebhooks is bool) { | ||
| enableUserWebhooks = nestedUserWebhooks; | ||
| } | ||
|
Comment on lines
+392
to
+395
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Preserve top-level This fallback block overwrites the canonical top-level value whenever both keys are present. A payload like Suggested fix- bool enableUserWebhooks = false;
+ bool enableUserWebhooks = false;
+ var hasTopLevelEnableUserWebhooks = false;
...
final userWebhooksValue = json['enable_user_webhooks'];
- if (userWebhooksValue is bool) enableUserWebhooks = userWebhooksValue;
+ if (userWebhooksValue is bool) {
+ enableUserWebhooks = userWebhooksValue;
+ hasTopLevelEnableUserWebhooks = true;
+ }
...
final nestedUserWebhooks = features['enable_user_webhooks'];
- if (nestedUserWebhooks is bool) {
+ if (nestedUserWebhooks is bool && !hasTopLevelEnableUserWebhooks) {
enableUserWebhooks = nestedUserWebhooks;
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| return BackendConfig( | ||
|
|
@@ -398,6 +412,7 @@ class BackendConfig { | |
| oauthProviders: oauthProviders, | ||
| enableLdap: enableLdap, | ||
| enableLoginForm: enableLoginForm, | ||
| enableUserWebhooks: enableUserWebhooks, | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ import '../../features/chat/providers/chat_providers.dart'; | |
| import '../../features/chat/providers/remap_route_sync_provider.dart'; | ||
| import '../../features/notifications/providers/notification_socket_listener.dart'; | ||
| import '../../features/notifications/services/local_notification_service.dart'; | ||
| import '../../features/notifications/services/remote_push_service.dart'; | ||
|
|
||
| part 'app_startup_providers.g.dart'; | ||
|
|
||
|
|
@@ -117,6 +118,19 @@ Future<void> _cleanupUserScopedProvidersAfterSignOut(Ref ref) async { | |
| ); | ||
| }), | ||
| ); | ||
| unawaited( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium
Also found in 1 other location(s)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| ref | ||
| .read(remotePushServiceProvider) | ||
|
Comment on lines
+122
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Signing out of one session now calls Context Used: AGENTS.md (source) |
||
| .unregisterAllLocalSubscriptions() | ||
| .catchError((Object e, StackTrace st) { | ||
| DebugLogger.error( | ||
| 'failed to unregister push subscriptions on sign-out', | ||
| scope: 'notifications/push', | ||
| error: e, | ||
| stackTrace: st, | ||
| ); | ||
| }), | ||
| ); | ||
| ref.invalidate(notificationRouterProvider); | ||
| ref.invalidate(notificationSocketListenerProvider); | ||
| } catch (error, stackTrace) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
models/backend_config.dart:392In
BackendConfig.fromJson, the nested fallback forfeatures['enable_user_webhooks']overwrites the already-parsed top-leveljson['enable_user_webhooks']value unconditionally, unlike the other auth fields which only overwrite when the top-level value wasn't set. If a server sends bothenable_user_webhooksandfeatures.enable_user_webhookswith different values during a rollout, the app ignores the canonical top-level flag and uses the legacy nested value. Add a guard so the nested value is only used when the top-level one was absent.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: