diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1eb4bca148..d8374ee121 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -119,12 +119,18 @@ jobs: - name: GitHub pulse tests run: bash scripts/test-github-pulse.sh + - name: GitHub branch CI tests + run: bash scripts/test-github-branch-ci.sh + - name: GitHub activity tests run: bash scripts/test-github-activity.sh - name: Wardrobe tests run: bash scripts/test-wardrobe.sh + - name: Google Calendar tests + run: bash scripts/test-google-calendar.sh + - name: Pill colour tests run: bash scripts/test-pill-colors.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b716b1f5..462bc325b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- GitHub: the CI of your Claude Code session's branch, read from its .git folder. It takes the CI row of the GitHub card (macOS, GitHub build) and the CI detail lists it first. Mochi speaks up when a run on that branch finishes or someone reviews one of your pull requests, and pull requests untouched for a month drop off the list — thanks @JhoanG956 +- Google Calendar (macOS): a new pill with your next events and Join for the meeting that's on, and a reminder card when Google Calendar would ring; signs in with your own read-only OAuth client — thanks @JhoanG956 +- Dragging a file onto the island and dropping it elsewhere (or pressing Escape) puts the island back the way it was, instead of leaving the drop zone open — thanks @JhoanG956 +- Shell steps in the ticker drop the `cd &&` prefix, so the command itself shows — thanks @JhoanG956 + ## 0.5.2 — October 20, 2026 New versions now come to you: Mochi says it in the notch and one click installs it. Plus four new languages and a round of community work. diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index 7aa0758d12..1a1201a149 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 0099C844CFD9424CEAA5CA77 /* ClaudeResponseText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 444604C7E2C530B664849EF9 /* ClaudeResponseText.swift */; }; + 00BA6C52545395A5ACF2BDDE /* CaptureCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BA58E1F273995F1C51AAAC /* CaptureCardView.swift */; }; + 00CF5D0B758667454A7D2F4E /* GitHubBranchCI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4BCD6218DEDA865537F39F2 /* GitHubBranchCI.swift */; }; 01B96F947DF2BDC352E8526D /* WeeklyRecapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888B3E1420BCB6292BDC8117 /* WeeklyRecapView.swift */; }; 0325A44358904B44C2237CCE /* ApprovalQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BAE224C1C435C9FFD05BF65 /* ApprovalQueue.swift */; }; 0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */; }; @@ -18,24 +20,30 @@ 0408FE7F86EBEE61CAED32DA /* TurnRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2DFAEAF1DD1CBF03D3C314E /* TurnRecorder.swift */; }; 0417DE195518EE21267F8479 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; 053B30CF4568F7FE5138A898 /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; + 05F087B838004891988FBA79 /* FlowAudioBands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0596FD03875A10BD2CCBE332 /* FlowAudioBands.swift */; }; 065FCE817315454DE3F85F25 /* IslandViewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA02B08886939FF6FE0475DD /* IslandViewContent.swift */; }; 0685C3D47D27D06FCB6E4AD1 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; 06BF5FD95E03010CAFDBF2D0 /* NotchBuddyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */; }; 077ECE0906D8E820BD5FA764 /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; + 077F2D815859C061E9C5E08A /* MochiMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE096F3D7C3CD989EEBA55C4 /* MochiMood.swift */; }; 07B49D6825179DAA18DFF4D3 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; 07EC91820A69E8BF11EC778D /* PillColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37564F5F041B38C976DF3DEF /* PillColors.swift */; }; 08A485F2AC63FF5298ED897B /* OpenLeadMochiIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D96E57E8DADE6B82628E3E /* OpenLeadMochiIntent.swift */; }; 091EC3B2F1060D7EE1317E4E /* IslandWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D710BD7834228045869D0184 /* IslandWindowController.swift */; }; 0A267EE2955C7AD8E02A296C /* N8nPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */; }; + 0BD9756F8AD5C1ECA6B604FB /* FlowDictation.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD4A635006365A493A57B18 /* FlowDictation.swift */; }; 0D1AD905638B2B021B684A09 /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; 0E83C1DD4D20E7FB7A904450 /* VercelPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */; }; + 0EB57943A58F60AF72906BB1 /* FlowHubSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B249C39EA29E941BC52A6126 /* FlowHubSettings.swift */; }; 0FAAA8683282E9E8EA9E2416 /* AgentNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D05714C97CF5CAB32DCE7 /* AgentNotifier.swift */; }; 118D3ABA767EE8A7E55B0F8E /* EntityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC1DEBFCAEBE6F0EEB865F3 /* EntityResolver.swift */; }; 118FC4AE0AC74D84B4A134D8 /* VercelPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */; }; + 11CF033FB4438541075CAE25 /* MochiMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE096F3D7C3CD989EEBA55C4 /* MochiMood.swift */; }; 11F14DB97C93CF84DF9D5C43 /* TerminalTarget+AppKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244A7909EEDA4BFF4C2EA9B5 /* TerminalTarget+AppKit.swift */; }; 11F49CD9D12E69CE18DFEDCC /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; 134D136F3CF0B91AEBCA84EB /* finish.wav in Resources */ = {isa = PBXBuildFile; fileRef = 37147F42613183DBA8896835 /* finish.wav */; }; 13663D98590927AF71C17628 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; + 14869F429444B31D072A75B8 /* FlowHubNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2669CBBE5BDF9C775132D1 /* FlowHubNotes.swift */; }; 1558BB00C530B721172F6E65 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; }; 15D69752806E1D1D589B20A7 /* VoiceIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A7C62058BB0C9D0DF58DCB /* VoiceIntent.swift */; }; 160EDB9D06275E2607DA5151 /* N8nPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */; }; @@ -47,31 +55,14 @@ 1C2BB61FA83015C42B321E0E /* PillColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37564F5F041B38C976DF3DEF /* PillColors.swift */; }; 1D23613B270FE8B11C5E27A7 /* IslandDisplayChoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A09E73F1E58019EF6EA2A62 /* IslandDisplayChoice.swift */; }; 1D4CE0CCD2B5A0FFE50C7F06 /* TerminalTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87AF6F7A027AB1D46309DD49 /* TerminalTarget.swift */; }; - 8885D645C2235B74ACFFBD5C /* FlowHubMochis.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA206D2358F698DD382DA32 /* FlowHubMochis.swift */; }; - C25D058C2C5B4B7A93238F3A /* NoteTaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A6EB483F6EC87C05CC6C69 /* NoteTaker.swift */; }; - AA4B80F3F49E23FED830EC7D /* NoteAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BE0A486C4ED7BEDDC96548 /* NoteAudio.swift */; }; - A52241F5F37D87CB9BB8E4E0 /* NoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BB44A1EDFFD50C0688BD09 /* NoteModels.swift */; }; - 43346F544B8BF45F651C87B4 /* ParakeetEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9007267453D60200C2CB8F4 /* ParakeetEngine.swift */; }; - 8090516AA01E97B3F0D16261 /* FlowHubNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2FE56D7F32FCF7453CFB1B /* FlowHubNotes.swift */; }; - 7FBFCF6C9075D9EFD351961B /* FlowHubSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19503827102C75174006BF9E /* FlowHubSettings.swift */; }; - 88376FD9D48E9A77EF57C0FD /* FlowHubWords.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27F4F64C08DD3F72AF4C2FB /* FlowHubWords.swift */; }; - 299AF513444806BD9BA0CF96 /* FlowHubInsights.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF9B196F3433D1B9ED839E6 /* FlowHubInsights.swift */; }; - D3ADD680385678E53FC78655 /* FlowHubHome.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACE1C363C3E70B1E3B4D23B /* FlowHubHome.swift */; }; - 3E0F7C69420EFF9A4957A626 /* FlowHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82CF1F66182B1E903A14454D /* FlowHub.swift */; }; - C023D194AD85560A6F98E478 /* FlowCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0505B873924343CA2616B88 /* FlowCleanup.swift */; }; - 19D47205BAFE0A10B0CBE339 /* FlowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8265AEA7DA40AB9C850741B /* FlowStore.swift */; }; - 3A42E18567D03203D3DD3486 /* FlowText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5DB76AD36667A057426F959 /* FlowText.swift */; }; - 5663BA3BFBE656C2F5C00290 /* DictationTranscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78597C8DAC0BA94D4AD6E870 /* DictationTranscript.swift */; }; - 0AF77F5721986BE11151CE87 /* FlowSettingsBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0565D4D4D369E3ADD9CEF697 /* FlowSettingsBox.swift */; }; - 614F9783757FE777EF4CABB5 /* FlowPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D06C32348E26EAA1087BFC /* FlowPill.swift */; }; - F05CD98E6BF8679F12880345 /* FlowDictation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17870B121906BE24A935C407 /* FlowDictation.swift */; }; - 8B6743B43F9BB104B7C47981 /* FlowAudioBands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2878AB6B6D0ED6F04F1583 /* FlowAudioBands.swift */; }; - EA0082A25D2A06EE27CA34DF /* FlowKeyLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7D186A42376F4F1277FED3 /* FlowKeyLogic.swift */; }; 1DAB0704A136FF8E6FB34191 /* ClaudeResponseText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 444604C7E2C530B664849EF9 /* ClaudeResponseText.swift */; }; 1DCF28998358810CDBC93F91 /* ClaudeHookDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA7D927F6BB2667D37AA963 /* ClaudeHookDetection.swift */; }; 1E55440B97C640993E93D04D /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; 1EAFEE80A8066B77F9410025 /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */; }; + 1EE6D325934B60A16520ECA1 /* FlowHubInsights.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CE2B25C51E1F2186416192 /* FlowHubInsights.swift */; }; + 1F0E4663F0DF988ADDDE013C /* ClaudeDesktopChatWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95FF987AE2A4E9EFB6A15519 /* ClaudeDesktopChatWatcher.swift */; }; 1F11EC4E6F604C612A1ADAD1 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E4F18AA940EFF7E047B9F7 /* AppState.swift */; }; + 1F6F5652DDB4E75CD434ACF8 /* GoogleCalendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */; }; 1FE62D4EEA85FB25C17FDBA2 /* ServiceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */; }; 2104B8000768F551064B17E4 /* PhoneLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = C22AF9F301D7A23DA6254D49 /* PhoneLink.swift */; }; 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; }; @@ -79,17 +70,23 @@ 2405F4EC8C89EE56F612BE2B /* CryptoViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DD402B4D54D4A7DD36AB2 /* CryptoViews.swift */; }; 244EC9B77D1903C8DD505D04 /* MochiLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA6A002ABEC0CC57667FBE0 /* MochiLiveActivity.swift */; }; 2540E4198492CC2B9237ED99 /* SpotifyController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A9E6EB7C8468A8339CF6A5C /* SpotifyController.swift */; }; + 25C48FFCCCBBD66DFDAF2DB1 /* WhatsAppGOWAInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1B680E79F7E7C05EA7E15B /* WhatsAppGOWAInstaller.swift */; }; 25F66E99460D62478899A292 /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; + 26D933FDB84F83D33BE6B91B /* FlowPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD88FB0222D3F8893AF1D33D /* FlowPill.swift */; }; 26E35C79CCD09FEBA04A3EF6 /* LinkTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762C38A8952A90F394EDFE4 /* LinkTestView.swift */; }; 273B404F55739D661D9489D8 /* VoiceBrain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C563E55884A7750B40EC9A01 /* VoiceBrain.swift */; }; 273FC0FA14AAF6CE62FD14AE /* DesktopMochi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */; }; + 2768A639F60C4B4C6EC98436 /* DictationTranscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2317343D062ACCAF4E5BD /* DictationTranscript.swift */; }; + 27D6A055D1535690B95989E6 /* WhatsAppProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59FE9B117259A7B1E44FC75 /* WhatsAppProvider.swift */; }; 2875610F701376BD65573386 /* WindowContextCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */; }; 295F8FE5A9E2E8F1A6389DEE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53E4CAD2BCC77B280689B0F /* HomeView.swift */; }; 2A30FA2F046492E22A1C5719 /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; 2A57CCE7D6813E6340C036BE /* MailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F907A5CC3CC05876548E96 /* MailViews.swift */; }; + 2C14F1DB0449CEB5F29DFB4A /* ClaudeDesktopChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A5CB029F97C5F050CCF9A79 /* ClaudeDesktopChat.swift */; }; 2C798C3837A63B9BB561A1A0 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; }; 2CC2E3B688B15BBEB3AE3BCE /* WindowContextCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */; }; 2DEB459FBD0BA3CEAAA5FDE9 /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */; }; + 2E0DC1F3BCD0D894C8B94E7C /* WhatsAppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADD0D8C37F00BF1877153D8 /* WhatsAppModels.swift */; }; 2F36E3C6C073E968A682AFA4 /* NotionPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */; }; 326971DDA23F331116E5A628 /* VoiceBrain.swift in Sources */ = {isa = PBXBuildFile; fileRef = C563E55884A7750B40EC9A01 /* VoiceBrain.swift */; }; 3451DB0668FAEEF09503D808 /* WakePhrase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12EC8F605BBC37F8223D7AA /* WakePhrase.swift */; }; @@ -100,24 +97,34 @@ 390486F7CDC01C1BCDBB27F9 /* VoiceActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2737EC4885ADB8FDDA1C610 /* VoiceActionRunner.swift */; }; 3C8CBC6F9B6B3AEC1AD6647C /* CryptoViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59DD402B4D54D4A7DD36AB2 /* CryptoViews.swift */; }; 3CF69605BB03E567C2DB0935 /* MacDictation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6544ED02D945CF9F00647AD1 /* MacDictation.swift */; }; + 3D9DF8094D4B5FADBC64A071 /* CaptureCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BA58E1F273995F1C51AAAC /* CaptureCardView.swift */; }; 3E84EA0AE7C450941ED943CF /* NowPlayingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = F56C7E1EBC74664A7F1689CF /* NowPlayingViews.swift */; }; 40473EEC3029D466B2769672 /* AppleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 681E15F99FCF1E60C2553004 /* AppleStyle.swift */; }; 40F7576FC56ED7BC1D2B717E /* PillColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37564F5F041B38C976DF3DEF /* PillColors.swift */; }; + 410F0237661DBC02AD886663 /* FlowPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD88FB0222D3F8893AF1D33D /* FlowPill.swift */; }; 4117E7C8AA231AAE36E7509D /* UploadCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */; }; 41966EEF7599793AD70DFCEA /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; 422C116E73D2D9A96DAE104D /* MochiLive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265262A422C270C1798F4A80 /* MochiLive.swift */; }; + 43C858397BE8373A5B30623B /* ClaudeDesktopChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A5CB029F97C5F050CCF9A79 /* ClaudeDesktopChat.swift */; }; 440AAF9FA27CC3DF7CC9EF51 /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; 44C7DD4E6F3527820DE1E4EA /* PortfolioSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0793C2DA3333A385E075A04E /* PortfolioSettingsView.swift */; }; 4755AAC633DCE5DAA6D54A3C /* ServiceDetailRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AAC67875F84A862BC2A9AF /* ServiceDetailRunner.swift */; }; 4778852A12726803BCF8B357 /* SessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A27178AA6007F7B56901750 /* SessionDetailView.swift */; }; + 4804DA9B3008C5C477E2818C /* FlowHubSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B249C39EA29E941BC52A6126 /* FlowHubSettings.swift */; }; + 4811B6D9CA21A22E303A7419 /* IntegrationNews.swift in Sources */ = {isa = PBXBuildFile; fileRef = E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */; }; + 485EF08DEE9EA88DA565EC7D /* FlowText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324FEF6CF23F9D4A95799431 /* FlowText.swift */; }; 48FA5A0DE13526F6C2A6B836 /* MailController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2F18663F42A246DACCD639 /* MailController.swift */; }; 4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */; }; + 49EF271474F09D5251E16552 /* FlowDictation.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD4A635006365A493A57B18 /* FlowDictation.swift */; }; 4A32FB2EA69E8BA70EBEA8A7 /* ServiceLiveDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21B710512DB99390EC564487 /* ServiceLiveDetail.swift */; }; 4B6AC00F22EC75E90E0157D5 /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */; }; 4B83070258D5AAAC8FCEB15F /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; 4B99E2AD54926078AB087B43 /* SpotifyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914A792788D403F836BEB570 /* SpotifyViews.swift */; }; + 4C142089AC68774050AB551B /* WhatsAppGOWAProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AA2388D0E7690653E62BBB /* WhatsAppGOWAProcess.swift */; }; + 4C3CCE6691BBDB4F5FD4B49D /* WhatsAppGateway.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D4F560E1BEFB7B9D5865101 /* WhatsAppGateway.swift */; }; 4CA7948E8518229C04C6BE5A /* WakeWindowBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C3405BCC9BF5F378094BFB /* WakeWindowBackoff.swift */; }; 4D34FF10654B19B69FE1964C /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; + 4D3F75B2546ACB133C604638 /* NoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74146BD3F4B9884F6A43936F /* NoteModels.swift */; }; 4D958F2DDCE9504490FDA357 /* DiffEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FB972D02162720E566674 /* DiffEngine.swift */; }; 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */; }; 4F4176856B544D8C620B6204 /* ConversationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A67DA3B8EEF063C44E07F7E /* ConversationContext.swift */; }; @@ -126,9 +133,15 @@ 52F0DF1FBCB522951910390E /* InstructionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADC1FE5696B1075932D0B7C /* InstructionRunner.swift */; }; 530443F46F896F037A56F663 /* HotKeyCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1218B86E249691AF44F819AC /* HotKeyCenter.swift */; }; 535EC818E1DAF0FA588545D8 /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; + 53712E2AA1F851324A3CB3CF /* FlowCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF7FA8DEA10FA889A60447B /* FlowCleanup.swift */; }; 541AF30CF808CC0FC1B8D97D /* ElevenLabsTTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046F9CD91704C34AE5BE9393 /* ElevenLabsTTS.swift */; }; 56BE8C733F10A030A638FB60 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; }; + 56E12FFCAAB2C4F31600B0F8 /* FlowHubHome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982CBB722685931BB8958162 /* FlowHubHome.swift */; }; + 57251211943B23F85582E786 /* ClaudeDesktopChatWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95FF987AE2A4E9EFB6A15519 /* ClaudeDesktopChatWatcher.swift */; }; + 5970C8B051B6BE24FBF2A6F5 /* MacUpdateViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3C36DE7C1DC055250C811D /* MacUpdateViews.swift */; }; 59FABCC0B6794FE95319825C /* CoucouNotificationContent.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6DF93125491729E14636AD8 /* CoucouNotificationContent.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5A3DA40DB6C8161E6CB504C7 /* FlowSettingsBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8436291D94C826F1D9DA3130 /* FlowSettingsBox.swift */; }; + 5B6C4CB6C2FF022E59FC7B54 /* MacUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3701DD3FA89D1E44B7FBC3 /* MacUpdater.swift */; }; 5B9A00AFB0837731FA678E4E /* IslandStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */; }; 5C6D0BC8C546718CAA82BF90 /* MusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F142FB3952B5F0639383047 /* MusicController.swift */; }; 607E548D1DD274CD73FDC290 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; @@ -145,32 +158,18 @@ 654A8E7D8BA79AF90FA8B0DA /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; 658C579523CF5F8D67B31369 /* InstructionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADC1FE5696B1075932D0B7C /* InstructionRunner.swift */; }; 65CB53E20B2289CA3AC3E8FE /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; + 66069F348A37AE19806376A7 /* FlowHubWords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1A34DE2886E394C879CF64 /* FlowHubWords.swift */; }; 6613EB914852E6EEB1FF37BF /* IntentParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39753CFF90B494D449CCD95 /* IntentParser.swift */; }; 6726F74966EDE7F82BFE89B2 /* TerminalTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87AF6F7A027AB1D46309DD49 /* TerminalTarget.swift */; }; - C839C6CBE10ADE51BBC9D954 /* FlowHubMochis.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA206D2358F698DD382DA32 /* FlowHubMochis.swift */; }; - BAFE1A37B0ACEFDCE29E2B1E /* NoteTaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A6EB483F6EC87C05CC6C69 /* NoteTaker.swift */; }; - B42017CDFF5A9ED72E2FF99B /* NoteAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BE0A486C4ED7BEDDC96548 /* NoteAudio.swift */; }; - ACC4A6ED5F5915F5B0DE9DD6 /* NoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BB44A1EDFFD50C0688BD09 /* NoteModels.swift */; }; - B63AC65188449D52DB5DDC29 /* ParakeetEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9007267453D60200C2CB8F4 /* ParakeetEngine.swift */; }; - C500511EDFF4761BDA4488CE /* FlowHubNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2FE56D7F32FCF7453CFB1B /* FlowHubNotes.swift */; }; - A5260F0D566968C8A2FE43BD /* FlowHubSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19503827102C75174006BF9E /* FlowHubSettings.swift */; }; - 4176623B12ACEAFC98ED5821 /* FlowHubWords.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27F4F64C08DD3F72AF4C2FB /* FlowHubWords.swift */; }; - C9B8E68C2A81617292FEECBB /* FlowHubInsights.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF9B196F3433D1B9ED839E6 /* FlowHubInsights.swift */; }; - 6B3C267F0AD334A56AC6C8A4 /* FlowHubHome.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACE1C363C3E70B1E3B4D23B /* FlowHubHome.swift */; }; - 0CF1FAFFA2A1589B1A32AD64 /* FlowHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82CF1F66182B1E903A14454D /* FlowHub.swift */; }; - 011FB76783F2CF532EEFF7FC /* FlowCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0505B873924343CA2616B88 /* FlowCleanup.swift */; }; - E3E32D45E5570AE6CF52DAC0 /* FlowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8265AEA7DA40AB9C850741B /* FlowStore.swift */; }; - 4FAE0FDA3500A44DC05E7852 /* FlowText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5DB76AD36667A057426F959 /* FlowText.swift */; }; - AAC6AB081F959CEE5FE628C8 /* DictationTranscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78597C8DAC0BA94D4AD6E870 /* DictationTranscript.swift */; }; - 98863553DD449BD791A1E00C /* FlowSettingsBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0565D4D4D369E3ADD9CEF697 /* FlowSettingsBox.swift */; }; - 15C9E266B443EFE70908510B /* FlowPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D06C32348E26EAA1087BFC /* FlowPill.swift */; }; - 8BF7CB586067C38AB5C4738C /* FlowDictation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17870B121906BE24A935C407 /* FlowDictation.swift */; }; - B9DE5D941F640D9C701D0437 /* FlowAudioBands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2878AB6B6D0ED6F04F1583 /* FlowAudioBands.swift */; }; - A093B567173936F128B68053 /* FlowKeyLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7D186A42376F4F1277FED3 /* FlowKeyLogic.swift */; }; + 67B768A8B9395BC6AC32AD54 /* FlowHubHome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982CBB722685931BB8958162 /* FlowHubHome.swift */; }; 68A950E3C23FD612639D9776 /* IslandViewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA02B08886939FF6FE0475DD /* IslandViewContent.swift */; }; 68C3ACC708B69035FD0CB8C4 /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; + 6905D946C73F57F8BE5B852B /* GcalPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */; }; 694AB4504CD65662E1681E5C /* ServicePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0C229BD085D76EB3DD94DF /* ServicePublisher.swift */; }; 6A1AC92C431FA842928E8115 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB831F5623E699A2506698E /* NotificationViewController.swift */; }; + 6AB2EDACB4E80F218AC2A156 /* WhatsAppProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59FE9B117259A7B1E44FC75 /* WhatsAppProvider.swift */; }; + 6ABDBE19E95D661216B8DE13 /* FlowHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BFB4C9E1BBD89E2E6319DCC /* FlowHub.swift */; }; + 6B0B39207A8DA88EA35E785A /* ScreenCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26CBF0D968BFB1AB0E36B18F /* ScreenCapture.swift */; }; 6B4A9D635FD529CE1C2692B8 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; 6BB32D6B5E627555994DC3CB /* SpotterGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EF48A44D82B5422E631FE0 /* SpotterGeneration.swift */; }; 6BE6AA73FC83A588F21D9747 /* VoiceGenderNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065B61C0809F2E3A461E9466 /* VoiceGenderNames.swift */; }; @@ -179,6 +178,7 @@ 6D71A1FA10C766E465F326D4 /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; 6DDBB5EDE6BEFD4225238C28 /* approval.wav in Resources */ = {isa = PBXBuildFile; fileRef = 11FCBBE20532B1F0A184EA06 /* approval.wav */; }; 6FC65C190ED614C664C9BC17 /* SafeWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */; }; + 713F32381B1A47C5042063BB /* FlowLanguages.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB761EA15EABCD584DD012B /* FlowLanguages.swift */; }; 714B5BB7FFB0C8491AF67AD1 /* VoiceTranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE925A6844FE3E3C09131CE0 /* VoiceTranscriptHistory.swift */; }; 718410ED630AE3AC5DF59A95 /* VoiceSpeaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD11C7925B0A7831BFC2199C /* VoiceSpeaker.swift */; }; 71CBD1D39A6668BF454CA1C5 /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; @@ -187,7 +187,11 @@ 72B1AEDB2152BF73659EB598 /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; 733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */; }; 73BD92A06DA7C72FFF26DD0F /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; + 74B4C23EEEADCD73D66CBA2E /* FlowLanguages.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB761EA15EABCD584DD012B /* FlowLanguages.swift */; }; 74E97082F2241C36E5DD22EE /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; + 752E9E15BD9476A98DF4571D /* GitHubBranchCI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4BCD6218DEDA865537F39F2 /* GitHubBranchCI.swift */; }; + 75BE63D342A2AE68006F351C /* MacUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4A343F2C25DBD9A401ECAD /* MacUpdate.swift */; }; + 760E362FDF46869A0525D8C3 /* MochiMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE096F3D7C3CD989EEBA55C4 /* MochiMood.swift */; }; 770F21C6A4D840AD3B0A9CB9 /* MochiLive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265262A422C270C1798F4A80 /* MochiLive.swift */; }; 78B3C6814431B2DD9D1F4949 /* SoundEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */; }; 78F045A55E6F4EEA5787EEEF /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; @@ -195,9 +199,12 @@ 79BD23BDFE42A929764635E7 /* MailController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2F18663F42A246DACCD639 /* MailController.swift */; }; 7A449E237CAAB62758508C7C /* RecapStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78FB882D49084DEA6A10DB4D /* RecapStore.swift */; }; 7AEED9FBB924F0DD1D07E1A5 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B2AA83D80260660475FAB052 /* Localizable.xcstrings */; }; + 7B7CBFAADFF687004012609D /* FlowHubWords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1A34DE2886E394C879CF64 /* FlowHubWords.swift */; }; 7C75CCB3DC0116D8D3B12313 /* AskQuestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB84A66045EB20CAC93A8A6 /* AskQuestion.swift */; }; + 7CF0E28C3C049624200FFB36 /* WhatsAppController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A7538E542C7A0696EB3FF2 /* WhatsAppController.swift */; }; 7CF3242DAD44B442297116E5 /* Decisions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369F5AFDE37A03A1C25EB262 /* Decisions.swift */; }; 7D50086C2D8B6AEA0BD138FC /* QuestionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6993B34A0FFF8E3D454ADC /* QuestionPayload.swift */; }; + 7E99FD5D8E88EC7FC10E91EF /* WhatsAppWebhook.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E5DF01F97CBDED4D54508F /* WhatsAppWebhook.swift */; }; 80B0B3C777A9DC8C82952FD2 /* VoiceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B328028D18F590D6680B8A2B /* VoiceEngine.swift */; }; 80D1CB884F0587EFCB839315 /* LiveVoiceControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E47E39D2D52584FA219FA1 /* LiveVoiceControls.swift */; }; 81250AE3E83493CF806D459B /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; @@ -240,11 +247,14 @@ 9492F8E414E8E1814F01EE66 /* ApprovalCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BEAB5F20A13E5806A42EC9 /* ApprovalCard.swift */; }; 94E0849D13762C6EC8548FEF /* ClaudePlanCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */; }; 967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; }; + 968D08C757C0DD1CB593DC89 /* WhatsAppGateway.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D4F560E1BEFB7B9D5865101 /* WhatsAppGateway.swift */; }; 9728564FCBF0334E68E9E6E7 /* SessionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824E56D2255CA86DAC350DBF /* SessionPublisher.swift */; }; + 9771BC47A02F63FD66FB2F67 /* WhatsAppViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047257909800DC0D5A7DBEE1 /* WhatsAppViews.swift */; }; 992C904D46CB503D947C0940 /* PillColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37564F5F041B38C976DF3DEF /* PillColors.swift */; }; 995B59B7DE9B0537F240047A /* PillSessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF1D0C0784ED873065DD8C0 /* PillSessionRegistry.swift */; }; 99A1CF41C797EA7F98F2B5B1 /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; 9A04B16AEEAB1A344D13CE74 /* InstructionComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E74B3586BC0861C69E70A /* InstructionComposer.swift */; }; + 9AB1B9FDB64BC503CD484EAC /* FlowAudioBands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0596FD03875A10BD2CCBE332 /* FlowAudioBands.swift */; }; 9AD370335A3ECC9C82375F93 /* CodexPlanGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FA4DF1BE5246CCFFB72E5C /* CodexPlanGauge.swift */; }; 9AEFD05D0A39D4AF3C091B3A /* QuestionRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB78996E9F9A2E8A80D45E54 /* QuestionRelay.swift */; }; 9B0A1CABB8FB8209E20D9B06 /* EntityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC1DEBFCAEBE6F0EEB865F3 /* EntityResolver.swift */; }; @@ -257,19 +267,30 @@ 9EA178EA395B116336C1FA10 /* TerminalTarget+AppKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244A7909EEDA4BFF4C2EA9B5 /* TerminalTarget+AppKit.swift */; }; 9ECEE890D4486B6E83BD5B7F /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; 9F43BE6E77E371FCEB7FE8BA /* ServiceDetailRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AAC67875F84A862BC2A9AF /* ServiceDetailRunner.swift */; }; + A048CDED45A2E27E50057245 /* WhatsAppGOWAInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1B680E79F7E7C05EA7E15B /* WhatsAppGOWAInstaller.swift */; }; A0ED41FB1AC8845BE1E30A66 /* WakeWindowBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C3405BCC9BF5F378094BFB /* WakeWindowBackoff.swift */; }; + A225E8D82794DEDCAE672058 /* FlowCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF7FA8DEA10FA889A60447B /* FlowCleanup.swift */; }; + A5AC7A40E65A47174367DBC9 /* NoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74146BD3F4B9884F6A43936F /* NoteModels.swift */; }; A7733EE60694EA3E2A86C37A /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */; }; + A7B04680036A2443AC6162CD /* NoteTaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A666A5E3F809301028F0DF8 /* NoteTaker.swift */; }; A7EEF05A08F6B95370712089 /* error.wav in Resources */ = {isa = PBXBuildFile; fileRef = 871CA2BADC67D97C97C7009D /* error.wav */; }; + A83A59ADF9760FCC86086CD1 /* NoteEcho.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA29FF3215B8371EEB91D0E2 /* NoteEcho.swift */; }; A885B7E6DC43231BECE36E3D /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; A94F75489794D9C95E584B56 /* EnergyVAD.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E2C3ACD815230A0ECAB014 /* EnergyVAD.swift */; }; + A9EA458EBA699F51AB7A7DAD /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = 309025C92C81E04B357F560C /* FluidAudio */; }; + AA1BFE6FE3A8BD1D5E3C2E81 /* WhatsAppController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A7538E542C7A0696EB3FF2 /* WhatsAppController.swift */; }; + AA9E15E26466C32F5D613599 /* GoogleCalendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */; }; AAECF326505B361B5F818150 /* LiveVoiceControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E47E39D2D52584FA219FA1 /* LiveVoiceControls.swift */; }; ABE0CEF17018F1BAD8F7DBFA /* TurnEndPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787D306C2399FBAE8BBB86ED /* TurnEndPolicy.swift */; }; + ACBC410CF50E4B96706DA074 /* FlowKeyLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4983B1317913482E61EB344D /* FlowKeyLogic.swift */; }; AD94156F1F9CFF62B35202FB /* ApprovalRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD426E81DF79CA79888059D9 /* ApprovalRelay.swift */; }; ADB4EF797FBD6CC90F8D25D3 /* MochiActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 323F242B14A61A7C071EC115 /* MochiActivityAttributes.swift */; }; ADBB9C15DDA4044625B325BE /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; + AEFD43F6ECAAF9E8CDD8A6F4 /* IntegrationNews.swift in Sources */ = {isa = PBXBuildFile; fileRef = E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */; }; AFCA04C905C4E92BC480E4A2 /* VoiceQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654AF6918593586EFF1989C6 /* VoiceQuery.swift */; }; B09E884B33A35FA34543C64B /* IntentParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39753CFF90B494D449CCD95 /* IntentParser.swift */; }; B0CE5AC8B579866A14BD61D0 /* DemoEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF5BD66B00BB64BB80AF38C /* DemoEngine.swift */; }; + B1FB8C566645AC0A2AB34B24 /* WhatsAppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADD0D8C37F00BF1877153D8 /* WhatsAppModels.swift */; }; B2584220B877056366C8DCAE /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */; }; B314F09AD7924387D0E04B45 /* BotEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66E6F5FC81429871B6F766A8 /* BotEngine.swift */; }; @@ -277,22 +298,29 @@ B43ED72DC305F4A70A391094 /* PillSessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF1D0C0784ED873065DD8C0 /* PillSessionRegistry.swift */; }; B4F12F8F978FCA7E5309DE28 /* EnergyVAD.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E2C3ACD815230A0ECAB014 /* EnergyVAD.swift */; }; B5206E9D10AD77C0C24463AB /* WakeSpotter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1DE3225F5239394398E6B6 /* WakeSpotter.swift */; }; + B54AE47279E534DCE812BF9A /* VitalsHoverLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7889BA1980AB9D261DDE8A25 /* VitalsHoverLine.swift */; }; B555DFF3451AFB22CAF8E594 /* SpotterGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EF48A44D82B5422E631FE0 /* SpotterGeneration.swift */; }; + B595A51BC53F255F055798FF /* FlowHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BFB4C9E1BBD89E2E6319DCC /* FlowHub.swift */; }; + B5D82540DC98DADCF3455E17 /* GcalPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */; }; B5D8D34DD7A72F340030EF46 /* MacStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */; }; B605F976F4141435F27CF266 /* IslandRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */; }; B69DE31E2B0C2E35594CAA87 /* RecapStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78FB882D49084DEA6A10DB4D /* RecapStore.swift */; }; + B6C18F8EA3DCAFEAB19ED804 /* WhatsAppViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047257909800DC0D5A7DBEE1 /* WhatsAppViews.swift */; }; B733A76DD197F6D6D949284C /* HotKeyCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1218B86E249691AF44F819AC /* HotKeyCenter.swift */; }; B7C93262AA9D5DEAA0B79C7C /* MacStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */; }; B7ED198C941104866BB3BC57 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B99CF65C5E4BC887888C6 /* Haptics.swift */; }; B9560210C8B8140D6522B8F7 /* VoiceGenderNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065B61C0809F2E3A461E9466 /* VoiceGenderNames.swift */; }; + B957D76CBE6A0DF91E918C57 /* FlowHubInsights.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CE2B25C51E1F2186416192 /* FlowHubInsights.swift */; }; BA65FC4A40F7227837C26214 /* DesktopMochiLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */; }; BBCA973A1AE1385BBE79A834 /* VoiceWakeFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD7A2C29B15EFFAF67FF2DE /* VoiceWakeFilter.swift */; }; BC6819C32C715E203254B9C6 /* TurnShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E156CF1C215B8FE713B3A0 /* TurnShare.swift */; }; BCB4B7AEA1140868C36F39CC /* SessionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F622EF0C553F053716C2 /* SessionStatus.swift */; }; BDD8A210DD6D9DA2D15BC5EE /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; BDEFF4223DA949E2B8AB85A7 /* VoiceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B328028D18F590D6680B8A2B /* VoiceEngine.swift */; }; + BEC3D806C75A6D6DDE9CCFDC /* FlowHubNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2669CBBE5BDF9C775132D1 /* FlowHubNotes.swift */; }; BEC5DDB55B3DA67C61CA8470 /* DesktopMochi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */; }; BF3F437B1FE9C246E9014F76 /* VoiceActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2737EC4885ADB8FDDA1C610 /* VoiceActionRunner.swift */; }; + BFCBB2517D4BC82685A2EE46 /* MochiMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE096F3D7C3CD989EEBA55C4 /* MochiMood.swift */; }; C07E9A04E827284FDF33DB16 /* ShortcutLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA51E5B140C20812641168EA /* ShortcutLogic.swift */; }; C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; }; C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */; }; @@ -301,13 +329,18 @@ C3BA3FA2A4025194FC088C5A /* VoiceIslandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E03C8E267399FA25C75877 /* VoiceIslandView.swift */; }; C3D39D96E087E98F8BF59226 /* LastTurnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112D2BD70628CD67ACDD18AF /* LastTurnView.swift */; }; C3ECA730677F467D28910AF6 /* LiveActivityRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136626568FF0C3EAB1D08067 /* LiveActivityRelay.swift */; }; + C420F939B6D425B4AEE4C516 /* MochiMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE096F3D7C3CD989EEBA55C4 /* MochiMood.swift */; }; + C47046BF30AC3E2350DABFDC /* ParakeetEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8178EB447DDE9C03427B36 /* ParakeetEngine.swift */; }; C5549C2D60998E297A7DC593 /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; C5737FE7AE6B2BA92B9A8F72 /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; C61DDE2EC5FDDB39E4AA3B34 /* IslandWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D710BD7834228045869D0184 /* IslandWindowController.swift */; }; C61E45A1B325BEFBE854AB53 /* PortfolioViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A9F0D1E310731155BCB3D89 /* PortfolioViews.swift */; }; C67B690DFC0A294C0E65FCBB /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; C6EB0156909305CEF8B84A8D /* VoiceDataSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = F289B4DB7C57BC593C2E7B7F /* VoiceDataSources.swift */; }; + C7C4953290E7C198E2918E14 /* VitalsHoverLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7889BA1980AB9D261DDE8A25 /* VitalsHoverLine.swift */; }; + C7DCF18B4588088307E49FAD /* ParakeetEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8178EB447DDE9C03427B36 /* ParakeetEngine.swift */; }; C808B654C84D9F71DBF8493A /* GithubPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */; }; + C8F0DA5F0EF2503581EFE5A1 /* NoteAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5A3ADB1304C23C4AEDD48 /* NoteAudio.swift */; }; C93BA975D35B8CD7473CF995 /* DesktopMochiLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */; }; C940B651C6CA952D99388E04 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192844E7299DEED5C2372067 /* AppDelegate.swift */; }; C9ED8FFDC406DD1A23AABC7C /* TurnSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */; }; @@ -320,12 +353,17 @@ CE0A1BD64DF3E08D200691A6 /* TurnRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2DFAEAF1DD1CBF03D3C314E /* TurnRecorder.swift */; }; CE5ACDD856C76B717ABA295F /* VoiceResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C432FE1286945E42D3E11D7 /* VoiceResultView.swift */; }; CE5EBAEC47402D4987EA86C7 /* MacStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */; }; + CF89B87E2E6E6D838ECE9B8F /* MachineVitals.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D4076443F9849FF25CCCFB /* MachineVitals.swift */; }; + D00A95A24714141AB82621BE /* FlowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7124E98A85D75D3378F0AC8 /* FlowStore.swift */; }; D092D0D597D1CBCE6B775B5E /* ServicePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0C229BD085D76EB3DD94DF /* ServicePublisher.swift */; }; D19FA3394EE99B34184684ED /* MochiWardrobe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */; }; + D1C243401794E8BB087D8B10 /* FlowText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324FEF6CF23F9D4A95799431 /* FlowText.swift */; }; D20B2B175A5C1D9B410B51C0 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C64C4C5D705CE486221B74 /* PillCatalog.swift */; }; D2746E6D92F2E4C46892D5AA /* ServiceDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F64746B2740D5FE26B7E5A /* ServiceDetail.swift */; }; D34D31C27D97D23C0441D1BE /* MochiActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE18399D1CD69468CCF57966 /* MochiActivityState.swift */; }; D45D9C8EAF67FC2F46E9F352 /* CloudProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28323DC20034A281F6829884 /* CloudProbe.swift */; }; + D4E60449D7E33D93016192EC /* FlowSettingsBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8436291D94C826F1D9DA3130 /* FlowSettingsBox.swift */; }; + D62CFFA91DA4BC5755E55186 /* FlowHubMochis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79E7E2106BCE86A472ED8051 /* FlowHubMochis.swift */; }; D6559770BBFB06FD4E90187B /* OpenLeadMochiIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D96E57E8DADE6B82628E3E /* OpenLeadMochiIntent.swift */; }; D69E561062D9358837380ABD /* CryptoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DF398C822D98F541B62F89 /* CryptoController.swift */; }; D7FD1948C7F97D6A2904CDBE /* ElevenLabsTTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046F9CD91704C34AE5BE9393 /* ElevenLabsTTS.swift */; }; @@ -333,8 +371,10 @@ D9EEB1079ACBCEEF74439C44 /* ClaudeSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B238F6E7B20EFDED5CD3E8E /* ClaudeSettingsFile.swift */; }; D9FF132804EB458F6FA69116 /* GitHubActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */; }; DA813AE042EEFD29AD06802A /* PortfolioController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0364C7995680F9FA9F85F905 /* PortfolioController.swift */; }; + DAA8FBD53C6187FF4739EFF4 /* MacUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3701DD3FA89D1E44B7FBC3 /* MacUpdater.swift */; }; DBA38F8F673F436CE1D4A2E0 /* MochiActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 323F242B14A61A7C071EC115 /* MochiActivityAttributes.swift */; }; DCAC390FF9EC65B945A15B24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506824380E9C80B9BC6DCE72 /* Assets.xcassets */; }; + DCD9F5ECAFDBC52F388976C2 /* NoteTaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A666A5E3F809301028F0DF8 /* NoteTaker.swift */; }; DD2EEF113DB5EAA35CD88312 /* MochiOutfitDrawing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */; }; DD89FAA8EE64C5FCBBFD9960 /* PortfolioViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A9F0D1E310731155BCB3D89 /* PortfolioViews.swift */; }; DE05D09A32117582AC93A24A /* NotchBuddyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */; }; @@ -342,14 +382,18 @@ DEDE942C3CD3F30B492BEEF5 /* CryptoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DF398C822D98F541B62F89 /* CryptoController.swift */; }; E1581F8F2725B26E90D91C4A /* IntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B2EED5205DD7161E51BE1E1 /* IntroView.swift */; }; E2414533EDC7565286141198 /* SpotifyController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A9E6EB7C8468A8339CF6A5C /* SpotifyController.swift */; }; + E4B93DC2345688488FEAE078 /* ScreenCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26CBF0D968BFB1AB0E36B18F /* ScreenCapture.swift */; }; E4B9986674F4D425826F15A8 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; }; E5D08513CFEA5D5E6760ECA4 /* ConversationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A67DA3B8EEF063C44E07F7E /* ConversationContext.swift */; }; + E5F2FA35ADBC6E08BE2ABECD /* MacUpdateViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3C36DE7C1DC055250C811D /* MacUpdateViews.swift */; }; E701BC8996229D08362CA732 /* ServiceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF705275522F99CAE7E1429 /* ServiceStatus.swift */; }; E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; }; E81575CE4433C6F88E424A8F /* LocalChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8790ACE9F73BD74640A0F20C /* LocalChat.swift */; }; E82CA1269BF4859EEDE322AE /* MochiStill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045E7300878138F0AC385920 /* MochiStill.swift */; }; + E9342A768208D5054F6A74AD /* WhatsAppGOWAProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AA2388D0E7690653E62BBB /* WhatsAppGOWAProcess.swift */; }; E9C18F039035C78173C306C7 /* MailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F907A5CC3CC05876548E96 /* MailViews.swift */; }; E9C21D11BCB72C392436D19E /* ClaudePlanGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */; }; + EAEC8E117E130E5902295F73 /* FlowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7124E98A85D75D3378F0AC8 /* FlowStore.swift */; }; EB1D9680D3DB0F0707D4A043 /* CodexPlanGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FA4DF1BE5246CCFFB72E5C /* CodexPlanGauge.swift */; }; EB23ED9F1C12EA8B54A76A91 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; }; EC7904DC421C6EB6DCD77889 /* DenyApprovalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D849B1A27AD17AEE1C7FFD1A /* DenyApprovalIntent.swift */; }; @@ -359,14 +403,19 @@ ED62D1B9E6A35B1F85074A2A /* SharedSessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1064AD8471C51AFF4F12258 /* SharedSessions.swift */; }; EE3AB10544113500486A9684 /* ColorHex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ACB4350729C6513824D1ADF /* ColorHex.swift */; }; EF5562030A7BA718EFA63278 /* VoiceDataSources.swift in Sources */ = {isa = PBXBuildFile; fileRef = F289B4DB7C57BC593C2E7B7F /* VoiceDataSources.swift */; }; + EF584BA65AA62FCABD6FB6B2 /* FlowKeyLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4983B1317913482E61EB344D /* FlowKeyLogic.swift */; }; EF9E6418C3F39F6FE88FD812 /* CoucouPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D9CEB5655FCA197D6B10A91 /* CoucouPhoneApp.swift */; }; EFAA9936D5C9DE635CFBA2AB /* SharedSessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1064AD8471C51AFF4F12258 /* SharedSessions.swift */; }; + F032CA1CBF8253D9E3981A90 /* NoteAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5A3ADB1304C23C4AEDD48 /* NoteAudio.swift */; }; F06EE0A97CB77096A337AA8B /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = C066E2B991AA4F56B2724263 /* sounds */; }; F0B7062107F8A91394707706 /* ClaudeVoiceBrain.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFE8C8C843B4B30C547FF64A /* ClaudeVoiceBrain.swift */; }; F157D7A9BF878DB2B286B1F7 /* DemoEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF5BD66B00BB64BB80AF38C /* DemoEngine.swift */; }; F1EE04000665E204E02DC5AC /* DenyApprovalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D849B1A27AD17AEE1C7FFD1A /* DenyApprovalIntent.swift */; }; F25EB7EF3662AFF99AA1C8A1 /* TeamConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C1BB0DF7770A6B263995E6 /* TeamConfiguration.swift */; }; F28275101D83F121E582B1B1 /* LiveActivityRelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136626568FF0C3EAB1D08067 /* LiveActivityRelay.swift */; }; + F3272363A452F211726823A1 /* MachineVitals.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D4076443F9849FF25CCCFB /* MachineVitals.swift */; }; + F558DBB511DF88077B8A6159 /* FlowHubMochis.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79E7E2106BCE86A472ED8051 /* FlowHubMochis.swift */; }; + F5831D59B0C58C90139A37A4 /* WhatsAppWebhook.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E5DF01F97CBDED4D54508F /* WhatsAppWebhook.swift */; }; F5D6F8E2CA65AB16B487DDD0 /* IslandScreenGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */; }; F6CA56F362902839EF9A3E2E /* PhoneAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 60401D1023D17C3FD30D7113 /* PhoneAssets.xcassets */; }; F6D57EEBD7E937EFD2B715CF /* ClaudeHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */; }; @@ -376,11 +425,13 @@ F9685A58729FE64B23FCCFC5 /* VoiceResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C432FE1286945E42D3E11D7 /* VoiceResultView.swift */; }; FAD78D74C6906D4EC8AA4101 /* UploadSequenceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */; }; FB063FE6B8A4BB8FBAAF56CB /* LiveActivityLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA2D136C81C6D930C5357BD /* LiveActivityLink.swift */; }; + FB4E9143139138567993D601 /* MacUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4A343F2C25DBD9A401ECAD /* MacUpdate.swift */; }; FB58020A389126865363C342 /* WeeklyRecapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888B3E1420BCB6292BDC8117 /* WeeklyRecapView.swift */; }; FBBC4CDE82B5886A83A27D7A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506824380E9C80B9BC6DCE72 /* Assets.xcassets */; }; + FD4B5673A5427128DE1910F3 /* DictationTranscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A2317343D062ACCAF4E5BD /* DictationTranscript.swift */; }; FDF87CF7C5F703EDD9A7CC14 /* CloudProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28323DC20034A281F6829884 /* CloudProbe.swift */; }; FEFEF1EBAB0E6CA74A33C10B /* SafeWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */; }; - 8896DCFCB54BA53FE1EE49AC /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = FAFFA455BB88CDC7BB499851 /* FluidAudio */; }; + FF8CEDBF43600E4AF5089A98 /* NoteEcho.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA29FF3215B8371EEB91D0E2 /* NoteEcho.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -421,10 +472,13 @@ 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = ""; }; 045E7300878138F0AC385920 /* MochiStill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiStill.swift; sourceTree = ""; }; 046F9CD91704C34AE5BE9393 /* ElevenLabsTTS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElevenLabsTTS.swift; sourceTree = ""; }; + 047257909800DC0D5A7DBEE1 /* WhatsAppViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppViews.swift; sourceTree = ""; }; + 0596FD03875A10BD2CCBE332 /* FlowAudioBands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowAudioBands.swift; sourceTree = ""; }; 065B61C0809F2E3A461E9466 /* VoiceGenderNames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceGenderNames.swift; sourceTree = ""; }; 0793C2DA3333A385E075A04E /* PortfolioSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortfolioSettingsView.swift; sourceTree = ""; }; 08128342A3F894D567AD9E6B /* ServiceViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceViews.swift; sourceTree = ""; }; 097F57179CC066459E64303A /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + 0BFB4C9E1BBD89E2E6319DCC /* FlowHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHub.swift; sourceTree = ""; }; 0D9CEB5655FCA197D6B10A91 /* CoucouPhoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoucouPhoneApp.swift; sourceTree = ""; }; 0FA2D136C81C6D930C5357BD /* LiveActivityLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityLink.swift; sourceTree = ""; }; 112D2BD70628CD67ACDD18AF /* LastTurnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastTurnView.swift; sourceTree = ""; }; @@ -435,6 +489,7 @@ 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandRootView.swift; sourceTree = ""; }; 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubActivity.swift; sourceTree = ""; }; 192844E7299DEED5C2372067 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 1A1A34DE2886E394C879CF64 /* FlowHubWords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubWords.swift; sourceTree = ""; }; 1B2EED5205DD7161E51BE1E1 /* IntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroView.swift; sourceTree = ""; }; 1BAE224C1C435C9FFD05BF65 /* ApprovalQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalQueue.swift; sourceTree = ""; }; 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEngine.swift; sourceTree = ""; }; @@ -444,14 +499,19 @@ 23F907A5CC3CC05876548E96 /* MailViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailViews.swift; sourceTree = ""; }; 244A7909EEDA4BFF4C2EA9B5 /* TerminalTarget+AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalTarget+AppKit.swift"; sourceTree = ""; }; 265262A422C270C1798F4A80 /* MochiLive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiLive.swift; sourceTree = ""; }; + 26CBF0D968BFB1AB0E36B18F /* ScreenCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCapture.swift; sourceTree = ""; }; 28323DC20034A281F6829884 /* CloudProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudProbe.swift; sourceTree = ""; }; 29E156CF1C215B8FE713B3A0 /* TurnShare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnShare.swift; sourceTree = ""; }; 2A1C4152EBDBD7C24CABFFD7 /* TurnSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnSnapshot.swift; sourceTree = ""; }; 2A67DA3B8EEF063C44E07F7E /* ConversationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationContext.swift; sourceTree = ""; }; + 2B3701DD3FA89D1E44B7FBC3 /* MacUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacUpdater.swift; sourceTree = ""; }; + 2D4A343F2C25DBD9A401ECAD /* MacUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacUpdate.swift; sourceTree = ""; }; 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripePoller.swift; sourceTree = ""; }; 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalcomPoller.swift; sourceTree = ""; }; 3232F258523DD8D09B670363 /* FileDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropView.swift; sourceTree = ""; }; 323F242B14A61A7C071EC115 /* MochiActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiActivityAttributes.swift; sourceTree = ""; }; + 324FEF6CF23F9D4A95799431 /* FlowText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowText.swift; sourceTree = ""; }; + 35CE2B25C51E1F2186416192 /* FlowHubInsights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubInsights.swift; sourceTree = ""; }; 369F5AFDE37A03A1C25EB262 /* Decisions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Decisions.swift; sourceTree = ""; }; 37147F42613183DBA8896835 /* finish.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = finish.wav; sourceTree = ""; }; 37564F5F041B38C976DF3DEF /* PillColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillColors.swift; sourceTree = ""; }; @@ -459,7 +519,9 @@ 3A27178AA6007F7B56901750 /* SessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDetailView.swift; sourceTree = ""; }; 3A8FB03801C49690C3789EFD /* VoiceCaptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCaptionManager.swift; sourceTree = ""; }; 3B238F6E7B20EFDED5CD3E8E /* ClaudeSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeSettingsFile.swift; sourceTree = ""; }; + 3D3C36DE7C1DC055250C811D /* MacUpdateViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacUpdateViews.swift; sourceTree = ""; }; 444604C7E2C530B664849EF9 /* ClaudeResponseText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeResponseText.swift; sourceTree = ""; }; + 4983B1317913482E61EB344D /* FlowKeyLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowKeyLogic.swift; sourceTree = ""; }; 49E39852175E81C9632A59C9 /* KitPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KitPreviewView.swift; sourceTree = ""; }; 4A09E73F1E58019EF6EA2A62 /* IslandDisplayChoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandDisplayChoice.swift; sourceTree = ""; }; 4DC2A07E42245A8CF3BBD2AB /* CoucouAppStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoucouAppStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -467,9 +529,12 @@ 506824380E9C80B9BC6DCE72 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5108F622EF0C553F053716C2 /* SessionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStatus.swift; sourceTree = ""; }; 54E47E39D2D52584FA219FA1 /* LiveVoiceControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveVoiceControls.swift; sourceTree = ""; }; + 56A2317343D062ACCAF4E5BD /* DictationTranscript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationTranscript.swift; sourceTree = ""; }; + 5A5CB029F97C5F050CCF9A79 /* ClaudeDesktopChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeDesktopChat.swift; sourceTree = ""; }; 5C3E2269A202B640E7F3B5E0 /* CoucouWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = CoucouWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5C406C508A86E215B3CBE3FC /* CoucouControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoucouControl.swift; sourceTree = ""; }; 60401D1023D17C3FD30D7113 /* PhoneAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = PhoneAssets.xcassets; sourceTree = ""; }; + 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleCalendar.swift; sourceTree = ""; }; 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResendPoller.swift; sourceTree = ""; }; 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; 6544ED02D945CF9F00647AD1 /* MacDictation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacDictation.swift; sourceTree = ""; }; @@ -489,38 +554,26 @@ 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VercelPoller.swift; sourceTree = ""; }; 73DF398C822D98F541B62F89 /* CryptoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoController.swift; sourceTree = ""; }; 73FA4DF1BE5246CCFFB72E5C /* CodexPlanGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexPlanGauge.swift; sourceTree = ""; }; + 74146BD3F4B9884F6A43936F /* NoteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteModels.swift; sourceTree = ""; }; + 74BA58E1F273995F1C51AAAC /* CaptureCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureCardView.swift; sourceTree = ""; }; + 77A7538E542C7A0696EB3FF2 /* WhatsAppController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppController.swift; sourceTree = ""; }; 787D306C2399FBAE8BBB86ED /* TurnEndPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnEndPolicy.swift; sourceTree = ""; }; + 7889BA1980AB9D261DDE8A25 /* VitalsHoverLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VitalsHoverLine.swift; sourceTree = ""; }; 78FB882D49084DEA6A10DB4D /* RecapStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecapStore.swift; sourceTree = ""; }; 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiWardrobe.swift; sourceTree = ""; }; 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookServer.swift; sourceTree = ""; }; + 79E7E2106BCE86A472ED8051 /* FlowHubMochis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubMochis.swift; sourceTree = ""; }; + 7A666A5E3F809301028F0DF8 /* NoteTaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteTaker.swift; sourceTree = ""; }; 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 7C432FE1286945E42D3E11D7 /* VoiceResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceResultView.swift; sourceTree = ""; }; + 7C8178EB447DDE9C03427B36 /* ParakeetEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParakeetEngine.swift; sourceTree = ""; }; 824E56D2255CA86DAC350DBF /* SessionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPublisher.swift; sourceTree = ""; }; 8296E14A0C15515E6C3C1DBB /* QuestionCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionCard.swift; sourceTree = ""; }; + 8436291D94C826F1D9DA3130 /* FlowSettingsBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowSettingsBox.swift; sourceTree = ""; }; 84ECC59BE7CDDEE348FC7096 /* ServiceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceSnapshot.swift; sourceTree = ""; }; 871CA2BADC67D97C97C7009D /* error.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = error.wav; sourceTree = ""; }; 8790ACE9F73BD74640A0F20C /* LocalChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalChat.swift; sourceTree = ""; }; 87AF6F7A027AB1D46309DD49 /* TerminalTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTarget.swift; sourceTree = ""; }; - EDA206D2358F698DD382DA32 /* FlowHubMochis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubMochis.swift; sourceTree = ""; }; - 91A6EB483F6EC87C05CC6C69 /* NoteTaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteTaker.swift; sourceTree = ""; }; - D2BE0A486C4ED7BEDDC96548 /* NoteAudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteAudio.swift; sourceTree = ""; }; - 66BB44A1EDFFD50C0688BD09 /* NoteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteModels.swift; sourceTree = ""; }; - C9007267453D60200C2CB8F4 /* ParakeetEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParakeetEngine.swift; sourceTree = ""; }; - 3B2FE56D7F32FCF7453CFB1B /* FlowHubNotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubNotes.swift; sourceTree = ""; }; - 19503827102C75174006BF9E /* FlowHubSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubSettings.swift; sourceTree = ""; }; - C27F4F64C08DD3F72AF4C2FB /* FlowHubWords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubWords.swift; sourceTree = ""; }; - FAF9B196F3433D1B9ED839E6 /* FlowHubInsights.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubInsights.swift; sourceTree = ""; }; - EACE1C363C3E70B1E3B4D23B /* FlowHubHome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubHome.swift; sourceTree = ""; }; - 82CF1F66182B1E903A14454D /* FlowHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHub.swift; sourceTree = ""; }; - A0505B873924343CA2616B88 /* FlowCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowCleanup.swift; sourceTree = ""; }; - A8265AEA7DA40AB9C850741B /* FlowStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowStore.swift; sourceTree = ""; }; - A5DB76AD36667A057426F959 /* FlowText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowText.swift; sourceTree = ""; }; - 78597C8DAC0BA94D4AD6E870 /* DictationTranscript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationTranscript.swift; sourceTree = ""; }; - 0565D4D4D369E3ADD9CEF697 /* FlowSettingsBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowSettingsBox.swift; sourceTree = ""; }; - 79D06C32348E26EAA1087BFC /* FlowPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowPill.swift; sourceTree = ""; }; - 17870B121906BE24A935C407 /* FlowDictation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowDictation.swift; sourceTree = ""; }; - 6F2878AB6B6D0ED6F04F1583 /* FlowAudioBands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowAudioBands.swift; sourceTree = ""; }; - CA7D186A42376F4F1277FED3 /* FlowKeyLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowKeyLogic.swift; sourceTree = ""; }; 888B3E1420BCB6292BDC8117 /* WeeklyRecapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyRecapView.swift; sourceTree = ""; }; 8ACB4350729C6513824D1ADF /* ColorHex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorHex.swift; sourceTree = ""; }; 8BF22817EC70748F2D1FB2E7 /* ClaudePlanGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanGauge.swift; sourceTree = ""; }; @@ -528,9 +581,14 @@ 8F2F18663F42A246DACCD639 /* MailController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailController.swift; sourceTree = ""; }; 914A792788D403F836BEB570 /* SpotifyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyViews.swift; sourceTree = ""; }; 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandScreenGeometry.swift; sourceTree = ""; }; + 94AA2388D0E7690653E62BBB /* WhatsAppGOWAProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppGOWAProcess.swift; sourceTree = ""; }; + 95FF987AE2A4E9EFB6A15519 /* ClaudeDesktopChatWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeDesktopChatWatcher.swift; sourceTree = ""; }; 96C64C4C5D705CE486221B74 /* PillCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillCatalog.swift; sourceTree = ""; }; + 982CBB722685931BB8958162 /* FlowHubHome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubHome.swift; sourceTree = ""; }; 9AF705275522F99CAE7E1429 /* ServiceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceStatus.swift; sourceTree = ""; }; 9B66D0C664FB9FF74517AAF5 /* MacStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacStubs.swift; sourceTree = ""; }; + 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GcalPoller.swift; sourceTree = ""; }; + 9D4F560E1BEFB7B9D5865101 /* WhatsAppGateway.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppGateway.swift; sourceTree = ""; }; 9EF1D0C0784ED873065DD8C0 /* PillSessionRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillSessionRegistry.swift; sourceTree = ""; }; 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = ""; }; A12EC8F605BBC37F8223D7AA /* WakePhrase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WakePhrase.swift; sourceTree = ""; }; @@ -542,10 +600,12 @@ A87F39805C78722F614295B4 /* VoiceAudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceAudio.swift; sourceTree = ""; }; AB78996E9F9A2E8A80D45E54 /* QuestionRelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionRelay.swift; sourceTree = ""; }; ABD7A2C29B15EFFAF67FF2DE /* VoiceWakeFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceWakeFilter.swift; sourceTree = ""; }; + AD88FB0222D3F8893AF1D33D /* FlowPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowPill.swift; sourceTree = ""; }; ADA6A002ABEC0CC57667FBE0 /* MochiLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiLiveActivity.swift; sourceTree = ""; }; AEF5BD66B00BB64BB80AF38C /* DemoEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoEngine.swift; sourceTree = ""; }; B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopMochi.swift; sourceTree = ""; }; B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadCanvasView.swift; sourceTree = ""; }; + B249C39EA29E941BC52A6126 /* FlowHubSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubSettings.swift; sourceTree = ""; }; B2AA83D80260660475FAB052 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeWebURL.swift; sourceTree = ""; }; B328028D18F590D6680B8A2B /* VoiceEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceEngine.swift; sourceTree = ""; }; @@ -554,21 +614,28 @@ B59DD402B4D54D4A7DD36AB2 /* CryptoViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoViews.swift; sourceTree = ""; }; B6723392BE01AF210E484316 /* CoucouWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoucouWidgets.swift; sourceTree = ""; }; B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = N8nPoller.swift; sourceTree = ""; }; + BB1B680E79F7E7C05EA7E15B /* WhatsAppGOWAInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppGOWAInstaller.swift; sourceTree = ""; }; BC1DE3225F5239394398E6B6 /* WakeSpotter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WakeSpotter.swift; sourceTree = ""; }; BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotionPoller.swift; sourceTree = ""; }; + BE096F3D7C3CD989EEBA55C4 /* MochiMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiMood.swift; sourceTree = ""; }; BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMarkdownView.swift; sourceTree = ""; }; C066E2B991AA4F56B2724263 /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; name = sounds; path = Resources/sounds; sourceTree = SOURCE_ROOT; }; C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopMochiLogic.swift; sourceTree = ""; }; C22AF9F301D7A23DA6254D49 /* PhoneLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneLink.swift; sourceTree = ""; }; C2DFAEAF1DD1CBF03D3C314E /* TurnRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnRecorder.swift; sourceTree = ""; }; C2E4F18AA940EFF7E047B9F7 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + C2E5DF01F97CBDED4D54508F /* WhatsAppWebhook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppWebhook.swift; sourceTree = ""; }; C563E55884A7750B40EC9A01 /* VoiceBrain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceBrain.swift; sourceTree = ""; }; C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GreetingCanvasView.swift; sourceTree = ""; }; + CC2669CBBE5BDF9C775132D1 /* FlowHubNotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHubNotes.swift; sourceTree = ""; }; + CCF7FA8DEA10FA889A60447B /* FlowCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowCleanup.swift; sourceTree = ""; }; CD11C7925B0A7831BFC2199C /* VoiceSpeaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSpeaker.swift; sourceTree = ""; }; + D0E5A3ADB1304C23C4AEDD48 /* NoteAudio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteAudio.swift; sourceTree = ""; }; D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHost.swift; sourceTree = ""; }; D1A7C62058BB0C9D0DF58DCB /* VoiceIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceIntent.swift; sourceTree = ""; }; D1D96E57E8DADE6B82628E3E /* OpenLeadMochiIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenLeadMochiIntent.swift; sourceTree = ""; }; D4543CB771E0B723783F529E /* VoiceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSettings.swift; sourceTree = ""; }; + D59FE9B117259A7B1E44FC75 /* WhatsAppProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppProvider.swift; sourceTree = ""; }; D6DF93125491729E14636AD8 /* CoucouNotificationContent.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = CoucouNotificationContent.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D710BD7834228045869D0184 /* IslandWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandWindowController.swift; sourceTree = ""; }; D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMarkdown.swift; sourceTree = ""; }; @@ -579,11 +646,16 @@ DDB831F5623E699A2506698E /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DE18399D1CD69468CCF57966 /* MochiActivityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiActivityState.swift; sourceTree = ""; }; DE925A6844FE3E3C09131CE0 /* VoiceTranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceTranscriptHistory.swift; sourceTree = ""; }; + E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationNews.swift; sourceTree = ""; }; E18E74B3586BC0861C69E70A /* InstructionComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionComposer.swift; sourceTree = ""; }; + E4BCD6218DEDA865537F39F2 /* GitHubBranchCI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubBranchCI.swift; sourceTree = ""; }; E6AAC67875F84A862BC2A9AF /* ServiceDetailRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDetailRunner.swift; sourceTree = ""; }; E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MochiOutfitDrawing.swift; sourceTree = ""; }; E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowContextCapture.swift; sourceTree = ""; }; + EADD0D8C37F00BF1877153D8 /* WhatsAppModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsAppModels.swift; sourceTree = ""; }; + EBB761EA15EABCD584DD012B /* FlowLanguages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLanguages.swift; sourceTree = ""; }; EBB84A66045EB20CAC93A8A6 /* AskQuestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskQuestion.swift; sourceTree = ""; }; + ECD4A635006365A493A57B18 /* FlowDictation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowDictation.swift; sourceTree = ""; }; EDC1DEBFCAEBE6F0EEB865F3 /* EntityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityResolver.swift; sourceTree = ""; }; EE60A834934474CDAE022FF8 /* SearchAndSpotlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndSpotlight.swift; sourceTree = ""; }; EFE8C8C843B4B30C547FF64A /* ClaudeVoiceBrain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeVoiceBrain.swift; sourceTree = ""; }; @@ -594,21 +666,24 @@ F39753CFF90B494D449CCD95 /* IntentParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentParser.swift; sourceTree = ""; }; F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudePlanCardView.swift; sourceTree = ""; }; F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentity.swift; sourceTree = ""; }; + F4D4076443F9849FF25CCCFB /* MachineVitals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineVitals.swift; sourceTree = ""; }; F4EF48A44D82B5422E631FE0 /* SpotterGeneration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotterGeneration.swift; sourceTree = ""; }; F56C7E1EBC74664A7F1689CF /* NowPlayingViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingViews.swift; sourceTree = ""; }; + F7124E98A85D75D3378F0AC8 /* FlowStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowStore.swift; sourceTree = ""; }; F86B99CF65C5E4BC887888C6 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; F8D3AD61802A32C4833F3EDA /* GitHubPulse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubPulse.swift; sourceTree = ""; }; FA02B08886939FF6FE0475DD /* IslandViewContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandViewContent.swift; sourceTree = ""; }; + FA29FF3215B8371EEB91D0E2 /* NoteEcho.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteEcho.swift; sourceTree = ""; }; FA51E5B140C20812641168EA /* ShortcutLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutLogic.swift; sourceTree = ""; }; FADC1FE5696B1075932D0B7C /* InstructionRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionRunner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - DDB8BA12A0DFFF47015C999A /* Frameworks */ = { + 37CC26065EDFBD271EB49947 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 8896DCFCB54BA53FE1EE49AC /* FluidAudio in Frameworks */, + A9EA458EBA699F51AB7A7DAD /* FluidAudio in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -636,8 +711,11 @@ EBB84A66045EB20CAC93A8A6 /* AskQuestion.swift */, A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */, 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, + 74BA58E1F273995F1C51AAAC /* CaptureCardView.swift */, D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */, BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */, + 5A5CB029F97C5F050CCF9A79 /* ClaudeDesktopChat.swift */, + 95FF987AE2A4E9EFB6A15519 /* ClaudeDesktopChatWatcher.swift */, 1EA7D927F6BB2667D37AA963 /* ClaudeHookDetection.swift */, D0F0D0F67E7C56B9E37E7648 /* ClaudeHost.swift */, F4263B550838CC5614569D13 /* ClaudePlanCardView.swift */, @@ -651,13 +729,34 @@ AEF5BD66B00BB64BB80AF38C /* DemoEngine.swift */, B1DDC96E7967032F5D0BD585 /* DesktopMochi.swift */, C086E1932F86B9C2B559BE03 /* DesktopMochiLogic.swift */, + 56A2317343D062ACCAF4E5BD /* DictationTranscript.swift */, 3232F258523DD8D09B670363 /* FileDropView.swift */, + 0596FD03875A10BD2CCBE332 /* FlowAudioBands.swift */, + CCF7FA8DEA10FA889A60447B /* FlowCleanup.swift */, + ECD4A635006365A493A57B18 /* FlowDictation.swift */, + 0BFB4C9E1BBD89E2E6319DCC /* FlowHub.swift */, + 982CBB722685931BB8958162 /* FlowHubHome.swift */, + 35CE2B25C51E1F2186416192 /* FlowHubInsights.swift */, + 79E7E2106BCE86A472ED8051 /* FlowHubMochis.swift */, + CC2669CBBE5BDF9C775132D1 /* FlowHubNotes.swift */, + B249C39EA29E941BC52A6126 /* FlowHubSettings.swift */, + 1A1A34DE2886E394C879CF64 /* FlowHubWords.swift */, + 4983B1317913482E61EB344D /* FlowKeyLogic.swift */, + EBB761EA15EABCD584DD012B /* FlowLanguages.swift */, + AD88FB0222D3F8893AF1D33D /* FlowPill.swift */, + 8436291D94C826F1D9DA3130 /* FlowSettingsBox.swift */, + F7124E98A85D75D3378F0AC8 /* FlowStore.swift */, + 324FEF6CF23F9D4A95799431 /* FlowText.swift */, + 9C754B64B7114DC5C6A33984 /* GcalPoller.swift */, 168A9A73B519018FAB0AA465 /* GitHubActivity.swift */, + E4BCD6218DEDA865537F39F2 /* GitHubBranchCI.swift */, 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */, F8D3AD61802A32C4833F3EDA /* GitHubPulse.swift */, + 60F56FCD2FB4EDA773F00C30 /* GoogleCalendar.swift */, C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */, 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */, 1218B86E249691AF44F819AC /* HotKeyCenter.swift */, + E016B4D59A5BFA0B5A6D57AA /* IntegrationNews.swift */, 4A09E73F1E58019EF6EA2A62 /* IslandDisplayChoice.swift */, 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */, D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */, @@ -665,13 +764,22 @@ D710BD7834228045869D0184 /* IslandWindowController.swift */, 8790ACE9F73BD74640A0F20C /* LocalChat.swift */, 6544ED02D945CF9F00647AD1 /* MacDictation.swift */, + F4D4076443F9849FF25CCCFB /* MachineVitals.swift */, + 2D4A343F2C25DBD9A401ECAD /* MacUpdate.swift */, + 2B3701DD3FA89D1E44B7FBC3 /* MacUpdater.swift */, + 3D3C36DE7C1DC055250C811D /* MacUpdateViews.swift */, 8F2F18663F42A246DACCD639 /* MailController.swift */, 23F907A5CC3CC05876548E96 /* MailViews.swift */, 4F142FB3952B5F0639383047 /* MusicController.swift */, B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */, F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */, + D0E5A3ADB1304C23C4AEDD48 /* NoteAudio.swift */, + FA29FF3215B8371EEB91D0E2 /* NoteEcho.swift */, + 74146BD3F4B9884F6A43936F /* NoteModels.swift */, + 7A666A5E3F809301028F0DF8 /* NoteTaker.swift */, BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */, F56C7E1EBC74664A7F1689CF /* NowPlayingViews.swift */, + 7C8178EB447DDE9C03427B36 /* ParakeetEngine.swift */, 9EF1D0C0784ED873065DD8C0 /* PillSessionRegistry.swift */, 0364C7995680F9FA9F85F905 /* PortfolioController.swift */, 0793C2DA3333A385E075A04E /* PortfolioSettingsView.swift */, @@ -679,6 +787,7 @@ 78FB882D49084DEA6A10DB4D /* RecapStore.swift */, 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */, B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */, + 26CBF0D968BFB1AB0E36B18F /* ScreenCapture.swift */, 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */, FA51E5B140C20812641168EA /* ShortcutLogic.swift */, 62CF42AE7611EF212CA0458F /* ShortcutsSettingsView.swift */, @@ -687,32 +796,21 @@ 914A792788D403F836BEB570 /* SpotifyViews.swift */, 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */, 87AF6F7A027AB1D46309DD49 /* TerminalTarget.swift */, - EDA206D2358F698DD382DA32 /* FlowHubMochis.swift */, - 91A6EB483F6EC87C05CC6C69 /* NoteTaker.swift */, - D2BE0A486C4ED7BEDDC96548 /* NoteAudio.swift */, - 66BB44A1EDFFD50C0688BD09 /* NoteModels.swift */, - C9007267453D60200C2CB8F4 /* ParakeetEngine.swift */, - 3B2FE56D7F32FCF7453CFB1B /* FlowHubNotes.swift */, - 19503827102C75174006BF9E /* FlowHubSettings.swift */, - C27F4F64C08DD3F72AF4C2FB /* FlowHubWords.swift */, - FAF9B196F3433D1B9ED839E6 /* FlowHubInsights.swift */, - EACE1C363C3E70B1E3B4D23B /* FlowHubHome.swift */, - 82CF1F66182B1E903A14454D /* FlowHub.swift */, - A0505B873924343CA2616B88 /* FlowCleanup.swift */, - A8265AEA7DA40AB9C850741B /* FlowStore.swift */, - A5DB76AD36667A057426F959 /* FlowText.swift */, - 78597C8DAC0BA94D4AD6E870 /* DictationTranscript.swift */, - 0565D4D4D369E3ADD9CEF697 /* FlowSettingsBox.swift */, - 79D06C32348E26EAA1087BFC /* FlowPill.swift */, - 17870B121906BE24A935C407 /* FlowDictation.swift */, - 6F2878AB6B6D0ED6F04F1583 /* FlowAudioBands.swift */, - CA7D186A42376F4F1277FED3 /* FlowKeyLogic.swift */, 244A7909EEDA4BFF4C2EA9B5 /* TerminalTarget+AppKit.swift */, B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */, 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */, F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */, 71718D1B87A39A4879DD2A9D /* VercelPoller.swift */, + 7889BA1980AB9D261DDE8A25 /* VitalsHoverLine.swift */, 888B3E1420BCB6292BDC8117 /* WeeklyRecapView.swift */, + 77A7538E542C7A0696EB3FF2 /* WhatsAppController.swift */, + 9D4F560E1BEFB7B9D5865101 /* WhatsAppGateway.swift */, + BB1B680E79F7E7C05EA7E15B /* WhatsAppGOWAInstaller.swift */, + 94AA2388D0E7690653E62BBB /* WhatsAppGOWAProcess.swift */, + EADD0D8C37F00BF1877153D8 /* WhatsAppModels.swift */, + D59FE9B117259A7B1E44FC75 /* WhatsAppProvider.swift */, + 047257909800DC0D5A7DBEE1 /* WhatsAppViews.swift */, + C2E5DF01F97CBDED4D54508F /* WhatsAppWebhook.swift */, E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */, 9207F9B32D9C7844F5332A31 /* PhoneLink */, FB8E4AAB0B5E949069B8D03D /* Voice */, @@ -847,6 +945,7 @@ 9466FDE02C9B3FB4EF01DFF8 /* IslandScreenGeometry.swift */, 0365DDE3C554F5E8570055F4 /* IslandTypes.swift */, DE18399D1CD69468CCF57966 /* MochiActivityState.swift */, + BE096F3D7C3CD989EEBA55C4 /* MochiMood.swift */, E84E5D91129352E8A1D32AA4 /* MochiOutfitDrawing.swift */, 045E7300878138F0AC385920 /* MochiStill.swift */, 79A7807ADD0963FF260D1FAD /* MochiWardrobe.swift */, @@ -920,8 +1019,8 @@ buildPhases = ( 002591692F471861383A29CD /* Sources */, A0E1A749D68BF03D3FDE4473 /* Resources */, + 37CC26065EDFBD271EB49947 /* Frameworks */, FDEB6FD7AAB82BAA78A891E1 /* Write Git Hash to Info.plist */, - DDB8BA12A0DFFF47015C999A /* Frameworks */, ); buildRules = ( ); @@ -929,7 +1028,7 @@ ); name = NotchBuddy; packageProductDependencies = ( - FAFFA455BB88CDC7BB499851 /* FluidAudio */, + 309025C92C81E04B357F560C /* FluidAudio */, ); productName = NotchBuddy; productReference = A14A61070D293AB4BBA303F7 /* NotchBuddy.app */; @@ -1028,7 +1127,7 @@ mainGroup = C8A9AE71DB5C532D78C7784B; minimizedProjectReferenceProxies = 1; packageReferences = ( - 1DFD1CFF91133E3B9FCD52E3 /* XCRemoteSwiftPackageReference "FluidAudio" */, + E6BB9E09B54DE04E56CF0AEA /* XCRemoteSwiftPackageReference "FluidAudio" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6CF11AAA7898712CF6DB364A /* Products */; @@ -1115,8 +1214,11 @@ CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */, 845D762E9F4F0AD0D5FCEDF4 /* BotEngine.swift in Sources */, 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */, + 3D9DF8094D4B5FADBC64A071 /* CaptureCardView.swift in Sources */, 8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */, 4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */, + 2C14F1DB0449CEB5F29DFB4A /* ClaudeDesktopChat.swift in Sources */, + 57251211943B23F85582E786 /* ClaudeDesktopChatWatcher.swift in Sources */, 1DCF28998358810CDBC93F91 /* ClaudeHookDetection.swift in Sources */, 88A88E8704759E7F1F9D52BA /* ClaudeHost.swift in Sources */, 94E0849D13762C6EC8548FEF /* ClaudePlanCardView.swift in Sources */, @@ -1134,18 +1236,39 @@ B0CE5AC8B579866A14BD61D0 /* DemoEngine.swift in Sources */, BEC5DDB55B3DA67C61CA8470 /* DesktopMochi.swift in Sources */, BA65FC4A40F7227837C26214 /* DesktopMochiLogic.swift in Sources */, + FD4B5673A5427128DE1910F3 /* DictationTranscript.swift in Sources */, 077ECE0906D8E820BD5FA764 /* DiffEngine.swift in Sources */, 541AF30CF808CC0FC1B8D97D /* ElevenLabsTTS.swift in Sources */, B4F12F8F978FCA7E5309DE28 /* EnergyVAD.swift in Sources */, 9B0A1CABB8FB8209E20D9B06 /* EntityResolver.swift in Sources */, E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */, + 9AB1B9FDB64BC503CD484EAC /* FlowAudioBands.swift in Sources */, + 53712E2AA1F851324A3CB3CF /* FlowCleanup.swift in Sources */, + 0BD9756F8AD5C1ECA6B604FB /* FlowDictation.swift in Sources */, + 6ABDBE19E95D661216B8DE13 /* FlowHub.swift in Sources */, + 56E12FFCAAB2C4F31600B0F8 /* FlowHubHome.swift in Sources */, + 1EE6D325934B60A16520ECA1 /* FlowHubInsights.swift in Sources */, + D62CFFA91DA4BC5755E55186 /* FlowHubMochis.swift in Sources */, + 14869F429444B31D072A75B8 /* FlowHubNotes.swift in Sources */, + 4804DA9B3008C5C477E2818C /* FlowHubSettings.swift in Sources */, + 7B7CBFAADFF687004012609D /* FlowHubWords.swift in Sources */, + EF584BA65AA62FCABD6FB6B2 /* FlowKeyLogic.swift in Sources */, + 74B4C23EEEADCD73D66CBA2E /* FlowLanguages.swift in Sources */, + 26D933FDB84F83D33BE6B91B /* FlowPill.swift in Sources */, + D4E60449D7E33D93016192EC /* FlowSettingsBox.swift in Sources */, + D00A95A24714141AB82621BE /* FlowStore.swift in Sources */, + D1C243401794E8BB087D8B10 /* FlowText.swift in Sources */, + B5D82540DC98DADCF3455E17 /* GcalPoller.swift in Sources */, D9FF132804EB458F6FA69116 /* GitHubActivity.swift in Sources */, + 00CF5D0B758667454A7D2F4E /* GitHubBranchCI.swift in Sources */, 862CE6CB220D55E46F646DA9 /* GitHubPulse.swift in Sources */, C808B654C84D9F71DBF8493A /* GithubPoller.swift in Sources */, + AA9E15E26466C32F5D613599 /* GoogleCalendar.swift in Sources */, 8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */, 2C798C3837A63B9BB561A1A0 /* HookServer.swift in Sources */, B733A76DD197F6D6D949284C /* HotKeyCenter.swift in Sources */, 658C579523CF5F8D67B31369 /* InstructionRunner.swift in Sources */, + 4811B6D9CA21A22E303A7419 /* IntegrationNews.swift in Sources */, B09E884B33A35FA34543C64B /* IntentParser.swift in Sources */, 9E34044A001B01C3A514D20A /* IslandDisplayChoice.swift in Sources */, CA3321C14A11FED94E48B3E3 /* IslandRootView.swift in Sources */, @@ -1158,17 +1281,27 @@ 80D1CB884F0587EFCB839315 /* LiveVoiceControls.swift in Sources */, 6D158183870434FEDF07E050 /* LocalChat.swift in Sources */, F8F95EAD88F70E0A147D1662 /* MacDictation.swift in Sources */, + 75BE63D342A2AE68006F351C /* MacUpdate.swift in Sources */, + 5970C8B051B6BE24FBF2A6F5 /* MacUpdateViews.swift in Sources */, + 5B6C4CB6C2FF022E59FC7B54 /* MacUpdater.swift in Sources */, + CF89B87E2E6E6D838ECE9B8F /* MachineVitals.swift in Sources */, 48FA5A0DE13526F6C2A6B836 /* MailController.swift in Sources */, 2A57CCE7D6813E6340C036BE /* MailViews.swift in Sources */, D34D31C27D97D23C0441D1BE /* MochiActivityState.swift in Sources */, + C420F939B6D425B4AEE4C516 /* MochiMood.swift in Sources */, DD2EEF113DB5EAA35CD88312 /* MochiOutfitDrawing.swift in Sources */, 72B1AEDB2152BF73659EB598 /* MochiStill.swift in Sources */, 25F66E99460D62478899A292 /* MochiWardrobe.swift in Sources */, 5C6D0BC8C546718CAA82BF90 /* MusicController.swift in Sources */, 0A267EE2955C7AD8E02A296C /* N8nPoller.swift in Sources */, 06BF5FD95E03010CAFDBF2D0 /* NotchBuddyApp.swift in Sources */, + F032CA1CBF8253D9E3981A90 /* NoteAudio.swift in Sources */, + A83A59ADF9760FCC86086CD1 /* NoteEcho.swift in Sources */, + A5AC7A40E65A47174367DBC9 /* NoteModels.swift in Sources */, + A7B04680036A2443AC6162CD /* NoteTaker.swift in Sources */, 2F36E3C6C073E968A682AFA4 /* NotionPoller.swift in Sources */, 03FDC0AA0CEDE913B59D0F4D /* NowPlayingViews.swift in Sources */, + C47046BF30AC3E2350DABFDC /* ParakeetEngine.swift in Sources */, BDD8A210DD6D9DA2D15BC5EE /* PillCatalog.swift in Sources */, 07EC91820A69E8BF11EC778D /* PillColors.swift in Sources */, B43ED72DC305F4A70A391094 /* PillSessionRegistry.swift in Sources */, @@ -1180,6 +1313,7 @@ 7A449E237CAAB62758508C7C /* RecapStore.swift in Sources */, 967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */, 6FC65C190ED614C664C9BC17 /* SafeWebURL.swift in Sources */, + 6B0B39207A8DA88EA35E785A /* ScreenCapture.swift in Sources */, 2A30FA2F046492E22A1C5719 /* ServiceDetail.swift in Sources */, 9F43BE6E77E371FCEB7FE8BA /* ServiceDetailRunner.swift in Sources */, 694AB4504CD65662E1681E5C /* ServicePublisher.swift in Sources */, @@ -1195,26 +1329,6 @@ 1558BB00C530B721172F6E65 /* StripePoller.swift in Sources */, 9EA178EA395B116336C1FA10 /* TerminalTarget+AppKit.swift in Sources */, 1D4CE0CCD2B5A0FFE50C7F06 /* TerminalTarget.swift in Sources */, - 8885D645C2235B74ACFFBD5C /* FlowHubMochis.swift in Sources */, - C25D058C2C5B4B7A93238F3A /* NoteTaker.swift in Sources */, - AA4B80F3F49E23FED830EC7D /* NoteAudio.swift in Sources */, - A52241F5F37D87CB9BB8E4E0 /* NoteModels.swift in Sources */, - 43346F544B8BF45F651C87B4 /* ParakeetEngine.swift in Sources */, - 8090516AA01E97B3F0D16261 /* FlowHubNotes.swift in Sources */, - 7FBFCF6C9075D9EFD351961B /* FlowHubSettings.swift in Sources */, - 88376FD9D48E9A77EF57C0FD /* FlowHubWords.swift in Sources */, - 299AF513444806BD9BA0CF96 /* FlowHubInsights.swift in Sources */, - D3ADD680385678E53FC78655 /* FlowHubHome.swift in Sources */, - 3E0F7C69420EFF9A4957A626 /* FlowHub.swift in Sources */, - C023D194AD85560A6F98E478 /* FlowCleanup.swift in Sources */, - 19D47205BAFE0A10B0CBE339 /* FlowStore.swift in Sources */, - 3A42E18567D03203D3DD3486 /* FlowText.swift in Sources */, - 5663BA3BFBE656C2F5C00290 /* DictationTranscript.swift in Sources */, - 0AF77F5721986BE11151CE87 /* FlowSettingsBox.swift in Sources */, - 614F9783757FE777EF4CABB5 /* FlowPill.swift in Sources */, - F05CD98E6BF8679F12880345 /* FlowDictation.swift in Sources */, - 8B6743B43F9BB104B7C47981 /* FlowAudioBands.swift in Sources */, - EA0082A25D2A06EE27CA34DF /* FlowKeyLogic.swift in Sources */, 8551E592C5FFC6A0A7458A26 /* TurnEndPolicy.swift in Sources */, 0408FE7F86EBEE61CAED32DA /* TurnRecorder.swift in Sources */, 68C3ACC708B69035FD0CB8C4 /* TurnSnapshot.swift in Sources */, @@ -1222,6 +1336,7 @@ FAD78D74C6906D4EC8AA4101 /* UploadSequenceEngine.swift in Sources */, 8A6B3688D6B3BF728D106D8B /* UserIdentity.swift in Sources */, 118FC4AE0AC74D84B4A134D8 /* VercelPoller.swift in Sources */, + C7C4953290E7C198E2918E14 /* VitalsHoverLine.swift in Sources */, 390486F7CDC01C1BCDBB27F9 /* VoiceActionRunner.swift in Sources */, 88A8F2E4E754FAFABF6B1960 /* VoiceAudio.swift in Sources */, 326971DDA23F331116E5A628 /* VoiceBrain.swift in Sources */, @@ -1241,6 +1356,14 @@ 63B87D9E49F228ADCEC4C293 /* WakeSpotter.swift in Sources */, 4CA7948E8518229C04C6BE5A /* WakeWindowBackoff.swift in Sources */, 01B96F947DF2BDC352E8526D /* WeeklyRecapView.swift in Sources */, + 7CF0E28C3C049624200FFB36 /* WhatsAppController.swift in Sources */, + 25C48FFCCCBBD66DFDAF2DB1 /* WhatsAppGOWAInstaller.swift in Sources */, + 4C142089AC68774050AB551B /* WhatsAppGOWAProcess.swift in Sources */, + 968D08C757C0DD1CB593DC89 /* WhatsAppGateway.swift in Sources */, + B1FB8C566645AC0A2AB34B24 /* WhatsAppModels.swift in Sources */, + 6AB2EDACB4E80F218AC2A156 /* WhatsAppProvider.swift in Sources */, + B6C18F8EA3DCAFEAB19ED804 /* WhatsAppViews.swift in Sources */, + 7E99FD5D8E88EC7FC10E91EF /* WhatsAppWebhook.swift in Sources */, 2875610F701376BD65573386 /* WindowContextCapture.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1257,6 +1380,7 @@ B5D8D34DD7A72F340030EF46 /* MacStubs.swift in Sources */, 920EC34AE78BDC6DDD4452F9 /* MochiActivityState.swift in Sources */, 770F21C6A4D840AD3B0A9CB9 /* MochiLive.swift in Sources */, + 077F2D815859C061E9C5E08A /* MochiMood.swift in Sources */, 4B83070258D5AAAC8FCEB15F /* MochiOutfitDrawing.swift in Sources */, E82CA1269BF4859EEDE322AE /* MochiStill.swift in Sources */, B2584220B877056366C8DCAE /* MochiWardrobe.swift in Sources */, @@ -1299,6 +1423,7 @@ DBA38F8F673F436CE1D4A2E0 /* MochiActivityAttributes.swift in Sources */, 99A1CF41C797EA7F98F2B5B1 /* MochiActivityState.swift in Sources */, 422C116E73D2D9A96DAE104D /* MochiLive.swift in Sources */, + 760E362FDF46869A0525D8C3 /* MochiMood.swift in Sources */, 11F49CD9D12E69CE18DFEDCC /* MochiOutfitDrawing.swift in Sources */, 440AAF9FA27CC3DF7CC9EF51 /* MochiStill.swift in Sources */, D19FA3394EE99B34184684ED /* MochiWardrobe.swift in Sources */, @@ -1336,8 +1461,11 @@ 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */, B314F09AD7924387D0E04B45 /* BotEngine.swift in Sources */, 1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */, + 00BA6C52545395A5ACF2BDDE /* CaptureCardView.swift in Sources */, B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */, 733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */, + 43C858397BE8373A5B30623B /* ClaudeDesktopChat.swift in Sources */, + 1F0E4663F0DF988ADDDE013C /* ClaudeDesktopChatWatcher.swift in Sources */, 91B8AAADB623A12FF6769755 /* ClaudeHookDetection.swift in Sources */, F6D57EEBD7E937EFD2B715CF /* ClaudeHost.swift in Sources */, 18D47AE663EB96BE5638D5F7 /* ClaudePlanCardView.swift in Sources */, @@ -1355,18 +1483,39 @@ F157D7A9BF878DB2B286B1F7 /* DemoEngine.swift in Sources */, 273FC0FA14AAF6CE62FD14AE /* DesktopMochi.swift in Sources */, C93BA975D35B8CD7473CF995 /* DesktopMochiLogic.swift in Sources */, + 2768A639F60C4B4C6EC98436 /* DictationTranscript.swift in Sources */, 4D958F2DDCE9504490FDA357 /* DiffEngine.swift in Sources */, D7FD1948C7F97D6A2904CDBE /* ElevenLabsTTS.swift in Sources */, A94F75489794D9C95E584B56 /* EnergyVAD.swift in Sources */, 118D3ABA767EE8A7E55B0F8E /* EntityResolver.swift in Sources */, 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */, + 05F087B838004891988FBA79 /* FlowAudioBands.swift in Sources */, + A225E8D82794DEDCAE672058 /* FlowCleanup.swift in Sources */, + 49EF271474F09D5251E16552 /* FlowDictation.swift in Sources */, + B595A51BC53F255F055798FF /* FlowHub.swift in Sources */, + 67B768A8B9395BC6AC32AD54 /* FlowHubHome.swift in Sources */, + B957D76CBE6A0DF91E918C57 /* FlowHubInsights.swift in Sources */, + F558DBB511DF88077B8A6159 /* FlowHubMochis.swift in Sources */, + BEC3D806C75A6D6DDE9CCFDC /* FlowHubNotes.swift in Sources */, + 0EB57943A58F60AF72906BB1 /* FlowHubSettings.swift in Sources */, + 66069F348A37AE19806376A7 /* FlowHubWords.swift in Sources */, + ACBC410CF50E4B96706DA074 /* FlowKeyLogic.swift in Sources */, + 713F32381B1A47C5042063BB /* FlowLanguages.swift in Sources */, + 410F0237661DBC02AD886663 /* FlowPill.swift in Sources */, + 5A3DA40DB6C8161E6CB504C7 /* FlowSettingsBox.swift in Sources */, + EAEC8E117E130E5902295F73 /* FlowStore.swift in Sources */, + 485EF08DEE9EA88DA565EC7D /* FlowText.swift in Sources */, + 6905D946C73F57F8BE5B852B /* GcalPoller.swift in Sources */, 63093DC288B68F7F17249517 /* GitHubActivity.swift in Sources */, + 752E9E15BD9476A98DF4571D /* GitHubBranchCI.swift in Sources */, 38EEB2D3A6EC538958D92575 /* GitHubPulse.swift in Sources */, C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */, + 1F6F5652DDB4E75CD434ACF8 /* GoogleCalendar.swift in Sources */, C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */, 9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */, 530443F46F896F037A56F663 /* HotKeyCenter.swift in Sources */, 52F0DF1FBCB522951910390E /* InstructionRunner.swift in Sources */, + AEFD43F6ECAAF9E8CDD8A6F4 /* IntegrationNews.swift in Sources */, 6613EB914852E6EEB1FF37BF /* IntentParser.swift in Sources */, 1D23613B270FE8B11C5E27A7 /* IslandDisplayChoice.swift in Sources */, B605F976F4141435F27CF266 /* IslandRootView.swift in Sources */, @@ -1379,17 +1528,27 @@ AAECF326505B361B5F818150 /* LiveVoiceControls.swift in Sources */, E81575CE4433C6F88E424A8F /* LocalChat.swift in Sources */, 3CF69605BB03E567C2DB0935 /* MacDictation.swift in Sources */, + FB4E9143139138567993D601 /* MacUpdate.swift in Sources */, + E5F2FA35ADBC6E08BE2ABECD /* MacUpdateViews.swift in Sources */, + DAA8FBD53C6187FF4739EFF4 /* MacUpdater.swift in Sources */, + F3272363A452F211726823A1 /* MachineVitals.swift in Sources */, 79BD23BDFE42A929764635E7 /* MailController.swift in Sources */, E9C18F039035C78173C306C7 /* MailViews.swift in Sources */, 82E72E0AFCAB192D98AB2004 /* MochiActivityState.swift in Sources */, + 11CF033FB4438541075CAE25 /* MochiMood.swift in Sources */, ADBB9C15DDA4044625B325BE /* MochiOutfitDrawing.swift in Sources */, 78F045A55E6F4EEA5787EEEF /* MochiStill.swift in Sources */, A885B7E6DC43231BECE36E3D /* MochiWardrobe.swift in Sources */, 9C653B8291EFFA414565E8A8 /* MusicController.swift in Sources */, 160EDB9D06275E2607DA5151 /* N8nPoller.swift in Sources */, DE05D09A32117582AC93A24A /* NotchBuddyApp.swift in Sources */, + C8F0DA5F0EF2503581EFE5A1 /* NoteAudio.swift in Sources */, + FF8CEDBF43600E4AF5089A98 /* NoteEcho.swift in Sources */, + 4D3F75B2546ACB133C604638 /* NoteModels.swift in Sources */, + DCD9F5ECAFDBC52F388976C2 /* NoteTaker.swift in Sources */, 78FCEDC8F78B683AB10E6529 /* NotionPoller.swift in Sources */, 3E84EA0AE7C450941ED943CF /* NowPlayingViews.swift in Sources */, + C7DCF18B4588088307E49FAD /* ParakeetEngine.swift in Sources */, 13663D98590927AF71C17628 /* PillCatalog.swift in Sources */, 40F7576FC56ED7BC1D2B717E /* PillColors.swift in Sources */, 995B59B7DE9B0537F240047A /* PillSessionRegistry.swift in Sources */, @@ -1401,6 +1560,7 @@ B69DE31E2B0C2E35594CAA87 /* RecapStore.swift in Sources */, EB23ED9F1C12EA8B54A76A91 /* ResendPoller.swift in Sources */, FEFEF1EBAB0E6CA74A33C10B /* SafeWebURL.swift in Sources */, + E4B93DC2345688488FEAE078 /* ScreenCapture.swift in Sources */, 8C9AF392DAE8F2098A7BE7B6 /* ServiceDetail.swift in Sources */, 4755AAC633DCE5DAA6D54A3C /* ServiceDetailRunner.swift in Sources */, D092D0D597D1CBCE6B775B5E /* ServicePublisher.swift in Sources */, @@ -1416,26 +1576,6 @@ E4B9986674F4D425826F15A8 /* StripePoller.swift in Sources */, 11F14DB97C93CF84DF9D5C43 /* TerminalTarget+AppKit.swift in Sources */, 6726F74966EDE7F82BFE89B2 /* TerminalTarget.swift in Sources */, - C839C6CBE10ADE51BBC9D954 /* FlowHubMochis.swift in Sources */, - BAFE1A37B0ACEFDCE29E2B1E /* NoteTaker.swift in Sources */, - B42017CDFF5A9ED72E2FF99B /* NoteAudio.swift in Sources */, - ACC4A6ED5F5915F5B0DE9DD6 /* NoteModels.swift in Sources */, - B63AC65188449D52DB5DDC29 /* ParakeetEngine.swift in Sources */, - C500511EDFF4761BDA4488CE /* FlowHubNotes.swift in Sources */, - A5260F0D566968C8A2FE43BD /* FlowHubSettings.swift in Sources */, - 4176623B12ACEAFC98ED5821 /* FlowHubWords.swift in Sources */, - C9B8E68C2A81617292FEECBB /* FlowHubInsights.swift in Sources */, - 6B3C267F0AD334A56AC6C8A4 /* FlowHubHome.swift in Sources */, - 0CF1FAFFA2A1589B1A32AD64 /* FlowHub.swift in Sources */, - 011FB76783F2CF532EEFF7FC /* FlowCleanup.swift in Sources */, - E3E32D45E5570AE6CF52DAC0 /* FlowStore.swift in Sources */, - 4FAE0FDA3500A44DC05E7852 /* FlowText.swift in Sources */, - AAC6AB081F959CEE5FE628C8 /* DictationTranscript.swift in Sources */, - 98863553DD449BD791A1E00C /* FlowSettingsBox.swift in Sources */, - 15C9E266B443EFE70908510B /* FlowPill.swift in Sources */, - 8BF7CB586067C38AB5C4738C /* FlowDictation.swift in Sources */, - B9DE5D941F640D9C701D0437 /* FlowAudioBands.swift in Sources */, - A093B567173936F128B68053 /* FlowKeyLogic.swift in Sources */, ABE0CEF17018F1BAD8F7DBFA /* TurnEndPolicy.swift in Sources */, CE0A1BD64DF3E08D200691A6 /* TurnRecorder.swift in Sources */, 4D34FF10654B19B69FE1964C /* TurnSnapshot.swift in Sources */, @@ -1443,6 +1583,7 @@ ECECA2F225E82B7C951658D7 /* UploadSequenceEngine.swift in Sources */, 63F9F3AAC881D3EDDDD8AED9 /* UserIdentity.swift in Sources */, 0E83C1DD4D20E7FB7A904450 /* VercelPoller.swift in Sources */, + B54AE47279E534DCE812BF9A /* VitalsHoverLine.swift in Sources */, BF3F437B1FE9C246E9014F76 /* VoiceActionRunner.swift in Sources */, 64FE8055CCAD30B44FE398E7 /* VoiceAudio.swift in Sources */, 273B404F55739D661D9489D8 /* VoiceBrain.swift in Sources */, @@ -1462,6 +1603,14 @@ B5206E9D10AD77C0C24463AB /* WakeSpotter.swift in Sources */, A0ED41FB1AC8845BE1E30A66 /* WakeWindowBackoff.swift in Sources */, FB58020A389126865363C342 /* WeeklyRecapView.swift in Sources */, + AA1BFE6FE3A8BD1D5E3C2E81 /* WhatsAppController.swift in Sources */, + A048CDED45A2E27E50057245 /* WhatsAppGOWAInstaller.swift in Sources */, + E9342A768208D5054F6A74AD /* WhatsAppGOWAProcess.swift in Sources */, + 4C3CCE6691BBDB4F5FD4B49D /* WhatsAppGateway.swift in Sources */, + 2E0DC1F3BCD0D894C8B94E7C /* WhatsAppModels.swift in Sources */, + 27D6A055D1535690B95989E6 /* WhatsAppProvider.swift in Sources */, + 9771BC47A02F63FD66FB2F67 /* WhatsAppViews.swift in Sources */, + F5831D59B0C58C90139A37A4 /* WhatsAppWebhook.swift in Sources */, 2CC2E3B688B15BBEB3AE3BCE /* WindowContextCapture.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1482,6 +1631,7 @@ ADB4EF797FBD6CC90F8D25D3 /* MochiActivityAttributes.swift in Sources */, 74E97082F2241C36E5DD22EE /* MochiActivityState.swift in Sources */, 244EC9B77D1903C8DD505D04 /* MochiLiveActivity.swift in Sources */, + BFCBB2517D4BC82685A2EE46 /* MochiMood.swift in Sources */, 725365FA4E184D970E7BB8B4 /* MochiOutfitDrawing.swift in Sources */, CAC1A12153A798730E5FA835 /* MochiStill.swift in Sources */, 053B30CF4568F7FE5138A898 /* MochiWardrobe.swift in Sources */, @@ -2367,7 +2517,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 1DFD1CFF91133E3B9FCD52E3 /* XCRemoteSwiftPackageReference "FluidAudio" */ = { + E6BB9E09B54DE04E56CF0AEA /* XCRemoteSwiftPackageReference "FluidAudio" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/FluidInference/FluidAudio"; requirement = { @@ -2378,9 +2528,9 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - FAFFA455BB88CDC7BB499851 /* FluidAudio */ = { + 309025C92C81E04B357F560C /* FluidAudio */ = { isa = XCSwiftPackageProductDependency; - package = 1DFD1CFF91133E3B9FCD52E3 /* XCRemoteSwiftPackageReference "FluidAudio" */; + package = E6BB9E09B54DE04E56CF0AEA /* XCRemoteSwiftPackageReference "FluidAudio" */; productName = FluidAudio; }; /* End XCSwiftPackageProductDependency section */ diff --git a/NotchBuddy/Resources/CoucouAppStore.entitlements b/NotchBuddy/Resources/CoucouAppStore.entitlements index 0fc6072483..acc431ca6c 100644 --- a/NotchBuddy/Resources/CoucouAppStore.entitlements +++ b/NotchBuddy/Resources/CoucouAppStore.entitlements @@ -6,6 +6,9 @@ com.apple.security.network.client + + com.apple.security.network.server + com.apple.security.files.user-selected.read-write com.apple.security.files.bookmarks.app-scope diff --git a/NotchBuddy/Resources/Localizable.xcstrings b/NotchBuddy/Resources/Localizable.xcstrings index 176eed50a5..c1d3f8de7a 100644 --- a/NotchBuddy/Resources/Localizable.xcstrings +++ b/NotchBuddy/Resources/Localizable.xcstrings @@ -73937,6 +73937,974 @@ } } } + }, + "Google account not connected" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حساب Google غير متصل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google অ্যাকাউন্ট সংযুক্ত নয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google-Konto nicht verbunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google account not connected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuenta de Google no conectada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compte Google non connecté" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google खाता कनेक्ट नहीं है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akun Google belum terhubung" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conta do Google não conectada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Аккаунт Google не подключён" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยังไม่ได้เชื่อมต่อบัญชี Google" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google hesabı bağlı değil" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未连接 Google 账号" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未連接 Google 帳號" + } + } + } + }, + "integrations.github.ci-hint.appstore" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "(تحتاج CI لفرع جلسة Claude Code إلى إصدار التنزيل المباشر: لا يمكن لبيئة App Store المعزولة قراءة مجلد .git في مشروعك.)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "(আপনার Claude Code সেশনের ব্রাঞ্চের CI-এর জন্য সরাসরি ডাউনলোড সংস্করণ লাগবে: App Store-এর স্যান্ডবক্স আপনার প্রজেক্টের .git ফোল্ডার পড়তে পারে না।)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Die CI des Branches deiner Claude-Code-Sitzung braucht die Version zum direkten Download: Die App-Store-Sandbox kann den .git-Ordner deines Projekts nicht lesen.)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(The CI of your Claude Code session's branch needs the direct-download version: the App Store sandbox can't read your project's .git folder.)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "(La CI de la rama de tu sesión de Claude Code necesita la versión de descarga directa: el sandbox de la App Store no puede leer la carpeta .git de tu proyecto.)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "(La CI de la branche de ta session Claude Code demande la version en téléchargement direct : le bac à sable de l'App Store ne peut pas lire le dossier .git de ton projet.)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "(आपके Claude Code सत्र की ब्रांच के CI के लिए सीधे डाउनलोड वाला संस्करण चाहिए: App Store का सैंडबॉक्स आपके प्रोजेक्ट का .git फ़ोल्डर नहीं पढ़ सकता।)" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "(CI untuk branch sesi Claude Code Anda memerlukan versi unduhan langsung: sandbox App Store tidak dapat membaca folder .git proyek Anda.)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "(A CI da branch da sua sessão do Claude Code precisa da versão de download direto: o sandbox da App Store não consegue ler a pasta .git do seu projeto.)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Для CI ветки вашей сессии Claude Code нужна версия для прямой загрузки: песочница App Store не может читать папку .git вашего проекта.)" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "(CI ของ branch ในเซสชัน Claude Code ต้องใช้เวอร์ชันดาวน์โหลดโดยตรง: แซนด์บ็อกซ์ของ App Store อ่านโฟลเดอร์ .git ของโปรเจกต์ไม่ได้)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Claude Code oturumunun dalının CI'ı için doğrudan indirilen sürüm gerekir: App Store korumalı alanı projenin .git klasörünü okuyamaz.)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Claude Code 会话所在分支的 CI 需要直接下载版:App Store 沙盒无法读取项目的 .git 文件夹。)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Claude Code 工作階段所在分支的 CI 需要直接下載版:App Store 沙盒無法讀取專案的 .git 資料夾。)" + } + } + } + }, + "Default branch CI" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI للفرع الافتراضي" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ডিফল্ট ব্রাঞ্চের CI" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI des Standard-Branches" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default branch CI" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI de la rama principal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI de la branche par défaut" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "डिफ़ॉल्ट ब्रांच का CI" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI branch default" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI da branch padrão" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI основной ветки" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI ของ branch หลัก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsayılan dal CI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认分支 CI" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "預設分支 CI" + } + } + } + }, + "integrations.github.ci-hint" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتبع سطر CI فرع آخر جلسة Claude Code لديك." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI সারিটি আপনার শেষ Claude Code সেশনের ব্রাঞ্চ অনুসরণ করে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die CI-Zeile folgt dem Branch deiner letzten Claude-Code-Sitzung." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The CI row follows the branch of your last Claude Code session." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La fila de CI sigue la rama de tu última sesión de Claude Code." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La ligne CI suit la branche de ta dernière session Claude Code." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI पंक्ति आपके पिछले Claude Code सत्र की ब्रांच का अनुसरण करती है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baris CI mengikuti branch sesi Claude Code terakhir Anda." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A linha de CI segue a branch da sua última sessão do Claude Code." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строка CI следует за веткой вашей последней сессии Claude Code." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "แถว CI จะติดตาม branch ของเซสชัน Claude Code ล่าสุดของคุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI satırı, son Claude Code oturumunun dalını izler." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 行跟随你最近一次 Claude Code 会话所在的分支。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 列會跟隨你最近一次 Claude Code 工作階段所在的分支。" + } + } + } + }, + "integrations.gcal.hint" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يملك Coucou خادمًا، لذا يسجّل الدخول باستخدام عميل OAuth الخاص بك: في Google Cloud، فعّل Google Calendar API، وأنشئ عميل OAuth من نوع تطبيق سطح المكتب، والصق معرّفه وسرّه هنا. ثم انشر شاشة الموافقة (قيد الإنتاج): سيحذّرك Google من أن التطبيق غير موثّق — إنه تطبيقك، تابع. إذا تركته في وضع الاختبار، فلن يتمكن من تسجيل الدخول إلا مستخدمو الاختبار المدرجون هناك، ويسجّل Google خروجهم كل 7 أيام. للقراءة فقط. يذكّرك Mochi في الأوقات التي تحددها تذكيرات Google لأحداثك." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou-এর কোনো সার্ভার নেই, তাই এটি আপনার নিজের OAuth ক্লায়েন্ট দিয়ে সাইন ইন করে: Google Cloud-এ Google Calendar API চালু করুন, Desktop app ধরনের একটি OAuth ক্লায়েন্ট তৈরি করুন, এবং এর ID ও secret এখানে পেস্ট করুন। তারপর consent screen প্রকাশ করুন (In production): Google সতর্ক করবে যে অ্যাপটি যাচাই করা হয়নি — এটি আপনারই, চালিয়ে যান। Testing অবস্থায় রাখলে শুধু সেখানে তালিকাভুক্ত টেস্ট ব্যবহারকারীরা সাইন ইন করতে পারবেন, আর Google প্রতি 7 দিনে তাদের সাইন আউট করে দেয়। শুধু পড়ার জন্য। আপনার ইভেন্টগুলোর নিজস্ব Google রিমাইন্ডারের সময়েই Mochi আপনাকে মনে করিয়ে দেয়।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou hat keinen Server und meldet sich deshalb mit deinem eigenen OAuth-Client an: Aktiviere in Google Cloud die Google Calendar API, erstelle einen OAuth-Client vom Typ Desktop-App und füge hier seine ID und sein Secret ein. Veröffentliche dann den Zustimmungsbildschirm (In Produktion): Google warnt, dass die App nicht überprüft ist – sie gehört dir, fahre fort. Bleibt er im Test, können sich nur die dort eingetragenen Testnutzer anmelden, und Google meldet sie alle 7 Tage ab. Nur Lesezugriff. Mochi erinnert dich zu den Zeiten, die die Google-Erinnerungen deiner Termine vorgeben." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou has no server, so it signs in with your own OAuth client: in Google Cloud, enable the Google Calendar API, create an OAuth client of type Desktop app, and paste its ID and secret here. Then publish the consent screen (In production): Google will warn that the app isn't verified — it's your own, continue. Left in Testing, only the test users listed there can sign in, and Google signs them out every 7 days. Read-only. Mochi reminds you at the times your events' own Google reminders say." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou no tiene servidor, así que inicia sesión con tu propio cliente OAuth: en Google Cloud, activa la API de Google Calendar, crea un cliente OAuth de tipo App de escritorio y pega aquí su ID y su secreto. Después publica la pantalla de consentimiento (En producción): Google avisará de que la app no está verificada; es la tuya, continúa. Si la dejas en Prueba, solo pueden iniciar sesión los usuarios de prueba que aparezcan allí, y Google cierra su sesión cada 7 días. Solo lectura. Mochi te avisa a las horas que marcan los propios recordatorios de Google de tus eventos." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou n'a pas de serveur, il se connecte donc avec ton propre client OAuth : dans Google Cloud, active l'API Google Calendar, crée un client OAuth de type Application de bureau et colle ici son ID et son secret. Publie ensuite l'écran de consentement (En production) : Google préviendra que l'application n'est pas validée — c'est la tienne, continue. Laissé en Test, seuls les utilisateurs test listés peuvent se connecter, et Google les déconnecte tous les 7 jours. Lecture seule. Mochi te prévient aux heures fixées par les rappels Google de tes événements." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou का कोई सर्वर नहीं है, इसलिए यह आपके अपने OAuth क्लाइंट से साइन इन करता है: Google Cloud में Google Calendar API चालू करें, Desktop app प्रकार का OAuth क्लाइंट बनाएँ, और उसकी ID और secret यहाँ पेस्ट करें। फिर consent screen प्रकाशित करें (In production): Google चेतावनी देगा कि ऐप सत्यापित नहीं है — यह आपका अपना है, जारी रखें। Testing में छोड़ने पर केवल वहाँ सूचीबद्ध टेस्ट उपयोगकर्ता साइन इन कर सकते हैं, और Google उन्हें हर 7 दिन में साइन आउट कर देता है। केवल पढ़ने के लिए। Mochi आपको उन्हीं समयों पर याद दिलाता है जो आपके इवेंट्स के Google रिमाइंडर में तय हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou tidak punya server, jadi masuk dengan klien OAuth milik Anda sendiri: di Google Cloud, aktifkan Google Calendar API, buat klien OAuth berjenis Aplikasi desktop, lalu tempel ID dan rahasianya di sini. Kemudian publikasikan layar persetujuan (Dalam produksi): Google akan memperingatkan bahwa aplikasi belum diverifikasi — itu milik Anda, lanjutkan. Jika dibiarkan dalam Pengujian, hanya pengguna uji yang terdaftar di sana yang bisa masuk, dan Google mengeluarkan mereka setiap 7 hari. Hanya baca. Mochi mengingatkan Anda pada waktu yang ditentukan pengingat Google untuk acara Anda." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O Coucou não tem servidor, então ele entra com o seu próprio cliente OAuth: no Google Cloud, ative a API do Google Calendar, crie um cliente OAuth do tipo App para computador e cole aqui o ID e o segredo. Depois publique a tela de consentimento (Em produção): o Google vai avisar que o app não foi verificado — é o seu, continue. Se ficar em Teste, só os usuários de teste listados lá podem entrar, e o Google os desconecta a cada 7 dias. Somente leitura. O Mochi avisa você nos horários definidos pelos próprios lembretes do Google dos seus eventos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У Coucou нет сервера, поэтому вход выполняется через ваш собственный OAuth-клиент: в Google Cloud включите Google Calendar API, создайте OAuth-клиент типа «Приложение для компьютера» и вставьте сюда его ID и секрет. Затем опубликуйте экран согласия («В рабочем режиме»): Google предупредит, что приложение не проверено, — оно ваше, продолжайте. В режиме тестирования войти смогут только указанные там тестовые пользователи, и Google будет выводить их из аккаунта каждые 7 дней. Только чтение. Mochi напоминает о событиях в то время, которое задано в их собственных напоминаниях Google." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou ไม่มีเซิร์ฟเวอร์ จึงลงชื่อเข้าใช้ด้วยไคลเอนต์ OAuth ของคุณเอง: ใน Google Cloud ให้เปิดใช้ Google Calendar API สร้างไคลเอนต์ OAuth ประเภทแอปเดสก์ท็อป แล้ววางรหัสและข้อมูลลับของไคลเอนต์ที่นี่ จากนั้นเผยแพร่หน้าจอขอความยินยอม (In production): Google จะเตือนว่าแอปยังไม่ได้รับการยืนยัน ซึ่งเป็นแอปของคุณเอง ให้ดำเนินการต่อ หากปล่อยไว้ในโหมด Testing จะมีเพียงผู้ใช้ทดสอบที่อยู่ในรายการเท่านั้นที่ลงชื่อเข้าใช้ได้ และ Google จะออกจากระบบทุก 7 วัน อ่านอย่างเดียว Mochi จะเตือนคุณตามเวลาที่ตั้งไว้ในการแจ้งเตือนของ Google สำหรับกิจกรรมของคุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou'nun sunucusu yok, bu yüzden kendi OAuth istemcinle oturum açar: Google Cloud'da Google Calendar API'yi etkinleştir, Masaüstü uygulaması türünde bir OAuth istemcisi oluştur ve kimliğini ve gizli anahtarını buraya yapıştır. Ardından izin ekranını yayınla (In production): Google uygulamanın doğrulanmadığı konusunda uyarır; uygulama senin, devam et. Test modunda bırakılırsa yalnızca orada listelenen test kullanıcıları oturum açabilir ve Google onların oturumunu 7 günde bir kapatır. Salt okunur. Mochi, etkinliklerinin kendi Google hatırlatıcılarının belirttiği zamanlarda sana hatırlatır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou 没有服务器,所以使用你自己的 OAuth 客户端登录:在 Google Cloud 中启用 Google Calendar API,创建一个“桌面应用”类型的 OAuth 客户端,并将其 ID 和密钥粘贴到这里。然后发布同意屏幕(正式版):Google 会提示应用未经验证——这是你自己的应用,继续即可。如果保留在“测试”状态,只有其中列出的测试用户可以登录,而且 Google 每 7 天会让他们退出登录。只读。Mochi 会在你的活动自带的 Google 提醒时间提醒你。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coucou 沒有伺服器,所以會使用你自己的 OAuth 用戶端登入:在 Google Cloud 中啟用 Google Calendar API,建立一個「桌面應用程式」類型的 OAuth 用戶端,並將其 ID 和密鑰貼到這裡。接著發布同意畫面(正式版):Google 會提示應用程式未經驗證——這是你自己的應用程式,繼續即可。如果保留在「測試」狀態,只有列在其中的測試使用者可以登入,而且 Google 每 7 天會將他們登出。唯讀。Mochi 會在你活動自帶的 Google 提醒時間提醒你。" + } + } + } + }, + "Client ID (….apps.googleusercontent.com)" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "معرّف العميل (….apps.googleusercontent.com)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ক্লায়েন্ট আইডি (….apps.googleusercontent.com)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Client-ID (….apps.googleusercontent.com)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Client ID (….apps.googleusercontent.com)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID de cliente (….apps.googleusercontent.com)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID client (….apps.googleusercontent.com)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्लाइंट ID (….apps.googleusercontent.com)" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID klien (….apps.googleusercontent.com)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID do cliente (….apps.googleusercontent.com)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID клиента (….apps.googleusercontent.com)" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รหัสไคลเอนต์ (….apps.googleusercontent.com)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstemci kimliği (….apps.googleusercontent.com)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "客户端 ID (….apps.googleusercontent.com)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用戶端 ID (….apps.googleusercontent.com)" + } + } + } + }, + "Client secret (GOCSPX-…)" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "سرّ العميل (GOCSPX-…)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ক্লায়েন্ট সিক্রেট (GOCSPX-…)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Client-Secret (GOCSPX-…)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Client secret (GOCSPX-…)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secreto de cliente (GOCSPX-…)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret client (GOCSPX-…)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्लाइंट सीक्रेट (GOCSPX-…)" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rahasia klien (GOCSPX-…)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Segredo do cliente (GOCSPX-…)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Секрет клиента (GOCSPX-…)" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อมูลลับไคลเอนต์ (GOCSPX-…)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İstemci gizli anahtarı (GOCSPX-…)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "客户端密钥 (GOCSPX-…)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用戶端密鑰 (GOCSPX-…)" + } + } + } + }, + "Disconnect" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قطع الاتصال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সংযোগ বিচ্ছিন্ন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trennen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnect" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déconnecter" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "डिस्कनेक्ट करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Putuskan" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключить" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยกเลิกการเชื่อมต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bağlantıyı kes" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "断开连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "中斷連線" + } + } + } + }, + "Google Cloud console" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وحدة تحكم Google Cloud" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Cloud কনসোল" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Cloud Console" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Cloud console" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consola de Google Cloud" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Console Google Cloud" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Cloud कंसोल" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konsol Google Cloud" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Console do Google Cloud" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Консоль Google Cloud" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คอนโซล Google Cloud" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Cloud konsolu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Cloud 控制台" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Google Cloud 控制台" + } + } + } + }, + "Join" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "انضمام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "যোগ দিন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beitreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Join" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unirse" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rejoindre" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जुड़ें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gabung" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключиться" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เข้าร่วม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Katıl" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入" + } + } + } + }, + "Nothing on the calendar this week" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا شيء في التقويم هذا الأسبوع" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই সপ্তাহে ক্যালেন্ডারে কিছু নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Woche steht nichts im Kalender" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing on the calendar this week" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nada en el calendario esta semana" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rien dans l'agenda cette semaine" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस हफ़्ते कैलेंडर में कुछ नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak ada apa pun di kalender minggu ini" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nada na agenda esta semana" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "На этой неделе в календаре ничего нет" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่มีอะไรในปฏิทินสัปดาห์นี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu hafta takvimde bir şey yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本周日历上没有安排" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本週行事曆上沒有安排" + } + } + } } }, "version" : "1.0" diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift index c3d56957b9..db59a26111 100644 --- a/NotchBuddy/Sources/App/AppDelegate.swift +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -289,6 +289,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { StripePoller.shared.start() CalcomPoller.shared.start() NotionPoller.shared.start() + GcalPoller.shared.start() NotificationCenter.default.addObserver(self, selector: #selector(openSettingsFromNotification(_:)), name: .openFullSettings, object: nil) // After the greeting ends, fly Mochi back to the desktop if it was there at last quit diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 28803fedcc..977a70fece 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -363,6 +363,7 @@ final class AppState: ObservableObject { if !activeIntegrations.contains("integration_github") && oldValue.contains("integration_github") { githubPulse = nil githubActivity = nil + githubError = nil } } } @@ -381,6 +382,13 @@ final class AppState: ObservableObject { @Published var githubStats: GitHubStats? = nil @Published var githubPulse: GitHubPulse? = nil @Published var githubActivity: GitHubActivity? = nil + @Published var githubError: String? = nil // last pulse API error (nil = ok) + + // Google Calendar (populated by GcalPoller). nil = not connected or not loaded yet. + @Published var gcalEvents: [GcalEvent]? = nil + @Published var gcalError: String? = nil + // The event the reminder card is about + @Published var reminder: GcalEvent? = nil // Stripe (populated by StripePoller) @Published var stripePayments: [StripePayment] = [] @@ -645,11 +653,11 @@ final class AppState: ObservableObject { var sound: String? for event in events { switch event { - case .ciFailed, .mainFailed: + case .ciFailed, .mainFailed, .branchCIFailed, .changesRequested: if level < 3 { level = 3; badge = .error; sound = "error" } - case .reviewRequested: + case .reviewRequested, .reviewCommented: if level < 2 { level = 2; badge = .finished; sound = "question" } - case .ciPassed: + case .ciPassed, .branchCIPassed, .reviewApproved: if level < 1 { level = 1; badge = .finished; sound = "finish" } } } @@ -758,6 +766,61 @@ final class AppState: ObservableObject { } } + // MARK: - Integration news (Google Calendar) + + private var integrationClearWork: [String: DispatchWorkItem] = [:] + + /// Shows what a poller has to say on its pill: state, badge, sound, and the + /// compact island so the badge is seen. A calendar reminder opens its own card. + func announce(_ news: IntegrationNews, for id: String, reminder event: GcalEvent? = nil) { + guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } + // Something waiting on you (a meeting about to start) reads as a question + // with the amber badge, not as done or broken. + tasks[idx].state = news.attention ? .question : news.success ? .finished : .error + tasks[idx].steps = news.detail.map { [news.label, $0] } ?? [news.label] + tasks[idx].stepIndex = tasks[idx].steps.count - 1 + if focusId != id { + tasks[idx].pillBadge = news.attention ? .approval : news.success ? .finished : .error + } + + if let event { + // A reminder is a message, not a badge: open a card that says what, + // when and where, with Join / Open. Unless you are in the middle of + // something — the chat, an approval, a file drop — then it waits as + // the amber badge on the Calendar pill. + SoundEngine.shared.play("approval") + let busy: Set = [.prompt, .approval, .question, .upload, .uploading, .choose, .mail] + if mode == .expanded && busy.contains(view) { + NotificationCenter.default.post(name: .hookReveal, object: nil) + } else { + reminder = event + setFocus(id) + if mode == .expanded { view = .reminder } + else { NotificationCenter.default.post(name: .islandAlert, object: IslandView.reminder) } + } + } else { + SoundEngine.shared.play(news.attention ? "question" : news.success ? "finish" : "error") + // Same as the other pollers: show the compact island so the badge is seen, + // but never steal the screen. + NotificationCenter.default.post(name: .hookReveal, object: nil) + } + + // Auto-clear the pill after 60 s (the card's data stays). + integrationClearWork[id]?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.integrationClearWork[id] = nil + guard let i = self.tasks.firstIndex(where: { $0.id == id }), + [.finished, .error, .question].contains(self.tasks[i].state) else { return } + self.tasks[i].state = .idle + self.tasks[i].steps = [] + self.tasks[i].stepIndex = 0 + self.tasks[i].pillBadge = nil + } + integrationClearWork[id] = work + DispatchQueue.main.asyncAfter(deadline: .now() + 60, execute: work) + } + } // MARK: - Supporting types diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index a9cbb06aa9..5813941efd 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -74,6 +74,9 @@ final class KeychainStore: @unchecked Sendable { "stripe-api-key", "calcom-api-key", "notion-api-key", + // Google Calendar: the user's own Desktop OAuth client, and the refresh + // token Google returns once they have consented. + "gcal-client-id", "gcal-client-secret", "gcal-refresh-token", "elevenlabs-api-key", // WhatsApp (local gateway): its address, credentials and the webhook's signing secret "whatsapp-url", "whatsapp-api-key", "whatsapp-webhook-secret", diff --git a/NotchBuddy/Sources/App/FileDropView.swift b/NotchBuddy/Sources/App/FileDropView.swift index 9a47a33dcd..fede9a7ba3 100644 --- a/NotchBuddy/Sources/App/FileDropView.swift +++ b/NotchBuddy/Sources/App/FileDropView.swift @@ -10,6 +10,8 @@ final class FileDropNSView: NSView { var onDragUpdated: ((CGPoint) -> Void)? var onDragExited: (() -> Void)? var onFilesDropped: (([URL]) -> Void)? + /// A drop with no file in it (text, a link, a file promise): the drag is over all the same. + var onDropRejected: (() -> Void)? override init(frame: NSRect) { super.init(frame: frame) @@ -34,7 +36,10 @@ final class FileDropNSView: NSView { guard let urls = sender.draggingPasteboard.readObjects( forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] - ) as? [URL], !urls.isEmpty else { return false } + ) as? [URL], !urls.isEmpty else { + onDropRejected?() + return false + } onFilesDropped?(urls) return true } diff --git a/NotchBuddy/Sources/App/GcalPoller.swift b/NotchBuddy/Sources/App/GcalPoller.swift new file mode 100644 index 0000000000..e969e1193e --- /dev/null +++ b/NotchBuddy/Sources/App/GcalPoller.swift @@ -0,0 +1,348 @@ +import AppKit +import Foundation + +// MARK: - GcalPoller +// Google Calendar, the network side of GoogleCalendar.swift. +// +// There is no Coucou server, so there is no shared "Sign in with Google": the +// user creates a Desktop OAuth client in their own Google Cloud project and +// pastes its ID and secret in Settings. Connecting then runs the installed-app +// flow Google documents for desktop apps — PKCE, a one-shot listener on +// 127.0.0.1, the browser for consent — and keeps only the refresh token, in the +// Keychain. Access tokens live in memory for their hour. +// +// Polled every minute so a five-minute reminder can't be skipped over. + +final class GcalPoller: @unchecked Sendable { + static let shared = GcalPoller() + + private var timer: DispatchSourceTimer? + private let lock = NSLock() + + /// Bumped by every Connect and Cancel click, so a newer attempt (or a cancel) + /// retires an older listener still waiting on a browser tab the user closed. + /// Google's own error pages — "access blocked" for an app in Testing whose user + /// isn't on the test list — never redirect back, so without this the wait could + /// only end at the timeout. + private var attempt = 0 + private var access: (token: String, until: Date)? + /// The calendar list barely changes: fetched every ten minutes, not every poll. + private var calendars: (list: [GcalCalendar], at: Date)? + private var reminders = GcalReminders() + private var polling = false + /// A poll asked for while one was out (Connect finishing during a timer poll): + /// run once more when it's done, so the new sign-in is never left unread. + private var pollAgain = false + + private static let calendarsTTL: TimeInterval = 10 * 60 + /// How long the browser has to come back with a code. + private static let connectTimeout: TimeInterval = 5 * 60 + + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 4, repeating: 60) + t.setEventHandler { + Task { + // A pill the user switched off makes no network calls at all. + guard await MainActor.run(body: { AppState.shared.activeIntegrations.contains("integration_gcal") }) + else { return } + await GcalPoller.shared.poll() + } + } + t.resume() + timer = t + } + + // MARK: - Connecting + + /// Settings → Cancel: stops waiting for Google within a second. + func cancel() { + lock.withLock { attempt += 1 } + } + + private func isCurrent(_ mine: Int) -> Bool { + lock.withLock { attempt == mine } + } + + /// Opens Google's consent page and returns once the refresh token is stored. + /// Throws a message to show: cancelled, timed out, wrong client… + func connect() async throws { + guard let clientId = KeychainStore.shared.get("gcal-client-id"), + let clientSecret = KeychainStore.shared.get("gcal-client-secret") else { + throw GcalProblem.shown("Save the client ID and client secret first.") + } + let mine = lock.withLock { () -> Int in attempt += 1; return attempt } + + let listener = try LoopbackListener() + defer { listener.close() } + let redirect = "http://127.0.0.1:\(listener.port)" + + let verifier = GcalOAuth.b64url(try GcalOAuth.randomBytes(32)) + let challenge = GcalOAuth.b64url(GcalOAuth.sha256(Array(verifier.utf8))) + let state = GcalOAuth.b64url(try GcalOAuth.randomBytes(16)) + + let query = GcalOAuth.form([ + ("client_id", clientId), + ("redirect_uri", redirect), + ("response_type", "code"), + ("scope", Gcal.scope), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ("state", state), + // A refresh token, every time — Google only hands one out on consent. + ("access_type", "offline"), + ("prompt", "consent"), + ]) + guard let url = URL(string: "\(Gcal.authURL)?\(query)") else { + throw GcalProblem.shown("Could not build the Google sign-in link.") + } + await MainActor.run { _ = NSWorkspace.shared.open(url) } + log("gcal: waiting for the browser") + + let code = try await waitForCode(listener, state: state, attempt: mine) + + let (status, body) = try await post(Gcal.tokenURL, GcalOAuth.form([ + ("code", code), + ("client_id", clientId), + ("client_secret", clientSecret), + ("redirect_uri", redirect), + ("grant_type", "authorization_code"), + ("code_verifier", verifier), + ])) + guard (200..<300).contains(status) else { + throw GcalProblem.shown(GcalOAuth.googleError(body) ?? "Google answered \(status)") + } + guard let refresh = body["refresh_token"] as? String else { + throw GcalProblem.shown("Google didn't return a refresh token.") + } + KeychainStore.shared.set("gcal-refresh-token", value: refresh) + lock.withLock { + remember(body) + reminders.clear() + calendars = nil + } + log("gcal: connected") + + await poll() + } + + /// Waits on a background thread for the browser to come back (LoopbackListener.waitForCode). + private func waitForCode(_ listener: LoopbackListener, state: String, attempt mine: Int) async throws -> String { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + let result = listener.waitForCode( + state: state, + deadline: Date().addingTimeInterval(Self.connectTimeout), + stillWanted: { self.isCurrent(mine) } + ) + cont.resume(with: result.mapError { $0 as Error }) + } + } + } + + /// Revokes the grant at Google (best effort) and forgets the token. + func disconnect() async { + cancel() + if let refresh = KeychainStore.shared.get("gcal-refresh-token") { + _ = try? await post(Gcal.revokeURL, GcalOAuth.form([("token", refresh)])) + } + KeychainStore.shared.remove("gcal-refresh-token") + lock.withLock { + access = nil + reminders.clear() + calendars = nil + } + await MainActor.run { + AppState.shared.gcalEvents = nil + AppState.shared.gcalError = nil + } + } + + // MARK: - Access tokens + + /// Call with `lock` held. + private func remember(_ body: [String: Any]) { + guard let token = body["access_token"] as? String else { return } + let ttl = jsonInt(body["expires_in"]) ?? 3600 + // A minute early, so a token never expires between check and use. + access = (token, Date().addingTimeInterval(TimeInterval(max(0, ttl - 60)))) + } + + private func accessToken(_ refresh: String) async throws -> String { + if let cached = lock.withLock({ access }), Date() < cached.until { return cached.token } + guard let clientId = KeychainStore.shared.get("gcal-client-id"), + let clientSecret = KeychainStore.shared.get("gcal-client-secret") else { + throw GcalProblem.shown("Client ID or secret missing") + } + let (status, body) = try await post(Gcal.tokenURL, GcalOAuth.form([ + ("client_id", clientId), + ("client_secret", clientSecret), + ("refresh_token", refresh), + ("grant_type", "refresh_token"), + ])) + guard (200..<300).contains(status) else { + // invalid_grant: revoked, or the 7-day expiry of an OAuth app left in + // "Testing" — either way only a new sign-in fixes it. + if body["error"] as? String == "invalid_grant" { throw GcalProblem.shown(Gcal.expired) } + throw GcalProblem.shown(GcalOAuth.googleError(body) ?? "Google answered \(status)") + } + lock.withLock { remember(body) } + guard let token = body["access_token"] as? String else { throw GcalProblem.shown("No access token") } + return token + } + + // MARK: - HTTP + + private func post(_ url: String, _ form: String) async throws -> (Int, [String: Any]) { + guard let u = URL(string: url) else { throw GcalProblem.offline } + var req = URLRequest(url: u, timeoutInterval: 15) + req.httpMethod = "POST" + req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + req.httpBody = Data(form.utf8) + return try await send(req) + } + + private func send(_ req: URLRequest) async throws -> (Int, [String: Any]) { + guard let (data, response) = try? await URLSession.shared.data(for: req), + let http = response as? HTTPURLResponse else { throw GcalProblem.offline } + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] + return (http.statusCode, json) + } + + /// GET with the cached access token, retrying once with a fresh one when it + /// turns out to have been revoked. + private func getJSON(_ refresh: String, _ url: String) async throws -> (Int, [String: Any]) { + for _ in 0..<2 { + let token = try await accessToken(refresh) + guard let u = URL(string: url) else { throw GcalProblem.offline } + var req = URLRequest(url: u, timeoutInterval: 15) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (status, json) = try await send(req) + if status == 401 { + lock.withLock { access = nil } + continue + } + return (status, json) + } + throw GcalProblem.shown(Gcal.expired) + } + + // MARK: - Polling + + /// Every calendar shown in Google Calendar — your own, shared and subscribed + /// ones alike — not just the primary one, which is often the emptiest. + private func calendarList(_ refresh: String) async throws -> [GcalCalendar] { + if let cached = lock.withLock({ calendars }), Date().timeIntervalSince(cached.at) < Self.calendarsTTL { + return cached.list + } + let url = "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader&maxResults=50" + let (status, json) = try await getJSON(refresh, url) + let list: [GcalCalendar] + if status == 403 { + // A token from before Coucou asked for the calendar list: the primary + // calendar is all it may read until the next Connect. + log("gcal: no calendar-list access, reading the primary calendar only") + list = [GcalCalendar(id: "primary", name: "", color: nil, primary: true)] + } else if !(200..<300).contains(status) { + throw GcalProblem.shown(GcalOAuth.googleError(json) ?? "API error \(status)") + } else { + list = GcalCalendar.parseList(json) + } + lock.withLock { calendars = (list, Date()) } + return list + } + + func pollNow() { + Task { await poll() } + } + + func poll() async { + guard KeychainStore.shared.get("gcal-refresh-token") != nil else { return } + guard lock.withLock({ () -> Bool in + if polling { pollAgain = true; return false } + polling = true + return true + }) else { return } + repeat { + await pollOnce() + } while lock.withLock({ () -> Bool in + let again = pollAgain + pollAgain = false + if !again { polling = false } + return again + }) + } + + private func pollOnce() async { + guard let refresh = KeychainStore.shared.get("gcal-refresh-token") else { return } + do { + let list = try await calendarList(refresh) + + let now = RFC3339.now() + let query = GcalOAuth.form([ + // Events ending after now: what's on right now counts. + ("timeMin", RFC3339.utc(now)), + ("timeMax", RFC3339.utc(now + 7 * 86_400)), + ("singleEvents", "true"), + ("orderBy", "startTime"), + ("maxResults", "10"), + ]) + + var events: [GcalEvent] = [] + var readAny = false + var firstError: String? + // Your main calendar's default reminders; the last resort is Gcal.fallbackLead. + var primaryDefaults: [Int] = [] + for var cal in list { + let url = "https://www.googleapis.com/calendar/v3/calendars/\(GcalOAuth.pct(cal.id))/events?\(query)" + let (status, json) = try await getJSON(refresh, url) + guard (200..<300).contains(status) else { + // One unreadable calendar (an import gone stale) shouldn't hide + // the others. Typically "Google Calendar API has not been used…". + log("gcal: calendar HTTP \(status)") + if firstError == nil { firstError = GcalOAuth.googleError(json) ?? "API error \(status)" } + continue + } + readAny = true + var defaults = reminderMinutes(json["defaultReminders"]) + if cal.primary { primaryDefaults = defaults } + if defaults.isEmpty { defaults = primaryDefaults.isEmpty ? [Gcal.fallbackLead] : primaryDefaults } + // The events response knows the calendar's real name even when + // the list was a fallback. + if cal.name.isEmpty { cal.name = json["summary"] as? String ?? "" } + events += (json["items"] as? [Any] ?? []).compactMap { + GcalEvent.parse($0, calendar: cal, defaults: defaults) + } + } + if !readAny, let firstError { throw GcalProblem.shown(firstError) } + + let merged = GcalEvent.merge(events) + let reminder = lock.withLock { reminders.next(merged, now: now) } + let shown = Array(merged.prefix(8)) + await MainActor.run { + let state = AppState.shared + // Disconnected while this poll was out: don't bring the card back. + guard KeychainStore.shared.get("gcal-refresh-token") != nil else { return } + state.gcalError = nil + state.gcalEvents = shown + if let reminder { + state.announce(reminder.news, for: "integration_gcal", reminder: reminder.event) + } + } + } catch GcalProblem.shown(let message) { + await MainActor.run { + guard KeychainStore.shared.get("gcal-refresh-token") != nil else { return } + AppState.shared.gcalError = message + } + } catch { + // Offline: say nothing, try again next poll. + } + } + + private func log(_ message: String) { + appendAppLog("integrations.log", message) + } +} diff --git a/NotchBuddy/Sources/App/GitHubBranchCI.swift b/NotchBuddy/Sources/App/GitHubBranchCI.swift new file mode 100644 index 0000000000..efd8447b9f --- /dev/null +++ b/NotchBuddy/Sources/App/GitHubBranchCI.swift @@ -0,0 +1,212 @@ +import Foundation + +// GitHub — CI for the branch you're on. Same rules as windows/src-tauri/src/github.rs. +// +// "The branch you're on" is the branch checked out in the folder of the last +// Claude Code session, read straight from its .git directory: no `git` process +// and nothing to configure. Its CI comes back with the pulse (GithubPoller), as +// the `sessionBranch` part of the same GraphQL query, and lands in +// GitHubPulse.branch. GitHubPulse.events says when a run on it finishes. + +// MARK: - The session's branch + +struct GitHubBranchRef: Equatable, Sendable { + var owner: String + var name: String + /// The branch's name on GitHub (its upstream), which is what CI ran on. + var branch: String +} + +enum GitHubBranch { + /// The GitHub repository and branch checked out in `cwd`, if there is one. + static func of(cwd: URL) -> GitHubBranchRef? { + guard let (gitDir, commonDir) = findGitDir(cwd), + let head = try? String(contentsOf: gitDir.appendingPathComponent("HEAD"), encoding: .utf8) + else { return nil } + // Detached HEAD (rebase, bisect, checkout of a tag): no branch to follow. + let trimmed = head.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("ref: refs/heads/") else { return nil } + let local = String(trimmed.dropFirst("ref: refs/heads/".count)) + guard let config = try? String(contentsOf: commonDir.appendingPathComponent("config"), encoding: .utf8) + else { return nil } + return resolveUpstream(config: config, local: local) + } + + /// `.git` is a directory in a normal checkout and a `gitdir: …` file in a + /// worktree, whose config then lives in the main repository (`commondir`). + static func findGitDir(_ start: URL) -> (URL, URL)? { + let fm = FileManager.default + var dir = start.standardizedFileURL + while true { + let dotGit = dir.appendingPathComponent(".git") + var isDir: ObjCBool = false + if fm.fileExists(atPath: dotGit.path, isDirectory: &isDir) { + if isDir.boolValue { return (dotGit, dotGit) } + guard let text = try? String(contentsOf: dotGit, encoding: .utf8) else { return nil } + let line = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.hasPrefix("gitdir:") else { return nil } + let target = line.dropFirst("gitdir:".count).trimmingCharacters(in: .whitespaces) + let gitDir = target.hasPrefix("/") + ? URL(fileURLWithPath: target) + : dir.appendingPathComponent(target).standardizedFileURL + var common = gitDir + if let c = try? String(contentsOf: gitDir.appendingPathComponent("commondir"), encoding: .utf8) { + let rel = c.trimmingCharacters(in: .whitespacesAndNewlines) + common = rel.hasPrefix("/") + ? URL(fileURLWithPath: rel) + : gitDir.appendingPathComponent(rel).standardizedFileURL + } + return (gitDir, common) + } + let parent = dir.deletingLastPathComponent() + if parent.path == dir.path || dir.path == "/" { return nil } + dir = parent + } + } + + /// Follows `branch..remote` / `.merge` to the GitHub repo and remote + /// branch name; a branch with no upstream is assumed to be pushed to origin + /// under the same name. + static func resolveUpstream(config: String, local: String) -> GitHubBranchRef? { + let entries = parseGitConfig(config) + func get(_ section: String, _ sub: String, _ key: String) -> String? { + entries.first { $0.section == section && $0.sub == sub && $0.key == key }?.value + } + + let remote = get("branch", local, "remote") ?? "origin" + var branch = local + if let merge = get("branch", local, "merge"), merge.hasPrefix("refs/heads/") { + branch = String(merge.dropFirst("refs/heads/".count)) + } + + // No such remote: take whichever remote points at GitHub. + guard let url = get("remote", remote, "url") + ?? entries.first(where: { $0.section == "remote" && $0.key == "url" && parseGitHubURL($0.value) != nil })?.value, + let (owner, name) = parseGitHubURL(url) else { return nil } + return GitHubBranchRef(owner: owner, name: name, branch: branch) + } + + /// (section, subsection, key, value) — just enough of git-config(1) for + /// `[remote "x"]` and `[branch "x"]`. Section and key names are case-insensitive. + static func parseGitConfig(_ text: String) -> [(section: String, sub: String, key: String, value: String)] { + var out: [(section: String, sub: String, key: String, value: String)] = [] + var section = "", sub = "" + let quotes = CharacterSet(charactersIn: "\"") + for raw in text.components(separatedBy: .newlines) { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue } + if line.hasPrefix("["), line.hasSuffix("]") { + let header = String(line.dropFirst().dropLast()) + if let space = header.firstIndex(where: { $0 == " " || $0 == "\t" }) { + section = header[.. [String: Any] { + guard let t = target else { return ["owner": "", "name": "", "ref": "", "withBranch": false] } + return ["owner": t.owner, "name": t.name, "ref": "refs/heads/\(t.branch)", "withBranch": true] + } +} + +// MARK: - Its CI + +struct GitHubBranchCI: Equatable { + var repo: String // "owner/repo" + var branch: String + /// False when GitHub doesn't know the branch yet (never pushed). + var pushed: Bool + var oid: String + var ci: CIState + /// Names of the checks that failed. + var failing: [String] + /// Straight to the failing job when there is one; the commit page (which lists + /// every check) otherwise; "" when the branch isn't pushed. + var url: String + /// Your open pull request for this branch ("owner/repo#n"), if there is one. + var prId: String? = nil + + /// The `sessionBranch` node of the pulse answer. A repository the token can't + /// see comes back null: no CI row then. + static func parse(_ node: Any?, target: GitHubBranchRef, myPRs: [GitHubPR]) -> GitHubBranchCI? { + guard let repoNode = node as? [String: Any], + let repo = repoNode["nameWithOwner"] as? String else { return nil } + let prId = myPRs.first { + $0.headRef == target.branch && $0.repo.lowercased() == repo.lowercased() + }?.id + + guard let ref = repoNode["ref"] as? [String: Any], + let commit = ref["target"] as? [String: Any] else { + return GitHubBranchCI(repo: repo, branch: target.branch, pushed: false, oid: "", + ci: .unknown, failing: [], url: "", prId: prId) + } + + let rollup = commit["statusCheckRollup"] as? [String: Any] + let contexts = (rollup?["contexts"] as? [String: Any])?["nodes"] as? [[String: Any]] ?? [] + var failing: [String] = [] + var failingURL: String? + for c in contexts { + let name: String, bad: Bool, url: String + switch c["__typename"] as? String { + case "CheckRun": + name = c["name"] as? String ?? "" + bad = ["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED"] + .contains(c["conclusion"] as? String ?? "") + url = c["detailsUrl"] as? String ?? "" + case "StatusContext": + name = c["context"] as? String ?? "" + bad = ["FAILURE", "ERROR"].contains(c["state"] as? String ?? "") + url = c["targetUrl"] as? String ?? "" + default: + continue + } + if bad { + if failingURL == nil, !url.isEmpty { failingURL = url } + failing.append(name) + } + } + + return GitHubBranchCI( + repo: repo, branch: target.branch, pushed: true, + oid: commit["oid"] as? String ?? "", + ci: CIState(rawGitHub: rollup?["state"] as? String), + failing: failing, + url: failingURL ?? commit["url"] as? String ?? "", + prId: prId + ) + } +} diff --git a/NotchBuddy/Sources/App/GitHubPulse.swift b/NotchBuddy/Sources/App/GitHubPulse.swift index 6cb21d4931..64e4f728f7 100644 --- a/NotchBuddy/Sources/App/GitHubPulse.swift +++ b/NotchBuddy/Sources/App/GitHubPulse.swift @@ -44,6 +44,16 @@ struct GitHubPR: Equatable { var ci: CIState var review: ReviewState var headSha: String? = nil // oid of last commit; nil if not fetched + var headRef: String? = nil // head branch name; ties the session's branch to its PR + var reviews: [GitHubReview] = [] // latest review of each reviewer +} + +// MARK: - GitHubReview + +struct GitHubReview: Equatable { + var id: String + var state: String // APPROVED, CHANGES_REQUESTED, COMMENTED, … + var author: String } // MARK: - GitHubRepoCI @@ -54,6 +64,13 @@ struct GitHubRepoCI: Equatable { var branch: String var ci: CIState var headSha: String? = nil // oid of HEAD commit; nil if not fetched + var link: String? = nil // the session branch's failing job or commit page + + /// Where a tap goes: `link` when set, the repo's Actions page otherwise. + var openURL: String { + if let link, !link.isEmpty { return link } + return url.hasSuffix("/") ? url + "actions" : url + "/actions" + } } // MARK: - GitHubDetailSection @@ -67,6 +84,13 @@ enum GitHubEvent: Equatable { case ciPassed(prId: String) case mainFailed(repo: String) case reviewRequested(prId: String) + // The branch of the last Claude Code session (GitHubBranchCI) + case branchCIPassed(repo: String, branch: String) + case branchCIFailed(repo: String, branch: String) + // A new review on one of your PRs, by someone else + case reviewApproved(prId: String) + case changesRequested(prId: String) + case reviewCommented(prId: String) } // MARK: - GitHubPulse @@ -77,16 +101,25 @@ struct GitHubPulse: Equatable { var toReview: [GitHubPR] var mainCI: [GitHubRepoCI] var fetchedAt: Date + /// CI of the branch checked out by the last Claude Code session; nil when + /// there is none, or its repository isn't on GitHub or visible to the token. + var branch: GitHubBranchCI? = nil + + /// Pull requests nobody has touched in a month are abandoned, not "on the go". + static let staleAfter: TimeInterval = 30 * 86_400 - /// True when at least one PR CI or default-branch CI is pending — triggers 60 s poll cadence. + /// True when at least one PR CI, default-branch CI or the session branch's CI + /// is pending — triggers 60 s poll cadence. var hasPending: Bool { myPRs.contains { $0.ci == .pending } || - mainCI.contains { $0.ci == .pending } + mainCI.contains { $0.ci == .pending } || + branch?.ci == .pending } // MARK: - Parse - static func parse(_ data: Data) -> GitHubPulse? { + /// `target` is the session's branch the query asked about (`sessionBranch`). + static func parse(_ data: Data, target: GitHubBranchRef? = nil, now: Date = Date()) -> GitHubPulse? { guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let dataNode = root["data"] as? [String: Any], let viewer = dataNode["viewer"] as? [String: Any] else { return nil } @@ -98,12 +131,17 @@ struct GitHubPulse: Equatable { if let prConn = viewer["pullRequests"] as? [String: Any], let nodes = prConn["nodes"] as? [[String: Any]] { var seenPR = Set() + let iso = ISO8601DateFormatter() for node in nodes { guard let number = node["number"] as? Int, let title = node["title"] as? String, let url = node["url"] as? String, let repoNode = node["repository"] as? [String: Any], let repo = repoNode["nameWithOwner"] as? String else { continue } + // Bots opening PRs under your name leave plenty of abandoned ones. + // A new review bumps updatedAt, so one coming back to life reappears. + if let updated = (node["updatedAt"] as? String).flatMap(iso.date(from:)), + now.timeIntervalSince(updated) > staleAfter { continue } let id = "\(repo)#\(number)" guard seenPR.insert(id).inserted else { continue } // skip duplicates let isDraft = node["isDraft"] as? Bool ?? false @@ -116,10 +154,17 @@ struct GitHubPulse: Equatable { let rollup = commit["statusCheckRollup"] as? [String: Any] return (rollup?["state"] as? String, commit["oid"] as? String) }() + let reviewNodes = (node["latestReviews"] as? [String: Any])?["nodes"] as? [[String: Any]] ?? [] + let reviews = reviewNodes.map { r in + GitHubReview(id: r["id"] as? String ?? "", + state: r["state"] as? String ?? "", + author: (r["author"] as? [String: Any])?["login"] as? String ?? "") + } myPRs.append(GitHubPR( id: id, title: title, url: url, repo: repo, number: number, isDraft: isDraft, ci: CIState(rawGitHub: ciRaw), - review: ReviewState(rawGitHub: reviewDecision), headSha: headSha + review: ReviewState(rawGitHub: reviewDecision), headSha: headSha, + headRef: node["headRefName"] as? String, reviews: reviews )) } } @@ -172,8 +217,12 @@ struct GitHubPulse: Equatable { } } + let branch = target.flatMap { + GitHubBranchCI.parse(dataNode["sessionBranch"], target: $0, myPRs: myPRs) + } + return GitHubPulse(login: login, myPRs: myPRs, toReview: toReview, - mainCI: mainCI, fetchedAt: Date()) + mainCI: mainCI, fetchedAt: now, branch: branch) } // MARK: - Events @@ -231,6 +280,35 @@ struct GitHubPulse: Equatable { } } + // The session's branch: same headSha logic as PRs, on the same repo and + // branch only. Switching to another branch or repo says nothing — that's + // old news, not something that just happened. + if let b = new.branch, b.pushed, let prev = old.branch, + prev.repo == b.repo, prev.branch == b.branch { + let finished: CIState? = prev.oid == b.oid + ? (prev.ci == .pending && b.ci != .pending ? b.ci : nil) + : b.ci + switch finished { + case .success: result.append(.branchCIPassed(repo: b.repo, branch: b.branch)) + case .failure: result.append(.branchCIFailed(repo: b.repo, branch: b.branch)) + default: break // still running: the next poll catches it + } + } + + // New reviews on your PRs, by anyone but you. Only on PRs already seen, + // so a PR coming back into the list doesn't replay its old reviews. + let oldReviewsSeen = Set(old.myPRs.flatMap { $0.reviews.map(\.id) }) + for pr in new.myPRs where oldPRmap[pr.id] != nil { + for r in pr.reviews where !r.id.isEmpty && r.author != new.login && !oldReviewsSeen.contains(r.id) { + switch r.state { + case "APPROVED": result.append(.reviewApproved(prId: pr.id)) + case "CHANGES_REQUESTED": result.append(.changesRequested(prId: pr.id)) + case "COMMENTED": result.append(.reviewCommented(prId: pr.id)) + default: break + } + } + } + return result } diff --git a/NotchBuddy/Sources/App/GithubPoller.swift b/NotchBuddy/Sources/App/GithubPoller.swift index 2435a17982..914af520af 100644 --- a/NotchBuddy/Sources/App/GithubPoller.swift +++ b/NotchBuddy/Sources/App/GithubPoller.swift @@ -9,6 +9,9 @@ final class GithubPoller: @unchecked Sendable { private var tokenGeneration = 0 private var activityInFlight = false private var nextActivity: DispatchWorkItem? + /// Folder of the Claude Code session that spoke last: its branch's CI rides + /// along with the pulse (GitHubBranchCI.swift). + private var sessionCwd: String? private init() {} func start() { @@ -41,6 +44,13 @@ final class GithubPoller: @unchecked Sendable { #endif } + /// Called for every Claude Code hook that carries a `cwd`: the CI row follows + /// the branch of whichever session spoke last. + @MainActor func noteCwd(_ cwd: String) { + guard !cwd.isEmpty else { return } + sessionCwd = cwd + } + // MARK: - Stats (unchanged logic) private func pollStats() { @@ -101,11 +111,14 @@ final class GithubPoller: @unchecked Sendable { } self.pulseInFlight = true let gen = self.tokenGeneration - DispatchQueue.global(qos: .background).async { self.fetchPulse(token: token, generation: gen) } + let cwd = self.sessionCwd + DispatchQueue.global(qos: .background).async { self.fetchPulse(token: token, generation: gen, cwd: cwd) } } } - private func fetchPulse(token: String, generation: Int) { + private func fetchPulse(token: String, generation: Int, cwd: String?) { + // Read straight from the session's .git folder: two small files, no `git`. + let target = cwd.flatMap { GitHubBranch.of(cwd: URL(fileURLWithPath: $0)) } guard let url = URL(string: "https://api.github.com/graphql") else { finishPulse(hasPending: false); return } @@ -113,7 +126,10 @@ final class GithubPoller: @unchecked Sendable { req.httpMethod = "POST" req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard let body = try? JSONSerialization.data(withJSONObject: ["query": Self.graphQLQuery]) else { + guard let body = try? JSONSerialization.data(withJSONObject: [ + "query": Self.graphQLQuery, + "variables": GitHubBranch.queryVariables(for: target), + ]) else { finishPulse(hasPending: false); return } req.httpBody = body @@ -123,6 +139,8 @@ final class GithubPoller: @unchecked Sendable { let code = (response as? HTTPURLResponse)?.statusCode ?? 0 guard let data, code == 200 else { self.nbLog("pulse HTTP \(code)") + // Offline (no answer at all): say nothing, try again next poll. + if code != 0 { self.reportPulseError(Self.httpError(code), generation: generation) } self.finishPulse(hasPending: false) return } @@ -133,10 +151,13 @@ final class GithubPoller: @unchecked Sendable { self.nbLog("pulse GraphQL errors: \(errors.count)") } guard root["data"] is [String: Any] else { + let message = ((root["errors"] as? [[String: Any]])?.first?["message"] as? String) + ?? "Unexpected answer from GitHub" + self.reportPulseError(String(message.prefix(80)), generation: generation) self.finishPulse(hasPending: false); return } } - guard let pulse = GitHubPulse.parse(data) else { + guard let pulse = GitHubPulse.parse(data, target: target) else { self.finishPulse(hasPending: false); return } DispatchQueue.main.async { @@ -145,6 +166,7 @@ final class GithubPoller: @unchecked Sendable { let old = AppState.shared.githubPulse let events = GitHubPulse.events(old: old, new: pulse) AppState.shared.githubPulse = pulse + AppState.shared.githubError = nil // Badge and sound only for the pill in the notch, not when the // fetch only feeds the iPhone. if AppState.shared.activeIntegrations.contains("integration_github") { @@ -187,6 +209,22 @@ final class GithubPoller: @unchecked Sendable { appendAppLog("github.log", message) } + /// Shown on the idle GitHub card instead of a pulse that can't be refreshed. + private func reportPulseError(_ message: String, generation: Int) { + DispatchQueue.main.async { + guard self.tokenGeneration == generation else { return } + AppState.shared.githubError = message + } + } + + private static func httpError(_ code: Int) -> String { + switch code { + case 401: return "Invalid token (401)" + case 403: return "Token lacks the needed scope" + default: return "API error \(code)" + } + } + /// Called from any thread; dispatches cleanup to main then schedules next poll. private func finishPulse(hasPending: Bool) { DispatchQueue.main.async { [weak self] in @@ -294,16 +332,17 @@ final class GithubPoller: @unchecked Sendable { // MARK: - GraphQL queries private static let graphQLQuery = """ - query { + query($owner: String!, $name: String!, $ref: String!, $withBranch: Boolean!) { viewer { login pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { - number title url isDraft reviewDecision + number title url isDraft reviewDecision headRefName updatedAt repository { nameWithOwner url } commits(last: 1) { nodes { commit { oid statusCheckRollup { state } } } } + latestReviews(first: 10) { nodes { id state author { login } } } } } repositories(first: 10, ownerAffiliations: [OWNER], orderBy: {field: PUSHED_AT, direction: DESC}) { @@ -326,6 +365,26 @@ final class GithubPoller: @unchecked Sendable { } } } + sessionBranch: repository(owner: $owner, name: $name) @include(if: $withBranch) { + nameWithOwner + ref(qualifiedName: $ref) { + target { + ... on Commit { + oid url + statusCheckRollup { + state + contexts(first: 50) { + nodes { + __typename + ... on CheckRun { name conclusion detailsUrl } + ... on StatusContext { context state targetUrl } + } + } + } + } + } + } + } } """ diff --git a/NotchBuddy/Sources/App/GoogleCalendar.swift b/NotchBuddy/Sources/App/GoogleCalendar.swift new file mode 100644 index 0000000000..990df414a7 --- /dev/null +++ b/NotchBuddy/Sources/App/GoogleCalendar.swift @@ -0,0 +1,459 @@ +import Darwin +import Foundation +import CryptoKit +import Security + +// Google Calendar — the next events of every calendar you show in Google Calendar, +// and a reminder when Google Calendar itself would ring. Same rules as +// windows/src-tauri/src/gcal.rs; the network side lives in GcalPoller. +// +// Read-only scopes. Nothing is ever written to the calendar. + +enum Gcal { + /// Read-only: events, and the list of calendars to read them from. + static let scope = "https://www.googleapis.com/auth/calendar.events.readonly " + + "https://www.googleapis.com/auth/calendar.calendarlist.readonly" + static let authURL = "https://accounts.google.com/o/oauth2/v2/auth" + static let tokenURL = "https://oauth2.googleapis.com/token" + static let revokeURL = "https://oauth2.googleapis.com/revoke" + + /// Minutes before an event Mochi speaks up when neither the event, its + /// calendar nor your primary calendar sets any reminder. + static let fallbackLead = 5 + + static let expired = "Google sign-in expired — reconnect in Settings" +} + +// MARK: - Calendars and events + +/// A calendar to read: the ones ticked in Google Calendar's sidebar. +struct GcalCalendar: Equatable, Sendable { + var id: String + var name: String + var color: String? + var primary: Bool + + /// The calendar list, keeping what Google Calendar shows; the primary calendar + /// first, since its default reminders stand in for calendars that have none. + static func parseList(_ json: [String: Any]) -> [GcalCalendar] { + let list: [GcalCalendar] = (json["items"] as? [Any] ?? []).compactMap { c in + let shown = jsonBool(jsonValue(c, "selected")) == true || jsonBool(jsonValue(c, "primary")) == true + guard shown, jsonBool(jsonValue(c, "hidden")) != true, + let id = jsonValue(c, "id") as? String else { return nil } + return GcalCalendar( + id: id, + // What you renamed it to, else its own name. + name: (jsonValue(c, "summaryOverride") as? String) ?? (jsonValue(c, "summary") as? String) ?? "", + color: jsonValue(c, "backgroundColor") as? String, + primary: jsonBool(jsonValue(c, "primary")) == true + ) + } + return list.filter(\.primary) + list.filter { !$0.primary } + } +} + +struct GcalEvent: Equatable, Sendable, Identifiable { + var id: String + var title: String + /// RFC 3339 for timed events, YYYY-MM-DD for all-day ones. + var start: String + var end: String + var startSecs: Int? + var endSecs: Int? + var allDay: Bool + var meetURL: String? + var url: String + /// The calendar's colour in Google Calendar, for the row's dot. + var color: String? + var calendar: String + var location: String? + /// Minutes before the start at which to speak up, ascending. Empty when the + /// event says "no notification". + var reminders: [Int] + + /// `defaults`: the calendar's default reminders, already resolved to the + /// primary calendar's (or Gcal.fallbackLead) when it has none. + static func parse(_ item: Any, calendar: GcalCalendar, defaults: [Int]) -> GcalEvent? { + if jsonString(item, "status") == "cancelled" || jsonString(item, "eventType") == "workingLocation" { + return nil + } + // Declined meetings aren't yours to go to. + let attendees = jsonValue(item, "attendees") as? [Any] ?? [] + if attendees.contains(where: { + jsonBool(jsonValue($0, "self")) == true && jsonString($0, "responseStatus") == "declined" + }) { return nil } + + func when(_ k: String) -> (String, Bool) { + if let t = jsonValue(item, k, "dateTime") as? String { return (t, false) } + return (jsonString(item, k, "date"), true) + } + let (start, allDay) = when("start") + let (end, _) = when("end") + if start.isEmpty { return nil } + + let video = (jsonValue(item, "conferenceData", "entryPoints") as? [Any] ?? []) + .first { jsonString($0, "entryPointType") == "video" } + let location = jsonValue(item, "location") as? String + let meetURL = (jsonValue(item, "hangoutLink") as? String) + ?? (video.flatMap { jsonValue($0, "uri") as? String }) + // Zoom and Teams links pasted into the location field. + ?? location.flatMap { $0.hasPrefix("https://") ? $0 : nil } + + let title = jsonString(item, "summary") + return GcalEvent( + id: jsonString(item, "id"), + title: title.isEmpty ? "(No title)" : title, + start: start, + end: end, + startSecs: allDay ? nil : RFC3339.parse(start), + endSecs: allDay ? nil : RFC3339.parse(end), + allDay: allDay, + meetURL: meetURL, + url: jsonString(item, "htmlLink"), + color: calendar.color, + calendar: calendar.name, + location: (location?.isEmpty ?? true) ? nil : location, + // Your own overrides on this event win — including "none at all". + reminders: jsonBool(jsonValue(item, "reminders", "useDefault")) == false + ? reminderMinutes(jsonValue(item, "reminders", "overrides")) + : defaults + ) + } + + var key: String { "\(id)@\(start)" } + + /// Chronological across calendars; an all-day event counts from midnight UTC. + var sortKey: Int { + startSecs ?? RFC3339.parse("\(start)T00:00:00Z") ?? Int.max + } + + /// An invitation can sit in two calendars at once: keep the first, then sort + /// chronologically (ties keep their order). + static func merge(_ events: [GcalEvent]) -> [GcalEvent] { + var seen = Set() + let unique = events.filter { seen.insert($0.id).inserted } + return unique.enumerated() + .sorted { ($0.element.sortKey, $0.offset) < ($1.element.sortKey, $1.offset) } + .map(\.element) + } +} + +/// Minutes from a Calendar `reminders` list (`[{method, minutes}]`), ascending. +/// Email and pop-up alike: either way you asked to be told then. +func reminderMinutes(_ list: Any?) -> [Int] { + let minutes = (list as? [Any] ?? []).compactMap { jsonInt(jsonValue($0, "minutes")) }.filter { $0 >= 0 } + return Array(Set(minutes)).sorted() +} + +// MARK: - Reminders + +struct GcalReminder: Equatable, Sendable { + var event: GcalEvent + /// Minutes until the start, rounded up. + var minutes: Int + var news: IntegrationNews +} + +/// "id@start#minutes" of every reminder already rung, so each rings once (and +/// again if the event is moved). +struct GcalReminders { + private(set) var reminded = Set() + + mutating func clear() { reminded.removeAll() } + + /// The reminder that has just come due, the way Google Calendar itself would + /// ring it: at each of the event's reminder times. + /// + /// Only the latest reminder due is ever shown — after a restart, a meeting in + /// 20 minutes says "in 20 min" once, not its day-before and its 30-minute + /// reminders back to back. One per poll; a second meeting due at the same + /// moment comes a minute later. + mutating func next(_ events: [GcalEvent], now: Int) -> GcalReminder? { + // Forget events that have dropped off the list, so the set stays small. + let live = events.map(\.key) + reminded = reminded.filter { k in live.contains { k.hasPrefix($0) } } + + for e in events { + guard let start = e.startSecs else { continue } + // A reminder "at the time of the event" still gets its minute. + if now >= start + 60 { continue } + let due = e.reminders.filter { start - $0 * 60 <= now } + guard let latest = due.min() else { continue } + if reminded.contains("\(e.key)#\(latest)") { continue } + for m in due { reminded.insert("\(e.key)#\(m)") } + let minutes = max(0, (start - now + 59) / 60) + return GcalReminder( + event: e, minutes: minutes, + news: IntegrationNews(success: true, label: e.title, detail: Self.startsIn(minutes), attention: true) + ) + } + return nil + } + + static func startsIn(_ minutes: Int) -> String { + switch minutes { + case ...0: return "Starting now" + case 1: return "Starts in a minute" + case 2...59: return "Starts in \(minutes) min" + case 60...1439: + let (h, m) = (minutes / 60, minutes % 60) + return m == 0 ? "Starts in \(h) h" : "Starts in \(h) h \(m) min" + default: + let d = minutes / 1440 + return d == 1 ? "Starts in a day" : "Starts in \(d) days" + } + } +} + +// MARK: - Signing in (installed-app flow) + +enum GcalOAuth { + /// application/x-www-form-urlencoded, also good for query strings. + static func form(_ pairs: [(String, String)]) -> String { + pairs.map { "\(pct($0.0))=\(pct($0.1))" }.joined(separator: "&") + } + + static func pct(_ s: String) -> String { + var out = "" + for b in s.utf8 { + switch b { + case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "0")...UInt8(ascii: "9"), + UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), UInt8(ascii: "~"): + out.unicodeScalars.append(Unicode.Scalar(b)) + default: + out += String(format: "%%%02X", b) + } + } + return out + } + + static func parseQuery(_ q: String) -> [(String, String)] { + q.split(separator: "&", omittingEmptySubsequences: true).map { p in + if let eq = p.firstIndex(of: "=") { + return (pctDecode(String(p[.. String { + let bytes = Array(s.utf8) + var out: [UInt8] = [] + var i = 0 + func hex(_ b: UInt8) -> UInt8? { + switch b { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return b - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): return b - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): return b - UInt8(ascii: "A") + 10 + default: return nil + } + } + while i < bytes.count { + switch bytes[i] { + case UInt8(ascii: "+"): + out.append(UInt8(ascii: " ")) + case UInt8(ascii: "%") where i + 2 < bytes.count: + if let hi = hex(bytes[i + 1]), let lo = hex(bytes[i + 2]) { + out.append(hi * 16 + lo) + i += 2 + } else { + out.append(UInt8(ascii: "%")) + } + case let b: + out.append(b) + } + i += 1 + } + return String(decoding: out, as: UTF8.self) + } + + static func b64url(_ bytes: [UInt8]) -> String { + Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func sha256(_ data: [UInt8]) -> [UInt8] { + Array(SHA256.hash(data: data)) + } + + /// The system CSPRNG, for the PKCE verifier and the `state` check. + static func randomBytes(_ n: Int) throws -> [UInt8] { + var buf = [UInt8](repeating: 0, count: n) + guard SecRandomCopyBytes(kSecRandomDefault, n, &buf) == errSecSuccess else { + throw GcalProblem.shown("No randomness available for the sign-in.") + } + return buf + } + + /// What the loopback listener does with one request from the browser. + enum Answer: Equatable { + /// A stray request (a favicon, a stale tab, a forged state): 404, keep waiting. + case stray + /// Google redirected back without a code: missing parameter, keep waiting. + case badRequest + /// The user said no (or Google said no for them). + case denied(String) + case code(String) + } + + /// `firstLine` is the HTTP request line, e.g. `GET /?state=…&code=… HTTP/1.1`. + static func route(_ firstLine: String, state: String) -> Answer { + guard firstLine.hasPrefix("GET ") else { return .stray } + let target = firstLine.dropFirst(4).split(separator: " ", maxSplits: 1).first.map(String.init) ?? "" + let (path, query): (String, String) + if let q = target.firstIndex(of: "?") { + (path, query) = (String(target[.. String? { params.first { $0.0 == k }?.1 } + + if path != "/" || param("state") != state { return .stray } + if let error = param("error") { return .denied(error) } + if let code = param("code") { return .code(code) } + return .badRequest + } + + static func response(status: String, message: String) -> String { + let html = "Coucou" + + "

\(message)

" + return "HTTP/1.1 \(status)\r\nContent-Type: text/html; charset=utf-8\r\n" + + "Content-Length: \(html.utf8.count)\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n\(html)" + } + + /// The message Google put in an error answer, shortened for the island. + static func googleError(_ body: [String: Any]) -> String? { + let message = (jsonValue(body, "error", "message") as? String) + ?? (body["error_description"] as? String) + ?? (body["error"] as? String) + return message.map { String($0.prefix(90)) } + } +} + +/// Why a Google request gave nothing to show. +enum GcalProblem: Error, Equatable { + /// Offline or timed out: say nothing, try again next poll. + case offline + /// A sign-in or API problem worth showing. + case shown(String) + + var message: String { + switch self { + case .offline: return "No connection" + case .shown(let m): return m + } + } +} + +// MARK: - One-shot loopback listener (127.0.0.1, a free port) + +final class LoopbackListener: @unchecked Sendable { + let port: UInt16 + private let fd: Int32 + private let closeLock = NSLock() + private var closed = false + + init() throws { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw GcalProblem.shown("Could not open a local port for the sign-in.") } + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, Darwin.listen(fd, 4) == 0 else { + Darwin.close(fd) + throw GcalProblem.shown("Could not open a local port for the sign-in.") + } + var got = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + _ = withUnsafeMutablePointer(to: &got) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) } + } + self.fd = fd + self.port = UInt16(bigEndian: got.sin_port) + } + + /// Serves the one redirect Google sends back, checking `state`, and answers the + /// browser with a page saying it can be closed. Stray requests (a favicon, a + /// stale tab) get a 404 and the wait goes on. Blocking: call off the main thread. + /// `stillWanted` is checked every second, so Cancel ends the wait even though + /// Google's "access blocked" page never redirects back. + func waitForCode(state: String, deadline: Date, stillWanted: () -> Bool) -> Result { + while true { + if !stillWanted() { return .failure(.shown("Sign-in cancelled.")) } + if Date() >= deadline { return .failure(.shown("Timed out waiting for Google.")) } + guard let client = accept(timeoutMs: 1000) else { continue } + switch GcalOAuth.route(Self.readRequestLine(client), state: state) { + case .stray: + Self.reply(client, GcalOAuth.response(status: "404 Not Found", message: "Nothing here.")) + case .badRequest: + Self.reply(client, GcalOAuth.response(status: "400 Bad Request", message: "Missing code.")) + case .denied(let error): + Self.reply(client, GcalOAuth.response( + status: "200 OK", message: "Coucou wasn't given access. You can close this tab.")) + return .failure(.shown("Google sign-in cancelled (\(error)).")) + case .code(let code): + Self.reply(client, GcalOAuth.response( + status: "200 OK", message: "Coucou is connected to Google Calendar. You can close this tab.")) + return .success(code) + } + } + } + + /// The next connection, or nil after `timeoutMs` without one. + func accept(timeoutMs: Int32) -> Int32? { + var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + guard Darwin.poll(&pfd, 1, timeoutMs) > 0 else { return nil } + let client = Darwin.accept(fd, nil, nil) + return client >= 0 ? client : nil + } + + /// Reads the request head (at most 16 KB, 5 s) and returns its first line. + static func readRequestLine(_ client: Int32) -> String { + var tv = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + var on: Int32 = 1 + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + var head: [UInt8] = [] + var chunk = [UInt8](repeating: 0, count: 2048) + let end: [UInt8] = [13, 10, 13, 10] + while head.count < 16 * 1024, head.firstRange(of: end) == nil { + let n = recv(client, &chunk, chunk.count, 0) + if n <= 0 { break } + head.append(contentsOf: chunk[0.. &&` / `cd ;` prefix Claude + /// puts in front of most commands dropped, since the card already names the + /// project. `oneLine` then caps the length for the ticker. + static func commandSummary(_ cmd: String) -> String { + var s = cmd.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let prefix = #"^(?:cd|Set-Location|pushd)\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*"# + if let r = s.range(of: prefix, options: [.regularExpression, .caseInsensitive]) { + let rest = String(s[r.upperBound...]) + if !rest.isEmpty { s = rest } + } + return String(s.prefix(120)) + } + /// Infers a localized verb from a shell command's first word. private func bashVerb(_ command: String) -> String { let first = command.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) ?? "" diff --git a/NotchBuddy/Sources/App/IntegrationNews.swift b/NotchBuddy/Sources/App/IntegrationNews.swift new file mode 100644 index 0000000000..204b5a9384 --- /dev/null +++ b/NotchBuddy/Sources/App/IntegrationNews.swift @@ -0,0 +1,129 @@ +import Foundation + +// MARK: - Integration news +// What a poller has to say when something happened since its last poll — the Swift +// side of IntegrationEvent in windows/src-tauri/src/integrations.rs. AppState turns +// it into the pill's state, badge and sound (AppState.announce). + +struct IntegrationNews: Equatable, Sendable { + var success: Bool + var label: String + var detail: String? + /// Something is waiting on the user (a review request, a meeting about to + /// start) rather than something that finished: amber badge, not green/red. + var attention: Bool = false +} + +// MARK: - Dates +// RFC 3339 both ways, in whole seconds since 1970, UTC — the same rules as +// windows/src-tauri/src/time.rs, so both apps read API timestamps alike. + +enum RFC3339 { + static func now() -> Int { Int(Date().timeIntervalSince1970) } + + /// Days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm). + private static func daysFromCivil(_ y: Int, _ m: Int, _ d: Int) -> Int { + let y = m <= 2 ? y - 1 : y + let era = floorDiv(y, 400) + let yoe = y - era * 400 + let mp = (m + 9) % 12 + let doy = (153 * mp + 2) / 5 + d - 1 + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy + return era * 146_097 + doe - 719_468 + } + + private static func civilFromDays(_ z: Int) -> (Int, Int, Int) { + let z = z + 719_468 + let era = floorDiv(z, 146_097) + let doe = z - era * 146_097 + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + let mp = (5 * doy + 2) / 153 + let d = doy - (153 * mp + 2) / 5 + 1 + let m = mp < 10 ? mp + 3 : mp - 9 + return (m <= 2 ? yoe + era * 400 + 1 : yoe + era * 400, m, d) + } + + private static func floorDiv(_ a: Int, _ b: Int) -> Int { + a >= 0 ? a / b : -((-a + b - 1) / b) + } + + static func utc(_ secs: Int) -> String { + let (y, m, d) = civilFromDays(floorDiv(secs, 86_400)) + let t = secs - floorDiv(secs, 86_400) * 86_400 + return String(format: "%04d-%02d-%02dT%02d:%02d:%02dZ", y, m, d, t / 3600, t % 3600 / 60, t % 60) + } + + /// `2026-10-01T14:30:00+02:00`, `…Z`, with or without fractional seconds. + static func parse(_ s: String) -> Int? { + let chars = Array(s.utf8) + func num(_ r: Range) -> Int? { + guard r.upperBound <= chars.count else { return nil } + let slice = chars[r] + guard slice.allSatisfy({ $0 >= 48 && $0 <= 57 }) else { return nil } + return Int(String(decoding: slice, as: UTF8.self)) + } + guard let y = num(0..<4), let mo = num(5..<7), let d = num(8..<10), + let h = num(11..<13), let mi = num(14..<16), let se = num(17..<19) else { return nil } + var i = 19 + if i < chars.count, chars[i] == UInt8(ascii: ".") { + i += 1 + while i < chars.count, chars[i] >= 48, chars[i] <= 57 { i += 1 } + } + let rest = Array(chars[min(i, chars.count)...]) + let offset: Int + if rest == [UInt8(ascii: "Z")] || rest == [UInt8(ascii: "z")] { + offset = 0 + } else { + guard let first = rest.first else { return nil } + let sign: Int + switch first { + case UInt8(ascii: "+"): sign = 1 + case UInt8(ascii: "-"): sign = -1 + default: return nil + } + func part(_ r: Range) -> Int? { + guard r.upperBound <= rest.count else { return nil } + let slice = rest[r] + guard slice.allSatisfy({ $0 >= 48 && $0 <= 57 }) else { return nil } + return Int(String(decoding: slice, as: UTF8.self)) + } + guard let oh = part(1..<3), let om = part(4..<6) else { return nil } + offset = sign * (oh * 3600 + om * 60) + } + return daysFromCivil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset + } +} + +// MARK: - Small JSON helpers (JSONSerialization trees) + +/// The value at `path` in nested dictionaries, e.g. `json(x, "repository", "nameWithOwner")`. +func jsonValue(_ root: Any?, _ path: String...) -> Any? { + var cur = root + for key in path { + guard let dict = cur as? [String: Any] else { return nil } + cur = dict[key] + } + return cur is NSNull ? nil : cur +} + +/// The string at `path`, or "" — like `s(v, "/a/b")` in the Rust pollers. +func jsonString(_ root: Any?, _ path: String...) -> String { + var cur = root + for key in path { + guard let dict = cur as? [String: Any] else { return "" } + cur = dict[key] + } + return cur as? String ?? "" +} + +/// Integers come back from JSONSerialization as NSNumber; booleans too, so they are excluded. +func jsonInt(_ value: Any?) -> Int? { + guard let n = value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID() else { return nil } + return n.intValue +} + +func jsonBool(_ value: Any?) -> Bool? { + guard let n = value as? NSNumber, CFGetTypeID(n) == CFBooleanGetTypeID() else { return nil } + return n.boolValue +} diff --git a/NotchBuddy/Sources/App/IslandStateMachine.swift b/NotchBuddy/Sources/App/IslandStateMachine.swift index 762e01c340..64bde54def 100644 --- a/NotchBuddy/Sources/App/IslandStateMachine.swift +++ b/NotchBuddy/Sources/App/IslandStateMachine.swift @@ -193,6 +193,19 @@ final class IslandStateMachine { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) } + /// Alert that should open the island the same way a click does (a calendar + /// reminder): straight to `.home`, so hovering it doesn't fold it back to compact. + func forceHome() { + cancelTimers() + transition(to: .home) + } + + /// A file drag that started from the hidden island ended elsewhere: back to hidden. + func forceHidden() { + cancelTimers() + transition(to: .hidden) + } + /// Non-alert work event: show compact from hidden (HookServer reveal) func reveal() { guard state == .hidden else { return } diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 2a45844cee..790ac61021 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -14,6 +14,7 @@ struct IslandViewContent: View { case .question: QuestionView(state: state) case .error: ErrorView(state: state) case .finished: FinishedView(state: state) + case .reminder: ReminderView(state: state) case .confused: ConfusedView() case .upload: UploadView(state: state) case .uploading: UploadingView(state: state) @@ -265,6 +266,8 @@ struct OverviewView: View { NSWorkspace.shared.open(URL(string: "https://notion.so")!) case "integration_calcom": NSWorkspace.shared.open(URL(string: "https://app.cal.com/bookings")!) + case "integration_gcal": + NSWorkspace.shared.open(URL(string: "https://calendar.google.com")!) case "agent_cursor": #if !APPSTORE if let url = NSWorkspace.shared.urlForApplication( @@ -782,6 +785,102 @@ struct FinishedView: View { } } +// MARK: - Calendar reminder + +struct ReminderView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack { + CardBackground(wash: .indigo) + if let r = state.reminder { + // Re-reads the clock every minute: "in 30 min" counts down. + TimelineView(.everyMinute) { context in + content(r, now: context.date) + } + } + } + } + + private func content(_ r: GcalEvent, now: Date) -> some View { + let start = r.startSecs.map { Date(timeIntervalSince1970: TimeInterval($0)) } ?? now + let end = r.endSecs.map { Date(timeIntervalSince1970: TimeInterval($0)) } + let time = { (d: Date) in d.formatted(date: .omitted, time: .shortened) } + let when = end.map { "\(time(start)) – \(time($0))" } ?? time(start) + // The place, up to its first comma: "Casino Zaragoza", not the postcode. + let place = r.location + .flatMap { $0.hasPrefix("https://") ? nil : $0.components(separatedBy: ",").first } + .flatMap { $0.isEmpty ? nil : $0 } + let detail = place.map { "\(when) · \($0)" } ?? when + let meet = safeWebURL(r.meetURL) + let open = safeWebURL(r.url) + + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + Circle().fill(gcalColor(r.color)).frame(width: 8, height: 8) + // Google names your primary calendar after your address: say Calendar. + Text(!r.calendar.isEmpty && !r.calendar.contains("@") ? r.calendar : "Calendar") + .font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1) + Text(startsIn(start, now: now)) + .font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + } + Text(r.title) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1).truncationMode(.tail) + Text(detail) + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#9398A1")) + .lineLimit(1).truncationMode(.tail) + HStack(spacing: 8) { + if let meet { + PrimaryButton("Join") { openAndClose(meet) } + } + if let open { + if meet == nil { + PrimaryButton("Open") { openAndClose(open) } + } else { + SecondaryButton("Open") { openAndClose(open) } + } + } + SecondaryButton("OK") { + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + } + } + .padding(.leading, 116) + .padding(.trailing, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func openAndClose(_ url: URL) { + NSWorkspace.shared.open(url) + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + + /// "in 30 min", "in 1 h 15 min", "starting now" — from the live clock, not the poll's. + private func startsIn(_ start: Date, now: Date) -> String { + let minutes = Int((start.timeIntervalSince(now) / 60).rounded(.up)) + if minutes <= 0 { return "starting now" } + if minutes < 60 { return "in \(minutes) min" } + if minutes < 1440 { + let (h, m) = (minutes / 60, minutes % 60) + return m == 0 ? "in \(h) h" : "in \(h) h \(m) min" + } + let d = minutes / 1440 + return d == 1 ? "tomorrow" : "in \(d) days" + } +} + +/// A calendar's colour as Google Calendar shows it, or Google blue. +func gcalColor(_ hex: String?) -> Color { + guard let hex, hex.count == 7, hex.hasPrefix("#"), + hex.dropFirst().allSatisfy(\.isHexDigit) else { return Color(hex: "#4285F4") } + return Color(hex: hex) +} + // MARK: - Diff Card struct DiffCardView: View { @@ -1986,6 +2085,8 @@ struct IntegrationCardView: View { case "integration_stripe": return KeychainStore.shared.get("stripe-api-key") != nil case "integration_notion": return KeychainStore.shared.get("notion-api-key") != nil case "integration_calcom": return KeychainStore.shared.get("calcom-api-key") != nil + // Connected once Google has handed back a refresh token. + case "integration_gcal": return KeychainStore.shared.get("gcal-refresh-token") != nil default: return false } } @@ -1998,10 +2099,11 @@ struct IntegrationCardView: View { if let s = KeychainStore.shared.get("n8n-url") { return URL(string: s) } return nil case "integration_vercel": return URL(string: "https://vercel.com/dashboard") - case "integration_github": return URL(string: "https://github.com") + case "integration_github": return URL(string: "https://github.com/pulls") case "integration_stripe": return URL(string: "https://dashboard.stripe.com/payments") case "integration_notion": return URL(string: "https://notion.so") case "integration_calcom": return URL(string: "https://app.cal.com/bookings") + case "integration_gcal": return URL(string: "https://calendar.google.com") default: return nil } } @@ -2029,14 +2131,20 @@ struct IntegrationCardView: View { task.id == "integration_resend" && !appState.resendEmails.isEmpty } - // GitHub with stats or pulse loaded + // GitHub with stats or pulse loaded (an API error shows on the idle card instead) private var githubHasData: Bool { - task.id == "integration_github" && (appState.githubPulse != nil || appState.githubStats != nil) + task.id == "integration_github" && appState.githubError == nil + && (appState.githubPulse != nil || appState.githubStats != nil) } // GitHub with pulse loaded (richer card) private var githubHasPulse: Bool { - task.id == "integration_github" && appState.githubPulse != nil + task.id == "integration_github" && appState.githubPulse != nil && appState.githubError == nil + } + + // Google Calendar once connected and polled; disconnecting goes back to the idle card + private var gcalHasData: Bool { + task.id == "integration_gcal" && appState.gcalEvents != nil && appState.gcalError == nil } // Stripe: show card as soon as first poll completes (balance OR payments) @@ -2065,6 +2173,17 @@ struct IntegrationCardView: View { #endif } + /// The last error a service's poller ran into, shown instead of its data. + private var serviceError: String? { + switch task.id { + case "integration_stripe": return appState.stripeError + case "integration_calcom": return appState.calcomError + case "integration_github": return appState.githubError + case "integration_gcal": return appState.gcalError + default: return nil + } + } + // Apple Mail: the message list once Mail is open and allowed (else the idle card says why) private var mailHasData: Bool { #if !APPSTORE @@ -2099,10 +2218,7 @@ struct IntegrationCardView: View { } #endif if PillCatalog.definition(for: task.id)?.comingSoon == true { return Color(hex: "#6B7079") } - let svcErr = task.id == "integration_stripe" ? appState.stripeError - : task.id == "integration_calcom" ? appState.calcomError - : nil - if svcErr != nil { return Color(hex: "#F4505E") } + if serviceError != nil { return Color(hex: "#F4505E") } return isConfigured ? Color(hex: "#22C55E") : Color(hex: "#F4505E") } @@ -2121,10 +2237,7 @@ struct IntegrationCardView: View { } #endif if PillCatalog.definition(for: task.id)?.comingSoon == true { return String(localized: "Coming soon") } - let svcErr = task.id == "integration_stripe" ? appState.stripeError - : task.id == "integration_calcom" ? appState.calcomError - : nil - if let err = svcErr { return err } + if let err = serviceError { return err } // Pills driven by hooks, never by a key: the idle card reports whether the // hooks are in place. integration_claude read "Connected · loading…" with // nothing left to load — a session replaces this card, it never resolves here. @@ -2160,6 +2273,7 @@ struct IntegrationCardView: View { let provider = ChatProvider(pillID: task.id)! return provider.isLocal ? String(localized: "Not connected") : String(localized: "Key not configured") } + if task.id == "integration_gcal" { return String(localized: "Google account not connected") } return String(localized: "Key not configured") } } @@ -2208,6 +2322,9 @@ struct IntegrationCardView: View { } else if githubHasData { GitHubStatsCardView(stats: appState.githubStats!) .transition(.opacity) + } else if gcalHasData { + GcalCardView(events: appState.gcalEvents ?? []) + .transition(.opacity) } else if stripeHasData { StripeCardView() .transition(.opacity) @@ -2423,6 +2540,15 @@ struct IntegrationCardView: View { .foregroundColor(Color(hex: "#C9956A").opacity(0.85)) .buttonStyle(.plain) } + if (task.id == "integration_github" || task.id == "integration_gcal") && isConfigured { + Button("Refresh") { + if task.id == "integration_github" { GithubPoller.shared.triggerPulseNow() } + else { GcalPoller.shared.pollNow() } + } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.85)) + .buttonStyle(.plain) + } // Settings button: shown when not configured, except cursor/codex and music if !isConfigured && task.id != "agent_cursor" @@ -2844,25 +2970,44 @@ struct GitHubPulseCardView: View { value: "\(reviewCount)" ) { onTapSection(.toReview) } - // Default branch CI - let mainWorst = mainCIWorst(pulse.mainCI) - let (ciIcon, ciIconColor, ciValue): (String, String, String) = { - switch mainWorst { - case .failure: - let n = pulse.mainCI.filter { $0.ci == .failure }.count - return ("xmark.octagon.fill", "#F4505E", "\(n) failing") - case .pending: - return ("checkmark.seal.fill", "#F5A524", "running") - case .success: - return ("checkmark.seal.fill", "#22C55E", "all green") - case .unknown: - return ("checkmark.seal.fill", "#6B7079", pulse.mainCI.isEmpty ? "no repos" : "unknown") - } - }() - GitHubStatRow( - icon: ciIcon, iconColor: ciIconColor, - label: "Default branch CI", value: ciValue - ) { onTapSection(.mainCI) } + // CI: the branch of the last Claude Code session when there is one + // (the detail lists it first, then the default branches), the + // default branches otherwise. + if let b = pulse.branch { + let (ciIcon, ciIconColor, ciValue): (String, String, String) = { + guard b.pushed else { return ("arrow.triangle.branch", "#6B7079", "not pushed") } + switch b.ci { + case .failure: return ("xmark.octagon.fill", "#F4505E", "failing") + case .pending: return ("checkmark.seal.fill", "#F5A524", "running") + case .success: return ("checkmark.seal.fill", "#22C55E", "passing") + case .unknown: return ("checkmark.seal.fill", "#6B7079", "no checks") + } + }() + GitHubStatRow( + icon: ciIcon, iconColor: ciIconColor, + label: "CI · \(b.branch)", value: ciValue + ) { onTapSection(.mainCI) } + .help(b.failing.isEmpty ? b.repo : "\(b.repo)\nFailing: \(b.failing.joined(separator: ", "))") + } else { + let mainWorst = mainCIWorst(pulse.mainCI) + let (ciIcon, ciIconColor, ciValue): (String, String, String) = { + switch mainWorst { + case .failure: + let n = pulse.mainCI.filter { $0.ci == .failure }.count + return ("xmark.octagon.fill", "#F4505E", "\(n) failing") + case .pending: + return ("checkmark.seal.fill", "#F5A524", "running") + case .success: + return ("checkmark.seal.fill", "#22C55E", "all green") + case .unknown: + return ("checkmark.seal.fill", "#6B7079", pulse.mainCI.isEmpty ? "no repos" : "unknown") + } + }() + GitHubStatRow( + icon: ciIcon, iconColor: ciIconColor, + label: "Default branch CI", value: ciValue + ) { onTapSection(.mainCI) } + } } .padding(.top, 6) .padding(.leading, 108) @@ -2896,6 +3041,8 @@ private struct GitHubStatRow: View { Text(label) .font(.system(size: 11)) .foregroundColor(Color(hex: "#6B7079")) + .lineLimit(1) + .truncationMode(.middle) Spacer() Text(value) .font(.system(size: 12, weight: .semibold)) @@ -2922,7 +3069,7 @@ struct GitHubDetailView: View { switch section { case .myPRs: return String(localized: "My PRs") case .toReview: return String(localized: "To review") - case .mainCI: return "Default branch CI" + case .mainCI: return pulse.branch == nil ? String(localized: "Default branch CI") : "CI" case .activity: return String(localized: "Activity") } } @@ -2936,7 +3083,12 @@ struct GitHubDetailView: View { } private var repoItems: [GitHubRepoCI] { - section == .mainCI ? pulse.mainCI : [] + guard section == .mainCI else { return [] } + // The session's branch first, then the default branches (not the same one twice). + guard let b = pulse.branch else { return pulse.mainCI } + let branchRow = GitHubRepoCI(repo: b.repo, url: "https://github.com/\(b.repo)", branch: b.branch, + ci: b.ci, headSha: b.oid, link: b.url) + return [branchRow] + pulse.mainCI.filter { !($0.repo == b.repo && $0.branch == b.branch) } } private var totalItems: Int { items.count + repoItems.count } @@ -3030,8 +3182,7 @@ struct GitHubDetailView: View { let repoIdx = sel - items.count guard repoIdx < repoItems.count else { return } let repo = repoItems[repoIdx] - let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions" - if let url = safeWebURL(actionsURL), url.host == "github.com" { + if let url = safeWebURL(repo.openURL), url.host == "github.com" { NSWorkspace.shared.open(url) } } @@ -3244,8 +3395,7 @@ private struct GitHubRepoCIRowView: View { var body: some View { Button(action: { - let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions" - if let url = safeWebURL(actionsURL), url.host == "github.com" { + if let url = safeWebURL(repo.openURL), url.host == "github.com" { NSWorkspace.shared.open(url) } }) { @@ -3352,6 +3502,167 @@ private struct StatRow: View { } } +// MARK: - Integration list rows (Calendar) + +private enum IntegrationColors { + static let green = "#22C55E" + static let amber = "#F5A524" + static let gcal = "#4285F4" +} + +/// Highlighted first row + plain rows, like the Resend and Vercel lists. +private struct IntegrationRow: View { + let accent: Color + let first: Bool + @ViewBuilder let content: () -> Content + + var body: some View { + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + content() + } + .padding(.horizontal, 8).padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .background(first ? accent.opacity(0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) + } +} + +private struct IntegrationCardHeader: View { + let color: String + let name: String + let kind: String + @ViewBuilder let extra: () -> Extra + + var body: some View { + HStack(spacing: 6) { + Circle().fill(Color(hex: color)).frame(width: 7, height: 7) + Text(name) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text(kind) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + extra() + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + } +} + +// MARK: - Google Calendar Card View + +struct GcalCardView: View { + let events: [GcalEvent] + + var body: some View { + // The rows say "now" and "in 12m": let them move with the clock even when + // a poll brings back the same events. + TimelineView(.everyMinute) { context in + content(now: context.date) + } + } + + private func content(now: Date) -> some View { + typealias C = IntegrationColors + // Meetings first; all-day events only fill what's left. + let ordered = Array((events.filter { !$0.allDay } + events.filter(\.allDay)).prefix(3)) + let kind = ordered.first.map { Calendar.current.isDate(Self.start(of: $0), inSameDayAs: now) } == true + ? "Today" : "Upcoming" + + return VStack(alignment: .leading, spacing: 0) { + IntegrationCardHeader(color: C.gcal, name: "Calendar", kind: kind) { EmptyView() } + + VStack(alignment: .leading, spacing: 3) { + if ordered.isEmpty { + Text("Nothing on the calendar this week") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.horizontal, 8).padding(.vertical, 3) + } + ForEach(Array(ordered.enumerated()), id: \.element.key) { i, e in + row(e, index: i, now: now) + } + } + .padding(.top, 5) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } + + private func row(_ e: GcalEvent, index i: Int, now: Date) -> some View { + typealias C = IntegrationColors + let nowSecs = Int(now.timeIntervalSince1970) + let ongoing = e.startSecs != nil && e.endSecs != nil && e.startSecs! <= nowSecs && nowSecs < e.endSecs! + let minutes = e.startSecs.map { Int((Double($0 - nowSecs) / 60).rounded(.up)) } + let soon = minutes.map { $0 > 0 && $0 <= 60 } ?? false + // Otherwise the calendar's own colour, as in Google Calendar. + let accent = ongoing ? Color(hex: C.green) + : soon && minutes! <= 5 ? Color(hex: C.amber) + : gcalColor(e.color) + // Join, for the meeting that is on or about to be: the one click that matters. + let join = i == 0 ? safeWebURL(e.meetURL) : nil + let showJoin = join != nil && (ongoing || (minutes.map { $0 <= 15 } ?? false)) + + return IntegrationRow(accent: accent, first: i == 0) { + Text(ongoing ? "now" : Self.when(e, now: now)) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(Color(hex: ongoing ? C.green : "#8AB4F8")) + .monospacedDigit() + .fixedSize() + Text(e.title) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: i == 0 ? "#C5C8CD" : "#9398A1")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 4) + if showJoin, let join { + Button { NSWorkspace.shared.open(join) } label: { + Text("Join") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(accent) + .padding(.horizontal, 7).padding(.vertical, 1) + .background(accent.opacity(0.14)) + .clipShape(Capsule()) + .fixedSize() + } + .buttonStyle(.plain) + } else if soon && !ongoing, let m = minutes { + Text("in \(m)m") + .font(.system(size: 10)) + .foregroundColor(Color(hex: m <= 5 ? C.amber : "#6B7079")) + .fixedSize() + } + } + .onTapGesture { + if let url = safeWebURL(e.url) { NSWorkspace.shared.open(url) } + } + } + + /// All-day events carry a bare YYYY-MM-DD: that day, at local midnight. + static func start(of e: GcalEvent) -> Date { + if let s = e.startSecs { return Date(timeIntervalSince1970: TimeInterval(s)) } + let parts = e.start.split(separator: "-").compactMap { Int($0) } + var c = DateComponents() + c.year = parts.first; c.month = parts.count > 1 ? parts[1] : 1; c.day = parts.count > 2 ? parts[2] : 1 + return Calendar.current.date(from: c) ?? Date.distantFuture + } + + /// "14:30" today, "Fri 09:00" later in the week, "All day" / "Fri" for all-day. + static func when(_ e: GcalEvent, now: Date) -> String { + let start = start(of: e) + let today = Calendar.current.isDate(start, inSameDayAs: now) + let weekday = start.formatted(.dateTime.weekday(.abbreviated)) + if e.allDay { return today ? "All day" : weekday } + let time = start.formatted(date: .omitted, time: .shortened) + return today ? time : "\(weekday) \(time)" + } +} + // MARK: - Stripe Card View struct StripeCardView: View { @@ -4092,6 +4403,9 @@ struct TickerShimmerText: View { struct AgentPillsView: View { @ObservedObject var state: AppState @State private var swapping = false + // Hover lives here, not in each pill: at most one pill is highlighted, and a + // pill can't stay lit after the grid reshuffles under a still cursor. + @State private var hoveredId: String? = nil private var others: [AgentTask] { state.tasks.filter { $0.id != state.focusId } @@ -4113,34 +4427,18 @@ struct AgentPillsView: View { ForEach(displayTasks) { task in #if !APPSTORE if task.id == "integration_music" { - MusicPill(task: task, state: state, swapping: $swapping) { - swapping = true - state.setFocus(task.id) - SoundEngine.shared.play("blip") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } - } + MusicPill(task: task, state: state, isHovered: hoveredId == task.id, + onHover: hover(task.id)) { select(task.id) } } else if task.id == "integration_spotify" { - SpotifyPill(task: task, swapping: $swapping) { - swapping = true - state.setFocus(task.id) - SoundEngine.shared.play("blip") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } - } + SpotifyPill(task: task, isHovered: hoveredId == task.id, + onHover: hover(task.id)) { select(task.id) } } else { - AgentPill(task: task, state: state, swapping: $swapping) { - swapping = true - state.setFocus(task.id) - SoundEngine.shared.play("blip") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } - } + AgentPill(task: task, isHovered: hoveredId == task.id, + onHover: hover(task.id)) { select(task.id) } } #else - AgentPill(task: task, state: state, swapping: $swapping) { - swapping = true - state.setFocus(task.id) - SoundEngine.shared.play("blip") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } - } + AgentPill(task: task, isHovered: hoveredId == task.id, + onHover: hover(task.id)) { select(task.id) } #endif } } @@ -4149,14 +4447,35 @@ struct AgentPillsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } + + private func hover(_ id: String) -> (Bool) -> Void { + { hovering in + if hovering { + // Ignore enters while the grid reshuffles, so the pill that + // slides under the cursor doesn't light up mid-swap. + guard !swapping else { return } + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { hoveredId = id } + } else if hoveredId == id { + // Exits are never dropped, otherwise the pill stays stuck. + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { hoveredId = nil } + } + } + } + + private func select(_ id: String) { + swapping = true + hoveredId = nil + state.setFocus(id) + SoundEngine.shared.play("blip") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } + } } struct AgentPill: View { let task: AgentTask - @ObservedObject var state: AppState - @Binding var swapping: Bool + let isHovered: Bool + let onHover: (Bool) -> Void let onTap: () -> Void - @State private var isHovered = false private var effectiveColor: String { task.color } @@ -4205,10 +4524,7 @@ struct AgentPill: View { .buttonStyle(.plain) .scaleEffect(isHovered ? 1.04 : 1.0) .brightness(isHovered ? 0.06 : 0) - .onHover { newHover in - guard !swapping else { return } - withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } - } + .onHover { onHover($0) } } } @@ -4218,9 +4534,9 @@ struct AgentPill: View { struct MusicPill: View { let task: AgentTask @ObservedObject var state: AppState - @Binding var swapping: Bool + let isHovered: Bool + let onHover: (Bool) -> Void let onTap: () -> Void - @State private var isHovered = false private var isPlaying: Bool { AppState.shared.musicPlaying } private var showControls: Bool { isHovered && MusicController.shared.trackTitle != nil } @@ -4285,10 +4601,7 @@ struct MusicPill: View { .shadow(color: Color(hex: task.color).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2) .scaleEffect(isHovered ? 1.04 : 1.0) .brightness(isHovered ? 0.06 : 0) - .onHover { newHover in - guard !swapping else { return } - withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } - } + .onHover { onHover($0) } } } diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index de86dd1ccf..a5b2551248 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -58,6 +58,12 @@ final class IslandWindowController: NSWindowController { private var highlightPanel: NSPanel? = nil private var highlightWindowPid: pid_t = 0 + // File drag: where the island was when a file first reached it, to go back there + // when the drag ends somewhere else (dropped elsewhere, or Escape). + private var dragOrigin: (state: IslandStateMachine.State, view: IslandView)? = nil + private var mouseDown = false + private var abandonWork: DispatchWorkItem? + // Notch real dimensions (set on init) private var notchW: CGFloat = IslandConst.notchWidth private var notchH: CGFloat = IslandConst.notchHeight @@ -126,6 +132,12 @@ final class IslandWindowController: NSWindowController { dropView.autoresizingMask = [.width, .height] dropView.onDragEntered = { [weak self] loc in Task { @MainActor in + if let self { + self.cancelAbandon() + // A drag is a held button, even if the poll hasn't reported it yet. + self.mouseDown = true + if self.dragOrigin == nil { self.dragOrigin = (self.fsm.state, self.state.view) } + } let iLoc = self?.windowToIsland(loc) ?? CGPoint(x: 320, y: 88) AppState.shared.fileDragOver = true // The voice mail card stays on screen: the file will be its attachment. @@ -146,16 +158,21 @@ final class IslandWindowController: NSWindowController { UploadSequenceEngine.shared.updateCursor(x: iLoc.x, y: iLoc.y) } } - dropView.onDragExited = { + dropView.onDragExited = { [weak self] in Task { @MainActor in AppState.shared.fileDragOver = false - // Do NOT collapse — drag session still active; island stays open. + // The island stays open while the drag session is alive — the file may + // come back. Once the button is up the drag is over, and waiting out + // the auto-close for a file that went elsewhere is no good. NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0)) UploadSequenceEngine.shared.exitZone() + if let self, !self.mouseDown { self.scheduleAbandon() } } } dropView.onFilesDropped = { [weak self] urls in Task { @MainActor in + self?.cancelAbandon() + self?.dragOrigin = nil #if !APPSTORE // During the voice email (above all after "any attachment?"), a file // dropped on the notch goes into that email. @@ -173,6 +190,18 @@ final class IslandWindowController: NSWindowController { } } + dropView.onDropRejected = { [weak self] in + Task { @MainActor in + guard let self else { return } + self.cancelAbandon() + self.dragOrigin = nil + AppState.shared.fileDragOver = false + NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0)) + // Leaving the upload view deactivates the drop sequence (IslandRootView). + if self.state.view == .upload { self.state.view = self.defaultView() } + } + } + container.addSubview(hosting) // z-bottom: SwiftUI + mouse events container.addSubview(dropView) // z-top: drag only (hitTest→nil, transparent to mouse) panel.contentView = container @@ -469,6 +498,15 @@ final class IslandWindowController: NSWindowController { AppState.shared.mousePosition = newPos } + // Left button pressed or released anywhere. Checked every frame, including + // when the cursor stands still: an abandoned drag usually ends that way. + let down = NSEvent.pressedMouseButtons & 1 != 0 + if down != mouseDown { + mouseDown = down + if down { cancelAbandon() } + else if dragOrigin != nil && !state.fileDragOver { scheduleAbandon() } + } + // AppState can hide the island by itself (last task ended): keep the FSM in step. if state.mode == .hidden && fsm.state == .petit { fsm.hiddenExternally() } @@ -587,6 +625,49 @@ final class IslandWindowController: NSWindowController { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) } + // MARK: - Abandoned file drag + + /// The file went elsewhere (or the drag was cancelled). A short grace lets a + /// late drop win, then the island goes back to where it was before the drag. + private func scheduleAbandon() { + cancelAbandon() + let item = DispatchWorkItem { [weak self] in + self?.abandonWork = nil + self?.abandonDrag() + } + abandonWork = item + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: item) + } + + private func cancelAbandon() { + abandonWork?.cancel() + abandonWork = nil + } + + private func abandonDrag() { + guard let origin = dragOrigin else { return } + dragOrigin = nil + guard !mouseDown, !state.fileDragOver else { return } + // A drop landed, or the user moved on to something else in the meantime: leave it alone. + guard state.mode == .expanded, state.view == .upload else { return } + UploadSequenceEngine.shared.deactivate() + switch origin.state { + case .hidden: + // Still under the cursor (an Escape over the island): stay reachable. + if wasInIsland { + collapse() + } else { + fsm.forceHidden() + setMode(.hidden) + window?.resignKey() + } + case .home: + state.view = origin.view + case .petit, .coucou, .listening: + collapse() + } + } + // MARK: - Mode transitions private func modeLevel(_ m: IslandMode) -> Int { @@ -877,6 +958,17 @@ final class IslandWindowController: NSWindowController { self.expand(to: view) } + // Integration alerts (a calendar reminder): open through the FSM like a click, + // then show the alert's view. Auto-closes like any open island once the mouse is away. + NotificationCenter.default.addObserver(forName: .islandAlert, object: nil, queue: .main) { [weak self] note in + guard let self, let view = note.object as? IslandView else { return } + MainActor.assumeIsolated { + self.fsm.forceHome() + self.expand(to: view) + if !self.wasInIsland { self.fsm.mouseLeft() } + } + } + // Hook server compact reveal (non-alert work events: session start, tool use, etc.) NotificationCenter.default.addObserver(forName: .hookReveal, object: nil, queue: .main) { [weak self] _ in guard let self else { return } @@ -2027,6 +2119,7 @@ extension Notification.Name { static let islandActivateCardSelection = Notification.Name("notchBuddy.islandActivateCardSelection") static let openFullSettings = Notification.Name("notchBuddy.openFullSettings") static let hookReveal = Notification.Name("notchBuddy.hookReveal") + static let islandAlert = Notification.Name("notchBuddy.islandAlert") static let musicReveal = Notification.Name("notchBuddy.musicReveal") // Greeting ↔ IslandWindowController static let greetComplete = Notification.Name("notchBuddy.greetComplete") diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 6547d767f6..01818fd876 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -106,6 +106,8 @@ struct SettingsView: View { @State private var stripeKey: String = KeychainStore.shared.get("stripe-api-key") ?? "" @State private var calcomKey: String = KeychainStore.shared.get("calcom-api-key") ?? "" @State private var notionKey: String = KeychainStore.shared.get("notion-api-key") ?? "" + @State private var gcalClientId: String = KeychainStore.shared.get("gcal-client-id") ?? "" + @State private var gcalClientSecret: String = KeychainStore.shared.get("gcal-client-secret") ?? "" @AppStorage(CryptoSettings.exchangeKey) private var cryptoExchange = CryptoExchange.coinbase.rawValue @AppStorage(CryptoSettings.currencyKey) private var cryptoCurrency = "EUR" @AppStorage(CryptoSettings.coinsKey) private var cryptoCoins = CryptoSettings.defaultCoins @@ -1296,9 +1298,10 @@ struct SettingsView: View { } SecureField("Personal Access Token", text: $githubToken) .textFieldStyle(.roundedBorder) - Text(String(localized: "integrations.github.token-hint")) + Text(String(localized: "integrations.github.token-hint") + " " + Self.githubCIHint) .font(.system(size: 10)) .foregroundColor(Color(hex: "#8E939C")) + .fixedSize(horizontal: false, vertical: true) } // Stripe @@ -1331,6 +1334,23 @@ struct SettingsView: View { .textFieldStyle(.roundedBorder) } + // Google Calendar + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#4285F4")).frame(width: 8, height: 8) + Text(verbatim: "Google Calendar").font(.system(size: 12, weight: .semibold)) + } + TextField("Client ID (….apps.googleusercontent.com)", text: $gcalClientId) + .textFieldStyle(.roundedBorder) + SecureField("Client secret (GOCSPX-…)", text: $gcalClientSecret) + .textFieldStyle(.roundedBorder) + GcalConnectRow(clientId: gcalClientId, clientSecret: gcalClientSecret) + Text(String(localized: "integrations.gcal.hint")) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#8E939C")) + .fixedSize(horizontal: false, vertical: true) + } + // Crypto: public prices, no key. Changes apply at once. VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { @@ -1386,6 +1406,12 @@ struct SettingsView: View { } } + #if APPSTORE + private static var githubCIHint: String { String(localized: "integrations.github.ci-hint.appstore") } + #else + private static var githubCIHint: String { String(localized: "integrations.github.ci-hint") } + #endif + // MARK: - Voice section #if !APPSTORE @@ -2090,6 +2116,7 @@ struct SettingsView: View { let nextGithubToken = KeychainStore.shared.get("github-token") if nextGithubToken != prevGithubToken { AppState.shared.githubPulse = nil + AppState.shared.githubError = nil AppState.shared.githubActivity = nil if nextGithubToken == nil { AppState.shared.githubStats = nil } if nextGithubToken != nil { @@ -2101,6 +2128,8 @@ struct SettingsView: View { saveKey("stripe-api-key", value: stripeKey) saveKey("calcom-api-key", value: calcomKey) saveKey("notion-api-key", value: notionKey) + saveKey("gcal-client-id", value: gcalClientId.trimmingCharacters(in: .whitespacesAndNewlines)) + saveKey("gcal-client-secret", value: gcalClientSecret.trimmingCharacters(in: .whitespacesAndNewlines)) statusMessage = String(localized: "status.integrations-saved") } @@ -2339,6 +2368,121 @@ struct SettingsSidebarRow: View { // MARK: - Integration filter row (reusable for Vercel / n8n) +// MARK: - Google Calendar: Connect / Cancel / Disconnect + +struct GcalConnectRow: View { + /// What the fields say right now; Connect saves them, so "Save integrations" isn't needed first. + let clientId: String + let clientSecret: String + + @State private var connected = KeychainStore.shared.get("gcal-refresh-token") != nil + @State private var waiting = false + /// Which Connect click the screen belongs to: a stale one never redraws it. + @State private var attempt = 0 + @State private var feedback: Feedback? = nil + + struct Feedback { + enum Kind { case hint, ok, warn, err } + let kind: Kind + let text: String + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(Color(hex: connected ? "#22C55E" : "#F4505E")) + .frame(width: 7, height: 7) + Text(connected ? "Connected" : "Not connected") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + } + HStack(spacing: 8) { + Button(waiting ? "Waiting for Google…" : connected ? "Reconnect…" : "Connect Google account…") { + connect() + } + .disabled(waiting) + if waiting { + Button("Cancel") { cancel() } + } + if connected && !waiting { + Button("Disconnect") { disconnect() } + } + Button("Google Cloud console") { + if let url = URL(string: "https://console.cloud.google.com/apis/credentials") { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.link) + } + .controlSize(.small) + if let feedback { + Text(feedback.text) + .font(.system(size: 11)) + .foregroundColor(color(feedback.kind)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func color(_ kind: Feedback.Kind) -> Color { + switch kind { + case .hint: return .secondary + case .ok: return Color(hex: "#22C55E") + case .warn: return .orange + case .err: return .red + } + } + + private func connect() { + let id = clientId.trimmingCharacters(in: .whitespacesAndNewlines) + let secret = clientSecret.trimmingCharacters(in: .whitespacesAndNewlines) + if !id.isEmpty { KeychainStore.shared.set("gcal-client-id", value: id) } + if !secret.isEmpty { KeychainStore.shared.set("gcal-client-secret", value: secret) } + guard KeychainStore.shared.get("gcal-client-id") != nil, + KeychainStore.shared.get("gcal-client-secret") != nil else { + feedback = Feedback(kind: .warn, text: "Fill in the client ID and client secret first.") + return + } + attempt += 1 + let mine = attempt + waiting = true + // Google's "access blocked" page is a dead end: it never comes back here. + feedback = Feedback(kind: .hint, text: "Finish signing in in your browser. If Google says access is blocked, your address isn't a test user of the app yet: fix it in the console's OAuth consent screen, then Cancel and connect again.") + Task { @MainActor in + let outcome: Feedback + do { + try await GcalPoller.shared.connect() + outcome = Feedback(kind: .ok, text: "Connected. Turn the Calendar pill on (Active pills) to see it next to Mochi.") + } catch { + outcome = Feedback(kind: .err, text: (error as? GcalProblem)?.message ?? error.localizedDescription) + } + guard mine == attempt else { return } // cancelled, or another attempt took over + waiting = false + feedback = outcome + connected = KeychainStore.shared.get("gcal-refresh-token") != nil + } + } + + /// Takes effect at once here; the listener stops within a second. + private func cancel() { + attempt += 1 + waiting = false + GcalPoller.shared.cancel() + feedback = nil + } + + private func disconnect() { + feedback = nil + Task { @MainActor in + await GcalPoller.shared.disconnect() + connected = false + feedback = Feedback(kind: .ok, text: "Disconnected, and access revoked at Google.") + } + } +} + struct IntegrationFilterRow: View { let label: String let items: [String] diff --git a/NotchBuddy/Sources/App/SpotifyViews.swift b/NotchBuddy/Sources/App/SpotifyViews.swift index 5674b959e4..1d9bf5505b 100644 --- a/NotchBuddy/Sources/App/SpotifyViews.swift +++ b/NotchBuddy/Sources/App/SpotifyViews.swift @@ -6,10 +6,10 @@ import AppKit struct SpotifyPill: View { let task: AgentTask - @Binding var swapping: Bool + let isHovered: Bool + let onHover: (Bool) -> Void let onTap: () -> Void @ObservedObject private var controller = SpotifyController.shared - @State private var isHovered = false private var showControls: Bool { isHovered && controller.track != nil } @@ -73,10 +73,7 @@ struct SpotifyPill: View { .shadow(color: Color(hex: task.color).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2) .scaleEffect(isHovered ? 1.04 : 1.0) .brightness(isHovered ? 0.06 : 0) - .onHover { newHover in - guard !swapping else { return } - withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } - } + .onHover { onHover($0) } } } diff --git a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift index 20187ccedf..aa733a5db0 100644 --- a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift +++ b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift @@ -9,7 +9,7 @@ enum IslandMode: String, CaseIterable { // MARK: - Island View enum IslandView: String, CaseIterable { - case overview, empty, approval, question, error, finished + case overview, empty, approval, question, error, finished, reminder case confused, upload, uploading, choose, mail, prompt case searching, result, note, settings, greeting, wardrobe, recap case listening // voice wake detected — "À l'écoute…" with live transcript @@ -221,6 +221,7 @@ enum IslandConst { .question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), .error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), .finished: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), + .reminder: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), .confused: ViewLayout(height: 160, botX: 76, botY: nil, botDiameter: 66, agentMode: .column), .upload: ViewLayout(height: 176, botX: 140, botY: 104, botDiameter: 62, agentMode: .column), .uploading: ViewLayout(height: 176, botX: 46, botY: 118, botDiameter: 20, agentMode: .none), diff --git a/NotchBuddy/Sources/CoucouKit/PillCatalog.swift b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift index 8736fafe38..423bacd930 100644 --- a/NotchBuddy/Sources/CoucouKit/PillCatalog.swift +++ b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift @@ -118,6 +118,8 @@ enum PillCatalog { category: .service, subtitle: String(localized: "Integration"), source: .n8n), .init(id: "integration_calcom", name: "Cal.com", color: "#C9956A", category: .service, subtitle: String(localized: "Integration"), source: .n8n), + .init(id: "integration_gcal", name: "Calendar", color: "#4285F4", + category: .service, subtitle: String(localized: "Integration"), source: .n8n), .init(id: "integration_stripe", name: "Stripe", color: "#0570DE", category: .service, subtitle: String(localized: "Integration"), source: .n8n), .init(id: "integration_music", name: "Apple Music", color: "#FA2D48", diff --git a/README.md b/README.md index 2f7fd0e223..781e1b70b6 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Flow dictation and the Notetaker are on the Mac for now; Windows and Linux are n - 📎 **Drop a file on the notch** — Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. - 🖥️ **Mochi on the desktop** — drag Mochi out of the island to set him loose on your desktop: he floats as a 120 pt companion, follows your cursor, wears his outfit, reacts to alerts by flying home and flying back, and comes back where you left him on next launch. - 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. -- 🔌 **Integrations** — Stripe payments, n8n workflows, GitHub (open PRs, reviews requested, CI status), Vercel deployments, Resend emails, Notion, Cal.com. Each one gets its own little colored Mochi. +- 🔌 **Integrations** — Stripe payments, n8n workflows, GitHub (open PRs, reviews requested, CI status, and the CI of the branch you're on), Vercel deployments, Resend emails, Notion, Cal.com, Google Calendar (with meeting reminders). Each one gets its own little colored Mochi. - 🎵 **Apple Music pill** *(macOS, GitHub build)* — add the Apple Music pill in Settings → Active pills to see what's playing and control playback from the notch; Mochi dances while it plays. - 🎧 **Spotify pill** *(macOS, GitHub build)* — add the Spotify pill in Settings → Active pills: album cover, title and artist, a progress bar you drag to seek, play/pause, previous/next, shuffle, repeat and volume, right from the notch; Mochi dances while it plays. - 👗 **Dress Mochi up** — right-click him for the wardrobe. He also dresses up for the seasons on his own. @@ -330,7 +330,7 @@ On the Mac, open Coucou from the Dock or the menu bar: its window has every sett | **Active pills** *(macOS)* | choose which tools and agents appear in the island | Settings → Active pills | | **Flow dictation** *(macOS, GitHub build)* | hold a key, speak, the words are pasted | Coucou → Flow → Settings · the Accessibility and Microphone permissions | | **Parakeet** *(macOS, Apple silicon)* | offline dictation, more accurate | Coucou → Flow → Settings → Recognition → **Download** (about 650 MB) | -| Stripe, n8n, GitHub, Vercel, Resend, Notion, Cal.com | the service pills | Keychain / Windows Credential Manager / Secret Service, all optional | +| Stripe, n8n, GitHub, Vercel, Resend, Notion, Cal.com, Google Calendar (your own OAuth client) | the service pills | Keychain / Windows Credential Manager / Secret Service, all optional | | **WhatsApp** *(macOS, GitHub build, experimental)* | your WhatsApp messages in the notch, and replies | Settings → Integrations → WhatsApp · a gateway on your Mac, see [WhatsApp (experimental)](#whatsapp-experimental) | If Coucou isn't running, the hook exits immediately: **Claude Code is never blocked.** diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 4f36ddfe58..92e769fcba 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -140,7 +140,7 @@ Sur `PostToolUse` pour `Edit`, `MultiEdit` et `Write` (Claude Code, Cursor), l'a ## 1quater. GitHub (pulse) -**Plateforme** : macOS uniquement (build GitHub) +**Plateforme** : macOS (CI de la branche de session : build GitHub seulement). **Token** : token classique avec scope `repo`, ou token fin avec accès en lecture à Pull requests, Commit statuses et Actions. Stocké dans le Trousseau (`github-token`). @@ -149,11 +149,14 @@ Sur `PostToolUse` pour `Edit`, `MultiEdit` et `Write` (Claude Code, Cursor), l'a Deux requêtes GraphQL séparées (POST `https://api.github.com/graphql`, même token, même en-tête) : **Requête pulse** : -- **Mes PRs ouvertes** (20 dernières par date de mise à jour) : numéro, titre, URL, isDraft, `reviewDecision`, `oid` du dernier commit, état CI du dernier commit (`statusCheckRollup.state`) +- **Mes PRs ouvertes** (20 dernières par date de mise à jour) : numéro, titre, URL, isDraft, `reviewDecision`, `headRefName`, `oid` du dernier commit, état CI du dernier commit (`statusCheckRollup.state`), `latestReviews` (dernière review de chaque relecteur). Les PR sans activité depuis 30 jours (`updatedAt`) sont ignorées : les PR de bots ouvertes au nom de l'utilisateur, typiquement Snyk. - **PRs à reviewer** (recherche `is:pr is:open review-requested:@me`, 20 max) : numéro, titre, URL, auteur - **CI branche par défaut** (10 derniers dépôts propres, non archivés) : `oid` et état CI du commit HEAD sur `defaultBranchRef` +- **CI de la branche de session** (`sessionBranch`, `@include(if: $withBranch)`) : `oid`, URL du commit, `statusCheckRollup` avec ses `contexts` (CheckRun / StatusContext) pour nommer les checks en échec et pointer vers le premier job en échec. La branche est celle du `cwd` de la dernière session Claude Code affichée par l'île (`GithubPoller.noteCwd`, appelé par HookServer), lue directement dans `.git` : `HEAD`, puis `branch..remote` / `.merge` dans `config` (le `commondir` pour un worktree). Pas de processus `git` (`GitHubBranchCI.swift`). Version App Store : le bac à sable interdit de lire le `.git` du projet, donc pas de branche de session. -**Cadence pulse** : 5 min (pas de PRs en attente) ou 60 s (au moins une PR/CI en état `PENDING` ou `EXPECTED`). Première requête 10 s après le lancement. +**Cadence pulse** : 5 min (pas de PRs en attente) ou 60 s (au moins une PR/CI, y compris la branche de session, en état `PENDING` ou `EXPECTED`). Première requête 10 s après le lancement. + +**Carte** : la troisième ligne montre le CI de la branche de session quand il y en a une (« CI · » : passing / failing / running / no checks / not pushed), le CI des branches par défaut sinon. Le détail « CI » liste la branche de session en premier (clic : job en échec, ou page du commit), puis les branches par défaut. Erreur API (401, 403, autre code HTTP, réponse sans `data`) : `AppState.githubError`, affichée sur la carte au repos à la place des données, avec **Refresh**. **Rafraîchissement pulse à l'ouverture** : `GithubPoller.refreshIfStale(maxAge: 60)` appelé quand `integration_github` prend le focus, quand l'île s'étend avec GitHub en focus, et à l'ouverture d'une vue détail (sauf Activity). Si données < 60 s ou requête en vol, ignoré. @@ -193,11 +196,15 @@ Premier poll après lancement : toujours silencieux. Polls suivants : - CI = `failure` → `.ciFailed` / `.mainFailed` - CI = `pending` → rien (le poll suivant, même sha, verra la transition) +**Branche de session** : mêmes règles (`.branchCIPassed` / `.branchCIFailed`), mais seulement sur le même dépôt et la même branche qu'au poll précédent : changer de branche ou de dépôt ne dit rien, c'est de l'histoire ancienne. + +**Reviews** : une nouvelle review (id absent au poll précédent) sur une de ses PR déjà vue, par quelqu'un d'autre : `.reviewApproved`, `.changesRequested`, `.reviewCommented`. + | Événement | Badge | Son | |-----------------------|---------|------------| -| CI PR failure / main | `.error` (rouge) | `error` | -| Nouvelle review demandée | `.finished` (vert) | `question` | -| CI PR success | `.finished` (vert) | `finish` | +| CI PR / main / branche failure, changes requested | `.error` (rouge) | `error` | +| Nouvelle review demandée, nouveau commentaire de review | `.finished` (vert) | `question` | +| CI PR / branche success, PR approuvée | `.finished` (vert) | `finish` | Priorité : failure > review demandée > success. Un seul badge/son par cycle. @@ -353,4 +360,16 @@ Pas d'API WhatsApp pour un compte perso : Coucou parle à une **passerelle local Aucune permission Accessibilité nécessaire. +--- + +## 8. Google Calendar (macOS) + +Code : `GoogleCalendar.swift` (règles, PKCE, écoute locale) + `GcalPoller.swift` (connexion, poll), cartes `GcalCardView` / `ReminderView` dans `IslandViewContent.swift`. Tests : `scripts/test-google-calendar.sh`. +### Google Calendar +- Pas de serveur Coucou, donc pas de client OAuth partagé : l'utilisateur crée un client OAuth « Desktop app » dans son propre projet Google Cloud et colle l'ID + le secret dans les Réglages. Écran de consentement à passer « In production », sinon Google invalide le refresh token au bout de 7 jours. +- Flux installed-app : PKCE (S256 ; hash et aléa via CryptoKit / `SecRandomCopyBytes`), écoute ponctuelle sur `127.0.0.1:`, consentement dans le navigateur, vérification de `state`. Seul le refresh token est gardé (Trousseau) ; le jeton d'accès reste en mémoire. La version App Store déclare `com.apple.security.network.server` pour cette écoute. +- Scopes `calendar.events.readonly` + `calendar.calendarlist.readonly`. Tous les agendas cochés dans Google Calendar (`selected`, plus le principal), pas seulement le principal — les événements vivent souvent dans un agenda partagé ou importé. Liste rafraîchie toutes les 10 min, événements des 7 jours à venir chaque minute, dédoublonnés et triés ; chaque ligne prend la couleur de son agenda. Un jeton antérieur au scope `calendarlist` (403) retombe sur l’agenda principal jusqu’à la prochaine connexion. Les événements refusés et les « working location » sont ignorés. +- Rappels aux mêmes moments que Google Calendar : rappels propres de l'événement (`reminders.overrides`, y compris « aucun »), sinon ceux de son agenda (`defaultReminders`), sinon ceux de l'agenda principal (les agendas importés n'en ont pas), sinon 5 min. Seul le dernier rappel échu sonne (après un redémarrage, pas de rafale), un par poll. Les événements « toute la journée » ne sonnent pas. +- Le rappel ouvre la vue `reminder` (titre, « dans 30 min », horaire, lieu ; **Join** si visio, **Open**, **OK**), son `approval`, seulement si la pill Calendar est active. Si l'île est occupée (chat, approbation, dépôt de fichier), il reste un badge ambre sur la pill Calendar. +- « Disconnect » révoque le jeton chez Google puis l'efface. diff --git a/docs/SPEC.md b/docs/SPEC.md index ca99d44bdd..9ead915bc2 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -77,6 +77,7 @@ rouge `rgba(244,80,94,.55)`, vert `rgba(52,211,153,.5)`, rose `rgba(244,114,182, | `question` | 196 | 62, 56 | agent + question (1/N) + options en boutons (single-select ou multi-select) + « Reply in terminal » ; bouton Send/Next pour multi-select ou multi-questions ; « Other… » → saisie libre | 05 | | `error` | 190 | 62, 58 | agent + outil, titre, détail en rouge `#FF8D97`, Relancer, Ouvrir dans n8n | 06 | | `finished` | 170 | 62, 58 | agent + résumé, Voir le terminal, OK | 07 | +| `reminder` | 160 | 62, 58 | rappel Google Calendar (voile indigo) : agenda + « dans 30 min », titre, horaire · lieu, Join (visio), Open, OK | — | | `confused` | 160 | 76, 66 | « Trop de claques d'un coup. » | 08 | | `upload` | 176 | 140, 62 | zone pointillée, « Dépose tes fichiers ici », étiquettes | 09 | | `uploading` | 150 | sur la barre, Ø 28 | « Envoi de fichier » + %, barre verte, le bonhomme est le curseur de la barre | 10 | diff --git a/docs/privacy.html b/docs/privacy.html index 0ce97898e1..34b59ee03d 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -17,7 +17,7 @@

2. Data we collect

3. Data stored on your computer

- + @@ -35,7 +35,8 @@

4. Services you connect yourself

  • Google AI (Gemini API) (macOS) — when you chat using Google AI, the text you type and the current conversation are sent to Google's Gemini API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic or to a local model server you connected). The list of available models is fetched when you open the chat. Google privacy policy.
  • OpenAI (macOS) — when you chat using OpenAI, the text you type and the current conversation are sent to the OpenAI API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic or to a local model server you connected). The list of available models is fetched when you open the chat. OpenAI privacy policy.
  • Ollama / LM Studio (macOS) — when you chat using a local model, the text you type, the current conversation, and the content of any attached text file (up to 24 000 characters) are sent to the server address you configured in Settings → Chat → Local models. By default that address is 127.0.0.1 on your Mac — nothing leaves your computer. If you point it at a remote server, that server receives the data. The server address is stored in your Mac's user preferences (not the Keychain). The list of available models is fetched each time you open the model picker.
  • -
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n — Coucou reads data from your own accounts (payments, repositories, deployments, emails, pages, bookings, workflow runs) to display them. For GitHub specifically, Coucou also reads your open pull requests, reviews requested on you, CI status of your repositories' default branches, and your contribution calendar (the green squares on your profile); this data is kept in memory only and deleted when the app quits. Coucou only reads from them.
  • +
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n — Coucou reads data from your own accounts (payments, repositories, deployments, emails, pages, bookings, workflow runs) to display them. For GitHub specifically, Coucou also reads your open pull requests and their reviews, reviews requested on you, CI status of your repositories' default branches and of the branch your last Claude Code session is on, and your contribution calendar (the green squares on your profile); this data is kept in memory only and deleted when the app quits. Coucou finds that branch by reading the project’s local .git folder; nothing about it is sent anywhere but GitHub. Coucou only reads from them.
  • +
  • Google Calendar — once you connect your Google account with your own OAuth client, Coucou reads the list of your calendars and the events of the ones you show in Google Calendar, for the coming week (read-only access) to show them and remind you before a meeting. Google privacy policy.
  • Flow dictation and the Notetaker (macOS GitHub build, Windows and Linux) — your voice is turned into text on your computer: by Parakeet, or on the Mac by Apple’s speech recognition (on your Mac when it supports it, otherwise Apple’s speech service). Parakeet’s models are downloaded from Hugging Face only when you click Download; nothing about you is sent with that request. Only if you turn them on: with Claude’s clean-up (Flow → Styles), the text you dictated and the name of the app you dictate in are sent to Anthropic with your key before pasting; with Claude summaries, a call’s transcript (never its audio) is sent to Anthropic with your key. On the Mac, summaries can instead be written by Apple Intelligence on your Mac. Hugging Face privacy policy.
  • Voice: « OK Coucou » (macOS GitHub build, Windows and Linux; off by default) — what you say is recognized on your computer and never recorded or saved. On Windows and Linux, Parakeet hears « OK Coucou » on your computer; only the words after it, the previous turns of the same conversation and what Coucou reads to answer (your pills, your connected services’ data, your agents’ sessions, Spotify’s track) are sent to Anthropic with your key, and Anthropic may search the web for it; with ElevenLabs as the voice, the text of the answers is sent to ElevenLabs with your key; an email is prepared in your default mail app and sent only by you. On the Mac, only if you turn them on in Settings → Voice: with ElevenLabs as the voice, the text of Coucou’s answers is sent to ElevenLabs with your key to be spoken (ElevenLabs privacy policy); with Weather by voice, the city you entered is sent to Open-Meteo when you ask; with Claude as Coucou’s brain, what you say after « OK Coucou » and the previous turns of the same conversation are sent to Anthropic with your key, together with what Coucou reads to answer (your active pills, your connected services’ data, a contact’s email address); Anthropic may search the web for it. Audio never leaves your Mac. An email prepared by voice is sent from Apple Mail only when you click Send.
  • @@ -92,7 +93,7 @@

    2. Données collectées

    3. Données stockées sur ton ordinateur

    WhatWhereWhy
    API keys and tokens you enter (Anthropic, Google AI, OpenAI, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n)macOS Keychain, Windows Credential Manager, or the Secret Service on Linux (GNOME Keyring, KWallet)To call these services on your behalf
    API keys and tokens you enter (Anthropic, Google AI, OpenAI, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, and your Google OAuth client and the sign-in token Google returns)macOS Keychain, Windows Credential Manager, or the Secret Service on Linux (GNOME Keyring, KWallet)To call these services on your behalf
    Preferences (sound, delays, active pill selection, chat provider and model, shortcut)App preferences on your Mac, or %APPDATA%\Coucou on Windows; ~/.config/coucou on LinuxTo remember your settings
    Files you drop on the notchA copy in the app’s own folder (on Windows: %LOCALAPPDATA%\Coucou; on Linux: ~/.local/share/coucou)So you can ask a question about them or attach them to an email
    Flow dictation: the history of your dictations (text only, never audio), statistics, dictionary, snippets and stylesThe app’s Flow folder: ~/Library/Application Support/NotchBuddy/Flow on Mac, %LOCALAPPDATA%\Coucou\Flow on Windows, ~/.local/share/coucou/Flow on LinuxTo show your history and statistics, kept as long as you choose in Flow → Settings
    - + @@ -110,7 +111,8 @@

    4. Services que tu connectes toi-même

  • Google AI (API Gemini) (macOS) : quand tu discutes via Google AI, le texte saisi et la conversation en cours sont envoyés à l’API Gemini de Google. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic ou vers le serveur de modèle local que tu as connecté). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité de Google.
  • OpenAI (macOS) : quand tu discutes via OpenAI, le texte saisi et la conversation en cours sont envoyés à l’API OpenAI. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic ou vers le serveur de modèle local que tu as connecté). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité d’OpenAI.
  • Ollama / LM Studio (macOS) : quand tu discutes via un modèle local, le texte saisi, la conversation en cours et le contenu de tout fichier texte attaché (jusqu’à 24 000 caractères) sont envoyés à l’adresse de serveur configurée dans Réglages → Chat → Local models. Par défaut, cette adresse est 127.0.0.1 sur ton Mac — rien ne quitte ton ordinateur. Si tu la pointes vers un serveur distant, ce serveur reçoit les données. L’adresse est conservée dans les préférences utilisateur de ton Mac (pas dans le trousseau). La liste des modèles disponibles est récupérée à chaque ouverture du sélecteur de modèle.
  • -
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n : Coucou lit les données de tes propres comptes (paiements, dépôts, déploiements, e-mails, pages, rendez-vous, exécutions de workflows) pour les afficher. Pour GitHub spécifiquement, Coucou lit aussi tes pull requests ouvertes, les revues de code qui t'attendent, l'état CI de la branche par défaut de tes dépôts et ton calendrier de contributions (les carrés verts de ton profil) ; ces données sont conservées uniquement en mémoire et supprimées à la fermeture de l'app. Coucou ne fait que lire.
  • +
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n : Coucou lit les données de tes propres comptes (paiements, dépôts, déploiements, e-mails, pages, rendez-vous, exécutions de workflows) pour les afficher. Pour GitHub spécifiquement, Coucou lit aussi tes pull requests ouvertes et leurs reviews, les revues de code qui t'attendent, l'état CI de la branche par défaut de tes dépôts et de la branche de ta dernière session Claude Code, et ton calendrier de contributions (les carrés verts de ton profil) ; ces données sont conservées uniquement en mémoire et supprimées à la fermeture de l'app. Coucou trouve cette branche en lisant le dossier .git local du projet ; rien n’en part ailleurs que chez GitHub. Coucou ne fait que lire.
  • +
  • Google Calendar : une fois ton compte Google connecté avec ton propre client OAuth, Coucou lit la liste de tes agendas et les événements de ceux que tu affiches dans Google Calendar, pour la semaine à venir (accès en lecture seule) pour les afficher et te prévenir avant une réunion. Politique de confidentialité de Google.
  • Dictée Flow et Notetaker (version GitHub sur macOS, Windows et Linux) : ta voix est transformée en texte sur ton ordinateur, par Parakeet ou, sur Mac, par la reconnaissance vocale d’Apple (sur ton Mac quand il le permet, sinon par le service d’Apple). Les modèles de Parakeet sont téléchargés depuis Hugging Face uniquement quand tu cliques sur Télécharger ; aucune donnée te concernant n’accompagne cette requête. Seulement si tu les actives : avec le nettoyage par Claude (Flow → Styles), le texte dicté et le nom de l’app où tu dictes sont envoyés à Anthropic avec ta clé avant le collage ; avec les résumés par Claude, la transcription d’un appel (jamais son audio) est envoyée à Anthropic avec ta clé. Sur Mac, les résumés peuvent aussi être écrits par Apple Intelligence, sur ton Mac. Politique de confidentialité de Hugging Face.
  • Voix : « OK Coucou » (version GitHub sur macOS, Windows et Linux ; désactivé par défaut) : ce que tu dis est reconnu sur ton ordinateur, jamais enregistré ni conservé. Seuls les mots qui suivent « OK Coucou », les échanges précédents de la même conversation et ce que Coucou lit pour répondre (tes pilules, les données de tes services connectés, les sessions de tes agents, le morceau Spotify) sont envoyés à Anthropic avec ta clé, et Anthropic peut chercher sur le web pour y répondre. Avec ElevenLabs comme voix, le texte des réponses est envoyé à ElevenLabs avec ta clé (politique de confidentialité d’ElevenLabs) ; sur Mac, avec la météo par la voix, la ville saisie est envoyée à Open-Meteo quand tu la demandes. L’audio ne quitte jamais ton ordinateur. Un e-mail préparé à la voix s’ouvre dans ton app de mail et n’est envoyé que par toi.
  • diff --git a/scripts/test-github-branch-ci.sh b/scripts/test-github-branch-ci.sh new file mode 100755 index 0000000000..7838a32aa6 --- /dev/null +++ b/scripts/test-github-branch-ci.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-github-branch-ci.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/GitHubPulse.swift \ + NotchBuddy/Sources/App/GitHubBranchCI.swift \ + tests/GitHubBranchCITests.swift -o "$TEST_DIR/github-branch-ci-tests" +"$TEST_DIR/github-branch-ci-tests" diff --git a/scripts/test-github-pulse.sh b/scripts/test-github-pulse.sh index 7b6e984a10..a8ad3d8841 100755 --- a/scripts/test-github-pulse.sh +++ b/scripts/test-github-pulse.sh @@ -4,5 +4,6 @@ cd "$(dirname "$0")/.." TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-github-pulse.XXXXXX")" trap 'rm -rf "$TEST_DIR"' EXIT swiftc NotchBuddy/Sources/App/GitHubPulse.swift \ + NotchBuddy/Sources/App/GitHubBranchCI.swift \ tests/GitHubPulseTests.swift -o "$TEST_DIR/github-pulse-tests" "$TEST_DIR/github-pulse-tests" diff --git a/scripts/test-google-calendar.sh b/scripts/test-google-calendar.sh new file mode 100755 index 0000000000..e85715f8d4 --- /dev/null +++ b/scripts/test-google-calendar.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-gcal.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/IntegrationNews.swift \ + NotchBuddy/Sources/App/GoogleCalendar.swift \ + tests/GoogleCalendarTests.swift -o "$TEST_DIR/google-calendar-tests" +"$TEST_DIR/google-calendar-tests" diff --git a/tests/GitHubBranchCITests.swift b/tests/GitHubBranchCITests.swift new file mode 100644 index 0000000000..01e3ae379e --- /dev/null +++ b/tests/GitHubBranchCITests.swift @@ -0,0 +1,213 @@ +import Foundation + +// The session branch's CI and the reviews on your PRs, on top of GitHubPulse. +// Same cases as the tests in windows/src-tauri/src/github.rs. + +@main +enum GitHubBranchCITests { + nonisolated(unsafe) static var passed = 0 + + static func check(_ condition: Bool, _ what: String, line: Int = #line) { + precondition(condition, "line \(line): \(what)") + passed += 1 + } + + static func main() { + githubURLs() + upstreamFromConfig() + worktreeGitDir() + parsesAGraphQLAnswer() + branchCIIsAnnouncedOnlyWhenItFinishes() + newReviewsByOthersAreAnnounced() + print("GitHub branch CI: \(passed) checks passed") + } + + static func githubURLs() { + func ok(_ u: String) -> String? { GitHubBranch.parseGitHubURL(u).map { "\($0.0)/\($0.1)" } } + check(ok("https://github.com/Louis-CFM/coucou.git") == "Louis-CFM/coucou", "https .git") + check(ok("https://github.com/Louis-CFM/coucou") == "Louis-CFM/coucou", "https") + check(ok("https://me@github.com/a/b/") == "a/b", "user + trailing slash") + check(ok("git@github.com:a/b.git") == "a/b", "scp-like") + check(ok("ssh://git@github.com/a/b.git") == "a/b", "ssh") + check(ok("https://gitlab.com/a/b.git") == nil, "not GitHub") + check(ok("https://github.com/a") == nil, "no repo") + } + + static func upstreamFromConfig() { + let config = """ + [core] + \tbare = false + [remote "origin"] + \turl = git@github.com:Louis-CFM/coucou.git + \tfetch = +refs/heads/*:refs/remotes/origin/* + [remote "fork"] + \turl = https://github.com/jhoan/coucou.git + [branch "main"] + \tremote = origin + \tmerge = refs/heads/main + [branch "local-name"] + \tremote = fork + \tmerge = refs/heads/remote-name + """ + let main = GitHubBranch.resolveUpstream(config: config, local: "main") + check(main == GitHubBranchRef(owner: "Louis-CFM", name: "coucou", branch: "main"), "main") + let forked = GitHubBranch.resolveUpstream(config: config, local: "local-name") + check(forked?.owner == "jhoan" && forked?.branch == "remote-name", "fork upstream") + // Never pushed: same name on origin. + let fresh = GitHubBranch.resolveUpstream(config: config, local: "feat/x") + check(fresh?.owner == "Louis-CFM" && fresh?.branch == "feat/x", "never pushed") + // No origin at all: whichever remote points at GitHub. + let other = GitHubBranch.resolveUpstream(config: "[remote \"up\"]\n\turl = https://github.com/x/y\n", local: "dev") + check(other == GitHubBranchRef(owner: "x", name: "y", branch: "dev"), "any GitHub remote") + check(GitHubBranch.resolveUpstream(config: "[remote \"origin\"]\n\turl = https://gitlab.com/x/y\n", local: "dev") == nil, + "no GitHub remote") + } + + static func worktreeGitDir() { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("coucou-gh-\(ProcessInfo.processInfo.processIdentifier)") + defer { try? fm.removeItem(at: root) } + let mainGit = root.appendingPathComponent("repo/.git") + let wtGit = mainGit.appendingPathComponent("worktrees/wt") + let wt = root.appendingPathComponent("wt") + try! fm.createDirectory(at: wtGit, withIntermediateDirectories: true) + try! fm.createDirectory(at: wt.appendingPathComponent("sub"), withIntermediateDirectories: true) + try! "[remote \"origin\"]\n\turl = https://github.com/a/b.git\n" + .write(to: mainGit.appendingPathComponent("config"), atomically: true, encoding: .utf8) + try! "ref: refs/heads/fix/thing\n".write(to: wtGit.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + try! "../..\n".write(to: wtGit.appendingPathComponent("commondir"), atomically: true, encoding: .utf8) + try! "gitdir: \(wtGit.path)\n".write(to: wt.appendingPathComponent(".git"), atomically: true, encoding: .utf8) + + let got = GitHubBranch.of(cwd: wt.appendingPathComponent("sub")) + check(got == GitHubBranchRef(owner: "a", name: "b", branch: "fix/thing"), "worktree") + + // The main checkout itself, and a detached HEAD. + try! "ref: refs/heads/main\n".write(to: mainGit.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + check(GitHubBranch.of(cwd: root.appendingPathComponent("repo")) == GitHubBranchRef(owner: "a", name: "b", branch: "main"), + "normal checkout") + try! "4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" + .write(to: mainGit.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + check(GitHubBranch.of(cwd: root.appendingPathComponent("repo")) == nil, "detached HEAD") + check(GitHubBranch.of(cwd: URL(fileURLWithPath: "/")) == nil, "no repository") + } + + static func iso(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) } + + static func parsesAGraphQLAnswer() { + let now = Date() + let recent = iso(now.addingTimeInterval(-3600)) + let data = Data(""" + { + "data": { + "viewer": { + "login": "me", + "pullRequests": { "nodes": [ + { "number": 8, "title": "Calendar", "url": "u8", "isDraft": false, "headRefName": "feat/cal", + "reviewDecision": null, "updatedAt": "\(recent)", + "repository": { "nameWithOwner": "Louis-CFM/coucou", "url": "r" }, + "commits": { "nodes": [{ "commit": { "oid": "abc", "statusCheckRollup": { "state": "FAILURE" } } }] }, + "latestReviews": { "nodes": [{ "id": "r1", "state": "COMMENTED", "author": { "login": "louis" } }] } }, + { "number": 2, "title": "[Snyk] Fix", "url": "u2", "isDraft": false, "headRefName": "snyk-fix-1", + "reviewDecision": null, "updatedAt": "2025-12-14T14:33:19Z", + "repository": { "nameWithOwner": "me/old", "url": "r" }, "latestReviews": { "nodes": [] } } + ] }, + "repositories": { "nodes": [] } + }, + "reviewRequested": { "issueCount": 0, "nodes": [] }, + "sessionBranch": { + "nameWithOwner": "Louis-CFM/coucou", + "ref": { "target": { + "oid": "abc", "url": "commit-url", + "statusCheckRollup": { "state": "FAILURE", "contexts": { "nodes": [ + { "__typename": "CheckRun", "name": "lint", "conclusion": "SUCCESS", "detailsUrl": "l" }, + { "__typename": "CheckRun", "name": "build", "conclusion": "FAILURE", "detailsUrl": "job-url" }, + { "__typename": "StatusContext", "context": "vercel", "state": "ERROR", "targetUrl": "v" } + ] } } + } } + } + } + } + """.utf8) + let target = GitHubBranchRef(owner: "Louis-CFM", name: "coucou", branch: "feat/cal") + guard let p = GitHubPulse.parse(data, target: target, now: now) else { + check(false, "parses"); return + } + // The ten-month-old bot PR is gone. + check(p.myPRs.map(\.number) == [8], "stale pull requests left out") + check(p.myPRs.first?.headRef == "feat/cal", "head branch") + check(p.myPRs.first?.reviews == [GitHubReview(id: "r1", state: "COMMENTED", author: "louis")], "reviews") + let b = p.branch + check(b?.ci == .failure && b?.failing == ["build", "vercel"] && b?.url == "job-url" + && b?.prId == "Louis-CFM/coucou#8" && b?.pushed == true && b?.oid == "abc", "branch CI") + check(p.hasPending == false, "nothing running") + + // A branch GitHub doesn't know yet, and a repository the token can't see. + func answer(_ sessionBranch: String) -> Data { + Data(#"{"data": {"viewer": {"login": "me"}, "sessionBranch": \#(sessionBranch)}}"#.utf8) + } + let newBranch = GitHubBranchRef(owner: "a", name: "b", branch: "new") + let unpushed = GitHubPulse.parse(answer(#"{"nameWithOwner": "a/b", "ref": null}"#), target: newBranch) + check(unpushed?.branch?.pushed == false && unpushed?.branch?.ci == .unknown, "not pushed") + check(GitHubPulse.parse(answer("null"), target: newBranch)?.branch == nil, "repository not visible") + check(GitHubPulse.parse(data, target: nil)?.branch == nil, "no session branch") + } + + static func pulse(branch: GitHubBranchCI?, reviews: [(String, String, String)] = []) -> GitHubPulse { + let pr = GitHubPR(id: "a/b#7", title: "Fix", url: "u7", repo: "a/b", number: 7, isDraft: false, + ci: .unknown, review: .unknown, headRef: "fix", + reviews: reviews.map { GitHubReview(id: $0.0, state: $0.1, author: $0.2) }) + return GitHubPulse(login: "me", myPRs: [pr], toReview: [], mainCI: [], fetchedAt: Date(), branch: branch) + } + + static func ci(_ state: CIState, oid: String = "abc", branch: String = "fix", repo: String = "a/b") -> GitHubBranchCI { + GitHubBranchCI(repo: repo, branch: branch, pushed: true, oid: oid, ci: state, + failing: state == .failure ? ["build"] : [], url: "", prId: "a/b#7") + } + + static func branchCIIsAnnouncedOnlyWhenItFinishes() { + // First poll: nothing, whatever the state. + check(GitHubPulse.events(old: nil, new: pulse(branch: ci(.failure))).isEmpty, "first poll silent") + check(pulse(branch: ci(.pending)).hasPending, "a running branch speeds up polling") + + // Watched going from running to done. + let failed = GitHubPulse.events(old: pulse(branch: ci(.pending)), new: pulse(branch: ci(.failure))) + check(failed == [.branchCIFailed(repo: "a/b", branch: "fix")], "CI failed") + let passed = GitHubPulse.events(old: pulse(branch: ci(.pending)), new: pulse(branch: ci(.success))) + check(passed == [.branchCIPassed(repo: "a/b", branch: "fix")], "CI passed") + // Still red on the next poll: already said. + check(GitHubPulse.events(old: pulse(branch: ci(.failure)), new: pulse(branch: ci(.failure))).isEmpty, "said once") + + // A new commit that finished between two polls is news too. + let fast = GitHubPulse.events(old: pulse(branch: ci(.success, oid: "old")), new: pulse(branch: ci(.failure, oid: "new"))) + check(fast == [.branchCIFailed(repo: "a/b", branch: "fix")], "fast CI on a new commit") + check(GitHubPulse.events(old: pulse(branch: ci(.success, oid: "old")), + new: pulse(branch: ci(.pending, oid: "new"))).isEmpty, "new commit still running") + + // Switching to another branch or repo is old news. + check(GitHubPulse.events(old: pulse(branch: ci(.pending)), + new: pulse(branch: ci(.failure, branch: "other"))).isEmpty, "another branch") + check(GitHubPulse.events(old: pulse(branch: ci(.pending)), + new: pulse(branch: ci(.failure, repo: "c/d"))).isEmpty, "another repo") + check(GitHubPulse.events(old: pulse(branch: nil), new: pulse(branch: ci(.failure))).isEmpty, "first session") + } + + static func newReviewsByOthersAreAnnounced() { + let before = pulse(branch: nil, reviews: [("r1", "COMMENTED", "louis")]) + check(GitHubPulse.events(old: before, new: before).isEmpty, "nothing new") + + let approved = pulse(branch: nil, reviews: [("r1", "COMMENTED", "louis"), ("r2", "APPROVED", "ana")]) + check(GitHubPulse.events(old: before, new: approved) == [.reviewApproved(prId: "a/b#7")], "approval") + let changes = pulse(branch: nil, reviews: [("r3", "CHANGES_REQUESTED", "louis")]) + check(GitHubPulse.events(old: before, new: changes) == [.changesRequested(prId: "a/b#7")], "changes requested") + let comment = pulse(branch: nil, reviews: [("r4", "COMMENTED", "ana")]) + check(GitHubPulse.events(old: before, new: comment) == [.reviewCommented(prId: "a/b#7")], "comment") + + // Your own review says nothing. + let mine = pulse(branch: nil, reviews: [("r5", "COMMENTED", "me")]) + check(GitHubPulse.events(old: before, new: mine).isEmpty, "own review") + + // A PR coming back into the list doesn't replay its reviews. + let empty = GitHubPulse(login: "me", myPRs: [], toReview: [], mainCI: [], fetchedAt: Date()) + check(GitHubPulse.events(old: empty, new: approved).isEmpty, "PR not seen before") + } +} diff --git a/tests/GoogleCalendarTests.swift b/tests/GoogleCalendarTests.swift new file mode 100644 index 0000000000..94eb94ddbc --- /dev/null +++ b/tests/GoogleCalendarTests.swift @@ -0,0 +1,264 @@ +import Foundation + +// Same cases as the tests in windows/src-tauri/src/gcal.rs, plus the calendar list. + +@main +enum GoogleCalendarTests { + nonisolated(unsafe) static var passed = 0 + + static func check(_ condition: Bool, _ what: String, line: Int = #line) { + precondition(condition, "line \(line): \(what)") + passed += 1 + } + + static func main() { + dates() + encodings() + pkcePrimitives() + loopbackListener() + calendarList() + allDayEventsSortAmongTimedOnes() + eventsAreFilteredAndParsed() + reminderMinutesAreReadAndSorted() + remindersRingLikeGoogleCalendar() + startsInReadsNaturally() + print("Google Calendar: \(passed) checks passed") + } + + // RFC 3339 lives in IntegrationNews.swift; same cases as windows/src-tauri/src/time.rs. + static func dates() { + check(RFC3339.utc(0) == "1970-01-01T00:00:00Z", "epoch") + check(RFC3339.utc(1_790_000_000) == "2026-09-21T14:13:20Z", "utc") + check(RFC3339.parse("2026-09-21T14:13:20Z") == 1_790_000_000, "Z") + check(RFC3339.parse("2026-09-21T16:13:20+02:00") == 1_790_000_000, "offset") + check(RFC3339.parse("2026-09-21T09:13:20.250-05:00") == 1_790_000_000, "fraction + negative offset") + check(RFC3339.parse("2024-02-29T00:00:00Z").map(RFC3339.utc) == "2024-02-29T00:00:00Z", "leap day") + check(RFC3339.parse("2026-10-01") == nil, "a date alone is not a time") + check(RFC3339.parse("") == nil, "empty") + } + + static func encodings() { + check(GcalOAuth.b64url([]) == "", "b64 empty") + check(GcalOAuth.b64url(Array("f".utf8)) == "Zg", "b64 f") + check(GcalOAuth.b64url(Array("fo".utf8)) == "Zm8", "b64 fo") + check(GcalOAuth.b64url(Array("foo".utf8)) == "Zm9v", "b64 foo") + check(GcalOAuth.b64url([0xfb, 0xff]) == "-_8", "b64 url alphabet") + check(GcalOAuth.pct("a b/c:d") == "a%20b%2Fc%3Ad", "pct") + check(GcalOAuth.pct("é") == "%C3%A9", "pct utf-8") + check(GcalOAuth.pctDecode("4%2F0Ab+x%zz%4") == "4/0Ab x%zz%4", "pct decode") + let q = GcalOAuth.parseQuery("state=abc&code=4%2F0AX&scope=x") + check(q.count == 3 && q[1].0 == "code" && q[1].1 == "4/0AX", "query") + check(GcalOAuth.form([("a", "1 2"), ("b", "x&y")]) == "a=1%202&b=x%26y", "form") + } + + static func pkcePrimitives() { + // FIPS 180-2's "abc" vector. + let hex = GcalOAuth.sha256(Array("abc".utf8)).map { String(format: "%02x", $0) }.joined() + check(hex == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "sha256") + let a = try! GcalOAuth.randomBytes(32) + check(a.count == 32 && a != (try! GcalOAuth.randomBytes(32)), "random") + // 32 bytes → the 43-character verifier RFC 7636 asks for. + check(GcalOAuth.b64url(a).count == 43, "verifier length") + } + + /// The loopback listener: strays are ignored, the right redirect yields its + /// code, and Cancel ends the wait even though Google never came back. + static func loopbackListener() { + check(GcalOAuth.route("GET /?state=s&error=access_denied HTTP/1.1", state: "s") == .denied("access_denied"), "denied") + check(GcalOAuth.route("GET /?state=s HTTP/1.1", state: "s") == .badRequest, "no code") + check(GcalOAuth.route("POST /?state=s&code=c HTTP/1.1", state: "s") == .stray, "not a GET") + check(GcalOAuth.route("GET /other?state=s&code=c HTTP/1.1", state: "s") == .stray, "other path") + + let listener = try! LoopbackListener() + defer { listener.close() } + let port = listener.port + check(port > 0, "free port") + + // One after the other, like a browser: the listener stops at the redirect + // it wants, so anything sent after it would go unanswered. + nonisolated(unsafe) var replies: [String] = [] + let browser = Thread { + for path in ["/favicon.ico", "/?state=wrong&code=evil", "/?state=s3cr3t&code=4%2F0Abc&scope=x"] { + replies.append(get(port: port, path: path)) + } + } + browser.start() + let code = listener.waitForCode(state: "s3cr3t", deadline: Date().addingTimeInterval(10), stillWanted: { true }) + while !browser.isFinished { usleep(10_000) } + check(code == .success("4/0Abc"), "code") + check(replies.count == 3, "three replies") + check(replies[0].hasPrefix("HTTP/1.1 404"), "favicon 404") + check(replies[1].hasPrefix("HTTP/1.1 404"), "forged state 404") + check(replies[2].contains("connected to Google Calendar"), "success page") + + // Google's "access blocked" page: no redirect ever arrives. + let started = Date() + let cancelAt = started.addingTimeInterval(0.3) + let result = listener.waitForCode(state: "s3cr3t", deadline: started.addingTimeInterval(10), + stillWanted: { Date() < cancelAt }) + check(result == .failure(.shown("Sign-in cancelled.")), "cancel") + check(Date().timeIntervalSince(started) < 3, "cancel is quick") + + let timedOut = listener.waitForCode(state: "s3cr3t", deadline: Date().addingTimeInterval(0.2), stillWanted: { true }) + check(timedOut == .failure(.shown("Timed out waiting for Google.")), "timeout") + } + + /// A minimal HTTP GET over a raw socket, like the browser's redirect. + static func get(port: UInt16, path: String) -> String { + let fd = socket(AF_INET, SOCK_STREAM, 0) + defer { close(fd) } + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let ok = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard ok == 0 else { return "" } + let request = Array("GET \(path) HTTP/1.1\r\nHost: x\r\n\r\n".utf8) + _ = request.withUnsafeBytes { send(fd, $0.baseAddress!, request.count, 0) } + var reply: [UInt8] = [] + var buf = [UInt8](repeating: 0, count: 4096) + while true { + let n = recv(fd, &buf, buf.count, 0) + if n <= 0 { break } + reply += buf[0.. [String: Any] { + try! JSONSerialization.jsonObject(with: Data(text.utf8)) as! [String: Any] + } + + static func calendarList() { + let list = GcalCalendar.parseList(json(""" + { "items": [ + { "id": "team", "summary": "Team", "selected": true, "backgroundColor": "#16a765" }, + { "id": "hidden", "summary": "Hidden", "selected": true, "hidden": true }, + { "id": "unticked", "summary": "Unticked" }, + { "id": "me@x.com", "summary": "me@x.com", "summaryOverride": "Mine", "primary": true } + ] } + """)) + check(list.map(\.id) == ["me@x.com", "team"], "shown calendars, primary first") + check(list[0].name == "Mine" && list[0].primary && list[1].color == "#16a765", "names and colours") + } + + static let cal = GcalCalendar(id: "primary", name: "Main", color: "#16a765", primary: true) + + static func allDayEventsSortAmongTimedOnes() { + func parse(_ text: String) -> GcalEvent { GcalEvent.parse(json(text), calendar: cal, defaults: [5])! } + let events = GcalEvent.merge([ + parse(#"{"id": "poker", "start": {"dateTime": "2026-10-02T17:30:00Z"}, "end": {"dateTime": "2026-10-02T21:30:00Z"}}"#), + parse(#"{"id": "holiday", "start": {"date": "2026-10-02"}, "end": {"date": "2026-10-03"}}"#), + parse(#"{"id": "today", "start": {"dateTime": "2026-10-01T19:00:00+02:00"}, "end": {"dateTime": "2026-10-01T20:00:00+02:00"}}"#), + // The same invitation seen through a second calendar. + parse(#"{"id": "today", "start": {"dateTime": "2026-10-01T19:00:00+02:00"}, "end": {"dateTime": "2026-10-01T20:00:00+02:00"}}"#), + ]) + check(events.map(\.id) == ["today", "holiday", "poker"], "chronological, deduplicated") + } + + static func event(_ id: String, _ start: String) -> [String: Any] { + ["id": id, "summary": id, "start": ["dateTime": start], "end": ["dateTime": start]] + } + + static func eventsAreFilteredAndParsed() { + let declined = json(""" + { "id": "d", "start": { "dateTime": "2026-10-01T10:00:00Z" }, "end": { "dateTime": "2026-10-01T11:00:00Z" }, + "attendees": [{ "self": true, "responseStatus": "declined" }] } + """) + check(GcalEvent.parse(declined, calendar: cal, defaults: [5]) == nil, "declined") + check(GcalEvent.parse(json(#"{"id": "c", "status": "cancelled", "start": {"date": "2026-10-02"}}"#), + calendar: cal, defaults: [5]) == nil, "cancelled") + check(GcalEvent.parse(json(#"{"id": "w", "eventType": "workingLocation", "start": {"date": "2026-10-02"}}"#), + calendar: cal, defaults: [5]) == nil, "working location") + let allDay = GcalEvent.parse(json(#"{"id": "a", "start": {"date": "2026-10-02"}, "end": {"date": "2026-10-03"}}"#), + calendar: cal, defaults: [5])! + check(allDay.allDay && allDay.startSecs == nil && allDay.title == "(No title)", "all day") + let meet = GcalEvent.parse(json(""" + { "id": "m", "summary": "Standup", "start": { "dateTime": "2026-10-01T10:00:00Z" }, + "end": { "dateTime": "2026-10-01T10:15:00Z" }, "htmlLink": "https://calendar.google.com/x", + "conferenceData": { "entryPoints": [ + { "entryPointType": "phone", "uri": "tel:+1" }, + { "entryPointType": "video", "uri": "https://meet.google.com/abc" } + ] } } + """), calendar: cal, defaults: [5])! + check(meet.meetURL == "https://meet.google.com/abc" && meet.calendar == "Main" && meet.color == "#16a765", + "video link") + let zoom = GcalEvent.parse(json(""" + { "id": "z", "start": { "dateTime": "2026-10-01T10:00:00Z" }, "end": { "dateTime": "2026-10-01T10:15:00Z" }, + "location": "https://zoom.us/j/1" } + """), calendar: cal, defaults: [5])! + check(zoom.meetURL == "https://zoom.us/j/1", "link in the location") + } + + static func reminderMinutesAreReadAndSorted() { + let list = json(#"{"r": [{"method": "popup", "minutes": 30}, {"method": "email", "minutes": 1440}, {"method": "popup", "minutes": 30}]}"#)["r"] + check(reminderMinutes(list) == [30, 1440], "sorted, deduplicated") + check(reminderMinutes(nil) == [], "none") + var e = event("x", "2026-10-02T17:30:00Z") + e["reminders"] = ["useDefault": false, "overrides": [Any]()] + // "No notification" on the event beats the calendar's defaults. + check(GcalEvent.parse(e, calendar: cal, defaults: [30])!.reminders.isEmpty, "no notification") + e["reminders"] = ["useDefault": false, "overrides": [["method": "popup", "minutes": 10]]] + check(GcalEvent.parse(e, calendar: cal, defaults: [30])!.reminders == [10], "overrides") + e["reminders"] = ["useDefault": true] + check(GcalEvent.parse(e, calendar: cal, defaults: [30, 1440])!.reminders == [30, 1440], "defaults") + } + + static func remindersRingLikeGoogleCalendar() { + let t = RFC3339.parse("2026-10-02T17:30:00Z")! + let min = 60 + let poker = { [GcalEvent.parse(event("poker", "2026-10-02T17:30:00Z"), calendar: cal, defaults: [30, 1440])!] } + + // A day ahead, then 30 minutes ahead; once each. + var r = GcalReminders() + let events = poker() + check(r.next(events, now: t - 25 * 60 * min) == nil, "too early") + check(r.next(events, now: t - 1440 * min + 30)?.news.detail == "Starts in a day", "day before") + check(r.next(events, now: t - 1439 * min) == nil, "once") + check(r.next(events, now: t - 31 * min) == nil, "not yet") + let e = r.next(events, now: t - 30 * min) + check(e?.news.label == "poker" && e?.news.detail == "Starts in 30 min" && e?.news.attention == true, "30 min") + check(e?.minutes == 30 && e?.event.calendar == "Main", "the event") + check(r.next(events, now: t - 29 * min) == nil, "once again") + + // Started 20 minutes before the meeting: one reminder, not both. + r = GcalReminders() + check(r.next(events, now: t - 20 * min)?.news.detail == "Starts in 20 min", "late start") + check(r.next(events, now: t - 19 * min) == nil, "no burst") + + // "At the time of the event" still rings, within its minute. + r = GcalReminders() + let standup = [GcalEvent.parse(event("standup", "2026-10-02T17:30:00Z"), calendar: cal, defaults: [0])!] + check(r.next(standup, now: t - 30) == nil, "before") + check(r.next(standup, now: t + 10)?.news.detail == "Starting now", "at the time") + r = GcalReminders() + check(r.next(standup, now: t + 90) == nil, "too late") + + // Two due at once: the earlier one now, the other on the next poll. + r = GcalReminders() + let two = [ + GcalEvent.parse(event("a", "2026-10-02T17:30:00Z"), calendar: cal, defaults: [30])!, + GcalEvent.parse(event("b", "2026-10-02T17:40:00Z"), calendar: cal, defaults: [30])!, + ] + check(r.next(two, now: t - 5 * min)?.news.label == "a", "first") + check(r.next(two, now: t - 4 * min)?.news.label == "b", "second") + check(r.next(two, now: t - 3 * min) == nil, "done") + + // Moved: rings again. + r = GcalReminders() + _ = r.next(two, now: t - 5 * min) + let moved = [GcalEvent.parse(event("a", "2026-10-02T17:35:00Z"), calendar: cal, defaults: [30])!] + check(r.next(moved, now: t - 4 * min)?.news.label == "a", "moved event") + } + + static func startsInReadsNaturally() { + check(GcalReminders.startsIn(0) == "Starting now", "0") + check(GcalReminders.startsIn(1) == "Starts in a minute", "1") + check(GcalReminders.startsIn(90) == "Starts in 1 h 30 min", "90") + check(GcalReminders.startsIn(120) == "Starts in 2 h", "120") + check(GcalReminders.startsIn(3000) == "Starts in 2 days", "3000") + } +}
    QuoiOùPourquoi
    Clés d’API et jetons saisis (Anthropic, Google AI, OpenAI, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n)Trousseau macOS, Gestionnaire d’identifiants Windows, ou Secret Service sous Linux (GNOME Keyring, KWallet)Appeler ces services pour toi
    Clés d’API et jetons saisis (Anthropic, Google AI, OpenAI, Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n, et ton client OAuth Google et le jeton de connexion que Google renvoie)Trousseau macOS, Gestionnaire d’identifiants Windows, ou Secret Service sous Linux (GNOME Keyring, KWallet)Appeler ces services pour toi
    Préférences (son, délais, pastilles actives, fournisseur et modèle du chat, raccourci)Préférences de l’app sur ton Mac, ou %APPDATA%\Coucou sous Windows ; ~/.config/coucou sous LinuxGarder tes réglages
    Fichiers déposés sur le notchUne copie dans le dossier de l’app (sous Windows : %LOCALAPPDATA%\Coucou ; sous Linux : ~/.local/share/coucou)Pouvoir poser une question dessus ou les joindre à un mail
    Dictée Flow : l’historique de tes dictées (texte seulement, jamais d’audio), les statistiques, le dictionnaire, les snippets et les stylesLe dossier Flow de l’app : ~/Library/Application Support/NotchBuddy/Flow sur Mac, %LOCALAPPDATA%\Coucou\Flow sous Windows, ~/.local/share/coucou/Flow sous LinuxAfficher ton historique et tes statistiques, gardés aussi longtemps que tu le choisis dans Flow → Réglages