diff --git a/.github/actions/deploy-backend-stack/action.yml b/.github/actions/deploy-backend-stack/action.yml index e24fddb5ecc..1ca178006dc 100644 --- a/.github/actions/deploy-backend-stack/action.yml +++ b/.github/actions/deploy-backend-stack/action.yml @@ -972,7 +972,10 @@ runs: ADMIN_KEY="$(gcloud secrets versions access latest --secret=ADMIN_KEY --project="$PROJECT_ID")" export ADMIN_KEY trap 'unset ADMIN_KEY' EXIT - python3 "$DEPLOY_CONTROL_SCRIPTS/smoke_what_matters_now.py" --base-url https://api.omi.dev + # api.omi.dev has never resolved (NXDOMAIN), so this smoke could not reach + # anything; api.omiapi.com is development's real public API host, matching + # the rest of the dev domain family (parakeet/nllb/pusher.omiapi.com). + python3 "$DEPLOY_CONTROL_SCRIPTS/smoke_what_matters_now.py" --base-url https://api.omiapi.com - name: Restore Cloud Run traffic snapshot after failed promotion if: >- diff --git a/app/lib/pages/conversation_detail/page.dart b/app/lib/pages/conversation_detail/page.dart index 0433323543b..b4443a5f000 100644 --- a/app/lib/pages/conversation_detail/page.dart +++ b/app/lib/pages/conversation_detail/page.dart @@ -385,21 +385,6 @@ class _ConversationDetailPageState extends State with Ti case 'download_audio': await _downloadAudio(context, provider); break; - // case 'export_transcript': - // showShareBottomSheet(context, provider.conversation, (fn) {}); - // break; - // case 'export_summary': - // showShareBottomSheet(context, provider.conversation, (fn) {}); - // break; - // case 'copy_raw_transcript': - // _copyContent(context, provider.conversation.getTranscript()); - // break; - // case 'copy_conversation_raw': - // _copyContent(context, provider.conversation.toJson().toString()); - // break; - // case 'trigger_integration': - // _triggerWebhookIntegration(context, provider.conversation); - // break; case 'test_prompt': routeToPage(context, TestPromptsPage(conversation: provider.conversation)); break; @@ -657,41 +642,6 @@ class _ConversationDetailPageState extends State with Ti } } - // void _triggerWebhookIntegration(BuildContext context, ServerConversation conversation) { - // if (SharedPreferencesUtil().webhookOnConversationCreated.isEmpty) { - // showDialog( - // context: context, - // builder: (c) => getDialog( - // context, - // () => Navigator.pop(context), - // () { - // Navigator.pop(context); - // routeToPage(context, const DeveloperSettingsPage()); - // }, - // 'Webhook URL not set', - // 'Please set the webhook URL in developer settings to use this feature.', - // okButtonText: 'Settings', - // ), - // ); - // return; - // } - // - // webhookOnConversationCreatedCall(conversation, returnRawBody: true).then((response) { - // showDialog( - // context: context, - // builder: (c) => getDialog( - // context, - // () => Navigator.pop(context), - // () => Navigator.pop(context), - // 'Result:', - // response, - // okButtonText: 'Ok', - // singleButton: true, - // ), - // ); - // }); - // } - @override Widget build(BuildContext context) { // Empty shell on first build (before initState's setCachedConversation @@ -1215,110 +1165,6 @@ class _ConversationDetailPageState extends State with Ti ), ), - // thinh's comment: temporary disabled - //// Unassigned segments notification - positioned above the bottom bar - //Positioned( - // bottom: 88, // Position above the bottom bar - // left: 16, - // right: 16, - // child: Selector( - // selector: (context, provider) { - // final conversation = provider.conversation; - // if (conversation == null) { - // return ( - // count: 0, - // shouldShow: false, - // ); - // } - // return ( - // count: conversation.unassignedSegmentsLength(), - // shouldShow: provider.showUnassignedFloatingButton && (selectedTab == ConversationTab.transcript), - // ); - // }, - // builder: (context, value, child) { - // if (value.count == 0 || !value.shouldShow) return const SizedBox.shrink(); - // return Container( - // padding: const EdgeInsets.symmetric( - // vertical: 8, - // horizontal: 16, - // ), - // decoration: BoxDecoration( - // borderRadius: BorderRadius.circular(16), - // color: const Color(0xFF1F1F25), - // boxShadow: [ - // BoxShadow( - // color: Colors.black.withValues(alpha: 0.3), - // spreadRadius: 1, - // blurRadius: 2, - // offset: const Offset(0, 1), - // ), - // ], - // ), - // child: Row( - // mainAxisAlignment: MainAxisAlignment.spaceBetween, - // children: [ - // Row( - // children: [ - // InkWell( - // onTap: () { - // var provider = Provider.of(context, listen: false); - // provider.setShowUnassignedFloatingButton(false); - // }, - // child: const Icon( - // Icons.close, - // color: Colors.white, - // ), - // ), - // const SizedBox(width: 8), - // Text( - // "${value.count} unassigned segment${value.count == 1 ? '' : 's'}", - // style: const TextStyle( - // color: Colors.white, - // fontSize: 16, - // ), - // ), - // ], - // ), - // ElevatedButton( - // style: ElevatedButton.styleFrom( - // backgroundColor: Colors.deepPurple.withValues(alpha: 0.5), - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(16), - // ), - // ), - // onPressed: () { - // var provider = Provider.of(context, listen: false); - // var speakerId = provider.conversation.speakerWithMostUnassignedSegments(); - // var segmentIdx = provider.conversation.firstSegmentIndexForSpeaker(speakerId); - // showModalBottomSheet( - // context: context, - // isScrollControlled: true, - // backgroundColor: Colors.black, - // shape: const RoundedRectangleBorder( - // borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - // ), - // builder: (context) { - // return NameSpeakerBottomSheet( - // segmentIdx: segmentIdx, - // speakerId: speakerId, - // ); - // }, - // ); - // }, - // child: const Text( - // "Tag", - // style: TextStyle( - // color: Colors.white, - // fontWeight: FontWeight.bold, - // ), - // ), - // ), - // ], - // ), - // ); - // }, - // ), - //), // Search overlay if (_isSearching) Positioned( diff --git a/app/lib/pages/conversations/auto_sync_page.dart b/app/lib/pages/conversations/auto_sync_page.dart index ca4d3c74e28..774e8dc2b2c 100644 --- a/app/lib/pages/conversations/auto_sync_page.dart +++ b/app/lib/pages/conversations/auto_sync_page.dart @@ -18,6 +18,7 @@ import 'package:omi/utils/other/temp.dart'; import 'package:omi/utils/sync_confirmation.dart'; import 'synced_conversations_page.dart'; import 'wal_item_detail/wal_item_detail_page.dart'; +import 'package:omi/pages/conversations/widgets/status_action_pill.dart'; class AutoSyncPage extends StatefulWidget { const AutoSyncPage({super.key}); @@ -175,7 +176,7 @@ class _AutoSyncPageState extends State { default: title = s.isFetchingConversations ? l.syncCardProcessing : l.syncCardUploadingTitle; } - action = _statusActionPill(l.cancel, Colors.redAccent, () => _confirmCancel(context, p)); + action = statusActionPill(l.cancel, Colors.redAccent, () => _confirmCancel(context, p)); } else if (p.isRateLimited) { title = syncCooldownTitle(p.rateLimitReason, l); titleColor = Colors.orangeAccent; @@ -188,12 +189,12 @@ class _AutoSyncPageState extends State { } else if (attention > 0) { title = l.syncCardNeedsAttention(attention); titleColor = Colors.orangeAccent; - action = _statusActionPill(l.sync, Colors.deepPurpleAccent, () async { + action = statusActionPill(l.sync, Colors.deepPurpleAccent, () async { if (await confirmSyncForCustomStt(context) && context.mounted) p.syncWals(); }); } else if (readyToBackUp > 0) { title = l.syncCardReadyCount(readyToBackUp); - action = _statusActionPill(l.sync, Colors.deepPurpleAccent, () async { + action = statusActionPill(l.sync, Colors.deepPurpleAccent, () async { if (await confirmSyncForCustomStt(context) && context.mounted) p.syncWals(); }); } else if (hasAnyRecording) { @@ -249,20 +250,6 @@ class _AutoSyncPageState extends State { ); } - Widget _statusActionPill(String label, Color color, VoidCallback onTap) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration(color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(100)), - child: Text( - label, - style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500), - ), - ), - ); - } - // ───────────────────────────────────────── // Conversations created // ───────────────────────────────────────── diff --git a/app/lib/pages/conversations/sync_page.dart b/app/lib/pages/conversations/sync_page.dart index 856dc52a7c4..9d60c6ec982 100644 --- a/app/lib/pages/conversations/sync_page.dart +++ b/app/lib/pages/conversations/sync_page.dart @@ -21,6 +21,7 @@ import 'local_storage_page.dart'; import 'private_cloud_sync_page.dart'; import 'synced_conversations_page.dart'; import 'wal_item_detail/wal_item_detail_page.dart'; +import 'package:omi/pages/conversations/widgets/status_action_pill.dart'; Widget _buildFaIcon(FaIconData icon, {double size = 18, Color color = const Color(0xFF8E8E93)}) { return Padding( @@ -516,12 +517,12 @@ class _SyncPageState extends State { case SyncPhase.downloadingFromDevice: title = l.syncCardDownloadingTitle; subtitle = _progressLine(s, speedStr); - action = _statusActionPill(l.cancel, Colors.redAccent, () => _showCancelSyncDialog(context, syncProvider)); + action = statusActionPill(l.cancel, Colors.redAccent, () => _showCancelSyncDialog(context, syncProvider)); break; case SyncPhase.uploadingToCloud: title = l.syncCardUploadingTitle; subtitle = _progressLine(s, null); - action = _statusActionPill(l.cancel, Colors.redAccent, () => _showCancelSyncDialog(context, syncProvider)); + action = statusActionPill(l.cancel, Colors.redAccent, () => _showCancelSyncDialog(context, syncProvider)); break; case SyncPhase.processingOnServer: title = l.syncCardProcessing; @@ -535,7 +536,7 @@ class _SyncPageState extends State { title = l.syncCardUploadingTitle; subtitle = _progressLine(s, speedStr); if (syncProvider.isSdCardSyncing) { - action = _statusActionPill(l.cancel, Colors.redAccent, () => _showCancelSyncDialog(context, syncProvider)); + action = statusActionPill(l.cancel, Colors.redAccent, () => _showCancelSyncDialog(context, syncProvider)); } break; } @@ -548,7 +549,7 @@ class _SyncPageState extends State { subtitle = l.syncProcessingBackgroundHint; } else if (readyToSync > 0) { title = l.syncCardReadyCount(readyToSync); - action = _statusActionPill(l.sync, Colors.deepPurpleAccent, () { + action = statusActionPill(l.sync, Colors.deepPurpleAccent, () { if (context.read().isConnected) { _handleSyncWals(context, syncProvider); } else { @@ -616,20 +617,6 @@ class _SyncPageState extends State { return parts.isEmpty ? null : parts.join(' · '); } - Widget _statusActionPill(String label, Color color, VoidCallback onTap) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration(color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(100)), - child: Text( - label, - style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500), - ), - ), - ); - } - Widget _buildSyncErrorCard(SyncProvider syncProvider) { final errorMessage = syncProvider.syncError!; return Container( @@ -652,7 +639,7 @@ class _SyncPageState extends State { ), ), const SizedBox(width: 8), - _statusActionPill(context.l10n.retry, Colors.redAccent, () => syncProvider.retrySync()), + statusActionPill(context.l10n.retry, Colors.redAccent, () => syncProvider.retrySync()), ], ), ); diff --git a/app/lib/pages/conversations/widgets/status_action_pill.dart b/app/lib/pages/conversations/widgets/status_action_pill.dart new file mode 100644 index 00000000000..0d6e2ce49df --- /dev/null +++ b/app/lib/pages/conversations/widgets/status_action_pill.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +Widget statusActionPill(String label, Color color, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration(color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(100)), + child: Text( + label, + style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ); +} diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index db15bdbc9ad..0ea118e2245 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -787,62 +787,8 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker connectivityProvider.isInitialized && connectivityProvider.previousConnection != isConnected) { previousConnection = isConnected; - if (!isConnected) { - // TODO: Re-enable when internet connection banners are redesigned - // Future.delayed(const Duration(seconds: 2), () { - // if (mounted && !connectivityProvider.isConnected) { - // ScaffoldMessenger.of(ctx).showMaterialBanner( - // MaterialBanner( - // content: const Text( - // 'No internet connection. Please check your connection.', - // style: TextStyle(color: Colors.white70), - // ), - // backgroundColor: const Color(0xFF424242), // Dark gray instead of red - // leading: const Icon(Icons.wifi_off, color: Colors.white70), - // actions: [ - // TextButton( - // onPressed: () { - // ScaffoldMessenger.of(ctx).hideCurrentMaterialBanner(); - // }, - // child: const Text('Dismiss', style: TextStyle(color: Colors.white70)), - // ), - // ], - // ), - // ); - // } - // }); - } else { + if (isConnected) { Future.delayed(Duration.zero, () { - // TODO: Re-enable when internet connection banners are redesigned - // if (mounted) { - // ScaffoldMessenger.of(ctx).hideCurrentMaterialBanner(); - // ScaffoldMessenger.of(ctx).showMaterialBanner( - // MaterialBanner( - // content: const Text( - // 'Internet connection is restored.', - // style: TextStyle(color: Colors.white), - // ), - // backgroundColor: const Color(0xFF2E7D32), // Dark green instead of bright green - // leading: const Icon(Icons.wifi, color: Colors.white), - // actions: [ - // TextButton( - // onPressed: () { - // if (mounted) { - // ScaffoldMessenger.of(ctx).hideCurrentMaterialBanner(); - // } - // }, - // child: const Text('Dismiss', style: TextStyle(color: Colors.white)), - // ), - // ], - // onVisible: () => Future.delayed(const Duration(seconds: 3), () { - // if (mounted) { - // ScaffoldMessenger.of(ctx).hideCurrentMaterialBanner(); - // } - // }), - // ), - // ); - // } - WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; diff --git a/app/lib/pages/memories/category_memories_page.dart b/app/lib/pages/memories/category_memories_page.dart index 2ed7f7431f7..f89aa796fe0 100644 --- a/app/lib/pages/memories/category_memories_page.dart +++ b/app/lib/pages/memories/category_memories_page.dart @@ -79,11 +79,6 @@ class CategoryMemoriesPage extends StatelessWidget { } void _showQuickEditSheet(BuildContext context, Memory memory, MemoriesProvider provider) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => MemoryEditSheet(memory: memory, provider: provider, onDelete: (_, __, ___) {}), - ); + showMemoryQuickEditSheet(context, memory, provider); } } diff --git a/app/lib/pages/memories/page.dart b/app/lib/pages/memories/page.dart index 0416e9684dc..920479000e7 100644 --- a/app/lib/pages/memories/page.dart +++ b/app/lib/pages/memories/page.dart @@ -438,50 +438,7 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien } void _showQuickEditSheet(BuildContext context, Memory memory, MemoriesProvider provider) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => MemoryEditSheet(memory: memory, provider: provider, onDelete: (_, __, ___) {}), - ); - } - - // ignore: unused_element - void _showDeleteAllConfirmation(BuildContext context, MemoriesProvider provider) { - if (provider.memories.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.noMemoriesToDelete), duration: const Duration(seconds: 2))); - return; - } - - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF1F1F25), - title: Text(context.l10n.clearMemoryTitle, style: const TextStyle(color: Colors.white)), - content: Text(context.l10n.clearMemoryMessage, style: TextStyle(color: Colors.grey.shade300)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - MaterialLocalizations.of(context).cancelButtonLabel, - style: TextStyle(color: Colors.grey.shade400), - ), - ), - TextButton( - onPressed: () { - provider.deleteAllMemories(); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.memoryClearedSuccess), duration: const Duration(seconds: 2)), - ); - }, - child: Text(context.l10n.clearMemoryButton, style: const TextStyle(color: Colors.red)), - ), - ], - ), - ); + showMemoryQuickEditSheet(context, memory, provider); } void scrollToTop() { @@ -500,28 +457,3 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien ); } } - -// ignore: unused_element -class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { - final double minHeight; - final double maxHeight; - final Widget child; - - _SliverSearchBarDelegate({required this.minHeight, required this.maxHeight, required this.child}); - - @override - double get minExtent => minHeight; - - @override - double get maxExtent => maxHeight; - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - return SizedBox.expand(child: child); - } - - @override - bool shouldRebuild(_SliverSearchBarDelegate oldDelegate) { - return maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight || child != oldDelegate.child; - } -} diff --git a/app/lib/pages/memories/widgets/memory_edit_sheet.dart b/app/lib/pages/memories/widgets/memory_edit_sheet.dart index c3db53d2fa7..06fab7224e5 100644 --- a/app/lib/pages/memories/widgets/memory_edit_sheet.dart +++ b/app/lib/pages/memories/widgets/memory_edit_sheet.dart @@ -7,6 +7,20 @@ import 'package:omi/utils/logger.dart'; import 'package:omi/widgets/extensions/string.dart'; import 'delete_confirmation.dart'; +void showMemoryQuickEditSheet( + BuildContext context, + Memory memory, + MemoriesProvider provider, { + Function(BuildContext, Memory, MemoriesProvider)? onDelete, +}) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => MemoryEditSheet(memory: memory, provider: provider, onDelete: onDelete), + ); +} + class MemoryEditSheet extends StatefulWidget { final Memory memory; final MemoriesProvider provider; diff --git a/app/lib/pages/memories/widgets/memory_item.dart b/app/lib/pages/memories/widgets/memory_item.dart index 7cdc9b88893..b96d26a904f 100644 --- a/app/lib/pages/memories/widgets/memory_item.dart +++ b/app/lib/pages/memories/widgets/memory_item.dart @@ -233,107 +233,4 @@ class MemoryItem extends StatelessWidget { context, ).showSnackBar(SnackBar(content: Text(context.l10n.conversationNotFoundOrDeleted), backgroundColor: Colors.red)); } - - // Widget _buildVisibilityButton(BuildContext context) { - // return PopupMenuButton( - // padding: EdgeInsets.zero, - // position: PopupMenuPosition.under, - // surfaceTintColor: Colors.transparent, - // color: AppStyles.backgroundTertiary, - // shape: RoundedRectangleBorder( - // borderRadius: BorderRadius.circular(AppStyles.radiusLarge), - // ), - // offset: const Offset(0, 4), - // child: Container( - // height: 36, - // width: 56, - // decoration: BoxDecoration( - // color: Colors.white.withValues(alpha: 0.1), - // borderRadius: BorderRadius.circular(AppStyles.radiusMedium), - // ), - // child: Row( - // mainAxisSize: MainAxisSize.min, - // mainAxisAlignment: MainAxisAlignment.center, - // children: [ - // Icon( - // memory.visibility == MemoryVisibility.private ? Icons.lock_outline : Icons.public, - // size: 16, - // color: Colors.white70, - // ), - // const SizedBox(width: 6), - // const Icon( - // Icons.keyboard_arrow_down, - // size: 18, - // color: Colors.white70, - // ), - // ], - // ), - // ), - // itemBuilder: (context) => [ - // _buildVisibilityItem( - // context, - // MemoryVisibility.private, - // Icons.lock_outline, - // 'Will not be used for personas', - // ), - // _buildVisibilityItem( - // context, - // MemoryVisibility.public, - // Icons.public, - // 'Will be used for personas', - // ), - // ], - // onSelected: (visibility) { - // provider.updateMemoryVisibility(memory, visibility); - // PlatformManager.instance.analytics.memoryVisibilityChanged(memory, visibility); - // }, - // ); - // } - - // PopupMenuItem _buildVisibilityItem( - // BuildContext context, - // MemoryVisibility visibility, - // FaIconData icon, - // String description, - // ) { - // final isSelected = memory.visibility == visibility; - // return PopupMenuItem( - // value: visibility, - // child: Container( - // padding: const EdgeInsets.symmetric(vertical: 4), - // child: Row( - // children: [ - // Icon( - // icon, - // size: 18, - // color: isSelected ? Colors.white : Colors.white70, - // ), - // const SizedBox(width: 12), - // Expanded( - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text( - // visibility.name[0].toUpperCase() + visibility.name.substring(1), - // style: TextStyle( - // color: isSelected ? Colors.white : Colors.white70, - // fontSize: 14, - // fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - // ), - // ), - // Text( - // description, - // style: TextStyle(color: Colors.grey.shade400, fontSize: 12), - // maxLines: 2, - // overflow: TextOverflow.ellipsis, - // ), - // ], - // ), - // ), - // if (isSelected) const Icon(Icons.check, size: 18, color: Colors.white), - // ], - // ), - // ), - // ); - // } } diff --git a/app/lib/pages/phone_calls/active_call_banner.dart b/app/lib/pages/phone_calls/active_call_banner.dart index 1189ca191f4..a62eebdef38 100644 --- a/app/lib/pages/phone_calls/active_call_banner.dart +++ b/app/lib/pages/phone_calls/active_call_banner.dart @@ -8,6 +8,7 @@ import 'package:omi/backend/schema/phone_call.dart'; import 'package:omi/pages/phone_calls/active_call_page.dart'; import 'package:omi/providers/phone_call_provider.dart'; import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/pages/phone_calls/call_duration_format.dart'; /// Compact call banner shown on the home screen when a phone call is active. /// Displays contact info, live transcript snippet, and inline call controls. @@ -89,14 +90,6 @@ class _CallInfoRow extends StatelessWidget { required this.state, }); - String _formatDuration(Duration d) { - String twoDigits(int n) => n.toString().padLeft(2, '0'); - if (d.inHours > 0) { - return '${twoDigits(d.inHours)}:${twoDigits(d.inMinutes.remainder(60))}:${twoDigits(d.inSeconds.remainder(60))}'; - } - return '${twoDigits(d.inMinutes)}:${twoDigits(d.inSeconds.remainder(60))}'; - } - @override Widget build(BuildContext context) { String statusText; @@ -108,7 +101,7 @@ class _CallInfoRow extends StatelessWidget { statusText = context.l10n.callStateRinging; break; case PhoneCallState.active: - statusText = _formatDuration(duration); + statusText = formatPhoneCallDuration(duration); break; default: statusText = ''; @@ -324,11 +317,7 @@ class ActiveCallTopBar extends StatelessWidget { if (!isCallInProgress) return const SizedBox.shrink(); - String twoDigits(int n) => n.toString().padLeft(2, '0'); - Duration d = provider.callDuration; - String timeStr = d.inHours > 0 - ? '${twoDigits(d.inHours)}:${twoDigits(d.inMinutes.remainder(60))}:${twoDigits(d.inSeconds.remainder(60))}' - : '${twoDigits(d.inMinutes)}:${twoDigits(d.inSeconds.remainder(60))}'; + String timeStr = formatPhoneCallDuration(provider.callDuration); String displayName = provider.contactName ?? provider.remoteNumber ?? ''; diff --git a/app/lib/pages/phone_calls/active_call_page.dart b/app/lib/pages/phone_calls/active_call_page.dart index 1d817739292..8cc8fc8f0a5 100644 --- a/app/lib/pages/phone_calls/active_call_page.dart +++ b/app/lib/pages/phone_calls/active_call_page.dart @@ -8,6 +8,7 @@ import 'package:omi/backend/schema/transcript_segment.dart'; import 'package:omi/models/audio_route.dart'; import 'package:omi/providers/phone_call_provider.dart'; import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/pages/phone_calls/call_duration_format.dart'; class ActiveCallPage extends StatefulWidget { const ActiveCallPage({super.key}); @@ -160,14 +161,6 @@ class _CallInfoHeader extends StatelessWidget { required this.state, }); - String _formatDuration(Duration d) { - String twoDigits(int n) => n.toString().padLeft(2, '0'); - if (d.inHours > 0) { - return '${twoDigits(d.inHours)}:${twoDigits(d.inMinutes.remainder(60))}:${twoDigits(d.inSeconds.remainder(60))}'; - } - return '${twoDigits(d.inMinutes)}:${twoDigits(d.inSeconds.remainder(60))}'; - } - String _stateLabel(BuildContext context) { switch (state) { case PhoneCallState.connecting: @@ -175,7 +168,7 @@ class _CallInfoHeader extends StatelessWidget { case PhoneCallState.ringing: return context.l10n.callStateRinging; case PhoneCallState.active: - return _formatDuration(duration); + return formatPhoneCallDuration(duration); case PhoneCallState.ended: return context.l10n.callStateEnded; case PhoneCallState.failed: diff --git a/app/lib/pages/phone_calls/call_duration_format.dart b/app/lib/pages/phone_calls/call_duration_format.dart new file mode 100644 index 00000000000..94f0f58a62e --- /dev/null +++ b/app/lib/pages/phone_calls/call_duration_format.dart @@ -0,0 +1,7 @@ +String formatPhoneCallDuration(Duration d) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + if (d.inHours > 0) { + return '${twoDigits(d.inHours)}:${twoDigits(d.inMinutes.remainder(60))}:${twoDigits(d.inSeconds.remainder(60))}'; + } + return '${twoDigits(d.inMinutes)}:${twoDigits(d.inSeconds.remainder(60))}'; +} diff --git a/app/test/unit/call_duration_format_test.dart b/app/test/unit/call_duration_format_test.dart new file mode 100644 index 00000000000..387c95fc73a --- /dev/null +++ b/app/test/unit/call_duration_format_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/pages/phone_calls/call_duration_format.dart'; + +void main() { + group('formatPhoneCallDuration', () { + test('uses MM:SS below one hour', () { + expect(formatPhoneCallDuration(Duration.zero), '00:00'); + expect(formatPhoneCallDuration(const Duration(seconds: 9)), '00:09'); + expect(formatPhoneCallDuration(const Duration(minutes: 5, seconds: 3)), '05:03'); + expect(formatPhoneCallDuration(const Duration(minutes: 59, seconds: 59)), '59:59'); + }); + + test('uses HH:MM:SS at or above one hour', () { + expect(formatPhoneCallDuration(const Duration(hours: 1)), '01:00:00'); + expect(formatPhoneCallDuration(const Duration(hours: 2, minutes: 7, seconds: 5)), '02:07:05'); + expect(formatPhoneCallDuration(const Duration(hours: 25, minutes: 1)), '25:01:00'); + }); + }); +} diff --git a/backend/.env.dev.template b/backend/.env.dev.template index c988a540f70..3a7338730c8 100644 --- a/backend/.env.dev.template +++ b/backend/.env.dev.template @@ -23,8 +23,8 @@ GOOGLE_APPLICATION_CREDENTIALS=google-credentials-dev.json FIRESTORE_DATABASE_ID=(default) # --- API URLs (dev cloud) --- -BASE_API_URL=https://api.omi.dev -API_BASE_URL=https://api.omi.dev +BASE_API_URL=https://api.omiapi.com +API_BASE_URL=https://api.omiapi.com OPENAI_BASE_URL=https://api.openai.com/v1 # --- LangSmith (non-secret defaults) --- diff --git a/backend/deploy/_generate_runtime_env_sources.py b/backend/deploy/_generate_runtime_env_sources.py index 3ce9360bd1b..c6f4881ea27 100644 --- a/backend/deploy/_generate_runtime_env_sources.py +++ b/backend/deploy/_generate_runtime_env_sources.py @@ -182,15 +182,6 @@ def _project_fields(env: str, env_config: ConfigDict) -> ConfigDict: } -def _inject_config_map(env_config: ConfigDict, env: str) -> ConfigDict: - result = deepcopy(env_config) - gke = result.setdefault('gke', {}) - if not isinstance(gke, dict): - return result - gke['config_map'] = _build_config_map_section(env) - return result - - def _strip_legacy_project_keys(env_config: ConfigDict) -> ConfigDict: result = deepcopy(env_config) result.pop('gcp_project', None) diff --git a/backend/modal/speech_profile_modal.py b/backend/modal/speech_profile_modal.py index 34ca69e84ca..aeecb1f33c5 100644 --- a/backend/modal/speech_profile_modal.py +++ b/backend/modal/speech_profile_modal.py @@ -122,7 +122,8 @@ def endpoint(uid: str, audio_file: UploadFile = File(...), segments: str = Form( result = classify_segments(audio_filename, profile_path, people, transcript_segments) # print(result) return result - except: + except Exception: + logger.warning("speech profile classification failed; returning default segments") return default finally: os.remove(profile_path) diff --git a/backend/routers/mcp.py b/backend/routers/mcp.py index 9faaf6cae5f..b2b2e212dc3 100644 --- a/backend/routers/mcp.py +++ b/backend/routers/mcp.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Union -from utils.executors import db_executor, postprocess_executor +from utils.executors import postprocess_executor from utils.mcp_data import date_only_to_utc_epoch from fastapi import APIRouter, HTTPException, Depends @@ -42,6 +42,10 @@ ) from utils.other.endpoints import with_rate_limit, with_rate_limit_context from utils.log_sanitizer import sanitize_pii +from utils.memory.default_read_rollout import ( + MemoryReadDecision, + read_default_read_rollout, +) from utils.memory.product_authorization import ( ProductAuthorizationContext, authorize_memory_external_default_memory_read, diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 1805e4df7d4..a3c70124e83 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -285,7 +285,6 @@ def invalid_mcp_auth_exception( "get_chat_messages": "chat.read", "get_people": "people.read", "get_screen_activity": "screen_activity.read", - "get_daily_summaries": "conversations.read", } diff --git a/backend/routers/users.py b/backend/routers/users.py index b4ccd322d86..c3d03a7f25a 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -3,7 +3,6 @@ import re import uuid from typing import List, Dict, Any, Union, Optional -import hashlib import os import asyncio @@ -61,7 +60,6 @@ from models.geolocation import Geolocation, GeolocationInput, validated_geolocation_or_none from utils.conversations.factory import deserialize_conversation, deserialize_conversations from models.other import Person, CreatePerson -from models.shared import StatusResponse from typing import Optional from models.user_usage import UserUsageResponse, UsagePeriod from datetime import datetime, time, timedelta diff --git a/backend/tests/unit/test_async_app_integrations.py b/backend/tests/unit/test_async_app_integrations.py index e6d7c70177b..6a5a6b11830 100644 --- a/backend/tests/unit/test_async_app_integrations.py +++ b/backend/tests/unit/test_async_app_integrations.py @@ -4,6 +4,7 @@ use asyncio.gather + httpx instead of Thread+join + requests. """ +import inspect import os import sys import types @@ -577,7 +578,13 @@ async def _side_effect(*args, **kwargs): @pytest.mark.asyncio async def test_no_threading_used(self): - """Verify threading.Thread is NOT used in the async path.""" + """Verify realtime audio fan-out stays async (no threading import/use).""" + # Static tripwire on the real fan-out implementation (not the thin wrapper). + code = app_integrations._async_trigger_realtime_audio_bytes.__code__ + assert "threading" not in code.co_names + assert "Thread" not in code.co_names + assert "gather_safe" in code.co_names + app1 = _make_app("a1", "https://app1.test/hook", triggers_audio=True) mock_response = MagicMock() @@ -588,9 +595,9 @@ async def test_no_threading_used(self): with patch.object(app_integrations, "get_available_apps", return_value=[app1]), patch.object( app_integrations, "get_webhook_client", return_value=mock_client - ), patch.object(app_integrations, "threading") as mock_threading: + ): await app_integrations.trigger_realtime_audio_bytes("uid-1", 8000, bytearray(b'\x00')) - mock_threading.Thread.assert_not_called() + mock_client.post.assert_awaited() class TestAudioBytesChunkedFanOut: diff --git a/backend/tests/unit/test_async_webhooks.py b/backend/tests/unit/test_async_webhooks.py index dca576dee4d..b6548510565 100644 --- a/backend/tests/unit/test_async_webhooks.py +++ b/backend/tests/unit/test_async_webhooks.py @@ -28,7 +28,6 @@ def _stub_webhook_db_helpers(monkeypatch): monkeypatch.setattr(webhooks_module, "get_user_webhook_db", MagicMock(return_value="https://example.com/webhook")) monkeypatch.setattr(webhooks_module, "disable_user_webhook_db", MagicMock()) monkeypatch.setattr(webhooks_module, "enable_user_webhook_db", MagicMock()) - monkeypatch.setattr(webhooks_module, "set_user_webhook_db", MagicMock()) monkeypatch.setattr(webhooks_module, "record_dev_webhook_success", MagicMock()) monkeypatch.setattr(webhooks_module, "record_dev_webhook_failure", MagicMock(return_value=False)) diff --git a/backend/tests/unit/test_memory_ingestion_edit_distance.py b/backend/tests/unit/test_memory_ingestion_edit_distance.py new file mode 100644 index 00000000000..53053de68fd --- /dev/null +++ b/backend/tests/unit/test_memory_ingestion_edit_distance.py @@ -0,0 +1,19 @@ +"""Regression: shared Levenshtein helper (ids.edit_distance) used by pipeline + verify_output.""" + +from utils.memory_ingestion.ids import edit_distance +from utils.memory_ingestion.pipeline import _edit_distance as pipeline_edit_distance +from utils.memory_ingestion.stages.verify_output import _edit_distance as verify_edit_distance + + +def test_edit_distance_basic_cases(): + assert edit_distance("hello", "hello") == 0 + assert edit_distance("abc", "xyz") == 3 + assert edit_distance("hello", "helllo") == 1 + assert edit_distance("cat", "bat") == 1 + assert edit_distance("", "abc") == 3 + assert edit_distance("abc", "") == 3 + + +def test_pipeline_and_verify_share_same_edit_distance_implementation(): + assert pipeline_edit_distance is edit_distance + assert verify_edit_distance is edit_distance diff --git a/backend/tests/unit/test_smoke_what_matters_now.py b/backend/tests/unit/test_smoke_what_matters_now.py index cef88b00670..1b0c460b3d7 100644 --- a/backend/tests/unit/test_smoke_what_matters_now.py +++ b/backend/tests/unit/test_smoke_what_matters_now.py @@ -128,7 +128,7 @@ def test_auto_dev_smoke_uses_the_tagged_candidate_output_with_existing_auth(): 'Shift Cloud Run traffic to validated revisions' ) assert '--audience backend=${{ steps.candidate-urls.outputs.backend_audience }}' in workflow - assert 'smoke_what_matters_now.py --base-url https://api.omi.dev' not in workflow + assert 'smoke_what_matters_now.py --base-url https://api.omiapi.com' not in workflow def test_manual_development_smoke_keeps_its_existing_external_hostname_path(): @@ -139,7 +139,7 @@ def test_manual_development_smoke_keeps_its_existing_external_hostname_path(): # The SCA-33 workflow refactor invokes the smoke via the deploy-control scripts # root: `"$DEPLOY_CONTROL_SCRIPTS/smoke_what_matters_now.py" --base-url ...`, so # the quote from that path prefix sits between the script name and the flag. - assert 'smoke_what_matters_now.py" --base-url https://api.omi.dev' in workflow + assert 'smoke_what_matters_now.py" --base-url https://api.omiapi.com' in workflow assert 'id: smoke-what-matters-now-datastore-query' in workflow assert "steps.smoke-what-matters-now-datastore-query.outcome == 'failure'" in workflow restore = workflow.index('Restore Cloud Run traffic snapshot after failed promotion') diff --git a/backend/utils/app_integrations.py b/backend/utils/app_integrations.py index b2820c1f0a7..e1f04926f1c 100644 --- a/backend/utils/app_integrations.py +++ b/backend/utils/app_integrations.py @@ -1,5 +1,4 @@ import asyncio -import threading from typing import List import os import time @@ -19,7 +18,6 @@ from utils.async_tasks import gather_safe import utils.dev_cache as dev_cache -import database.notifications as notification_db import database.dev_api_key as dev_api_key_db from database import mem_db from database import redis_db @@ -46,7 +44,7 @@ incr_daily_notification_count, get_daily_notification_count, ) -from models.app import App, ProactiveNotification, UsageHistoryType +from models.app import App, UsageHistoryType from models.chat import Message from models.conversation import Conversation from models.conversation_enums import ConversationSource diff --git a/backend/utils/conversations/merge_conversations.py b/backend/utils/conversations/merge_conversations.py index 353d062e135..f9f9b654e92 100644 --- a/backend/utils/conversations/merge_conversations.py +++ b/backend/utils/conversations/merge_conversations.py @@ -37,7 +37,6 @@ list_audio_chunks, _get_storage_client, private_cloud_sync_bucket, - _get_extension_for_path, ) import logging diff --git a/backend/utils/memory_ingestion/ids.py b/backend/utils/memory_ingestion/ids.py index be1029addbe..9a789ad75be 100644 --- a/backend/utils/memory_ingestion/ids.py +++ b/backend/utils/memory_ingestion/ids.py @@ -25,3 +25,21 @@ def __init__(self, namespace: str): def new_id(self, prefix: str, *parts: Any) -> str: return f"{prefix}_{stable_hash(self.namespace, prefix, *parts, length=24)}" + + +def edit_distance(a: str, b: str) -> int: + """Levenshtein edit distance between two strings.""" + if len(a) < len(b): + return edit_distance(b, a) + if len(b) == 0: + return len(a) + prev_row = list(range(len(b) + 1)) + for i, ca in enumerate(a): + curr_row = [i + 1] + for j, cb in enumerate(b): + insertions = prev_row[j + 1] + 1 + deletions = curr_row[j] + 1 + substitutions = prev_row[j] + (ca != cb) + curr_row.append(min(insertions, deletions, substitutions)) + prev_row = curr_row + return prev_row[-1] diff --git a/backend/utils/memory_ingestion/pipeline.py b/backend/utils/memory_ingestion/pipeline.py index dd5c86b8555..ecf784cb8dc 100644 --- a/backend/utils/memory_ingestion/pipeline.py +++ b/backend/utils/memory_ingestion/pipeline.py @@ -58,6 +58,7 @@ ) from utils.memory_ingestion.redaction import redact_payload, redact_text from utils.memory_ingestion.stages.verify_output import verify_output +from utils.memory_ingestion.ids import edit_distance as _edit_distance class Clock(Protocol): @@ -1330,24 +1331,6 @@ def _triple_canonical(triple: DerivedTriple) -> str: return f"{subj}|{triple.predicate}|{obj_text}".casefold() -def _edit_distance(a: str, b: str) -> int: - """Levenshtein edit distance between two strings.""" - if len(a) < len(b): - return _edit_distance(b, a) - if len(b) == 0: - return len(a) - prev_row = list(range(len(b) + 1)) - for i, ca in enumerate(a): - curr_row = [i + 1] - for j, cb in enumerate(b): - insertions = prev_row[j + 1] + 1 - deletions = curr_row[j] + 1 - substitutions = prev_row[j] + (ca != cb) - curr_row.append(min(insertions, deletions, substitutions)) - prev_row = curr_row - return prev_row[-1] - - def _dedupe_triples( triples: list[DerivedTriple], ) -> list[DerivedTriple]: diff --git a/backend/utils/memory_ingestion/stages/verify_output.py b/backend/utils/memory_ingestion/stages/verify_output.py index 1feaf0fca65..d961541bb93 100644 --- a/backend/utils/memory_ingestion/stages/verify_output.py +++ b/backend/utils/memory_ingestion/stages/verify_output.py @@ -5,24 +5,7 @@ from utils.memory_ingestion.ids import stable_hash from utils.memory_ingestion.models import EvidenceSpan, LintResult, MemoryPipelineOutput - - -def _edit_distance(a: str, b: str) -> int: - """Levenshtein edit distance between two strings.""" - if len(a) < len(b): - return _edit_distance(b, a) - if len(b) == 0: - return len(a) - prev_row = list(range(len(b) + 1)) - for i, ca in enumerate(a): - curr_row = [i + 1] - for j, cb in enumerate(b): - insertions = prev_row[j + 1] + 1 - deletions = curr_row[j] + 1 - substitutions = prev_row[j] + (ca != cb) - curr_row.append(min(insertions, deletions, substitutions)) - prev_row = curr_row - return prev_row[-1] +from utils.memory_ingestion.ids import edit_distance as _edit_distance def _check_confidence_contradiction(output: MemoryPipelineOutput) -> list[LintResult]: diff --git a/backend/utils/retrieval/agentic.py b/backend/utils/retrieval/agentic.py index 414f083cba2..f9674bb2acc 100644 --- a/backend/utils/retrieval/agentic.py +++ b/backend/utils/retrieval/agentic.py @@ -75,8 +75,6 @@ from database.users import get_user_location_context_consent from models.geolocation import Geolocation from utils.conversations.location import async_get_google_maps_city -from utils.other.endpoints import timeit -from utils.observability.langsmith import is_langsmith_enabled import logging try: diff --git a/backend/utils/retrieval/tools/apple_health_tools.py b/backend/utils/retrieval/tools/apple_health_tools.py index a48847acbfb..414eb8271a4 100644 --- a/backend/utils/retrieval/tools/apple_health_tools.py +++ b/backend/utils/retrieval/tools/apple_health_tools.py @@ -116,7 +116,7 @@ def get_apple_health_steps_tool( try: sync_dt = datetime.fromisoformat(last_synced.replace('Z', '+00:00')) sync_info = f"\n\n(Data last synced: {sync_dt.strftime('%Y-%m-%d %H:%M')} UTC)" - except: + except (ValueError, TypeError): pass result = f"Apple Health Step Data (Last {period_days} days):\n\n" @@ -198,7 +198,7 @@ def get_apple_health_sleep_tool( try: sync_dt = datetime.fromisoformat(last_synced.replace('Z', '+00:00')) result += f"\n(Data last synced: {sync_dt.strftime('%Y-%m-%d %H:%M')} UTC)" - except: + except (ValueError, TypeError): pass return result.strip() @@ -258,7 +258,7 @@ def get_apple_health_heart_rate_tool( try: sync_dt = datetime.fromisoformat(last_synced.replace('Z', '+00:00')) result += f"\n(Data last synced: {sync_dt.strftime('%Y-%m-%d %H:%M')} UTC)" - except: + except (ValueError, TypeError): pass return result.strip() @@ -323,7 +323,7 @@ def get_apple_health_workouts_tool( try: start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc).astimezone(user_tz) date_str = f" - {start_dt.strftime('%m/%d %I:%M %p')}" - except: + except (ValueError, TypeError, OSError, OverflowError): pass result += f"{i}. {workout_type}{date_str}\n" @@ -340,7 +340,7 @@ def get_apple_health_workouts_tool( try: sync_dt = datetime.fromisoformat(last_synced.replace('Z', '+00:00')) result += f"(Data last synced: {sync_dt.strftime('%Y-%m-%d %H:%M')} UTC)" - except: + except (ValueError, TypeError): pass return result.strip() @@ -457,7 +457,7 @@ def get_apple_health_summary_tool( try: sync_dt = datetime.fromisoformat(last_synced.replace('Z', '+00:00')) result += f"\n(Data last synced: {sync_dt.strftime('%Y-%m-%d %H:%M')} UTC)" - except: + except (ValueError, TypeError): pass return result.strip() diff --git a/backend/utils/retrieval/tools/calendar_tools.py b/backend/utils/retrieval/tools/calendar_tools.py index b63d21829b4..d79ae56005a 100644 --- a/backend/utils/retrieval/tools/calendar_tools.py +++ b/backend/utils/retrieval/tools/calendar_tools.py @@ -663,13 +663,13 @@ async def get_calendar_events_tool( try: start_dt = datetime.fromisoformat(start['dateTime'].replace('Z', '+00:00')) events_with_time.append((start_dt, event)) - except: + except (ValueError, TypeError): events_with_time.append((datetime.min.replace(tzinfo=timezone.utc), event)) elif 'date' in start: try: start_dt = datetime.fromisoformat(start['date'] + 'T00:00:00+00:00') events_with_time.append((start_dt, event)) - except: + except (ValueError, TypeError): events_with_time.append((datetime.min.replace(tzinfo=timezone.utc), event)) else: events_with_time.append((datetime.min.replace(tzinfo=timezone.utc), event)) @@ -758,7 +758,7 @@ async def get_calendar_events_tool( try: start_dt = datetime.fromisoformat(start['dateTime'].replace('Z', '+00:00')) result += f" Start: {_format_event_dt(start_dt, display_tz, tz_label)}\n" - except: + except (ValueError, TypeError): result += f" Start: {start.get('dateTime', 'Unknown')}\n" elif 'date' in start: result += f" Date: {start.get('date', 'Unknown')}\n" @@ -769,7 +769,7 @@ async def get_calendar_events_tool( try: end_dt = datetime.fromisoformat(end['dateTime'].replace('Z', '+00:00')) result += f" End: {_format_event_dt(end_dt, display_tz, tz_label)}\n" - except: + except (ValueError, TypeError): result += f" End: {end.get('dateTime', 'Unknown')}\n" elif 'date' in end: result += f" End Date: {end.get('date', 'Unknown')}\n" diff --git a/backend/utils/subscription.py b/backend/utils/subscription.py index 2c0987125ea..6294a20cb9e 100644 --- a/backend/utils/subscription.py +++ b/backend/utils/subscription.py @@ -25,7 +25,7 @@ resolve_stripe_price_plan, ) from models.users import PlanType, SubscriptionStatus, Subscription, PlanLimits, TrialMetadata -from utils.byok import get_byok_key, get_byok_keys, get_byok_uid, get_cached_byok_state, has_validated_byok_keys +from utils.byok import get_byok_key, get_byok_uid, get_cached_byok_state, has_validated_byok_keys from utils.log_sanitizer import sanitize from utils.observability.fallback import record_fallback import logging diff --git a/backend/utils/webhooks.py b/backend/utils/webhooks.py index 6b450c36b98..df51ea9c98c 100644 --- a/backend/utils/webhooks.py +++ b/backend/utils/webhooks.py @@ -12,7 +12,6 @@ user_webhook_status_db, disable_user_webhook_db, enable_user_webhook_db, - set_user_webhook_db, ) from database.webhook_health import ( record_dev_webhook_failure, @@ -22,7 +21,6 @@ ) from models.conversation import Conversation from models.users import WebhookType, webhook_url_from_setting -import database.notifications as notification_db from utils.conversations.render import populate_speaker_names, populate_folder_names from utils.conversations.render import conversation_to_dict from utils.executors import db_executor, run_blocking diff --git a/desktop/macos/CHANGELOG.json b/desktop/macos/CHANGELOG.json index 1c52157f465..5a811983e5c 100644 --- a/desktop/macos/CHANGELOG.json +++ b/desktop/macos/CHANGELOG.json @@ -1,6 +1,27 @@ { "unreleased": [], "releases": [ + { + "version": "0.12.225", + "date": "2026-08-27", + "changes": [ + "Bug fixes and improvements" + ] + }, + { + "version": "0.12.224", + "date": "2026-08-27", + "changes": [ + "Chat failures now report which subsystem they came from, so a failed answer can be diagnosed instead of just counted" + ] + }, + { + "version": "0.12.223", + "date": "2026-08-27", + "changes": [ + "Bug fixes and improvements" + ] + }, { "version": "0.12.222", "date": "2026-08-26", diff --git a/desktop/macos/Desktop/Sources/AppState.swift b/desktop/macos/Desktop/Sources/AppState.swift index 06600eec392..1ddf9500dbb 100644 --- a/desktop/macos/Desktop/Sources/AppState.swift +++ b/desktop/macos/Desktop/Sources/AppState.swift @@ -319,7 +319,12 @@ class AppState: ObservableObject { /// continue into the WAL while the transport reconnects, so this stays /// visible until the backend is ready or the active session is reset. @Published var transcriptionServiceError: String? - var alertPresenter: any DesktopAlertPresenting = AppKitSheetAlertPresenter() + /// Assigned in `init()` rather than here: the pinned Xcode 16.4 toolchain + /// segfaults (signal 11 in `silgen emitStoredPropertyInitialization`) when + /// lowering this existential-erasure default initializer, introduced with + /// the presenter itself in d49f978512. Every desktop CI lane was red from + /// that commit until this dodge; behavior is identical on both toolchains. + var alertPresenter: any DesktopAlertPresenting /// Monotonically increasing counter — incremented for each recording start or stop request. /// Used to prevent asynchronous work from mutating a newer recording decision. var recordingGeneration: UInt64 = 0 @@ -655,6 +660,7 @@ class AppState: ObservableObject { } init() { + alertPresenter = AppKitSheetAlertPresenter() // Fold any legacy PTT-only microphone choice into the shared preference before // anything reads it. Running this only from PTT routing meant a user who had picked a // PTT microphone saw "System Default" in Transcription — and was recorded by it — diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+Permissions.swift b/desktop/macos/Desktop/Sources/AppState/AppState+Permissions.swift index c1cece87475..ff6df61b54e 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+Permissions.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+Permissions.swift @@ -103,7 +103,9 @@ final class AppKitSheetAlertPresenter: DesktopAlertPresenting { } @objc private func presentPendingAlertIfPossible() { - guard !queuePausedUntilForeground, !isPresentingAlert, !isRevealingMainWindow, !pendingAlerts.isEmpty else { return } + guard !queuePausedUntilForeground, !isPresentingAlert, !isRevealingMainWindow, !pendingAlerts.isEmpty else { + return + } let pending = pendingAlerts[0] guard let window = shellWindowProvider() else { revealMainWindowIfNeeded() diff --git a/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift b/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift index 10a9d69c0d8..f7afd8c0a31 100644 --- a/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift +++ b/desktop/macos/Desktop/Sources/AppState/AppState+Transcription.swift @@ -845,7 +845,7 @@ extension AppState { // Pause before the hand-off. NSWorkspace.open can return while Omi is // still the active app, and a queued alert must not attach a sheet that // System Settings then covers. didBecomeActive resumes the queue. - alertPresenter.pauseQueueUntilAppActive() + self.alertPresenter.pauseQueueUntilAppActive() if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") { NSWorkspace.shared.open(url) } diff --git a/desktop/macos/Desktop/Sources/Chat/ChatQueryTelemetry.swift b/desktop/macos/Desktop/Sources/Chat/ChatQueryTelemetry.swift index 13d71ec00a0..d4291957139 100644 --- a/desktop/macos/Desktop/Sources/Chat/ChatQueryTelemetry.swift +++ b/desktop/macos/Desktop/Sources/Chat/ChatQueryTelemetry.swift @@ -17,6 +17,70 @@ enum ChatQueryErrorClass: String, Equatable, Sendable { case toolStall = "tool_stall" case transientNetwork = "transient_network" case unknown + + /// Which subsystem a failed turn is attributed to. `error_class` is the + /// symptom a person saw; `root_cause` is the owner of the defect, and it is + /// what triage and churn analysis need to group on. Bounded by construction — + /// this never carries exception text. + var rootCause: ChatQueryRootCause { + switch self { + // Both auth and billing failures originate in the model provider account, + // not on the device. `provider_claude` is the value already published for + // `.authentication`; keep it so existing PostHog breakdowns stay valid. + case .authentication, .quota: return .providerClaude + case .agentError, .agentRuntime, .timeout, .toolStall: return .agentRuntime + case .bridgeUnavailable, .bridgeStartFailed: return .bridgeProcess + case .sessionSetup, .concurrentRequest: return .localSession + case .attachmentUpload: return .attachmentPipeline + case .browserExtensionMissing: return .browserExtension + case .encoding: return .requestEncoding + case .resourceExhausted: return .deviceResources + case .transientNetwork: return .network + case .unknown: return .unclassified + } + } + + /// Bounded `error_code` for failures that reach analytics without a + /// `ChatQueryErrorDetail`. Only the bridge catch path in `ChatProvider` + /// supplies a detail, so without this every other terminal — timeouts, tool + /// stalls, session setup, bridge unavailability — arrives with no code at all + /// and the event cannot explain itself. + func fallbackErrorCode(watchdogFired: Bool) -> String { + switch self { + // The two timeouts have different owners: the watchdog is ours, the bridge + // timeout is the runtime's. Collapsing them loses the only actionable bit. + case .timeout: return watchdogFired ? "watchdog_timeout" : "bridge_timeout" + case .toolStall: return "tool_stall_abort" + case .sessionSetup: return "session_setup_failed" + case .bridgeUnavailable: return "bridge_unavailable" + case .bridgeStartFailed: return "bridge_start_failed" + case .browserExtensionMissing: return "browser_extension_missing" + case .attachmentUpload: return "attachment_upload_failed" + case .concurrentRequest: return "request_already_active" + case .encoding: return "encoding_failed" + case .resourceExhausted: return "out_of_memory" + case .transientNetwork: return "transient_network" + case .quota: return "quota_exceeded" + case .authentication: return "authentication" + case .agentError: return "agent_error" + case .agentRuntime: return "agent_runtime_failure" + case .unknown: return "unclassified" + } + } +} + +/// Closed vocabulary for `chat_agent_error.root_cause`. +enum ChatQueryRootCause: String, Equatable, Sendable { + case agentRuntime = "agent_runtime" + case attachmentPipeline = "attachment_pipeline" + case bridgeProcess = "bridge_process" + case browserExtension = "browser_extension" + case deviceResources = "device_resources" + case localSession = "local_session" + case network + case providerClaude = "provider_claude" + case requestEncoding = "request_encoding" + case unclassified } enum ChatQueryCancellationReason: String, Equatable, Sendable { @@ -403,8 +467,12 @@ extension ChatQueryTelemetryEvent { "error_class": errorClass.rawValue, "partial_response": partialResponse, "watchdog_fired": watchdogFired, + // Populated for every failure, not only the ones that carry a detail. + "error_code": errorClass.fallbackErrorCode(watchdogFired: watchdogFired), + "root_cause": errorClass.rootCause.rawValue, ] if let detail { + // A detail is a strictly better code than the class fallback. properties["error_code"] = detail.errorCode if let retryable = detail.retryable { properties["retryable"] = retryable } if let failureCode = detail.failureCode { properties["failure_code"] = failureCode } @@ -456,7 +524,6 @@ extension ChatQueryTelemetryEvent { properties["error"] = errorClass.rawValue if errorClass == .authentication { properties["turn_disposition"] = "auth_blocked" - properties["root_cause"] = "provider_claude" } } return ChatQueryAnalyticsPayload(eventName: eventName, properties: properties) diff --git a/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift b/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift index 26ed7ade1e9..7a6a5842afb 100644 --- a/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift +++ b/desktop/macos/Desktop/Tests/AgentRuntimeProcessTests.swift @@ -1169,6 +1169,7 @@ final class AgentRuntimeProcessTests: XCTestCase { uniqueKeysWithValues: BYOKProvider.allCases.map { provider in (provider, UserDefaults.standard.string(forKey: provider.storageKey)) }) + let savedFingerprints = APIKeyService.enrolledFingerprints() defer { for provider in BYOKProvider.allCases { if let saved = savedKeys[provider] ?? nil { @@ -1183,6 +1184,7 @@ final class AgentRuntimeProcessTests: XCTestCase { } else { UserDefaults.standard.removeObject(forKey: .byokLLMProvider) } + APIKeyService.persistEnrolledFingerprints(savedFingerprints) } for provider in BYOKProvider.allCases { @@ -1190,6 +1192,12 @@ final class AgentRuntimeProcessTests: XCTestCase { } UserDefaults.standard.set(BYOKLLMProvider.openai.rawValue, forKey: .byokLLMProvider) let openAIKey = APIKeyService.byokKey(.openai)! + // usableBYOKEnvironment() gates on isByokActive, which requires the + // selected provider's key to be enrolled (#11454's fingerprint contract), + // separately from the per-request health suppression this test exercises. + APIKeyService.persistEnrolledFingerprints([ + BYOKProvider.openai.rawValue: APIKeyService.byokFingerprint(openAIKey) + ]) CredentialHealthManager.shared.recordProviderFailure( .providerAuthFailed(provider: .openai, mode: .byok), provider: .openai, @@ -1210,6 +1218,7 @@ final class AgentRuntimeProcessTests: XCTestCase { uniqueKeysWithValues: BYOKProvider.allCases.map { provider in (provider, UserDefaults.standard.string(forKey: provider.storageKey)) }) + let savedFingerprints = APIKeyService.enrolledFingerprints() defer { for provider in BYOKProvider.allCases { if let saved = savedKeys[provider] ?? nil { @@ -1224,12 +1233,18 @@ final class AgentRuntimeProcessTests: XCTestCase { } else { UserDefaults.standard.removeObject(forKey: .byokLLMProvider) } + APIKeyService.persistEnrolledFingerprints(savedFingerprints) } for provider in BYOKProvider.allCases { UserDefaults.standard.set("sk-agent-\(provider.rawValue)", forKey: provider.storageKey) } UserDefaults.standard.set(BYOKLLMProvider.openrouter.rawValue, forKey: .byokLLMProvider) + // usableBYOKEnvironment() gates on isByokActive, which requires the + // selected provider's key to be enrolled (#11454's fingerprint contract). + APIKeyService.persistEnrolledFingerprints([ + BYOKProvider.openrouter.rawValue: APIKeyService.byokFingerprint("sk-agent-openrouter") + ]) let result = AgentRuntimeProcess.usableBYOKEnvironment() diff --git a/desktop/macos/Desktop/Tests/BYOKPaywallTests.swift b/desktop/macos/Desktop/Tests/BYOKPaywallTests.swift index b4fa06da6b8..466fcaee3a9 100644 --- a/desktop/macos/Desktop/Tests/BYOKPaywallTests.swift +++ b/desktop/macos/Desktop/Tests/BYOKPaywallTests.swift @@ -20,6 +20,20 @@ import XCTest } } + /// `isByokActive` requires the selected provider's *current* key to match a + /// fingerprint already persisted by `activateBYOK` reconciliation — raw + /// UserDefaults presence alone is not enough (#11454 replaced the old + /// all-keys-present check with this enrollment contract). Tests that + /// exercise `isByokActive`/`isPaywalledEffective` must enroll the provider + /// whose key they just set, and re-enroll whenever that key's value changes. + private func enroll(_ p: BYOKProvider) { + guard let key = APIKeyService.byokKey(p) else { + XCTFail("enroll(\(p)) called before \(p.storageKey) was set") + return + } + APIKeyService.persistEnrolledFingerprints([p.rawValue: APIKeyService.byokFingerprint(key)]) + } + override func tearDown() async throws { CredentialHealthManager.shared.reset() clearAllBYOKKeys() @@ -37,10 +51,14 @@ import XCTest for p in BYOKProvider.allCases.dropLast() { UserDefaults.standard.set("k", forKey: p.storageKey) } + enroll(.openrouter) XCTAssertTrue(APIKeyService.isByokActive) - // All configured providers remain active. + // All configured providers remain active. setAllBYOKKeys() rewrites + // openrouter's key to "sk-test-openrouter", which invalidates the + // fingerprint just enrolled above — re-enroll against the new value. setAllBYOKKeys() + enroll(.openrouter) XCTAssertTrue(APIKeyService.isByokActive) } @@ -55,6 +73,7 @@ import XCTest func testBuildHeadersAttachSelectedLLMByokKey() async throws { clearAllBYOKKeys() UserDefaults.standard.set("sk-test-openai", forKey: BYOKProvider.openai.storageKey) + enroll(.openai) let client = APIClient() await client.setTestAuthHeader("Bearer test-token") @@ -90,6 +109,7 @@ import XCTest func testBuildHeadersSuppressesOnlyInvalidByokHeader() async throws { setAllBYOKKeys() UserDefaults.standard.set(BYOKLLMProvider.openai.rawValue, forKey: .byokLLMProvider) + enroll(.openai) let openAIKey = try XCTUnwrap(APIKeyService.byokKey(.openai)) CredentialHealthManager.shared.recordProviderFailure( .providerAuthFailed(provider: .openai, mode: .byok), @@ -113,6 +133,11 @@ import XCTest // The exact bug: trial-expired flag set, then user configures BYOK keys. UserDefaults.standard.set(true, forKey: paywallKey) setAllBYOKKeys() + // Explicit provider selection: with every provider's key set, legacy + // inference (first BYOKLLMProvider.allCases with a key present) would + // silently pick whichever provider we did not enroll. + UserDefaults.standard.set(BYOKLLMProvider.openrouter.rawValue, forKey: .byokLLMProvider) + enroll(.openrouter) XCTAssertFalse( AppState.isPaywalledEffective, "BYOK-active user must NOT be paywalled even with the flag set") @@ -135,6 +160,11 @@ import XCTest func testRemovingDeepgramKeyLeavesSelectedLLMByokActive() { UserDefaults.standard.set(true, forKey: paywallKey) setAllBYOKKeys() + // Explicit provider selection: with every provider's key set, legacy + // inference (first BYOKLLMProvider.allCases with a key present) would + // silently pick whichever provider we did not enroll. + UserDefaults.standard.set(BYOKLLMProvider.openrouter.rawValue, forKey: .byokLLMProvider) + enroll(.openrouter) XCTAssertFalse(AppState.isPaywalledEffective) // Deepgram is optional when a selected LLM key remains configured. diff --git a/desktop/macos/Desktop/Tests/ChatQueryTelemetryTests.swift b/desktop/macos/Desktop/Tests/ChatQueryTelemetryTests.swift index a5da52c2949..03dfa7bc084 100644 --- a/desktop/macos/Desktop/Tests/ChatQueryTelemetryTests.swift +++ b/desktop/macos/Desktop/Tests/ChatQueryTelemetryTests.swift @@ -189,6 +189,7 @@ final class ChatQueryTelemetryTests: XCTestCase { Set(payload.properties.keys), Set([ "attempt_id", "surface", "harness", "duration_ms", "error_class", "error", + "error_code", "root_cause", "partial_response", "watchdog_fired", "telemetry_schema_version", "input_length_bucket", "attachment_count", "has_image", ]) @@ -198,6 +199,82 @@ final class ChatQueryTelemetryTests: XCTestCase { XCTAssertFalse(payload.properties.keys.contains("text")) } + /// The 2026-08 macOS churn cohort could not explain `chat_agent_error` because + /// only the bridge catch path supplied a `ChatQueryErrorDetail`; every other + /// terminal arrived with no `error_code` and no `root_cause`. Every failure + /// class must now classify itself. + func testEveryFailureClassCarriesABoundedCodeAndRootCause() { + let allClasses: [ChatQueryErrorClass] = [ + .agentError, .agentRuntime, .attachmentUpload, .authentication, .bridgeUnavailable, + .bridgeStartFailed, .browserExtensionMissing, .concurrentRequest, .encoding, .quota, + .resourceExhausted, .sessionSetup, .timeout, .toolStall, .transientNetwork, .unknown, + ] + let allowedRootCauses = Set( + [ + ChatQueryRootCause.agentRuntime, .attachmentPipeline, .bridgeProcess, .browserExtension, + .deviceResources, .localSession, .network, .providerClaude, .requestEncoding, .unclassified, + ].map(\.rawValue)) + + for errorClass in allClasses { + let payload = ChatQueryTelemetryEvent.failed( + ChatQueryTelemetryContext(attemptId: "a", surface: "main_chat", harness: "pimono"), + durationMs: 10, + errorClass: errorClass, + partialResponse: false, + detail: nil + ).analyticsPayload + let code = payload.properties["error_code"] as? String + let rootCause = payload.properties["root_cause"] as? String + XCTAssertNotNil(code, "\(errorClass.rawValue) emitted no error_code") + XCTAssertFalse(code?.isEmpty ?? true, "\(errorClass.rawValue) emitted an empty error_code") + XCTAssertNotNil(rootCause, "\(errorClass.rawValue) emitted no root_cause") + XCTAssertTrue( + allowedRootCauses.contains(rootCause ?? ""), + "\(errorClass.rawValue) emitted unbounded root_cause \(rootCause ?? "nil")") + } + } + + /// Auth kept the value already published to PostHog so existing breakdowns + /// stay valid, and the two timeouts stay distinguishable because they have + /// different owners. + func testRootCauseAndTimeoutCodesStayActionable() { + func payload(_ errorClass: ChatQueryErrorClass, watchdogFired: Bool = false) -> [String: Any] { + ChatQueryTelemetryEvent.failed( + ChatQueryTelemetryContext(attemptId: "a", surface: "main_chat", harness: "pimono"), + durationMs: 10, + errorClass: errorClass, + partialResponse: false, + detail: nil, + watchdogFired: watchdogFired + ).analyticsPayload.properties + } + + XCTAssertEqual(payload(.authentication)["root_cause"] as? String, "provider_claude") + XCTAssertEqual(payload(.authentication)["turn_disposition"] as? String, "auth_blocked") + XCTAssertEqual(payload(.quota)["root_cause"] as? String, "provider_claude") + XCTAssertEqual(payload(.bridgeUnavailable)["root_cause"] as? String, "bridge_process") + XCTAssertEqual(payload(.timeout, watchdogFired: true)["error_code"] as? String, "watchdog_timeout") + XCTAssertEqual(payload(.timeout)["error_code"] as? String, "bridge_timeout") + } + + /// A detail is strictly better information than the class fallback, so it + /// must win rather than be shadowed by it. + func testErrorDetailCodeOverridesTheClassFallback() { + let payload = ChatQueryTelemetryEvent.failed( + ChatQueryTelemetryContext(attemptId: "a", surface: "main_chat", harness: "pimono"), + durationMs: 10, + errorClass: .agentRuntime, + partialResponse: false, + detail: .from( + BridgeError.agentRuntimeFailure( + AgentRuntimeFailure(code: "adapter_not_registered", userMessage: "Agent run failed"))) + ).analyticsPayload + + XCTAssertEqual(payload.properties["failure_code"] as? String, "adapter_not_registered") + XCTAssertNotEqual(payload.properties["error_code"] as? String, "agent_runtime_failure") + XCTAssertEqual(payload.properties["root_cause"] as? String, "agent_runtime") + } + func testDecoratedToolAndFailureDimensionsCannotLeakContentOrExplodeCardinality() { let metrics = ChatQueryCompletionMetrics( toolCallCount: 4, diff --git a/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift b/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift index 25d9c2b498a..6f419b7c774 100644 --- a/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/FloatingBarNotificationPreviewPolicyTests.swift @@ -12,6 +12,23 @@ import XCTest /// enabled is the one case that falls back to a native system banner so the /// notification is never fully silenced. final class FloatingBarNotificationPreviewPolicyTests: XCTestCase { + /// Runtime owner authorization is process-wide and fails closed on an + /// out-of-band `authUserId` write, staying revoked for every later suite in + /// the xctest process. The two owner-seeding tests below therefore establish + /// their owner through the production transition boundary, and restore runs + /// in `tearDown` rather than a `defer` so a failed assertion cannot leave the + /// authority revoked for whatever runs next. + private var ownerFixture: RuntimeOwnerAuthorityTestFixture? + + override func setUp() async throws { + ownerFixture = await RuntimeOwnerAuthorityTestFixture() + } + + override func tearDown() async throws { + await ownerFixture?.restore() + ownerFixture = nil + } + func testPreviewsAndBarEnabledShowsPreviewWithNoForcedBanner() { XCTAssertTrue( FloatingBarNotificationPreviewPolicy.shouldShowInBarPreview( @@ -166,11 +183,9 @@ final class FloatingBarNotificationPreviewPolicyTests: XCTestCase { /// `presentContextDirectorNotification` makes this call return `.queued` from the /// banner path instead of `.suppressed`, failing the test. @MainActor - func testDirectorDeliveryWithDisabledCategoryToggleIsSuppressedAtTheEntryPoint() throws { + func testDirectorDeliveryWithDisabledCategoryToggleIsSuppressedAtTheEntryPoint() async throws { let defaults = UserDefaults.standard let pinnedKeys = [ - DefaultsKey.authUserId.rawValue, - DefaultsKey.automationOwnerOverride.rawValue, NotificationService.masterEnabledDefaultsKey, NotificationService.frequencyDefaultsKey, DefaultsKey.desktopIsPaywalled.rawValue, @@ -192,8 +207,8 @@ final class FloatingBarNotificationPreviewPolicyTests: XCTestCase { } let owner = "owner-category-gate-\(UUID().uuidString)" - defaults.set(owner, forKey: DefaultsKey.authUserId.rawValue) - defaults.removeObject(forKey: DefaultsKey.automationOwnerOverride.rawValue) + let fixture = try XCTUnwrap(ownerFixture) + await fixture.establish(authOwnerID: owner) defaults.set(true, forKey: NotificationService.masterEnabledDefaultsKey) defaults.set(5, forKey: NotificationService.frequencyDefaultsKey) defaults.set(false, forKey: DefaultsKey.desktopIsPaywalled.rawValue) @@ -228,11 +243,9 @@ final class FloatingBarNotificationPreviewPolicyTests: XCTestCase { /// host cannot perform, failing the test; with the gate present they return /// before any surface and leave the presentation ledger untouched. @MainActor - func testGoalAndMeetingProducersHonorTheirCategoryTogglesAtTheSharedBoundary() throws { + func testGoalAndMeetingProducersHonorTheirCategoryTogglesAtTheSharedBoundary() async throws { let defaults = UserDefaults.standard let pinnedKeys = [ - DefaultsKey.authUserId.rawValue, - DefaultsKey.automationOwnerOverride.rawValue, NotificationService.masterEnabledDefaultsKey, NotificationService.frequencyDefaultsKey, DefaultsKey.desktopIsPaywalled.rawValue, @@ -256,8 +269,8 @@ final class FloatingBarNotificationPreviewPolicyTests: XCTestCase { } let owner = "owner-producer-gate-\(UUID().uuidString)" - defaults.set(owner, forKey: DefaultsKey.authUserId.rawValue) - defaults.removeObject(forKey: DefaultsKey.automationOwnerOverride.rawValue) + let fixture = try XCTUnwrap(ownerFixture) + await fixture.establish(authOwnerID: owner) defaults.set(true, forKey: NotificationService.masterEnabledDefaultsKey) defaults.set(5, forKey: NotificationService.frequencyDefaultsKey) defaults.set(false, forKey: DefaultsKey.desktopIsPaywalled.rawValue) diff --git a/desktop/macos/Desktop/Tests/RewindCaptureExclusionGenerationTests.swift b/desktop/macos/Desktop/Tests/RewindCaptureExclusionGenerationTests.swift index a7c220b39ac..10896803672 100644 --- a/desktop/macos/Desktop/Tests/RewindCaptureExclusionGenerationTests.swift +++ b/desktop/macos/Desktop/Tests/RewindCaptureExclusionGenerationTests.swift @@ -191,21 +191,31 @@ final class RewindCaptureExclusionGenerationTests: XCTestCase { /// #11572: launch / CI window where `auth_userId` is set but RewindDatabase /// has not resolved `currentUserId` yet. Capture preferred auth; isCurrent /// used to compare only the DB id and permanently fail-closed. - func testOwnerSnapshotStaysCurrentWhenAuthLeadsUnresolvedRewindDatabase() { - let defaults = UserDefaults.standard - let previousAuth = defaults.object(forKey: .authUserId) + /// + /// #12039: establish the owner through the production transition boundary. + /// Mutating `auth_userId` directly makes the process-wide authorization + /// authority correctly revoke the out-of-band owner, so this test otherwise + /// depends on which owner-bound suite ran before it. + @MainActor + func testOwnerSnapshotStaysCurrentWhenAuthLeadsUnresolvedRewindDatabase() async { + let ownerFixture = RuntimeOwnerAuthorityTestFixture() + addTeardownBlock { @MainActor in + await ownerFixture.restore() + } let previousDB = RewindDatabase.currentUserId defer { - if let previousAuth { - defaults.set(previousAuth, forKey: .authUserId) - } else { - defaults.removeObject(forKey: .authUserId) - } RewindDatabase.currentUserId = previousDB } + // Reproduce the shared-state signature from #12039: another suite changed + // durable auth outside the transition boundary, so the authorization + // authority revoked itself before this test started. + await ownerFixture.establish(authOwnerID: "prior-owner-\(UUID().uuidString)") + UserDefaults.standard.set("out-of-band-owner-\(UUID().uuidString)", forKey: .authUserId) + XCTAssertNil(RuntimeOwnerIdentity.captureAuthorizationSnapshot()) + let authOwner = "auth-leading-\(UUID().uuidString)" - defaults.set(authOwner, forKey: .authUserId) + await ownerFixture.establish(authOwnerID: authOwner) RewindDatabase.currentUserId = nil guard let snapshot = RewindCaptureOwnerSnapshot.capture() else { diff --git a/desktop/macos/changelog/releases/0.12.223.json b/desktop/macos/changelog/releases/0.12.223.json new file mode 100644 index 00000000000..e392e94fa95 --- /dev/null +++ b/desktop/macos/changelog/releases/0.12.223.json @@ -0,0 +1,7 @@ +{ + "version": "0.12.223", + "date": "2026-08-27", + "changes": [ + "Bug fixes and improvements" + ] +} diff --git a/desktop/macos/changelog/releases/0.12.224.json b/desktop/macos/changelog/releases/0.12.224.json new file mode 100644 index 00000000000..e29db451657 --- /dev/null +++ b/desktop/macos/changelog/releases/0.12.224.json @@ -0,0 +1,7 @@ +{ + "version": "0.12.224", + "date": "2026-08-27", + "changes": [ + "Chat failures now report which subsystem they came from, so a failed answer can be diagnosed instead of just counted" + ] +} diff --git a/desktop/macos/changelog/releases/0.12.225.json b/desktop/macos/changelog/releases/0.12.225.json new file mode 100644 index 00000000000..a2d90b32fa7 --- /dev/null +++ b/desktop/macos/changelog/releases/0.12.225.json @@ -0,0 +1,7 @@ +{ + "version": "0.12.225", + "date": "2026-08-27", + "changes": [ + "Bug fixes and improvements" + ] +} diff --git a/desktop/windows/AGENTS.md b/desktop/windows/AGENTS.md index 147f3088919..7c3d8b50c84 100644 --- a/desktop/windows/AGENTS.md +++ b/desktop/windows/AGENTS.md @@ -26,6 +26,7 @@ ignored, breaking the pi-mono dependency-closure postinstall check not resolve on disk" error. Use `npx pnpm@10 ` if your system pnpm is a different major version — don't downgrade a system-managed pnpm install for this alone. +**Node version pin:** `>=22.19.0 <23` — `.nvmrc` sets it for `nvm use`; Node 24+ breaks vitest (jsdom localStorage shadow) — `pretest` calls `scripts/check-node-version.mjs`. ## Development Workflow diff --git a/scripts/cm-builds b/scripts/cm-builds index 188782bee0a..dc8d33897b2 100755 --- a/scripts/cm-builds +++ b/scripts/cm-builds @@ -1,25 +1,40 @@ #!/bin/bash # Codemagic build status checker # Usage: cm-builds [limit] +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" +APP_ID="${CODEMAGIC_APP_ID:-66c95e6ec76853c447b8bcbb}" -# Load token from .env.local if [ -f "$ROOT_DIR/.env.local" ]; then + # shellcheck disable=SC2046 export $(grep CODEMAGIC_API_TOKEN "$ROOT_DIR/.env.local" | xargs) fi -if [ -z "$CODEMAGIC_API_TOKEN" ]; then +if [ -z "${CODEMAGIC_API_TOKEN:-}" ]; then echo "Error: CODEMAGIC_API_TOKEN not set" echo "Add it to .env.local or export it" exit 1 fi LIMIT=${1:-10} +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT -curl -s -H "x-auth-token: $CODEMAGIC_API_TOKEN" \ - "https://api.codemagic.io/builds?limit=$LIMIT" | jq -r ' +HTTP_CODE=$(curl -sS -o "$TMP" -w "%{http_code}" \ + -H "x-auth-token: $CODEMAGIC_API_TOKEN" \ + "https://api.codemagic.io/builds?appId=$APP_ID&limit=$LIMIT") + +if [ "$HTTP_CODE" != "200" ]; then + echo "Error: Codemagic API returned HTTP $HTTP_CODE" >&2 + cat "$TMP" >&2 || true + exit 1 +fi + +jq -e '.builds' "$TMP" >/dev/null + +jq -r ' .builds | .[] | "\( .status | @@ -31,4 +46,4 @@ curl -s -H "x-auth-token: $CODEMAGIC_API_TOKEN" \ elif . == "skipped" then "⏭️ SKIPPED " else . end - ) \((.config.name // "unknown workflow")[0:45]) (\(.branch))"' + ) \((.config.name // "unknown workflow")[0:45]) (\(.branch))"' "$TMP" diff --git a/scripts/cm-builds.sh b/scripts/cm-builds.sh deleted file mode 100755 index 44135df95ab..00000000000 --- a/scripts/cm-builds.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# -# Codemagic Build Status Checker -# Usage: ./cm-builds.sh [limit] -# - -LIMIT=${1:-10} -APP_ID="66c95e6ec76853c447b8bcbb" - -# Check for API token -if [ -z "$CODEMAGIC_API_TOKEN" ]; then - echo "Error: CODEMAGIC_API_TOKEN not set" - echo "Add to ~/.zshrc: export CODEMAGIC_API_TOKEN=\"your-token\"" - exit 1 -fi - -echo "Recent Codemagic builds (limit: $LIMIT):" -echo "----------------------------------------" - -curl -s -H "Authorization: Bearer $CODEMAGIC_API_TOKEN" \ - "https://api.codemagic.io/builds?appId=$APP_ID&limit=$LIMIT" | \ - jq -r '.builds[] | - (if .status == "building" then "🔨" - elif .status == "finished" then "✅" - elif .status == "failed" then "❌" - elif .status == "skipped" then "⏭️" - elif .status == "queued" then "⏳" - else "❓" end) + " " + - (.index | tostring) + " | " + - .status + " | " + - (.config.name // "unknown") + " | " + - (.createdAt | split("T")[0])' diff --git a/scripts/export_onboarding_sync_bundle.sh b/scripts/export_onboarding_sync_bundle.sh index 766f0fa96e3..82b1873ebed 100755 --- a/scripts/export_onboarding_sync_bundle.sh +++ b/scripts/export_onboarding_sync_bundle.sh @@ -22,7 +22,7 @@ cleanup() { } trap cleanup EXIT -cd "$REPO_ROOT/desktop/Desktop" +cd "$REPO_ROOT/desktop/macos/Desktop" # Put Apple-provided tools first so SwiftPM does not pick up a broken Homebrew git on CI/macOS hosts. export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:$PATH" diff --git a/scripts/install_onboarding_figma_sync.sh b/scripts/install_onboarding_figma_sync.sh index 91bc0c4894c..cbfdfef07e5 100755 --- a/scripts/install_onboarding_figma_sync.sh +++ b/scripts/install_onboarding_figma_sync.sh @@ -57,8 +57,8 @@ cat >"$PLIST_PATH" <10 WatchPaths - $WATCH_REPO/desktop/Desktop/Sources - $WATCH_REPO/desktop/Desktop/Resources + $WATCH_REPO/desktop/macos/Desktop/Sources + $WATCH_REPO/desktop/macos/Desktop/Sources/Resources StandardOutPath $STATE_DIR/launchd.out.log diff --git a/scripts/low_conv_high_transcription.py b/scripts/low_conv_high_transcription.py index f171eb3a465..b71ef033020 100644 --- a/scripts/low_conv_high_transcription.py +++ b/scripts/low_conv_high_transcription.py @@ -6,6 +6,7 @@ python3 scripts/low_conv_high_transcription.py """ +import json import logging import os import sys @@ -22,7 +23,7 @@ if os.getenv('SERVICE_ACCOUNT_JSON'): service_account_info = os.environ["SERVICE_ACCOUNT_JSON"] cred = credentials.Certificate( - eval(service_account_info) if service_account_info.startswith('{') else service_account_info + json.loads(service_account_info) if service_account_info.startswith('{') else service_account_info ) else: cred = credentials.ApplicationDefault() @@ -118,7 +119,9 @@ def main(): print() # Per-bucket stats - print(f" {'Bucket':<22} {'Users':<10} {'% Users':<10} {'Total Transcription':<22} {'% of Total':<12} {'Avg/User':<14}") + print( + f" {'Bucket':<22} {'Users':<10} {'% Users':<10} {'Total Transcription':<22} {'% of Total':<12} {'Avg/User':<14}" + ) print(f" {'-'*22} {'-'*10} {'-'*10} {'-'*22} {'-'*12} {'-'*14}") for label in ['0 conversations', '1 conversation', '2-4 conversations', '<5 total', '5+ conversations']: diff --git a/scripts/run_onboarding_figma_sync.sh b/scripts/run_onboarding_figma_sync.sh index e684064abcd..003128810f6 100755 --- a/scripts/run_onboarding_figma_sync.sh +++ b/scripts/run_onboarding_figma_sync.sh @@ -49,17 +49,16 @@ trap 'rm -f "$FILES_TO_SYNC"; cleanup' EXIT ( cd "$SOURCE_REPO" - find desktop/Desktop/Sources -type f \ + find desktop/macos/Desktop/Sources -type f \ \( -name 'Onboarding*.swift' \ -o -name 'PostOnboardingPromptViews.swift' \ - -o -path 'desktop/Desktop/Sources/FileIndexing/OnboardingLoadingAnimation.swift' \ - -o -path 'desktop/Desktop/Sources/FloatingControlBar/ShortcutSettings.swift' \ - -o -path 'desktop/Desktop/Sources/Theme/OmiColors.swift' \) \ + -o -path 'desktop/macos/Desktop/Sources/FloatingControlBar/ShortcutSettings.swift' \ + -o -path 'desktop/macos/Desktop/Sources/Theme/OmiColors.swift' \) \ | sort ) >"$FILES_TO_SYNC" rsync -a --files-from="$FILES_TO_SYNC" "$SOURCE_REPO/" "$EXPORT_REPO/" -python3 "$EXPORT_REPO/scripts/apply_export_preview_overrides.py" "$EXPORT_REPO/desktop/Desktop/Sources" +python3 "$EXPORT_REPO/scripts/apply_export_preview_overrides.py" "$EXPORT_REPO/desktop/macos/Desktop/Sources" SOURCE_COMMIT=$(git -C "$SOURCE_REPO" rev-parse HEAD 2>/dev/null || echo local) SOURCE_BRANCH=$(git -C "$SOURCE_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || echo local) @@ -75,7 +74,7 @@ if ! lsof -ti tcp:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then fi pkill -f 'chrome-devtools-mcp' || true -pkill -f '/Users/nik/.cache/chrome-devtools-mcp/chrome-profile' || true +pkill -f "chrome-devtools-mcp/chrome-profile" || true pkill -f "codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -C $SITE_DIR" || true sleep 1 diff --git a/scripts/transcription_vs_conversations.py b/scripts/transcription_vs_conversations.py index ea478eaaa8c..2f5164a3a65 100644 --- a/scripts/transcription_vs_conversations.py +++ b/scripts/transcription_vs_conversations.py @@ -11,6 +11,7 @@ """ import argparse +import json import logging import os import sys @@ -28,7 +29,7 @@ if os.getenv('SERVICE_ACCOUNT_JSON'): service_account_info = os.environ["SERVICE_ACCOUNT_JSON"] cred = credentials.Certificate( - eval(service_account_info) if service_account_info.startswith('{') else service_account_info + json.loads(service_account_info) if service_account_info.startswith('{') else service_account_info ) else: cred = credentials.ApplicationDefault() @@ -131,9 +132,7 @@ def main(): print(f" (users with >= {format_duration(args.min_seconds)} transcription)") print(f" Formula: ratio = transcription_seconds / max(conversations, 1)") print(f"{'='*110}\n") - print( - f" {'Rank':<6} {'Transcription':<16} {'Convos':<10} {'Ratio':<14} {'Sec/Conv':<12} {'Email':<35} {'UID'}" - ) + print(f" {'Rank':<6} {'Transcription':<16} {'Convos':<10} {'Ratio':<14} {'Sec/Conv':<12} {'Email':<35} {'UID'}") print(f" {'-'*6} {'-'*16} {'-'*10} {'-'*14} {'-'*12} {'-'*35} {'-'*36}") for i, (uid, seconds, convs, ratio) in enumerate(top, 1): diff --git a/web/app/bun.lock b/web/app/bun.lock index 30d2f6e3c0b..0bc1aee3ba9 100644 --- a/web/app/bun.lock +++ b/web/app/bun.lock @@ -64,6 +64,8 @@ "eslint": "^9", "jsdom": "^29.1.1", "postcss": "~8.5.18", + "prettier": "^2.8.8", + "prettier-plugin-tailwindcss": "^0.3.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7", @@ -1253,6 +1255,10 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.3.0", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@shufo/prettier-plugin-blade": "*", "@trivago/prettier-plugin-sort-imports": "*", "prettier": ">=2.2.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*", "prettier-plugin-twig-melody": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@shufo/prettier-plugin-blade", "@trivago/prettier-plugin-sort-imports", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-style-order", "prettier-plugin-svelte", "prettier-plugin-twig-melody"] }, "sha512-009/Xqdy7UmkcTBpwlq7jsViDqXAYSOMLDrHAdTMlVZOrKfM2o9Ci7EMWTMZ7SkKBFTG04UM9F9iM2+4i6boDA=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], diff --git a/web/frontend/src/app/apps/[id]/page.tsx b/web/frontend/src/app/apps/[id]/page.tsx index 5c932125bbe..f50eec42ccb 100644 --- a/web/frontend/src/app/apps/[id]/page.tsx +++ b/web/frontend/src/app/apps/[id]/page.tsx @@ -153,11 +153,13 @@ function getPlatformLink(userAgent: string) { const isAndroid = /android/i.test(userAgent); const isIOS = /iphone|ipad|ipod/i.test(userAgent); - return isAndroid - ? 'https://play.google.com/store/apps/details?id=com.friend.ios' - : isIOS - ? 'https://apps.apple.com/us/app/friend-ai-wearable/id6502156163' - : 'https://omi.me'; + if (isAndroid) { + return 'https://play.google.com/store/apps/details?id=com.friend.ios'; + } + if (isIOS) { + return 'https://apps.apple.com/us/app/friend-ai-wearable/id6502156163'; + } + return 'https://omi.me'; } // Helper function to format date