Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions docs/push-notifications.md
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`.
3 changes: 3 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
APP_GROUP_ID = group.app.cogwheel.conduit.x2662v5dt2.debug;
APP_URL_SCHEME = "conduit-debug";
APS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug";
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
Expand Down Expand Up @@ -1003,6 +1004,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
APP_GROUP_ID = group.app.cogwheel.conduit.x2662v5dt2.debug;
APP_URL_SCHEME = "conduit-debug";
APS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug";
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
Expand Down Expand Up @@ -1043,6 +1045,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
APP_GROUP_ID = group.app.cogwheel.conduit;
APP_URL_SCHEME = conduit;
APS_ENVIRONMENT = production;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
Expand Down
1 change: 1 addition & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
<array>
<string>audio</string>
<string>processing</string>
<string>remote-notification</string>
<string>voip</string>
</array>
<key>UILaunchStoryboardName</key>
Expand Down
2 changes: 2 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
</array>
<key>com.apple.developer.carplay-voice-based-conversation</key>
<true/>
<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
</dict>
</plist>
15 changes: 15 additions & 0 deletions lib/core/models/backend_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand All @@ -173,6 +177,7 @@ class BackendConfig {
OAuthProviders? oauthProviders,
bool? enableLdap,
bool? enableLoginForm,
bool? enableUserWebhooks,
}) {
return BackendConfig(
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
Expand All @@ -191,6 +196,7 @@ class BackendConfig {
oauthProviders: oauthProviders ?? this.oauthProviders,
enableLdap: enableLdap ?? this.enableLdap,
enableLoginForm: enableLoginForm ?? this.enableLoginForm,
enableUserWebhooks: enableUserWebhooks ?? this.enableUserWebhooks,
);
}

Expand Down Expand Up @@ -231,6 +237,7 @@ class BackendConfig {
'oauth': {'providers': oauthProviders.toJson()},
'enable_ldap': enableLdap,
'enable_login_form': enableLoginForm,
'enable_user_webhooks': enableUserWebhooks,
};
}

Expand All @@ -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'];
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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'];

Copy link
Copy Markdown

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:392

In BackendConfig.fromJson, the nested fallback for features['enable_user_webhooks'] overwrites the already-parsed top-level json['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 both enable_user_webhooks and features.enable_user_webhooks with 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:
In file @lib/core/models/backend_config.dart around line 392:

In `BackendConfig.fromJson`, the nested fallback for `features['enable_user_webhooks']` overwrites the already-parsed top-level `json['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 both `enable_user_webhooks` and `features.enable_user_webhooks` with 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.

if (nestedUserWebhooks is bool) {
enableUserWebhooks = nestedUserWebhooks;
}
Comment on lines +392 to +395

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve top-level enable_user_webhooks precedence.

This fallback block overwrites the canonical top-level value whenever both keys are present. A payload like {'enable_user_webhooks': true, 'features': {'enable_user_webhooks': false}} now parses as false, so the UI can hide webhook support even when the server explicitly enabled it. Track whether the top-level key was seen before applying the nested fallback.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/core/models/backend_config.dart` around lines 392 - 395, The nested
`features['enable_user_webhooks']` fallback in `BackendConfig` is overwriting
the canonical top-level `enable_user_webhooks` value when both are present.
Update the parsing logic to track whether the top-level flag was already
provided before applying the nested fallback, and only read the nested value
when the top-level key was absent. Keep the fix localized to the `BackendConfig`
mapping/parsing code that sets `enableUserWebhooks`.

}

return BackendConfig(
Expand All @@ -398,6 +412,7 @@ class BackendConfig {
oauthProviders: oauthProviders,
enableLdap: enableLdap,
enableLoginForm: enableLoginForm,
enableUserWebhooks: enableUserWebhooks,
);
}
}
12 changes: 12 additions & 0 deletions lib/core/models/server_user_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class ServerUserSettings {
this.notificationEnabled,
this.notificationSound,
this.notificationSoundAlways,
this.notificationWebhookUrl,
});

final String? systemPrompt;
Expand All @@ -25,6 +26,10 @@ class ServerUserSettings {
final bool? notificationSound;
final bool? notificationSoundAlways;

/// Open WebUI stores the per-user notification webhook at
/// `ui.notifications.webhook_url`.
final String? notificationWebhookUrl;

/// The user's preferred default model, if one is configured.
String? get defaultModelId =>
defaultModelIds.isEmpty ? null : defaultModelIds.first;
Expand All @@ -42,6 +47,9 @@ class ServerUserSettings {
notificationEnabled: _coerceBool(json['notificationEnabled']),
notificationSound: _coerceBool(json['notificationSound']),
notificationSoundAlways: _coerceBool(json['notificationSoundAlways']),
notificationWebhookUrl: _normalizeString(
_coerceJsonMap(ui?['notifications'])?['webhook_url'],
),
);
}

Expand All @@ -53,6 +61,7 @@ class ServerUserSettings {
bool? notificationEnabled,
bool? notificationSound,
bool? notificationSoundAlways,
Object? notificationWebhookUrl = _serverUserSettingsUnset,
}) {
return ServerUserSettings(
systemPrompt: systemPrompt == _serverUserSettingsUnset
Expand All @@ -65,6 +74,9 @@ class ServerUserSettings {
notificationSound: notificationSound ?? this.notificationSound,
notificationSoundAlways:
notificationSoundAlways ?? this.notificationSoundAlways,
notificationWebhookUrl: notificationWebhookUrl == _serverUserSettingsUnset
? this.notificationWebhookUrl
: notificationWebhookUrl as String?,
);
}
}
Expand Down
14 changes: 14 additions & 0 deletions lib/core/providers/app_startup_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -117,6 +118,19 @@ Future<void> _cleanupUserScopedProvidersAfterSignOut(Ref ref) async {
);
}),
);
unawaited(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium providers/app_startup_providers.dart:121

unregisterAllLocalSubscriptions() is fire-and-forget via unawaited, so a new subscription can be written for the same serverId before the snapshot from the previous sign-out is fully processed. The still-running cleanup then deletes that new local record while the server-side push registration remains active. This leaves the app with a live remote-push registration and no local tracking, so subsequent sign-out or disable flows cannot unregister it and the device keeps receiving pushes. Consider awaiting the cleanup (or guarding it against concurrent writes) before new subscriptions are registered.

Also found in 1 other location(s)

lib/features/notifications/views/notification_settings_page.dart:183

When the system-notifications toggle is turned on, _setSystem starts remotePushService.syncForCurrentSettings() with unawaited(...) and discards its RemotePushSyncResult. Any registration failure (serverUserWebhooksDisabled, permissionDenied, existingWebhookConflict, tokenUnavailable, failed, etc.) is therefore silent: the switch stays enabled even though remote/background delivery was not configured. This leaves users believing notifications are active when the new push-registration step actually failed.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @lib/core/providers/app_startup_providers.dart around line 121:

`unregisterAllLocalSubscriptions()` is fire-and-forget via `unawaited`, so a new subscription can be written for the same `serverId` before the snapshot from the previous sign-out is fully processed. The still-running cleanup then deletes that new local record while the server-side push registration remains active. This leaves the app with a live remote-push registration and no local tracking, so subsequent sign-out or disable flows cannot unregister it and the device keeps receiving pushes. Consider awaiting the cleanup (or guarding it against concurrent writes) before new subscriptions are registered.

Also found in 1 other location(s):
- lib/features/notifications/views/notification_settings_page.dart:183 -- When the system-notifications toggle is turned on, `_setSystem` starts `remotePushService.syncForCurrentSettings()` with `unawaited(...)` and discards its `RemotePushSyncResult`. Any registration failure (`serverUserWebhooksDisabled`, `permissionDenied`, `existingWebhookConflict`, `tokenUnavailable`, `failed`, etc.) is therefore silent: the switch stays enabled even though remote/background delivery was not configured. This leaves users believing notifications are active when the new push-registration step actually failed.

ref
.read(remotePushServiceProvider)
Comment on lines +122 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sign-Out Removes Every Subscription

Signing out of one session now calls unregisterAllLocalSubscriptions(), which iterates every stored remote_push_subscription_v1_* entry. A user with multiple saved servers can sign out of one account and silently unregister remote notifications for the others until each server is opened and synced again.

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) {
Expand Down
Loading