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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Quando aprovar, seguir os passos abaixo:
| UI | SwiftUI (`NSApplicationActivationPolicy.accessory` — menu bar) |
| Design System | Liquid Glass (macOS 26+), MeshGradient (macOS 15+), SymbolEffect (macOS 14+) |
| Auth | Firebase Auth + Sign in with Apple |
| Notificações | FCM → APNs |
| Notificações | **Primário:** WebSocket (Phoenix Channel) + `UserNotifications` locais — sem conta Apple paga. **Futuro:** FCM → APNs por cima, mesmo payload. Ver [`docs/notifications-spec.md`](docs/notifications-spec.md). |
| Keychain | Security framework — tokens sempre aqui, nunca UserDefaults |
| Rede | Phoenix Channels (WebSocket) + URLSession (REST) |
| Auto-update | Sparkle (delta updates, background download) |
Expand All @@ -73,18 +73,27 @@ Quando aprovar, seguir os passos abaixo:
kura-macos/
├── .github/workflows/ci.yml
├── CLAUDE.md
├── SESSION.md # handoff de estado entre sessões (pode estar atrás do CLAUDE.md — CLAUDE.md é a fonte de verdade)
├── docs/
│ └── notifications-spec.md # spec do client: WebSocket + notificações locais (handoff do kura-server)
└── Kura/
├── Kura.xcodeproj/
└── Kura/
├── App/
│ ├── KuraApp.swift # @main, NSApplicationDelegateAdaptor
│ └── AppDelegate.swift # menu bar setup, NSStatusItem, NSPopover
├── Core/
│ ├── API/
│ │ └── KuraEndpoint.swift # URL do socket (DEBUG localhost / prod wss)
│ ├── AppState/
│ │ └── PopoverVisibility.swift # singleton; isShown pausa animações quando oculto
│ └── Auth/
│ ├── AuthManager.swift # ObservableObject, Sign in with Apple
│ └── KeychainHelper.swift
│ ├── Auth/
│ │ ├── AuthManager.swift # ObservableObject, Sign in with Apple
│ │ └── KeychainHelper.swift
│ └── Notifications/
│ ├── NotificationsClient.swift # Phoenix Socket+Channel notifications:<uid>
│ ├── LocalNotifier.swift # UserNotifications — entrega local, sem conta paga
│ └── PushPayload.swift # model do evento "push"
├── Design/
│ └── Theme/
│ └── Theme.swift # KuraFont, KuraSpacing, KuraLayout
Expand All @@ -99,6 +108,7 @@ kura-macos/
│ └── Settings/ # vazio — Fase 1
├── Assets.xcassets/ # 9 color tokens + AppIcon
├── GoogleService-Info.plist # AGORA É UM TEMPLATE
├── GoogleService-Info.plist.template # template versionado; CI gera o real via GitHub Secrets
├── Info.plist # LSUIElement=YES, macOS 14+
├── Kura.entitlements # sandbox=NO, network=YES
└── RootView.swift # roteador authState → login | dashboard
Expand Down Expand Up @@ -295,7 +305,7 @@ Todos em `.claude/hooks/`. Rodam automaticamente via `.claude/settings.json`.
- [ ] Player de áudio do briefing
- [ ] Inbox view + interação conversacional
- [ ] Badge no menu bar
- [ ] Push notifications + deep link
- [~] Push notifications + deep link — client WebSocket + `UserNotifications` locais implementado (`Core/Notifications/` + `Core/API/KuraEndpoint.swift`, wiring em `AppDelegate`). DEBUG conecta com `user_id` cru (server test env); path Firebase ID token pronto-mas-comentado em `NotificationsClient`. **Pendente:** adicionar pacote SwiftPhoenixClient via SPM no Xcode, E2E contra server, e deep-link por `data.type` (precisa das views Fase 4). Spec: [`docs/notifications-spec.md`](docs/notifications-spec.md). APNs entra depois, sem mudar o payload.

### Fase 5 — App Completo — 🔲 não iniciada
- [ ] ⌘K command palette
Expand Down
17 changes: 17 additions & 0 deletions Kura/Kura.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
448236612FCB239F00EC9A55 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 448236602FCB239F00EC9A55 /* FirebaseAnalytics */; };
448236632FCB239F00EC9A55 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 448236622FCB239F00EC9A55 /* FirebaseAuth */; };
5A1F00010000000000000001 /* SwiftPhoenixClient in Frameworks */ = {isa = PBXBuildFile; productRef = 5A1F00010000000000000002 /* SwiftPhoenixClient */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -43,6 +44,7 @@
files = (
448236612FCB239F00EC9A55 /* FirebaseAnalytics in Frameworks */,
448236632FCB239F00EC9A55 /* FirebaseAuth in Frameworks */,
5A1F00010000000000000001 /* SwiftPhoenixClient in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -87,6 +89,7 @@
packageProductDependencies = (
448236602FCB239F00EC9A55 /* FirebaseAnalytics */,
448236622FCB239F00EC9A55 /* FirebaseAuth */,
5A1F00010000000000000002 /* SwiftPhoenixClient */,
);
productName = Kura;
productReference = 448235DC2FCB1F0D00EC9A55 /* Kura.app */;
Expand Down Expand Up @@ -118,6 +121,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
4482365F2FCB239F00EC9A55 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
5A1F00010000000000000003 /* XCRemoteSwiftPackageReference "SwiftPhoenixClient" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 448235DD2FCB1F0D00EC9A55 /* Products */;
Expand Down Expand Up @@ -365,6 +369,14 @@
minimumVersion = 12.14.0;
};
};
5A1F00010000000000000003 /* XCRemoteSwiftPackageReference "SwiftPhoenixClient" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/davidstump/SwiftPhoenixClient";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.3.5;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -378,6 +390,11 @@
package = 4482365F2FCB239F00EC9A55 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseAuth;
};
5A1F00010000000000000002 /* SwiftPhoenixClient */ = {
isa = XCSwiftPackageProductDependency;
package = 5A1F00010000000000000003 /* XCRemoteSwiftPackageReference "SwiftPhoenixClient" */;
productName = SwiftPhoenixClient;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 448235D42FCB1F0D00EC9A55 /* Project object */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 56 additions & 1 deletion Kura/Kura/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
// Configures app as menu bar accessory (no dock icon) and bootstraps Firebase

import AppKit
import Combine
import SwiftUI
import UserNotifications

@MainActor
class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNUserNotificationCenterDelegate {
private var statusItem: NSStatusItem?
private var popover: NSPopover?

private let notifications = NotificationsClient()
private var authCancellable: AnyCancellable?

func applicationDidFinishLaunching(_ notification: Notification) {
// Hide dock icon — pure menu bar app
NSApp.setActivationPolicy(.accessory)
Expand All @@ -17,6 +22,27 @@
// FirebaseApp.configure()

setupMenuBar()
setupNotifications()
}

// MARK: - Notifications

private func setupNotifications() {
UNUserNotificationCenter.current().delegate = self
LocalNotifier.requestAuthorization()

// Conecta/desconecta o socket de notificações conforme o login. A entrega é local
// (UserNotifications) a partir de eventos WebSocket — ver docs/notifications-spec.md.
authCancellable = AuthManager.shared.$authState
.receive(on: RunLoop.main)
.sink { [weak self] state in
switch state {
case .signedIn(let userID):
self?.notifications.connect(userID: userID)
case .signedOut, .unknown:
self?.notifications.disconnect()
}
}
}

// MARK: - Menu Bar
Expand Down Expand Up @@ -64,4 +90,33 @@
}
}
}

private func showPopover() {
guard let button = statusItem?.button, let popover = popover, !popover.isShown else { return }
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}

// MARK: - UNUserNotificationCenterDelegate

/// Mostra o banner mesmo com o app em foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,

Check warning on line 104 in Kura/Kura/App/AppDelegate.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "center" or name it "_".

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF-jxuz03-S0xF-j&open=AZ6ZPF-jxuz03-S0xF-j&pullRequest=3
willPresent notification: UNNotification,

Check warning on line 105 in Kura/Kura/App/AppDelegate.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "notification" or name it "_".

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF-jxuz03-S0xF-k&open=AZ6ZPF-jxuz03-S0xF-k&pullRequest=3
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}

/// Tap na notificação: abre o popover. Roteamento profundo por `data.type`
/// (item_id/page_id/collector em `response.notification.request.content.userInfo`)
/// fica para a Fase 4, quando as views de Inbox/Dashboard existirem.
func userNotificationCenter(
_ center: UNUserNotificationCenter,

Check warning on line 115 in Kura/Kura/App/AppDelegate.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "center" or name it "_".

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF-jxuz03-S0xF-l&open=AZ6ZPF-jxuz03-S0xF-l&pullRequest=3
didReceive response: UNNotificationResponse,

Check warning on line 116 in Kura/Kura/App/AppDelegate.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "response" or name it "_".

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF-jxuz03-S0xF-m&open=AZ6ZPF-jxuz03-S0xF-m&pullRequest=3
withCompletionHandler completionHandler: @escaping () -> Void
) {
showPopover()
completionHandler()
}
}
17 changes: 17 additions & 0 deletions Kura/Kura/Core/API/KuraEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// KuraEndpoint.swift
// Fonte única do endereço do kura-server. Toda a camada de rede lê daqui.

import Foundation

enum KuraEndpoint {
/// URL do Phoenix socket (`KuraWeb.UserSocket`, mount `/socket/websocket`).
/// DEBUG aponta para o server local; produção usa TLS (`wss://`).
static var socketURL: String {
#if DEBUG
return "ws://127.0.0.1:4000/socket/websocket"

Check warning on line 11 in Kura/Kura/Core/API/KuraEndpoint.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF-Qxuz03-S0xF-g&open=AZ6ZPF-Qxuz03-S0xF-g&pullRequest=3
#else
// TODO(fase-firebase): trocar pelo host real do kura-server em produção.

Check warning on line 13 in Kura/Kura/Core/API/KuraEndpoint.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF-Rxuz03-S0xF-h&open=AZ6ZPF-Rxuz03-S0xF-h&pullRequest=3
return "wss://api.oute.pro/socket/websocket"

Check warning on line 14 in Kura/Kura/Core/API/KuraEndpoint.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF-Rxuz03-S0xF-i&open=AZ6ZPF-Rxuz03-S0xF-i&pullRequest=3
#endif
}
}
41 changes: 41 additions & 0 deletions Kura/Kura/Core/Notifications/LocalNotifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// LocalNotifier.swift
// Notificações locais via UserNotifications — NÃO exigem conta Apple paga, capability
// nem APNs. É o caminho de entrega enquanto o app (menu bar, sempre aberto) recebe os
// eventos por WebSocket. Ver docs/notifications-spec.md.

import UserNotifications

enum LocalNotifier {
/// Pede autorização uma vez no launch. Sem isto nenhuma notificação aparece.
static func requestAuthorization() {
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error {
print("[LocalNotifier] authorization error: \(error)")
} else {
print("[LocalNotifier] authorization granted: \(granted)")
}
}
}

/// Dispara a notificação imediatamente (`trigger: nil`).
static func show(_ payload: PushPayload) {
let content = UNMutableNotificationContent()
content.title = payload.title
content.body = payload.body
content.sound = .default
content.userInfo = payload.userInfo

let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)

UNUserNotificationCenter.current().add(request) { error in
if let error {
print("[LocalNotifier] failed to schedule notification: \(error)")
}
}
}
}
74 changes: 74 additions & 0 deletions Kura/Kura/Core/Notifications/NotificationsClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// NotificationsClient.swift
// Encapsula o Phoenix Socket + Channel de notificações (papel do ChannelClient previsto
// no CLAUDE.md, escopado a notificações). Conecta a notifications:<uid>, escuta "push" e
// dispara notificações locais. Ver docs/notifications-spec.md.

import Foundation
import SwiftPhoenixClient
// import FirebaseAuth // TODO(fase-firebase): descomentar para o path de token de produção

Check warning on line 8 in Kura/Kura/Core/Notifications/NotificationsClient.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF8Fxuz03-S0xF-e&open=AZ6ZPF8Fxuz03-S0xF-e&pullRequest=3

@MainActor
final class NotificationsClient {
private var socket: Socket?
private var channel: Channel?
private var currentUserID: String?

/// Conecta o socket e junta-se a `notifications:<userID>`. Idempotente para o mesmo
/// userID — reconectar não derruba uma sessão saudável.
func connect(userID: String) {
guard currentUserID != userID else { return }
disconnect()
currentUserID = userID

// Params lidos via closure: o SwiftPhoenixClient a reexecuta a cada (re)conexão,
// então o token sempre vai fresco sem reconstruir o Socket.
let socket = Socket(KuraEndpoint.socketURL, paramsClosure: {
// DEBUG: o server em test env aceita `user_id` cru — destrava o E2E sem a
// conta Apple paga (Firebase ainda desativado). Ver docs/notifications-spec.md.
["user_id": userID]

// TODO(fase-firebase): em produção o server exige o Firebase ID token e deriva

Check warning on line 30 in Kura/Kura/Core/Notifications/NotificationsClient.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=renatobardi_kura-macos&issues=AZ6ZPF8Fxuz03-S0xF-f&open=AZ6ZPF8Fxuz03-S0xF-f&pullRequest=3
// o uid de claims["sub"]. Trocar por:
// guard let token = AuthManager.shared.firebaseIDToken else { return nil }
// return ["token": token]
// O idToken Firebase expira (~1h); buscá-lo via user.idToken() (renova sozinho)
// e deixar esta closure devolver o valor fresco a cada reconexão.
})

socket.onError { error, _ in
print("[NotificationsClient] socket error: \(error)")
}
socket.onClose {
print("[NotificationsClient] socket closed")
}

let channel = socket.channel("notifications:\(userID)")
channel.on("push") { message in
guard let payload = PushPayload(payload: message.payload) else { return }
// UNUserNotificationCenter é thread-safe; entrega direto.
LocalNotifier.show(payload)
}

channel.join()
.receive("ok") { _ in
print("[NotificationsClient] joined notifications:\(userID)")
}
.receive("error") { response in
// token/uid mismatch ou token inválido
print("[NotificationsClient] join error: \(response.payload)")
}

socket.connect()

self.socket = socket
self.channel = channel
}

func disconnect() {
channel?.leave()
socket?.disconnect()
channel = nil
socket = nil
currentUserID = nil
}
}
Loading
Loading