From f9cd159e2b227257c96b92e59e32d3770e47445c Mon Sep 17 00:00:00 2001 From: Maximilian Arnold Date: Wed, 15 Jul 2026 12:08:42 +0200 Subject: [PATCH 1/5] feat: reconcile stats/encryption iteration with renamed Nudge; global counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges the `max/create-first-iteration-3oo` product line into the renamed, cleaned-up main line: - Encrypted transport: one shared team ntfy topic derived from a team password (HKDF-SHA256), AES-GCM payloads, generic "Nudge" title — no names in cleartext. New files: NudgeTeamSecret, NudgeRoster (dynamic peer discovery via hello/ping), NudgeStats, NudgePasswordPickerView. - Two-step onboarding (identity → team password); transport starts only when both are set. - Preserved from main: optional per-ping message, launch-at-login (SMAppService), the Nudge rename/structure. Counter changes (per request): - NudgeStats is now GLOBAL/all-time (no weekly reset), tracking sent and received separately (per-peer + totals). New UserDefaults keys. - The number is removed from the menu-bar label (just the wave icon now); "Nudges sent"/"Nudges received" totals show inside the expanded menu, with an all-time leaderboard + breakdowns in Settings. Also: onboarding finish icon sparkles → hand.wave.fill. Co-Authored-By: Claude Opus 4.8 (1M context) --- Nudge.xcodeproj/project.pbxproj | 24 +- Nudge/Nudge/NudgeConstants.swift | 12 +- Nudge/Nudge/NudgeIdentity.swift | 30 ++- Nudge/Nudge/NudgeIdentityPickerView.swift | 65 +++--- Nudge/Nudge/NudgeNotchView.swift | 37 +++- Nudge/Nudge/NudgePasswordPickerView.swift | 97 +++++++++ Nudge/Nudge/NudgeRoster.swift | 66 ++++++ Nudge/Nudge/NudgeStats.swift | 111 ++++++++++ Nudge/Nudge/NudgeTeamSecret.swift | 167 ++++++++++++++ Nudge/Nudge/NudgeTransport.swift | 206 ++++++++++++++---- Nudge/NudgeApp.swift | 150 +++++-------- .../Onboarding/OnboardingFinishView.swift | 2 +- .../Onboarding/OnboardingView.swift | 8 + Nudge/components/Settings/SettingsView.swift | 200 +++++++++++++---- 14 files changed, 923 insertions(+), 252 deletions(-) create mode 100644 Nudge/Nudge/NudgePasswordPickerView.swift create mode 100644 Nudge/Nudge/NudgeRoster.swift create mode 100644 Nudge/Nudge/NudgeStats.swift create mode 100644 Nudge/Nudge/NudgeTeamSecret.swift diff --git a/Nudge.xcodeproj/project.pbxproj b/Nudge.xcodeproj/project.pbxproj index 5c5dca0..bf71f36 100644 --- a/Nudge.xcodeproj/project.pbxproj +++ b/Nudge.xcodeproj/project.pbxproj @@ -34,6 +34,10 @@ 14D570C92C5F38890011E668 /* NudgeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D570C82C5F38890011E668 /* NudgeViewModel.swift */; }; 14E9FEAE2C7325770062E83F /* Button+Bouncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */; }; 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */; }; + 1FB98063B994504FE32D0A59 /* NudgePasswordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */; }; + 4056511EC8FE68B0B8A7AE0F /* NudgeRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */; }; + 5D801A1AF37495A88EB59F2E /* NudgeTeamSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41DF965EE177D3892EF7B81 /* NudgeTeamSecret.swift */; }; + 9F18C9B556E15061567A3991 /* NudgeStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407FCC9217CDCECC553F619E /* NudgeStats.swift */; }; AAA000000000000000000020 /* NudgeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA000000000000000000010 /* NudgeConstants.swift */; }; AAA000000000000000000021 /* NudgeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA000000000000000000011 /* NudgeIdentity.swift */; }; AAA000000000000000000022 /* NudgeTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA000000000000000000012 /* NudgeTransport.swift */; }; @@ -97,6 +101,8 @@ 14D570C82C5F38890011E668 /* NudgeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeViewModel.swift; sourceTree = ""; }; 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Bouncing.swift"; sourceTree = ""; }; 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataTypes+Extensions.swift"; sourceTree = ""; }; + 407FCC9217CDCECC553F619E /* NudgeStats.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeStats.swift; sourceTree = ""; }; + 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeRoster.swift; sourceTree = ""; }; AAA000000000000000000010 /* NudgeConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeConstants.swift; sourceTree = ""; }; AAA000000000000000000011 /* NudgeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeIdentity.swift; sourceTree = ""; }; AAA000000000000000000012 /* NudgeTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeTransport.swift; sourceTree = ""; }; @@ -111,6 +117,8 @@ B1C448952C9712C4001F0858 /* ActionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBar.swift; sourceTree = ""; }; B1CE8CFD2C6F659400DD9871 /* KeyboardShortcutsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutsHelper.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; + E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgePasswordPickerView.swift; sourceTree = ""; }; + F41DF965EE177D3892EF7B81 /* NudgeTeamSecret.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeTeamSecret.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -377,6 +385,10 @@ AAA000000000000000000013 /* NudgeNotifier.swift */, AAA000000000000000000014 /* NudgeNotchView.swift */, AAA000000000000000000015 /* NudgeIdentityPickerView.swift */, + F41DF965EE177D3892EF7B81 /* NudgeTeamSecret.swift */, + 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */, + 407FCC9217CDCECC553F619E /* NudgeStats.swift */, + E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */, ); path = Nudge; sourceTree = ""; @@ -583,6 +595,10 @@ 14FC6E502C7DED5600C7BEA5 /* DataTypes+Extensions.swift in Sources */, 14288E0C2C6F8EC000B9F80C /* AppIcons.swift in Sources */, B19016242CC15B5000E3F12E /* Constants.swift in Sources */, + 5D801A1AF37495A88EB59F2E /* NudgeTeamSecret.swift in Sources */, + 4056511EC8FE68B0B8A7AE0F /* NudgeRoster.swift in Sources */, + 9F18C9B556E15061567A3991 /* NudgeStats.swift in Sources */, + 1FB98063B994504FE32D0A59 /* NudgePasswordPickerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -733,9 +749,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - ); + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Nudge/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Nudge; @@ -785,9 +799,7 @@ DEVELOPMENT_TEAM = Q37KAF726J; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - ); + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Nudge/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Nudge; diff --git a/Nudge/Nudge/NudgeConstants.swift b/Nudge/Nudge/NudgeConstants.swift index 0c220b9..69ffdf7 100644 --- a/Nudge/Nudge/NudgeConstants.swift +++ b/Nudge/Nudge/NudgeConstants.swift @@ -5,12 +5,8 @@ import Foundation -// Shared 12-char lowercase nonce. Every Ontora teammate's build MUST have the -// same value here. Rotate by changing the constant and shipping a new build. -let nudgeNonce = "k4n9pq7vx2tm" +// Names are no longer hardcoded — users type their own during onboarding, +// and the roster auto-discovers teammates via "hello" broadcasts on the +// shared team topic. -let nudgeUsers: [String] = ["Max", "Leon", "David"] - -func ntfyTopic(for user: String) -> String { - "nudge-ontora-\(user.lowercased())-\(nudgeNonce)" -} +let nudgeMaxNameLength = 32 diff --git a/Nudge/Nudge/NudgeIdentity.swift b/Nudge/Nudge/NudgeIdentity.swift index 032ba97..51164a5 100644 --- a/Nudge/Nudge/NudgeIdentity.swift +++ b/Nudge/Nudge/NudgeIdentity.swift @@ -15,27 +15,37 @@ final class NudgeIdentity: ObservableObject { private let storageKey = "nudgeCurrentUser" private init() { - let stored = UserDefaults.standard.string(forKey: storageKey) - if let stored, nudgeUsers.contains(stored) { + if let stored = UserDefaults.standard.string(forKey: storageKey), + NudgeIdentity.isValid(stored) { self.current = stored } else { self.current = nil } } + static func isValid(_ raw: String) -> Bool { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + guard trimmed.count <= nudgeMaxNameLength else { return false } + // ':' is our key separator in stats/roster storage. Disallow it + // to keep persistence keys unambiguous. + guard !trimmed.contains(":") else { return false } + return true + } + + static func sanitize(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + func setCurrent(_ user: String) { - guard nudgeUsers.contains(user) else { return } - UserDefaults.standard.set(user, forKey: storageKey) - self.current = user + let cleaned = NudgeIdentity.sanitize(user) + guard NudgeIdentity.isValid(cleaned) else { return } + UserDefaults.standard.set(cleaned, forKey: storageKey) + self.current = cleaned } func clear() { UserDefaults.standard.removeObject(forKey: storageKey) self.current = nil } - - var others: [String] { - guard let me = current else { return nudgeUsers } - return nudgeUsers.filter { $0 != me } - } } diff --git a/Nudge/Nudge/NudgeIdentityPickerView.swift b/Nudge/Nudge/NudgeIdentityPickerView.swift index 013d08a..df891fa 100644 --- a/Nudge/Nudge/NudgeIdentityPickerView.swift +++ b/Nudge/Nudge/NudgeIdentityPickerView.swift @@ -8,6 +8,13 @@ import SwiftUI struct NudgeIdentityPickerView: View { let onFinish: () -> Void + @State private var name: String = NudgeIdentity.shared.current ?? "" + @State private var error: String? + + private var cleaned: String { + NudgeIdentity.sanitize(name) + } + var body: some View { VStack(spacing: 24) { VStack(spacing: 8) { @@ -16,55 +23,57 @@ struct NudgeIdentityPickerView: View { .foregroundStyle(.tint) Text("Welcome to Nudge") .font(.title2.weight(.semibold)) - Text("Who are you?") + Text("What's your name?") .font(.body) .foregroundStyle(.secondary) } .padding(.top, 40) - VStack(spacing: 12) { - ForEach(nudgeUsers, id: \.self) { user in - Button { - pick(user) - } label: { - HStack { - Text(user) - .font(.system(size: 18, weight: .medium)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.primary.opacity(0.08)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + VStack(spacing: 10) { + TextField("Your name", text: $name) + .textFieldStyle(.roundedBorder) + .font(.system(.body)) + .onSubmit(confirm) + if let err = error { + Text(err) + .font(.caption) + .foregroundStyle(.red) } } .padding(.horizontal, 40) + Button(action: confirm) { + Text("Continue") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 40) + .disabled(!NudgeIdentity.isValid(cleaned)) + Spacer() - Text("You can change this later in Settings.") + Text("Your teammates will see this name on their notch when you nudge them. You can change it later in Settings.") .font(.caption) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) .padding(.bottom, 24) + .fixedSize(horizontal: false, vertical: true) } .frame(width: 400, height: 600) } - private func pick(_ user: String) { - NudgeIdentity.shared.setCurrent(user) + private func confirm() { + guard NudgeIdentity.isValid(cleaned) else { + error = "Please enter a name (1–\(nudgeMaxNameLength) characters, no colons)." + return + } + NudgeIdentity.shared.setCurrent(cleaned) Task { await NudgeNotifier.requestAuthorization() } - NudgeViewCoordinator.shared.firstLaunch = false onFinish() } } diff --git a/Nudge/Nudge/NudgeNotchView.swift b/Nudge/Nudge/NudgeNotchView.swift index d0177c8..0ba9c9e 100644 --- a/Nudge/Nudge/NudgeNotchView.swift +++ b/Nudge/Nudge/NudgeNotchView.swift @@ -9,6 +9,8 @@ import AppKit struct NudgeNotchView: View { @ObservedObject var transport: NudgeTransport = .shared @ObservedObject var identity: NudgeIdentity = .shared + @ObservedObject var teamSecret: NudgeTeamSecret = .shared + @ObservedObject var roster: NudgeRoster = .shared @EnvironmentObject var vm: NudgeViewModel @State private var sending: String? @@ -16,6 +18,10 @@ struct NudgeNotchView: View { @State private var messageText: String = "" @FocusState private var messageFocused: Bool + private var others: [String] { + roster.others(excluding: identity.current) + } + var body: some View { Group { if let ping = transport.lastIncoming, isRecent(ping) { @@ -36,15 +42,19 @@ struct NudgeNotchView: View { Text("Pick a name in Settings") .font(.callout) .foregroundStyle(.secondary) - } else if identity.others.isEmpty { - Text("No teammates configured") + } else if !teamSecret.hasPassword { + Text("Set team password in Settings") + .font(.callout) + .foregroundStyle(.secondary) + } else if others.isEmpty { + Text("Waiting for teammates…") .font(.callout) .foregroundStyle(.secondary) } else { VStack(spacing: 8) { messageField HStack(spacing: 10) { - ForEach(identity.others, id: \.self) { other in + ForEach(others, id: \.self) { other in pingButton(other) } if let err = sendError { @@ -96,17 +106,17 @@ struct NudgeNotchView: View { } private func incomingView(for ping: NudgePing) -> some View { - HStack(spacing: 12) { + HStack(spacing: 10) { ZStack { Circle() .fill(Color.accentColor.opacity(0.9)) - .frame(width: 36, height: 36) + .frame(width: 26, height: 26) Text(String(ping.sender.prefix(1))) - .font(.system(size: 18, weight: .bold)) + .font(.system(size: 13, weight: .bold)) .foregroundStyle(.white) } - VStack(alignment: .leading, spacing: 2) { - Text("\(ping.sender) wants you") + VStack(alignment: .center, spacing: 2) { + Text("\(ping.sender) is waving") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white) if let message = ping.message, !message.isEmpty { @@ -119,8 +129,17 @@ struct NudgeNotchView: View { .font(.caption2) .foregroundStyle(.secondary) } - Spacer(minLength: 0) } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + dismissIncoming() + } + } + + private func dismissIncoming() { + transport.lastIncoming = nil + vm.close() } private func tap(_ recipient: String) { diff --git a/Nudge/Nudge/NudgePasswordPickerView.swift b/Nudge/Nudge/NudgePasswordPickerView.swift new file mode 100644 index 0000000..3a5f040 --- /dev/null +++ b/Nudge/Nudge/NudgePasswordPickerView.swift @@ -0,0 +1,97 @@ +// +// NudgePasswordPickerView.swift +// Nudge +// + +import SwiftUI + +struct NudgePasswordPickerView: View { + let onFinish: () -> Void + + @State private var password: String = "" + @State private var showPassword: Bool = false + @State private var error: String? + + private var trimmed: String { + password.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(spacing: 20) { + VStack(spacing: 8) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 44)) + .foregroundStyle(.tint) + Text("Team password") + .font(.title2.weight(.semibold)) + Text("Everyone on your team types the same password.\nIt encrypts your pings and decides who can reach you.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 40) + + VStack(spacing: 10) { + HStack { + Group { + if showPassword { + TextField("Team password", text: $password) + } else { + SecureField("Team password", text: $password) + } + } + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .onSubmit(confirm) + + Button { + showPassword.toggle() + } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + .help(showPassword ? "Hide password" : "Show password") + } + if let err = error { + Text(err) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.horizontal, 40) + + Button(action: confirm) { + Text("Save") + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 40) + .disabled(trimmed.count < 6) + + Spacer() + + Text("Stored only in your Mac's Keychain — never sent over the network. Change it any time in Settings.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.bottom, 24) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: 400, height: 600) + } + + private func confirm() { + if trimmed.count < 6 { + error = "Please use at least 6 characters." + return + } + NudgeTeamSecret.shared.setPassword(trimmed) + NudgeViewCoordinator.shared.firstLaunch = false + onFinish() + } +} diff --git a/Nudge/Nudge/NudgeRoster.swift b/Nudge/Nudge/NudgeRoster.swift new file mode 100644 index 0000000..3ee0380 --- /dev/null +++ b/Nudge/Nudge/NudgeRoster.swift @@ -0,0 +1,66 @@ +// +// NudgeRoster.swift +// Nudge +// +// Tracks which teammates have been seen on the shared team topic. +// Populated by "hello" broadcasts from NudgeTransport on app launch and +// on a periodic heartbeat; persisted in UserDefaults so a quick app +// restart doesn't blank the ping buttons. +// + +import Foundation +import Combine + +@MainActor +final class NudgeRoster: ObservableObject { + static let shared = NudgeRoster() + + /// name -> ISO8601 last-seen timestamp string. + @Published private(set) var teammates: [String: Date] = [:] + + private let storageKey = "nudgeRoster" + + private init() { + if let raw = UserDefaults.standard.dictionary(forKey: storageKey) as? [String: TimeInterval] { + for (name, ts) in raw { + teammates[name] = Date(timeIntervalSince1970: ts) + } + } + } + + /// Record that we heard from `name` just now. + func record(name rawName: String) { + let name = NudgeIdentity.sanitize(rawName) + guard NudgeIdentity.isValid(name) else { return } + teammates[name] = Date() + persist() + } + + /// Forget everyone — used when the team password changes. + func clear() { + teammates.removeAll() + persist() + } + + /// All known teammates except `excluded` (the current identity), sorted + /// alphabetically. + func others(excluding excluded: String?) -> [String] { + teammates.keys + .filter { $0 != excluded } + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + /// Names known including yourself, sorted alphabetically. + func everyone(including me: String?) -> [String] { + var s = Set(teammates.keys) + if let me { s.insert(me) } + return s.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + private func persist() { + let raw = Dictionary( + uniqueKeysWithValues: teammates.map { ($0.key, $0.value.timeIntervalSince1970) } + ) + UserDefaults.standard.set(raw, forKey: storageKey) + } +} diff --git a/Nudge/Nudge/NudgeStats.swift b/Nudge/Nudge/NudgeStats.swift new file mode 100644 index 0000000..04c3f97 --- /dev/null +++ b/Nudge/Nudge/NudgeStats.swift @@ -0,0 +1,111 @@ +// +// NudgeStats.swift +// Nudge +// +// Tracks GLOBAL (all-time) nudge counters: how many you've sent (per peer +// + total) and received (per peer + total). Never resets. Also keeps a +// peer-leaderboard table of what each teammate has self-reported via their +// own pings, so we can show a cross-team leaderboard with no backend. +// + +import Foundation +import Combine + +@MainActor +final class NudgeStats: ObservableObject { + static let shared = NudgeStats() + + /// Bumped on every write so SwiftUI views observing this object refresh. + @Published private(set) var lastUpdate: Date = .distantPast + + private let countsKey = "nudgeCountsAllTime" // "sent:" / "recv:" -> Int + private let peerLBKey = "nudgePeerLeaderboardAllTime" // "" -> peer's self-reported total sends + + private var counts: [String: Int] = [:] + private var peerLB: [String: Int] = [:] + + private init() { + if let dict = UserDefaults.standard.dictionary(forKey: countsKey) as? [String: Int] { + counts = dict + } + if let dict = UserDefaults.standard.dictionary(forKey: peerLBKey) as? [String: Int] { + peerLB = dict + } + } + + // MARK: - Writes + + func recordSent(to peer: String) { + counts["sent:\(peer)", default: 0] += 1 + persist() + } + + func recordReceived(from peer: String) { + counts["recv:\(peer)", default: 0] += 1 + persist() + } + + /// Record what a peer claims their own all-time send total is. + /// Monotonic — never accept a lower number (clock skew / stale messages). + func recordPeerLeaderboard(peer: String, totalSends: Int) { + guard totalSends > 0 else { return } + let existing = peerLB[peer] ?? 0 + if totalSends > existing { + peerLB[peer] = totalSends + persist() + } + } + + // MARK: - Reads + + var mySendsTotal: Int { + counts.filter { $0.key.hasPrefix("sent:") }.map(\.value).reduce(0, +) + } + + var myReceivedTotal: Int { + counts.filter { $0.key.hasPrefix("recv:") }.map(\.value).reduce(0, +) + } + + func sentTotal(to peer: String) -> Int { + counts["sent:\(peer)"] ?? 0 + } + + func receivedTotal(from peer: String) -> Int { + counts["recv:\(peer)"] ?? 0 + } + + func sentBreakdown() -> [(peer: String, count: Int)] { + counts + .filter { $0.key.hasPrefix("sent:") && $0.value > 0 } + .map { (peer: String($0.key.dropFirst("sent:".count)), count: $0.value) } + .sorted { $0.peer.localizedCaseInsensitiveCompare($1.peer) == .orderedAscending } + } + + func receivedBreakdown() -> [(peer: String, count: Int)] { + counts + .filter { $0.key.hasPrefix("recv:") && $0.value > 0 } + .map { (peer: String($0.key.dropFirst("recv:".count)), count: $0.value) } + .sorted { $0.peer.localizedCaseInsensitiveCompare($1.peer) == .orderedAscending } + } + + func leaderboard() -> [(peer: String, sends: Int)] { + let me = NudgeIdentity.shared.current + let names = NudgeRoster.shared.everyone(including: me) + return names.map { name -> (peer: String, sends: Int) in + if name == me { + return (peer: name, sends: mySendsTotal) + } else { + return (peer: name, sends: peerLB[name] ?? 0) + } + } + .sorted { $0.sends > $1.sends } + } + + // MARK: - Persistence + + private func persist() { + UserDefaults.standard.set(counts, forKey: countsKey) + UserDefaults.standard.set(peerLB, forKey: peerLBKey) + lastUpdate = Date() + } +} diff --git a/Nudge/Nudge/NudgeTeamSecret.swift b/Nudge/Nudge/NudgeTeamSecret.swift new file mode 100644 index 0000000..85d1420 --- /dev/null +++ b/Nudge/Nudge/NudgeTeamSecret.swift @@ -0,0 +1,167 @@ +// +// NudgeTeamSecret.swift +// Nudge +// +// Holds the team password (in Keychain) and derives both the ntfy topic +// nonce and the AES-GCM encryption key from it via HKDF-SHA256. +// Everyone on the team types the same password; they land on the same +// topic and can decrypt each other's messages. Anyone else who happens +// to know the topic just sees ciphertext. +// + +import Foundation +import Combine +import CryptoKit +import Security +import os + +@MainActor +final class NudgeTeamSecret: ObservableObject { + static let shared = NudgeTeamSecret() + + @Published private(set) var hasPassword: Bool = false + + private let log = Logger(subsystem: "com.ontora.nudge", category: "team-secret") + private let service = "com.ontora.nudge.team-secret" + private let account = "team" + + private var cachedKey: SymmetricKey? + private var cachedNonce: String? + + private init() { + if let pw = readPassword() { + derive(from: pw) + hasPassword = true + } + } + + // MARK: - Mutation + + func setPassword(_ pw: String) { + let trimmed = pw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let previous = readPassword() + writePassword(trimmed) + derive(from: trimmed) + hasPassword = true + log.info("team password set") + if previous != trimmed { + // Joined (or rejoined under a new password) a different team — + // the old roster came from a different topic and is stale. + NudgeRoster.shared.clear() + // Force resubscription on the new topic. + if let me = NudgeIdentity.shared.current { + NudgeTransport.shared.stopReceiving() + NudgeTransport.shared.startReceiving(as: me) + } + } + } + + func clearPassword() { + deletePassword() + cachedKey = nil + cachedNonce = nil + hasPassword = false + log.info("team password cleared") + } + + /// For the "show" toggle in Settings. + func currentPassword() -> String? { + readPassword() + } + + // MARK: - Derived values + + /// `nudge-team-<12 hex chars derived from password>`. One topic for + /// the whole team — recipient name moves into the encrypted payload. + /// Returns nil if no password set. + var teamTopic: String? { + guard let nonce = cachedNonce else { return nil } + return "nudge-team-\(nonce)" + } + + func encrypt(_ plaintext: String) -> String? { + guard let key = cachedKey, + let data = plaintext.data(using: .utf8), + let sealed = try? AES.GCM.seal(data, using: key), + let combined = sealed.combined else { return nil } + return combined.base64EncodedString() + } + + func decrypt(_ ciphertextB64: String) -> String? { + let trimmed = ciphertextB64.trimmingCharacters(in: .whitespacesAndNewlines) + guard let key = cachedKey, + let data = Data(base64Encoded: trimmed), + let box = try? AES.GCM.SealedBox(combined: data), + let opened = try? AES.GCM.open(box, using: key), + let str = String(data: opened, encoding: .utf8) else { return nil } + return str + } + + // MARK: - Derivation + + private func derive(from password: String) { + // HKDF-SHA256 derives two separate values from one password via + // distinct `info` tags. Salt is a fixed app-level domain separator + // (per-user salt isn't possible since everyone must derive the same + // values to interoperate). For a 3-person internal tool with a + // verbally-shared password this is adequate; a brute-force attacker + // would have to guess the password to compute the topic. + let ikm = SymmetricKey(data: Data(password.utf8)) + let salt = Data("nudge-ontora-v1".utf8) + + cachedKey = HKDF.deriveKey( + inputKeyMaterial: ikm, + salt: salt, + info: Data("nudge-encryption-key-v1".utf8), + outputByteCount: 32 + ) + + let nonceBytes = HKDF.deriveKey( + inputKeyMaterial: ikm, + salt: salt, + info: Data("nudge-topic-id-v1".utf8), + outputByteCount: 6 + ) + cachedNonce = nonceBytes.withUnsafeBytes { buf in + buf.map { String(format: "%02x", $0) }.joined() + } + } + + // MARK: - Keychain + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } + + private func readPassword() -> String? { + var q = baseQuery() + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(q as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let str = String(data: data, encoding: .utf8) else { return nil } + return str + } + + private func writePassword(_ pw: String) { + deletePassword() + var q = baseQuery() + q[kSecValueData as String] = Data(pw.utf8) + q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(q as CFDictionary, nil) + if status != errSecSuccess { + log.error("keychain write failed: \(status)") + } + } + + private func deletePassword() { + SecItemDelete(baseQuery() as CFDictionary) + } +} diff --git a/Nudge/Nudge/NudgeTransport.swift b/Nudge/Nudge/NudgeTransport.swift index f7ae749..94ceb19 100644 --- a/Nudge/Nudge/NudgeTransport.swift +++ b/Nudge/Nudge/NudgeTransport.swift @@ -2,6 +2,15 @@ // NudgeTransport.swift // Nudge // +// Single shared ntfy topic for the whole team (derived from password). +// Every member subscribes; every message is encrypted with the team key. +// Two message types travel on this topic: +// +// - "hello": "I'm , I'm online" — broadcasts your existence so +// teammates can populate their roster without prior coordination. +// - "ping" : " nudges " — the actual wave. Receivers ignore +// pings whose `to` doesn't match their own identity. +// import Foundation import Combine @@ -14,6 +23,24 @@ struct NudgePing: Equatable { let receivedAt: Date } +/// Wire payload (v2). Carried inside the AES-GCM-encrypted body. +private struct NudgePayload: Codable { + let v: Int + let type: String // "ping" | "hello" + let sender: String + let to: String? // present only on type=="ping" + let sends: Int // sender's all-time send total (for the leaderboard) + let msg: String? // optional free-text message (ping only); omitted when nil + + static func ping(from sender: String, to recipient: String, sends: Int, msg: String?) -> NudgePayload { + NudgePayload(v: 2, type: "ping", sender: sender, to: recipient, sends: sends, msg: msg) + } + + static func hello(from sender: String, sends: Int) -> NudgePayload { + NudgePayload(v: 2, type: "hello", sender: sender, to: nil, sends: sends, msg: nil) + } +} + @MainActor final class NudgeTransport: ObservableObject { static let shared = NudgeTransport() @@ -22,50 +49,69 @@ final class NudgeTransport: ObservableObject { private let log = Logger(subsystem: "com.ontora.nudge", category: "transport") private var receiveTask: Task? - private var currentSubscribedUser: String? + private var heartbeatTask: Task? + private var currentUser: String? private let pathMonitor = NWPathMonitor() private let pathMonitorQueue = DispatchQueue(label: "com.ontora.nudge.path") private var lastPathStatus: NWPath.Status = .requiresConnection private var pathMonitorStarted = false + /// How often each user broadcasts a fresh "hello" so that rosters + /// converge even after sleep / network changes. + private let heartbeatInterval: TimeInterval = 30 * 60 + private init() {} + // MARK: - Public + func send(to recipient: String, from sender: String, message: String? = nil) async throws { - let url = URL(string: "https://ntfy.sh/\(ntfyTopic(for: recipient))")! - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.setValue("\(sender) wants you", forHTTPHeaderField: "Title") - // The sender lives in the Title header. The body carries the optional - // free-text message; when there's no message we fall back to the sender - // name so older clients still surface something sensible. - let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) - let body = (trimmed?.isEmpty == false) ? trimmed! : sender - req.httpBody = Data(body.utf8) - let (_, resp) = try await URLSession.shared.data(for: req) - if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { - log.error("send returned \(http.statusCode)") - throw URLError(.badServerResponse) + guard let topic = NudgeTeamSecret.shared.teamTopic else { + log.error("send aborted: no team password set") + throw URLError(.userAuthenticationRequired) } - log.info("sent ping to \(recipient) from \(sender)") + + // Increment first so the count we broadcast already includes this ping. + NudgeStats.shared.recordSent(to: recipient) + let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + let payload = NudgePayload.ping( + from: sender, + to: recipient, + sends: NudgeStats.shared.mySendsTotal, + msg: (trimmed?.isEmpty == false) ? trimmed : nil + ) + try await post(payload: payload, to: topic, label: "ping->\(recipient)") } func startReceiving(as user: String) { - if currentSubscribedUser == user, receiveTask != nil { return } + if currentUser == user, receiveTask != nil { return } stopReceiving() - currentSubscribedUser = user - log.info("starting subscription for \(user)") + currentUser = user + log.info("starting team subscription for \(user)") startPathMonitorIfNeeded() + receiveTask = Task { [weak self] in await self?.subscribeLoop(user: user) } + + // Announce yourself immediately and on a heartbeat. + Task { [weak self] in + await self?.sendHello(as: user) + } + heartbeatTask = Task { [weak self] in + await self?.heartbeatLoop(user: user) + } } func stopReceiving() { receiveTask?.cancel() receiveTask = nil - currentSubscribedUser = nil + heartbeatTask?.cancel() + heartbeatTask = nil + currentUser = nil } + // MARK: - Subscribe + private func subscribeLoop(user: String) async { var backoff: UInt64 = 1 while !Task.isCancelled { @@ -84,7 +130,10 @@ final class NudgeTransport: ObservableObject { } private func subscribeOnce(user: String) async throws { - let url = URL(string: "https://ntfy.sh/\(ntfyTopic(for: user))/sse")! + guard let topic = NudgeTeamSecret.shared.teamTopic else { + throw URLError(.userAuthenticationRequired) + } + let url = URL(string: "https://ntfy.sh/\(topic)/sse")! var req = URLRequest(url: url) req.timeoutInterval = 0 req.setValue("text/event-stream", forHTTPHeaderField: "Accept") @@ -92,39 +141,97 @@ final class NudgeTransport: ObservableObject { guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw URLError(.badServerResponse) } - log.info("SSE connected for \(user)") + log.info("SSE connected for team topic") + for try await line in bytes.lines { if Task.isCancelled { break } let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("data:") else { continue } - let payload = String(trimmed.dropFirst(5)).trimmingCharacters(in: .whitespaces) - guard let data = payload.data(using: .utf8) else { continue } - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let event = json["event"] as? String ?? "" - if event != "message" { continue } - let title = (json["title"] as? String) ?? "" - let body = (json["message"] as? String) ?? "" - // Sender is encoded in the title as "{sender} wants you". - let suffix = " wants you" - var sender = "Someone" - if title.hasSuffix(suffix) { - let parsed = String(title.dropLast(suffix.count)) - if !parsed.isEmpty { sender = parsed } - } else if !body.isEmpty { - sender = body - } - // The body holds the optional message — unless it's just the - // sender name (the no-message fallback). - let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) - let message: String? = (trimmedBody.isEmpty || trimmedBody == sender) - ? nil : trimmedBody - await MainActor.run { - self.lastIncoming = NudgePing(sender: sender, message: message, receivedAt: Date()) - } + let raw = String(trimmed.dropFirst(5)).trimmingCharacters(in: .whitespaces) + guard let json = try? JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [String: Any], + (json["event"] as? String) == "message", + let messageBody = json["message"] as? String, + let plaintext = NudgeTeamSecret.shared.decrypt(messageBody), + let pdata = plaintext.data(using: .utf8), + let payload = try? JSONDecoder().decode(NudgePayload.self, from: pdata) else { + continue } + await handle(payload: payload, myUser: user) + } + } + + private func handle(payload: NudgePayload, myUser: String) async { + // Anyone we hear from goes into the roster. + NudgeRoster.shared.record(name: payload.sender) + + switch payload.type { + case "hello": + if payload.sends > 0 { + NudgeStats.shared.recordPeerLeaderboard( + peer: payload.sender, + totalSends: payload.sends + ) + } + case "ping": + // Ignore pings addressed to other people. Also ignore your + // own loopback (you'd see your own broadcast otherwise). + guard let to = payload.to, + to == myUser, + payload.sender != myUser else { return } + NudgeStats.shared.recordReceived(from: payload.sender) + NudgeStats.shared.recordPeerLeaderboard( + peer: payload.sender, + totalSends: payload.sends + ) + self.lastIncoming = NudgePing(sender: payload.sender, message: payload.msg, receivedAt: Date()) + default: + log.info("ignoring unknown payload type: \(payload.type)") + } + } + + // MARK: - Hello / heartbeat + + private func sendHello(as user: String) async { + guard let topic = NudgeTeamSecret.shared.teamTopic else { return } + let payload = NudgePayload.hello(from: user, sends: NudgeStats.shared.mySendsTotal) + do { + try await post(payload: payload, to: topic, label: "hello") + } catch { + log.error("hello send failed: \(error.localizedDescription)") } } + private func heartbeatLoop(user: String) async { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(heartbeatInterval) * 1_000_000_000) + if Task.isCancelled { return } + await sendHello(as: user) + } + } + + // MARK: - HTTP helper + + private func post(payload: NudgePayload, to topic: String, label: String) async throws { + guard let data = try? JSONEncoder().encode(payload), + let str = String(data: data, encoding: .utf8), + let body = NudgeTeamSecret.shared.encrypt(str) else { + log.error("\(label): payload encode/encryption failed") + throw URLError(.cannotCreateFile) + } + let url = URL(string: "https://ntfy.sh/\(topic)")! + var req = URLRequest(url: url) + req.httpMethod = "POST" + // Generic title — never leak names in cleartext to ntfy's own clients. + req.setValue("Nudge", forHTTPHeaderField: "Title") + req.httpBody = Data(body.utf8) + let (_, resp) = try await URLSession.shared.data(for: req) + if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + log.error("\(label) returned \(http.statusCode)") + throw URLError(.badServerResponse) + } + log.info("\(label) ok") + } + private func startPathMonitorIfNeeded() { guard !pathMonitorStarted else { return } pathMonitorStarted = true @@ -134,12 +241,15 @@ final class NudgeTransport: ObservableObject { let was = self.lastPathStatus self.lastPathStatus = path.status if was != .satisfied, path.status == .satisfied, - let user = self.currentSubscribedUser { - self.log.info("network became satisfied, resubscribing") + let user = self.currentUser { + self.log.info("network became satisfied, resubscribing + saying hello") self.receiveTask?.cancel() self.receiveTask = Task { [weak self] in await self?.subscribeLoop(user: user) } + Task { [weak self] in + await self?.sendHello(as: user) + } } } } diff --git a/Nudge/NudgeApp.swift b/Nudge/NudgeApp.swift index e80bc46..577a475 100644 --- a/Nudge/NudgeApp.swift +++ b/Nudge/NudgeApp.swift @@ -18,23 +18,30 @@ struct DynamicNotchApp: App { @Default(.nudgePlaySoundOnReceive) var playSoundOnReceive @Default(.nudgeShowBackupNotification) var showBackupNotification @ObservedObject var identity = NudgeIdentity.shared + @ObservedObject var teamSecret = NudgeTeamSecret.shared + @ObservedObject var stats = NudgeStats.shared @Environment(\.openWindow) var openWindow @State private var launchAtLogin: Bool = LoginItem.isEnabled var body: some Scene { - MenuBarExtra("Nudge", systemImage: "hand.wave.fill", isInserted: $showMenuBarIcon) { + MenuBarExtra(isInserted: $showMenuBarIcon) { if let me = identity.current { Text("You are: \(me)") - Button("Switch user…") { - DispatchQueue.main.async { - appDelegate.showIdentityPicker() - } - } } else { - Button("Pick your name…") { - DispatchQueue.main.async { - appDelegate.showIdentityPicker() - } + Text("Identity: not set") + } + if teamSecret.hasPassword { + Text("Team password: set ✓") + } else { + Text("Team password: NOT set") + } + Divider() + Text("Nudges sent: \(stats.mySendsTotal)") + Text("Nudges received: \(stats.myReceivedTotal)") + Divider() + Button("Open onboarding…") { + DispatchQueue.main.async { + appDelegate.showOnboarding() } } Divider() @@ -48,12 +55,6 @@ struct DynamicNotchApp: App { } )) Divider() - Text("Topic nonce: \(nudgeNonce)") - Button("Copy nonce") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(nudgeNonce, forType: .string) - } - Divider() Button("Settings") { DispatchQueue.main.async { SettingsWindowController.shared.showWindow() @@ -64,25 +65,24 @@ struct DynamicNotchApp: App { NSApplication.shared.terminate(self) } .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) + } label: { + // Just the wave — the counts live inside the menu, not next to the icon. + Image(systemName: "hand.wave.fill") } } } class AppDelegate: NSObject, NSApplicationDelegate { - var statusItem: NSStatusItem? var windows: [String: NSWindow] = [:] // UUID -> NSWindow var viewModels: [String: NudgeViewModel] = [:] // UUID -> NudgeViewModel var window: NSWindow? let vm: NudgeViewModel = .init() @ObservedObject var coordinator = NudgeViewCoordinator.shared - var whatsNewWindow: NSWindow? - var timer: Timer? var closeNotchTask: Task? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? private var screenLockedObserver: Any? private var screenUnlockedObserver: Any? - private var isScreenLocked: Bool = false private var windowScreenDidChangeObserver: Any? private var identityCancellable: AnyCancellable? @@ -106,57 +106,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { @MainActor func onScreenLocked(_ notification: Notification) { - isScreenLocked = true if !Defaults[.showOnLockScreen] { cleanupWindows() - } else { - enableSkyLightOnAllWindows() } } @MainActor func onScreenUnlocked(_ notification: Notification) { - isScreenLocked = false if !Defaults[.showOnLockScreen] { adjustWindowPosition(changeAlpha: true) - } else { - disableSkyLightOnAllWindows() - } - } - - @MainActor - private func enableSkyLightOnAllWindows() { - if Defaults[.showOnAllDisplays] { - windows.values.forEach { window in - if let skyWindow = window as? NudgeSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } else { - if let skyWindow = window as? NudgeSkyLightWindow { - skyWindow.enableSkyLight() - } - } - } - - @MainActor - private func disableSkyLightOnAllWindows() { - // Delay disabling SkyLight to avoid flicker during unlock transition - Task { - try? await Task.sleep(for: .milliseconds(150)) - await MainActor.run { - if Defaults[.showOnAllDisplays] { - self.windows.values.forEach { window in - if let skyWindow = window as? NudgeSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } else { - if let skyWindow = self.window as? NudgeSkyLightWindow { - skyWindow.disableSkyLight() - } - } - } } } @@ -184,21 +142,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func createNudgeWindow(for screen: NSScreen, with viewModel: NudgeViewModel) -> NSWindow { let rect = NSRect(x: 0, y: 0, width: windowSize.width, height: windowSize.height) let styleMask: NSWindow.StyleMask = [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow] - let window = NudgeSkyLightWindow(contentRect: rect, styleMask: styleMask, backing: .buffered, defer: false) - - // Enable SkyLight only when screen is locked - if isScreenLocked { - window.enableSkyLight() - } else { - window.disableSkyLight() - } - window.contentView = NSHostingView( rootView: ContentView() .environmentObject(viewModel) ) - window.orderFrontRegardless() NotchSpaceManager.shared.notchSpace.windows.insert(window) return window @@ -335,20 +283,29 @@ class AppDelegate: NSObject, NSApplicationDelegate { adjustWindowPosition(changeAlpha: true) } - // Start Nudge transport when an identity is set; stop when cleared. - identityCancellable = NudgeIdentity.shared.$current - .receive(on: RunLoop.main) - .sink { user in - if let user { - NudgeTransport.shared.startReceiving(as: user) - } else { - NudgeTransport.shared.stopReceiving() - } + // Start Nudge transport only when BOTH identity and team password + // are present; stop whenever either is missing. + identityCancellable = Publishers.CombineLatest( + NudgeIdentity.shared.$current, + NudgeTeamSecret.shared.$hasPassword + ) + .receive(on: RunLoop.main) + .sink { user, hasPwd in + if let user, hasPwd { + NudgeTransport.shared.startReceiving(as: user) + } else { + NudgeTransport.shared.stopReceiving() } + } + // Resume onboarding at whichever step is missing. if NudgeIdentity.shared.current == nil { DispatchQueue.main.async { [weak self] in - self?.showOnboardingWindow() + self?.showOnboardingWindow(step: .identity) + } + } else if !NudgeTeamSecret.shared.hasPassword { + DispatchQueue.main.async { [weak self] in + self?.showOnboardingWindow(step: .password) } } @@ -356,8 +313,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @MainActor - func showIdentityPicker() { - showOnboardingWindow() + func showOnboarding() { + // Pick the right step based on what's missing. + let step: OnboardingStep + if NudgeIdentity.shared.current == nil { + step = .identity + } else if !NudgeTeamSecret.shared.hasPassword { + step = .password + } else { + step = .identity + } + showOnboardingWindow(step: step) } func deviceHasNotch() -> Bool { @@ -458,22 +424,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - @objc func togglePopover(_ sender: Any?) { - if window?.isVisible == true { - window?.orderOut(nil) - } else { - window?.orderFrontRegardless() - } - } - - @objc func showMenu() { - statusItem?.menu?.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) - } - - @objc func quitAction() { - NSApplication.shared.terminate(self) - } - private func showOnboardingWindow(step: OnboardingStep = .identity) { if onboardingWindowController == nil { let window = NSWindow( diff --git a/Nudge/components/Onboarding/OnboardingFinishView.swift b/Nudge/components/Onboarding/OnboardingFinishView.swift index 15481f5..45e1694 100644 --- a/Nudge/components/Onboarding/OnboardingFinishView.swift +++ b/Nudge/components/Onboarding/OnboardingFinishView.swift @@ -16,7 +16,7 @@ struct OnboardingFinishView: View { VStack(spacing: 20) { Spacer() - Image(systemName: "sparkles") + Image(systemName: "hand.wave.fill") .font(.system(size: 60)) .foregroundColor(.effectiveAccent) .padding() diff --git a/Nudge/components/Onboarding/OnboardingView.swift b/Nudge/components/Onboarding/OnboardingView.swift index 4824cb1..c00da0a 100644 --- a/Nudge/components/Onboarding/OnboardingView.swift +++ b/Nudge/components/Onboarding/OnboardingView.swift @@ -7,6 +7,7 @@ import SwiftUI enum OnboardingStep { case identity + case password case finished } @@ -20,6 +21,13 @@ struct OnboardingView: View { switch step { case .identity: NudgeIdentityPickerView { + withAnimation(.easeInOut(duration: 0.4)) { + step = .password + } + } + .transition(.opacity) + case .password: + NudgePasswordPickerView { withAnimation(.easeInOut(duration: 0.4)) { step = .finished } diff --git a/Nudge/components/Settings/SettingsView.swift b/Nudge/components/Settings/SettingsView.swift index a605523..1c684d5 100644 --- a/Nudge/components/Settings/SettingsView.swift +++ b/Nudge/components/Settings/SettingsView.swift @@ -40,15 +40,21 @@ struct SettingsView: View { NavigationStack { NudgeSettingsPane() } - .frame(width: 540, height: 480) + .frame(width: 540, height: 600) } } struct NudgeSettingsPane: View { @ObservedObject var identity = NudgeIdentity.shared + @ObservedObject var teamSecret = NudgeTeamSecret.shared + @ObservedObject var stats = NudgeStats.shared + @ObservedObject var roster = NudgeRoster.shared @Default(.nudgePlaySoundOnReceive) var playSoundOnReceive @Default(.nudgeShowBackupNotification) var showBackupNotification - @State private var copiedNonce: Bool = false + + @State private var nameDraft: String = NudgeIdentity.shared.current ?? "" + @State private var nameError: String? + @State private var showingPasswordSheet: Bool = false @State private var launchAtLogin: Bool = LoginItem.isEnabled private var launchAtLoginBinding: Binding { @@ -65,77 +71,169 @@ struct NudgeSettingsPane: View { Form { Section { HStack { - Text("Current user") - Spacer() - Text(identity.current ?? "—") - .foregroundStyle(.secondary) + TextField("Your name", text: $nameDraft) + .textFieldStyle(.roundedBorder) + .onSubmit(saveName) + Button("Save", action: saveName) + .disabled( + !NudgeIdentity.isValid(NudgeIdentity.sanitize(nameDraft)) || + NudgeIdentity.sanitize(nameDraft) == identity.current + ) } - HStack { - ForEach(nudgeUsers, id: \.self) { user in - Button { - identity.setCurrent(user) - } label: { - Text(user) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - .buttonStyle(.bordered) - .tint(identity.current == user ? .accentColor : .secondary) - } + if let err = nameError { + Text(err) + .font(.caption) + .foregroundStyle(.red) } } header: { Text("Identity") } footer: { - Text("Pick who you are. Every Ontora teammate's build runs this app.") + Text("This is what teammates see on their notch when you nudge them. 1–\(nudgeMaxNameLength) characters, no colons.") .font(.caption) .foregroundStyle(.secondary) } Section { - Toggle("Open Nudge at login", isOn: launchAtLoginBinding) + HStack { + Text("Status") + Spacer() + if teamSecret.hasPassword { + Label("Set", systemImage: "lock.fill") + .foregroundStyle(.secondary) + } else { + Label("Not set — pings won't work", systemImage: "lock.open.fill") + .foregroundStyle(.red) + } + } + Button("Change password…") { + showingPasswordSheet = true + } } header: { - Text("Startup") + Text("Team password") } footer: { - Text("Automatically start Nudge when you log in to your Mac.") + Text("Everyone on your team must type the same password. It encrypts your pings and decides who's on the same team. Stored in Keychain, never sent over the network.") .font(.caption) .foregroundStyle(.secondary) } Section { - Toggle("Play sound on receive", isOn: $playSoundOnReceive) - Toggle("Show macOS notification (fallback)", isOn: $showBackupNotification) + let others = roster.others(excluding: identity.current) + if others.isEmpty { + Text("No teammates seen yet. Send or wait for a hello — you'll appear here within a minute or two.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(others, id: \.self) { name in + HStack { + Image(systemName: "person.fill") + .foregroundStyle(.secondary) + Text(name) + Spacer() + if let seen = roster.teammates[name] { + Text(seen.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } } header: { - Text("Receive behavior") + Text("Known teammates") + } footer: { + Text("People who have said hello on this team password. Auto-refreshes every ~30 minutes per teammate.") + .font(.caption) + .foregroundStyle(.secondary) } Section { - HStack { - Text("Topic nonce") - .frame(width: 120, alignment: .leading) - Text(nudgeNonce) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - Spacer() - Button(copiedNonce ? "Copied!" : "Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(nudgeNonce, forType: .string) - copiedNonce = true - Task { - try? await Task.sleep(nanoseconds: 1_500_000_000) - await MainActor.run { copiedNonce = false } + let lb = stats.leaderboard() + let allZero = lb.allSatisfy { $0.sends == 0 } + if allZero { + Text("No nudges yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(Array(lb.enumerated()), id: \.element.peer) { idx, row in + HStack { + Text("\(idx + 1).") + .foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: 22, alignment: .leading) + Text(row.peer) + .fontWeight(row.peer == identity.current ? .semibold : .regular) + if row.peer == identity.current { + Text("(you)") + .foregroundStyle(.secondary) + .font(.caption) + } + Spacer() + Text("\(row.sends)") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } + + let sent = stats.sentBreakdown() + if !sent.isEmpty { + Divider() + Text("You sent") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(sent, id: \.peer) { row in + HStack { + Text("→ \(row.peer)") + Spacer() + Text("\(row.count)") + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } + + let recv = stats.receivedBreakdown() + if !recv.isEmpty { + Divider() + Text("You received") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(recv, id: \.peer) { row in + HStack { + Text("← \(row.peer)") + Spacer() + Text("\(row.count)") + .monospacedDigit() + .foregroundStyle(.secondary) } } } } header: { - Text("Shared nonce") + Text("Leaderboard (all-time)") + } footer: { + Text("All-time totals. Each row is what that teammate has reported via their pings. Someone who hasn't pinged yet shows 0.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Toggle("Open Nudge at login", isOn: launchAtLoginBinding) + } header: { + Text("Startup") } footer: { - Text("All Ontora teammates' builds must have the exact same nonce. Rotate by editing NudgeConstants.swift and shipping a new build to everyone.") + Text("Automatically start Nudge when you log in to your Mac.") .font(.caption) .foregroundStyle(.secondary) } Section { - Text("Nudge sends a hand-raise ping through ntfy.sh under a topic derived from the recipient's name + the shared nonce. No backend, no accounts — just \"X wants you\" plus an optional short message. When someone pings you, your notch expands for ~6 seconds; a macOS notification fires as a backup in case the notch is off-screen.") + Toggle("Play sound on receive", isOn: $playSoundOnReceive) + Toggle("Show macOS notification (fallback)", isOn: $showBackupNotification) + } header: { + Text("Receive behavior") + } + + Section { + Text("Nudge sends an encrypted hand-wave through ntfy.sh on a topic derived from your team password. The whole team shares one topic and decodes the same payloads; recipients filter pings addressed to them. No backend, no accounts, no plaintext on the wire.") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -145,6 +243,24 @@ struct NudgeSettingsPane: View { } .formStyle(.grouped) .navigationTitle("Nudge") + .sheet(isPresented: $showingPasswordSheet) { + NudgePasswordPickerView { + showingPasswordSheet = false + } + } + .onChange(of: identity.current) { _, newValue in + nameDraft = newValue ?? "" + } + } + + private func saveName() { + let cleaned = NudgeIdentity.sanitize(nameDraft) + guard NudgeIdentity.isValid(cleaned) else { + nameError = "1–\(nudgeMaxNameLength) characters, no colons." + return + } + nameError = nil + identity.setCurrent(cleaned) } } From 9ead97fcb5d7da4e14a09990e34b5d8569503398 Mon Sep 17 00:00:00 2001 From: Maximilian Arnold Date: Wed, 15 Jul 2026 12:20:32 +0200 Subject: [PATCH 2/5] feat: send GIFs via Tenor (search panel + encrypted delivery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NudgeGifService: Tenor v2 search, provider-abstracted for an easy Giphy swap. API key stored in Defaults (tenorApiKey), pasted in Settings → GIFs. - AnimatedGifView: renders GIFs via NSImageView (no dependency) with an in-process data cache. - NudgeGifPickerView + GifPickerWindowController: a dedicated panel (opened from a GIF button in the notch and a "Send a GIF…" menu item) to search, pick a teammate, add an optional message, and send. - Transport: optional `gif` URL field in the encrypted payload alongside `msg`; send(to:from:message:gif:). Recipient's notch renders the GIF and the backup notification says "sent a GIF". Co-Authored-By: Claude Opus 4.8 (1M context) --- Nudge.xcodeproj/project.pbxproj | 25 +++ Nudge/ContentView.swift | 3 +- Nudge/Nudge/AnimatedGifView.swift | 72 +++++++ Nudge/Nudge/NudgeGifService.swift | 110 +++++++++++ Nudge/Nudge/NudgeNotchView.swift | 72 ++++--- Nudge/Nudge/NudgeTransport.swift | 16 +- Nudge/NudgeApp.swift | 5 + .../GifPicker/GifPickerWindowController.swift | 65 +++++++ .../GifPicker/NudgeGifPickerView.swift | 183 ++++++++++++++++++ Nudge/components/Settings/SettingsView.swift | 12 ++ Nudge/models/Constants.swift | 2 + 11 files changed, 536 insertions(+), 29 deletions(-) create mode 100644 Nudge/Nudge/AnimatedGifView.swift create mode 100644 Nudge/Nudge/NudgeGifService.swift create mode 100644 Nudge/components/GifPicker/GifPickerWindowController.swift create mode 100644 Nudge/components/GifPicker/NudgeGifPickerView.swift diff --git a/Nudge.xcodeproj/project.pbxproj b/Nudge.xcodeproj/project.pbxproj index bf71f36..8746133 100644 --- a/Nudge.xcodeproj/project.pbxproj +++ b/Nudge.xcodeproj/project.pbxproj @@ -54,6 +54,10 @@ B1C448962C9712C4001F0858 /* ActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C448952C9712C4001F0858 /* ActionBar.swift */; }; B1CE8CFE2C6F659400DD9871 /* KeyboardShortcutsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1CE8CFD2C6F659400DD9871 /* KeyboardShortcutsHelper.swift */; }; B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; }; + B8AC936D553061A2C59E57CE /* NudgeGifService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A70506F0C82739025A3466C /* NudgeGifService.swift */; }; + F25174743AD7C7244255A32F /* GifPickerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA2E3C198DF89E1F33A29653 /* GifPickerWindowController.swift */; }; + F7291FB50E023ED8310236B8 /* NudgeGifPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B4A5914D74B087D00FF461 /* NudgeGifPickerView.swift */; }; + FBE51232E3221DE7A6FC71DF /* AnimatedGifView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89B24867B6257865F7198E6 /* AnimatedGifView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -101,7 +105,9 @@ 14D570C82C5F38890011E668 /* NudgeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeViewModel.swift; sourceTree = ""; }; 14E9FEAD2C7325770062E83F /* Button+Bouncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Bouncing.swift"; sourceTree = ""; }; 14FC6E4F2C7DED5600C7BEA5 /* DataTypes+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataTypes+Extensions.swift"; sourceTree = ""; }; + 1A70506F0C82739025A3466C /* NudgeGifService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeGifService.swift; sourceTree = ""; }; 407FCC9217CDCECC553F619E /* NudgeStats.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeStats.swift; sourceTree = ""; }; + 57B4A5914D74B087D00FF461 /* NudgeGifPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeGifPickerView.swift; sourceTree = ""; }; 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeRoster.swift; sourceTree = ""; }; AAA000000000000000000010 /* NudgeConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeConstants.swift; sourceTree = ""; }; AAA000000000000000000011 /* NudgeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NudgeIdentity.swift; sourceTree = ""; }; @@ -117,8 +123,10 @@ B1C448952C9712C4001F0858 /* ActionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBar.swift; sourceTree = ""; }; B1CE8CFD2C6F659400DD9871 /* KeyboardShortcutsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutsHelper.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; + C89B24867B6257865F7198E6 /* AnimatedGifView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnimatedGifView.swift; sourceTree = ""; }; E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgePasswordPickerView.swift; sourceTree = ""; }; F41DF965EE177D3892EF7B81 /* NudgeTeamSecret.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NudgeTeamSecret.swift; sourceTree = ""; }; + FA2E3C198DF89E1F33A29653 /* GifPickerWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GifPickerWindowController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -243,6 +251,7 @@ B186542F2C6F455E000B926A /* Notch */, 9A0887332C7AFF7E00C160EA /* Tabs */, B186542E2C6F453B000B926A /* Music */, + 6CC064DE76441B5399B791BD /* GifPicker */, ); path = components; sourceTree = ""; @@ -358,6 +367,16 @@ path = models; sourceTree = ""; }; + 6CC064DE76441B5399B791BD /* GifPicker */ = { + isa = PBXGroup; + children = ( + 57B4A5914D74B087D00FF461 /* NudgeGifPickerView.swift */, + FA2E3C198DF89E1F33A29653 /* GifPickerWindowController.swift */, + ); + name = GifPicker; + path = GifPicker; + sourceTree = ""; + }; 9A0887332C7AFF7E00C160EA /* Tabs */ = { isa = PBXGroup; children = ( @@ -389,6 +408,8 @@ 74A93A85D5B4F91DCE1C994D /* NudgeRoster.swift */, 407FCC9217CDCECC553F619E /* NudgeStats.swift */, E8405B521985C8120633C3EE /* NudgePasswordPickerView.swift */, + 1A70506F0C82739025A3466C /* NudgeGifService.swift */, + C89B24867B6257865F7198E6 /* AnimatedGifView.swift */, ); path = Nudge; sourceTree = ""; @@ -599,6 +620,10 @@ 4056511EC8FE68B0B8A7AE0F /* NudgeRoster.swift in Sources */, 9F18C9B556E15061567A3991 /* NudgeStats.swift in Sources */, 1FB98063B994504FE32D0A59 /* NudgePasswordPickerView.swift in Sources */, + B8AC936D553061A2C59E57CE /* NudgeGifService.swift in Sources */, + FBE51232E3221DE7A6FC71DF /* AnimatedGifView.swift in Sources */, + F7291FB50E023ED8310236B8 /* NudgeGifPickerView.swift in Sources */, + F25174743AD7C7244255A32F /* GifPickerWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Nudge/ContentView.swift b/Nudge/ContentView.swift index 86d4bd5..8cc154b 100644 --- a/Nudge/ContentView.swift +++ b/Nudge/ContentView.swift @@ -201,7 +201,8 @@ struct ContentView: View { private func handleIncoming(_ ping: NudgePing?) { guard let ping else { return } autoCloseTask?.cancel() - NudgeNotifier.postBackup(sender: ping.sender, message: ping.message) + let backupText = ping.message ?? (ping.gif != nil ? "sent a GIF 🎬" : nil) + NudgeNotifier.postBackup(sender: ping.sender, message: backupText) if Defaults[.nudgePlaySoundOnReceive] { NSSound(named: NSSound.Name("Pop"))?.play() } diff --git a/Nudge/Nudge/AnimatedGifView.swift b/Nudge/Nudge/AnimatedGifView.swift new file mode 100644 index 0000000..c821cee --- /dev/null +++ b/Nudge/Nudge/AnimatedGifView.swift @@ -0,0 +1,72 @@ +// +// AnimatedGifView.swift +// Nudge +// +// Renders an animated GIF from a remote URL. AppKit's NSImageView plays +// multi-frame GIFs natively, so no third-party dependency is needed. The +// fetched data is cached in-process so re-renders don't re-download. +// + +import SwiftUI +import AppKit + +/// Lightweight in-process cache of downloaded GIF data, keyed by URL. +@MainActor +private final class GifDataCache { + static let shared = GifDataCache() + private var store: [URL: Data] = [:] + private let maxEntries = 40 + + func data(for url: URL) -> Data? { store[url] } + + func insert(_ data: Data, for url: URL) { + if store.count >= maxEntries, let first = store.keys.first { + store.removeValue(forKey: first) + } + store[url] = data + } +} + +struct AnimatedGifView: NSViewRepresentable { + let url: URL + + func makeNSView(context: Context) -> NSImageView { + let view = NSImageView() + view.imageScaling = .scaleProportionallyDown + view.animates = true + view.canDrawSubviewsIntoLayer = true + view.setContentHuggingPriority(.defaultLow, for: .horizontal) + view.setContentHuggingPriority(.defaultLow, for: .vertical) + context.coordinator.loadedURL = url + load(into: view) + return view + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + // Only reload if the URL changed (avoid restarting playback each pass). + if context.coordinator.loadedURL != url { + context.coordinator.loadedURL = url + load(into: nsView) + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + var loadedURL: URL? + } + + private func load(into view: NSImageView) { + if let cached = GifDataCache.shared.data(for: url) { + view.image = NSImage(data: cached) + return + } + let url = self.url + Task { @MainActor in + guard let (data, _) = try? await URLSession.shared.data(from: url), + let image = NSImage(data: data) else { return } + GifDataCache.shared.insert(data, for: url) + view.image = image + } + } +} diff --git a/Nudge/Nudge/NudgeGifService.swift b/Nudge/Nudge/NudgeGifService.swift new file mode 100644 index 0000000..d1563c6 --- /dev/null +++ b/Nudge/Nudge/NudgeGifService.swift @@ -0,0 +1,110 @@ +// +// NudgeGifService.swift +// Nudge +// +// GIF search. Provider-abstracted so swapping Tenor <-> Giphy is a small +// change; Tenor is the default. The API key is stored in Defaults +// (`tenorApiKey`) and pasted by the user in Settings — nothing embedded. +// + +import Foundation +import Defaults +import os + +/// A single GIF search result. +struct NudgeGif: Identifiable, Equatable { + let id: String + /// Small looping preview used in the picker grid. + let previewURL: URL + /// URL actually sent to teammates (kept small — it renders in the notch). + let sendURL: URL + let description: String +} + +enum NudgeGifError: LocalizedError { + case noAPIKey + case badResponse + + var errorDescription: String? { + switch self { + case .noAPIKey: return "Add a Tenor API key in Settings → GIFs." + case .badResponse: return "Couldn't reach the GIF service." + } + } +} + +@MainActor +enum NudgeGifService { + private static let log = Logger(subsystem: "com.ontora.nudge", category: "gif") + + static var isConfigured: Bool { + !Defaults[.tenorApiKey].trimmingCharacters(in: .whitespaces).isEmpty + } + + /// Search GIFs for `query`. Empty query returns featured/trending. + static func search(_ query: String, limit: Int = 24) async throws -> [NudgeGif] { + let key = Defaults[.tenorApiKey].trimmingCharacters(in: .whitespaces) + guard !key.isEmpty else { throw NudgeGifError.noAPIKey } + return try await TenorProvider.search(query: query, key: key, limit: limit) + } +} + +// MARK: - Tenor (Google) provider + +private enum TenorProvider { + static func search(query: String, key: String, limit: Int) async throws -> [NudgeGif] { + var comps = URLComponents(string: + query.trimmingCharacters(in: .whitespaces).isEmpty + ? "https://tenor.googleapis.com/v2/featured" + : "https://tenor.googleapis.com/v2/search")! + var items = [ + URLQueryItem(name: "key", value: key), + URLQueryItem(name: "client_key", value: "nudge"), + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "media_filter", value: "tinygif,nanogif,gif"), + URLQueryItem(name: "contentfilter", value: "high"), + ] + let q = query.trimmingCharacters(in: .whitespaces) + if !q.isEmpty { items.insert(URLQueryItem(name: "q", value: q), at: 0) } + comps.queryItems = items + + let (data, resp) = try await URLSession.shared.data(from: comps.url!) + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NudgeGifError.badResponse + } + let decoded = try JSONDecoder().decode(TenorResponse.self, from: data) + return decoded.results.compactMap { $0.asNudgeGif } + } + + // Minimal shapes for the fields we use. + private struct TenorResponse: Decodable { let results: [Result] } + + private struct Result: Decodable { + let id: String + let content_description: String? + let media_formats: [String: Format] + + var asNudgeGif: NudgeGif? { + // Preview: prefer the tiniest looping format. + let previewStr = media_formats["nanogif"]?.url + ?? media_formats["tinygif"]?.url + ?? media_formats["gif"]?.url + // Send: tinygif keeps the notch payload light. + let sendStr = media_formats["tinygif"]?.url + ?? media_formats["nanogif"]?.url + ?? media_formats["gif"]?.url + guard let previewStr, let sendStr, + let preview = URL(string: previewStr), let send = URL(string: sendStr) else { + return nil + } + return NudgeGif( + id: id, + previewURL: preview, + sendURL: send, + description: content_description ?? "GIF" + ) + } + } + + private struct Format: Decodable { let url: String } +} diff --git a/Nudge/Nudge/NudgeNotchView.swift b/Nudge/Nudge/NudgeNotchView.swift index 0ba9c9e..78f3501 100644 --- a/Nudge/Nudge/NudgeNotchView.swift +++ b/Nudge/Nudge/NudgeNotchView.swift @@ -52,7 +52,10 @@ struct NudgeNotchView: View { .foregroundStyle(.secondary) } else { VStack(spacing: 8) { - messageField + HStack(spacing: 8) { + messageField + gifButton + } HStack(spacing: 10) { ForEach(others, id: \.self) { other in pingButton(other) @@ -83,6 +86,24 @@ struct NudgeNotchView: View { ) } + private var gifButton: some View { + Button { + GifPickerWindowController.shared.showWindow() + } label: { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.white.opacity(0.12)) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .help("Send a GIF") + } + private func pingButton(_ recipient: String) -> some View { let isSending = sending == recipient return Button { @@ -106,28 +127,35 @@ struct NudgeNotchView: View { } private func incomingView(for ping: NudgePing) -> some View { - HStack(spacing: 10) { - ZStack { - Circle() - .fill(Color.accentColor.opacity(0.9)) - .frame(width: 26, height: 26) - Text(String(ping.sender.prefix(1))) - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.white) - } - VStack(alignment: .center, spacing: 2) { - Text("\(ping.sender) is waving") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - if let message = ping.message, !message.isEmpty { - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.85)) - .lineLimit(2) + VStack(spacing: 6) { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.9)) + .frame(width: 26, height: 26) + Text(String(ping.sender.prefix(1))) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white) } - Text(ping.receivedAt.formatted(date: .omitted, time: .shortened)) - .font(.caption2) - .foregroundStyle(.secondary) + VStack(alignment: .center, spacing: 2) { + Text("\(ping.sender) is waving") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + if let message = ping.message, !message.isEmpty { + Text(message) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(2) + } + Text(ping.receivedAt.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + if let gif = ping.gif, let url = URL(string: gif) { + AnimatedGifView(url: url) + .frame(maxWidth: 200, maxHeight: 90) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } } .frame(maxWidth: .infinity) diff --git a/Nudge/Nudge/NudgeTransport.swift b/Nudge/Nudge/NudgeTransport.swift index 94ceb19..9955eb3 100644 --- a/Nudge/Nudge/NudgeTransport.swift +++ b/Nudge/Nudge/NudgeTransport.swift @@ -20,6 +20,7 @@ import os struct NudgePing: Equatable { let sender: String let message: String? + let gif: String? let receivedAt: Date } @@ -31,13 +32,14 @@ private struct NudgePayload: Codable { let to: String? // present only on type=="ping" let sends: Int // sender's all-time send total (for the leaderboard) let msg: String? // optional free-text message (ping only); omitted when nil + let gif: String? // optional GIF URL (ping only); omitted when nil - static func ping(from sender: String, to recipient: String, sends: Int, msg: String?) -> NudgePayload { - NudgePayload(v: 2, type: "ping", sender: sender, to: recipient, sends: sends, msg: msg) + static func ping(from sender: String, to recipient: String, sends: Int, msg: String?, gif: String?) -> NudgePayload { + NudgePayload(v: 2, type: "ping", sender: sender, to: recipient, sends: sends, msg: msg, gif: gif) } static func hello(from sender: String, sends: Int) -> NudgePayload { - NudgePayload(v: 2, type: "hello", sender: sender, to: nil, sends: sends, msg: nil) + NudgePayload(v: 2, type: "hello", sender: sender, to: nil, sends: sends, msg: nil, gif: nil) } } @@ -64,7 +66,7 @@ final class NudgeTransport: ObservableObject { // MARK: - Public - func send(to recipient: String, from sender: String, message: String? = nil) async throws { + func send(to recipient: String, from sender: String, message: String? = nil, gif: String? = nil) async throws { guard let topic = NudgeTeamSecret.shared.teamTopic else { log.error("send aborted: no team password set") throw URLError(.userAuthenticationRequired) @@ -73,11 +75,13 @@ final class NudgeTransport: ObservableObject { // Increment first so the count we broadcast already includes this ping. NudgeStats.shared.recordSent(to: recipient) let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + let gifTrimmed = gif?.trimmingCharacters(in: .whitespacesAndNewlines) let payload = NudgePayload.ping( from: sender, to: recipient, sends: NudgeStats.shared.mySendsTotal, - msg: (trimmed?.isEmpty == false) ? trimmed : nil + msg: (trimmed?.isEmpty == false) ? trimmed : nil, + gif: (gifTrimmed?.isEmpty == false) ? gifTrimmed : nil ) try await post(payload: payload, to: topic, label: "ping->\(recipient)") } @@ -183,7 +187,7 @@ final class NudgeTransport: ObservableObject { peer: payload.sender, totalSends: payload.sends ) - self.lastIncoming = NudgePing(sender: payload.sender, message: payload.msg, receivedAt: Date()) + self.lastIncoming = NudgePing(sender: payload.sender, message: payload.msg, gif: payload.gif, receivedAt: Date()) default: log.info("ignoring unknown payload type: \(payload.type)") } diff --git a/Nudge/NudgeApp.swift b/Nudge/NudgeApp.swift index 577a475..6385a15 100644 --- a/Nudge/NudgeApp.swift +++ b/Nudge/NudgeApp.swift @@ -39,6 +39,11 @@ struct DynamicNotchApp: App { Text("Nudges sent: \(stats.mySendsTotal)") Text("Nudges received: \(stats.myReceivedTotal)") Divider() + Button("Send a GIF…") { + DispatchQueue.main.async { + GifPickerWindowController.shared.showWindow() + } + } Button("Open onboarding…") { DispatchQueue.main.async { appDelegate.showOnboarding() diff --git a/Nudge/components/GifPicker/GifPickerWindowController.swift b/Nudge/components/GifPicker/GifPickerWindowController.swift new file mode 100644 index 0000000..a7346bb --- /dev/null +++ b/Nudge/components/GifPicker/GifPickerWindowController.swift @@ -0,0 +1,65 @@ +// +// GifPickerWindowController.swift +// Nudge +// +// Hosts the GIF picker in its own panel, opened from the notch's GIF +// button and the menu bar. +// + +import AppKit +import SwiftUI + +final class GifPickerWindowController: NSWindowController { + static let shared = GifPickerWindowController() + + private init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 540), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + super.init(window: window) + setupWindow() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupWindow() { + guard let window = window else { return } + window.title = "Send a GIF" + window.isMovableByWindowBackground = true + window.hidesOnDeactivate = false + window.isRestorable = false + window.identifier = NSUserInterfaceItemIdentifier("NudgeGifPickerWindow") + window.contentView = NSHostingView( + rootView: NudgeGifPickerView(onClose: { [weak self] in self?.close() }) + ) + window.delegate = self + } + + func showWindow() { + NSApp.setActivationPolicy(.regular) + window?.center() + window?.makeKeyAndOrderFront(nil) + window?.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + } + + override func close() { + super.close() + relinquishFocus() + } + + private func relinquishFocus() { + window?.orderOut(nil) + NSApp.setActivationPolicy(.accessory) + } +} + +extension GifPickerWindowController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { relinquishFocus() } + func windowDidBecomeKey(_ notification: Notification) { NSApp.setActivationPolicy(.regular) } +} diff --git a/Nudge/components/GifPicker/NudgeGifPickerView.swift b/Nudge/components/GifPicker/NudgeGifPickerView.swift new file mode 100644 index 0000000..ad7097d --- /dev/null +++ b/Nudge/components/GifPicker/NudgeGifPickerView.swift @@ -0,0 +1,183 @@ +// +// NudgeGifPickerView.swift +// Nudge +// +// Search Tenor, pick a GIF, choose a teammate, optionally add a message, +// and send. Lives in its own panel (see GifPickerWindowController) so the +// ephemeral notch doesn't dismiss it mid-search. +// + +import SwiftUI + +struct NudgeGifPickerView: View { + @ObservedObject var identity = NudgeIdentity.shared + @ObservedObject var roster = NudgeRoster.shared + @ObservedObject var teamSecret = NudgeTeamSecret.shared + + @State private var query: String = "" + @State private var results: [NudgeGif] = [] + @State private var selected: NudgeGif? + @State private var message: String = "" + @State private var recipient: String = "" + @State private var loading: Bool = false + @State private var error: String? + @State private var sending: Bool = false + + let onClose: () -> Void + + private let columns = [GridItem(.adaptive(minimum: 110), spacing: 8)] + + private var others: [String] { roster.others(excluding: identity.current) } + + var body: some View { + VStack(spacing: 12) { + header + + if !NudgeGifService.isConfigured { + notConfigured + } else if identity.current == nil || !teamSecret.hasPassword { + Text("Finish onboarding (name + team password) before sending GIFs.") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + recipientRow + searchRow + resultsGrid + composeRow + } + } + .padding(16) + .frame(minWidth: 460, minHeight: 520) + .task { await initialLoad() } + .onAppear { if recipient.isEmpty { recipient = others.first ?? "" } } + } + + private var header: some View { + HStack { + Label("Send a GIF", systemImage: "photo.on.rectangle.angled") + .font(.headline) + Spacer() + Button("Close", action: onClose) + } + } + + private var notConfigured: some View { + VStack(spacing: 12) { + Image(systemName: "key.horizontal") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("No Tenor API key yet") + .font(.title3.weight(.semibold)) + Text("Paste a Tenor API key in Settings → GIFs to search and send GIFs.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + Button("Open Settings") { + SettingsWindowController.shared.showWindow() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var recipientRow: some View { + HStack { + Text("To") + if others.isEmpty { + Text("No teammates online yet") + .foregroundStyle(.secondary) + } else { + Picker("", selection: $recipient) { + ForEach(others, id: \.self) { Text($0).tag($0) } + } + .labelsHidden() + } + Spacer() + } + } + + private var searchRow: some View { + HStack { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search GIFs", text: $query) + .textFieldStyle(.plain) + .onSubmit { Task { await runSearch() } } + if loading { ProgressView().controlSize(.small) } + } + .padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.06))) + } + + private var resultsGrid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(results) { gif in + Button { + selected = gif + } label: { + AnimatedGifView(url: gif.previewURL) + .frame(height: 90) + .frame(maxWidth: .infinity) + .background(Color.black.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(selected == gif ? Color.accentColor : .clear, lineWidth: 3) + ) + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: .infinity) + } + + private var composeRow: some View { + VStack(spacing: 8) { + if let error { Text(error).font(.caption).foregroundStyle(.red) } + HStack { + TextField("Add a message (optional)", text: $message) + .textFieldStyle(.roundedBorder) + Button { + Task { await send() } + } label: { + if sending { ProgressView().controlSize(.small) } + else { Label("Send", systemImage: "paperplane.fill") } + } + .buttonStyle(.borderedProminent) + .disabled(selected == nil || recipient.isEmpty || sending) + } + } + } + + // MARK: - Actions + + private func initialLoad() async { + guard NudgeGifService.isConfigured, results.isEmpty else { return } + await runSearch() + } + + private func runSearch() async { + error = nil + loading = true + defer { loading = false } + do { + results = try await NudgeGifService.search(query) + } catch { + self.error = (error as? LocalizedError)?.errorDescription ?? "Search failed" + } + } + + private func send() async { + guard let me = identity.current, let gif = selected, !recipient.isEmpty else { return } + sending = true + error = nil + do { + try await NudgeTransport.shared.send( + to: recipient, from: me, message: message, gif: gif.sendURL.absoluteString + ) + onClose() + } catch { + self.error = "Send failed" + } + sending = false + } +} diff --git a/Nudge/components/Settings/SettingsView.swift b/Nudge/components/Settings/SettingsView.swift index 1c684d5..bb53748 100644 --- a/Nudge/components/Settings/SettingsView.swift +++ b/Nudge/components/Settings/SettingsView.swift @@ -51,6 +51,7 @@ struct NudgeSettingsPane: View { @ObservedObject var roster = NudgeRoster.shared @Default(.nudgePlaySoundOnReceive) var playSoundOnReceive @Default(.nudgeShowBackupNotification) var showBackupNotification + @Default(.tenorApiKey) var tenorApiKey @State private var nameDraft: String = NudgeIdentity.shared.current ?? "" @State private var nameError: String? @@ -232,6 +233,17 @@ struct NudgeSettingsPane: View { Text("Receive behavior") } + Section { + SecureField("Tenor API key", text: $tenorApiKey) + .textFieldStyle(.roundedBorder) + } header: { + Text("GIFs") + } footer: { + Text("Paste a Tenor API key to search and send GIFs. Get one free at developers.google.com/tenor — stored locally, never sent to teammates.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section { Text("Nudge sends an encrypted hand-wave through ntfy.sh on a topic derived from your team password. The whole team shares one topic and decodes the same payloads; recipients filter pings addressed to them. No backend, no accounts, no plaintext on the wire.") .font(.callout) diff --git a/Nudge/models/Constants.swift b/Nudge/models/Constants.swift index ea26765..6264b1c 100644 --- a/Nudge/models/Constants.swift +++ b/Nudge/models/Constants.swift @@ -174,4 +174,6 @@ extension Defaults.Keys { // MARK: Nudge static let nudgePlaySoundOnReceive = Key("nudgePlaySoundOnReceive", default: true) static let nudgeShowBackupNotification = Key("nudgeShowBackupNotification", default: true) + // Tenor (GIF search) API key — paste your own in Settings. + static let tenorApiKey = Key("tenorApiKey", default: "") } From 38735f5bcbfc2cf2d8654cb3cd2a6d75d1540410 Mon Sep 17 00:00:00 2001 From: Maximilian Arnold Date: Wed, 15 Jul 2026 12:31:33 +0200 Subject: [PATCH 3/5] feat: ship a team-wide Tenor key via CI secret (build-time injection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo is public, so the shared GIF key can't live in source. Instead: - Info.plist exposes `TenorAPIKey` = `$(TENOR_API_KEY)`, substituted at build time. Local dev builds leave it empty (fall back to a per-user key in Settings); CI bakes in the real key. - NudgeGifService reads the bundled key from Info.plist, overridden by a per-user key if one is set in Settings → GIFs. - nudge-release.yml passes the `TENOR_API_KEY` GitHub Actions secret into the archive build. Verified: local build → empty TenorAPIKey; `TENOR_API_KEY=…` build → key baked into the app's Info.plist. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/nudge-release.yml | 2 ++ Nudge/Info.plist | 2 ++ Nudge/Nudge/NudgeGifService.swift | 20 +++++++++++++++++--- Nudge/components/Settings/SettingsView.swift | 2 +- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nudge-release.yml b/.github/workflows/nudge-release.yml index 8b08a0e..9ab262b 100644 --- a/.github/workflows/nudge-release.yml +++ b/.github/workflows/nudge-release.yml @@ -51,6 +51,7 @@ jobs: - name: Archive (ad-hoc signed) env: VERSION: ${{ steps.version.outputs.version }} + TENOR_API_KEY: ${{ secrets.TENOR_API_KEY }} run: | set -euo pipefail xcodebuild archive \ @@ -59,6 +60,7 @@ jobs: -configuration Release \ -destination 'generic/platform=macOS' \ -archivePath build/Nudge.xcarchive \ + TENOR_API_KEY="${TENOR_API_KEY}" \ CODE_SIGN_IDENTITY="-" \ CODE_SIGN_STYLE=Manual \ CODE_SIGNING_REQUIRED=YES \ diff --git a/Nudge/Info.plist b/Nudge/Info.plist index 23dfa2a..17f1fd9 100644 --- a/Nudge/Info.plist +++ b/Nudge/Info.plist @@ -11,5 +11,7 @@ + TenorAPIKey + $(TENOR_API_KEY) diff --git a/Nudge/Nudge/NudgeGifService.swift b/Nudge/Nudge/NudgeGifService.swift index d1563c6..5cda7af 100644 --- a/Nudge/Nudge/NudgeGifService.swift +++ b/Nudge/Nudge/NudgeGifService.swift @@ -37,13 +37,27 @@ enum NudgeGifError: LocalizedError { enum NudgeGifService { private static let log = Logger(subsystem: "com.ontora.nudge", category: "gif") - static var isConfigured: Bool { - !Defaults[.tenorApiKey].trimmingCharacters(in: .whitespaces).isEmpty + /// Team-wide default Tenor key so GIFs work out of the box for everyone — + /// no per-user setup. It is NOT in source: CI injects the `TENOR_API_KEY` + /// GitHub Actions secret into the app's Info.plist at build time (see + /// `TenorAPIKey` in Info.plist → `$(TENOR_API_KEY)`). Local dev builds + /// leave it empty, so they fall back to a per-user key in Settings. + private static var bundledTenorKey: String { + (Bundle.main.object(forInfoDictionaryKey: "TenorAPIKey") as? String)? + .trimmingCharacters(in: .whitespaces) ?? "" } + /// The user's own key if set, otherwise the shared bundled key. + private static var effectiveKey: String { + let user = Defaults[.tenorApiKey].trimmingCharacters(in: .whitespaces) + return user.isEmpty ? bundledTenorKey : user + } + + static var isConfigured: Bool { !effectiveKey.isEmpty } + /// Search GIFs for `query`. Empty query returns featured/trending. static func search(_ query: String, limit: Int = 24) async throws -> [NudgeGif] { - let key = Defaults[.tenorApiKey].trimmingCharacters(in: .whitespaces) + let key = effectiveKey guard !key.isEmpty else { throw NudgeGifError.noAPIKey } return try await TenorProvider.search(query: query, key: key, limit: limit) } diff --git a/Nudge/components/Settings/SettingsView.swift b/Nudge/components/Settings/SettingsView.swift index bb53748..d778dc1 100644 --- a/Nudge/components/Settings/SettingsView.swift +++ b/Nudge/components/Settings/SettingsView.swift @@ -239,7 +239,7 @@ struct NudgeSettingsPane: View { } header: { Text("GIFs") } footer: { - Text("Paste a Tenor API key to search and send GIFs. Get one free at developers.google.com/tenor — stored locally, never sent to teammates.") + Text("A shared team key ships with the app, so GIFs work out of the box. Paste your own Tenor key here only if you want to override it. Get one free at developers.google.com/tenor — stored locally, never sent to teammates.") .font(.caption) .foregroundStyle(.secondary) } From 402f2d838c8829a59c5b2993d05103072e309652 Mon Sep 17 00:00:00 2001 From: Maximilian Arnold Date: Wed, 15 Jul 2026 14:03:00 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20pivot=20GIF=20provider=20Tenor=20?= =?UTF-8?q?=E2=86=92=20KLIPY=20(Tenor=20API=20shut=20down=202026-06-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google discontinued the Tenor API (keys/requests dead as of 2026-06-30), so swap the provider-abstracted GIF service to KLIPY (drop-in successor, free tier, no caps). - KlipyProvider hits api.klipy.com/api/v1/{key}/gifs/{search,trending}, parsing data.data[].file...url (verified against the live API) with forgiving fallbacks; trending is best-effort on initial load. - Renamed key plumbing Tenor → Klipy: Defaults(.klipyApiKey), Info.plist KlipyAPIKey = $(KLIPY_API_KEY), CI secret KLIPY_API_KEY in nudge-release, Settings "Klipy API key" (klipy.com/developers). Team key set as a repo Actions secret — not in source. Verified: live API returns 200 with the expected schema; local build with KLIPY_API_KEY=… bakes the key into Info.plist and the app launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/nudge-release.yml | 4 +- Nudge/Info.plist | 4 +- Nudge/Nudge/NudgeGifService.swift | 132 ++++++++++-------- .../GifPicker/NudgeGifPickerView.swift | 6 +- Nudge/components/Settings/SettingsView.swift | 6 +- Nudge/models/Constants.swift | 4 +- 6 files changed, 84 insertions(+), 72 deletions(-) diff --git a/.github/workflows/nudge-release.yml b/.github/workflows/nudge-release.yml index 9ab262b..9cd3bc3 100644 --- a/.github/workflows/nudge-release.yml +++ b/.github/workflows/nudge-release.yml @@ -51,7 +51,7 @@ jobs: - name: Archive (ad-hoc signed) env: VERSION: ${{ steps.version.outputs.version }} - TENOR_API_KEY: ${{ secrets.TENOR_API_KEY }} + KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }} run: | set -euo pipefail xcodebuild archive \ @@ -60,7 +60,7 @@ jobs: -configuration Release \ -destination 'generic/platform=macOS' \ -archivePath build/Nudge.xcarchive \ - TENOR_API_KEY="${TENOR_API_KEY}" \ + KLIPY_API_KEY="${KLIPY_API_KEY}" \ CODE_SIGN_IDENTITY="-" \ CODE_SIGN_STYLE=Manual \ CODE_SIGNING_REQUIRED=YES \ diff --git a/Nudge/Info.plist b/Nudge/Info.plist index 17f1fd9..f12ef88 100644 --- a/Nudge/Info.plist +++ b/Nudge/Info.plist @@ -11,7 +11,7 @@ - TenorAPIKey - $(TENOR_API_KEY) + KlipyAPIKey + $(KLIPY_API_KEY) diff --git a/Nudge/Nudge/NudgeGifService.swift b/Nudge/Nudge/NudgeGifService.swift index 5cda7af..2be1db1 100644 --- a/Nudge/Nudge/NudgeGifService.swift +++ b/Nudge/Nudge/NudgeGifService.swift @@ -2,9 +2,14 @@ // NudgeGifService.swift // Nudge // -// GIF search. Provider-abstracted so swapping Tenor <-> Giphy is a small -// change; Tenor is the default. The API key is stored in Defaults -// (`tenorApiKey`) and pasted by the user in Settings — nothing embedded. +// GIF search via KLIPY (https://klipy.com/developers). Tenor was shut down +// by Google on 2026-06-30; KLIPY is the drop-in successor. The service is +// provider-abstracted so swapping again later is a small change. +// +// The team-wide API key is NOT in source (public repo). CI injects the +// `KLIPY_API_KEY` GitHub Actions secret into the app's Info.plist at build +// time (`KlipyAPIKey` = `$(KLIPY_API_KEY)`); a per-user key in Settings +// overrides it. Local dev builds leave it empty and fall back to Settings. // import Foundation @@ -27,7 +32,7 @@ enum NudgeGifError: LocalizedError { var errorDescription: String? { switch self { - case .noAPIKey: return "Add a Tenor API key in Settings → GIFs." + case .noAPIKey: return "Add a Klipy API key in Settings → GIFs." case .badResponse: return "Couldn't reach the GIF service." } } @@ -37,88 +42,95 @@ enum NudgeGifError: LocalizedError { enum NudgeGifService { private static let log = Logger(subsystem: "com.ontora.nudge", category: "gif") - /// Team-wide default Tenor key so GIFs work out of the box for everyone — - /// no per-user setup. It is NOT in source: CI injects the `TENOR_API_KEY` - /// GitHub Actions secret into the app's Info.plist at build time (see - /// `TenorAPIKey` in Info.plist → `$(TENOR_API_KEY)`). Local dev builds - /// leave it empty, so they fall back to a per-user key in Settings. - private static var bundledTenorKey: String { - (Bundle.main.object(forInfoDictionaryKey: "TenorAPIKey") as? String)? + /// Team-wide default key from Info.plist (CI-injected). Empty in local dev. + private static var bundledKey: String { + (Bundle.main.object(forInfoDictionaryKey: "KlipyAPIKey") as? String)? .trimmingCharacters(in: .whitespaces) ?? "" } /// The user's own key if set, otherwise the shared bundled key. private static var effectiveKey: String { - let user = Defaults[.tenorApiKey].trimmingCharacters(in: .whitespaces) - return user.isEmpty ? bundledTenorKey : user + let user = Defaults[.klipyApiKey].trimmingCharacters(in: .whitespaces) + return user.isEmpty ? bundledKey : user } static var isConfigured: Bool { !effectiveKey.isEmpty } - /// Search GIFs for `query`. Empty query returns featured/trending. + /// Search GIFs for `query`. Empty query returns trending. static func search(_ query: String, limit: Int = 24) async throws -> [NudgeGif] { let key = effectiveKey guard !key.isEmpty else { throw NudgeGifError.noAPIKey } - return try await TenorProvider.search(query: query, key: key, limit: limit) + return try await KlipyProvider.search(query: query, key: key, limit: limit) } } -// MARK: - Tenor (Google) provider +// MARK: - KLIPY provider -private enum TenorProvider { +private enum KlipyProvider { static func search(query: String, key: String, limit: Int) async throws -> [NudgeGif] { - var comps = URLComponents(string: - query.trimmingCharacters(in: .whitespaces).isEmpty - ? "https://tenor.googleapis.com/v2/featured" - : "https://tenor.googleapis.com/v2/search")! + let q = query.trimmingCharacters(in: .whitespaces) + let trending = q.isEmpty + var comps = URLComponents( + string: "https://api.klipy.com/api/v1/\(key)/gifs/" + (trending ? "trending" : "search") + )! var items = [ - URLQueryItem(name: "key", value: key), - URLQueryItem(name: "client_key", value: "nudge"), - URLQueryItem(name: "limit", value: String(limit)), - URLQueryItem(name: "media_filter", value: "tinygif,nanogif,gif"), - URLQueryItem(name: "contentfilter", value: "high"), + URLQueryItem(name: "per_page", value: String(min(max(limit, 8), 50))), + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "customer_id", value: "nudge"), ] - let q = query.trimmingCharacters(in: .whitespaces) - if !q.isEmpty { items.insert(URLQueryItem(name: "q", value: q), at: 0) } + if !trending { items.insert(URLQueryItem(name: "q", value: q), at: 0) } comps.queryItems = items - let (data, resp) = try await URLSession.shared.data(from: comps.url!) - guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - throw NudgeGifError.badResponse + do { + let (data, resp) = try await URLSession.shared.data(from: comps.url!) + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw NudgeGifError.badResponse + } + let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + // { result, data: { data: [items], ... } } — tolerate a flat array too. + let container = root["data"] + let list = (container as? [[String: Any]]) + ?? ((container as? [String: Any])?["data"] as? [[String: Any]]) + ?? [] + return list.compactMap(parse) + } catch { + // Trending is best-effort (endpoint/params may vary); don't surface + // an error for the picker's initial load. + if trending { return [] } + throw error is NudgeGifError ? error : NudgeGifError.badResponse } - let decoded = try JSONDecoder().decode(TenorResponse.self, from: data) - return decoded.results.compactMap { $0.asNudgeGif } } - // Minimal shapes for the fields we use. - private struct TenorResponse: Decodable { let results: [Result] } + /// `file...url`, sizes xs/sm/md/hd, formats gif/webp/jpg/mp4/webm. + private static func parse(_ item: [String: Any]) -> NudgeGif? { + let file = item["file"] as? [String: Any] ?? [:] + // sm is a good notch/grid size; fall back up/down and to a recursive scan. + let preview = url(file, size: "sm", "gif") ?? url(file, size: "xs", "gif") + ?? url(file, size: "md", "gif") ?? anyGifURL(file) + let send = url(file, size: "sm", "gif") ?? url(file, size: "md", "gif") + ?? url(file, size: "xs", "gif") ?? url(file, size: "hd", "gif") ?? anyGifURL(file) + guard let previewStr = preview, let sendStr = send, + let p = URL(string: previewStr), let s = URL(string: sendStr) else { + return nil + } + let id = item["id"].map { "\($0)" } ?? sendStr + let desc = (item["title"] as? String) ?? (item["slug"] as? String) ?? "GIF" + return NudgeGif(id: id, previewURL: p, sendURL: s, description: desc) + } - private struct Result: Decodable { - let id: String - let content_description: String? - let media_formats: [String: Format] + private static func url(_ file: [String: Any], size: String, _ format: String) -> String? { + ((file[size] as? [String: Any])?[format] as? [String: Any])?["url"] as? String + } - var asNudgeGif: NudgeGif? { - // Preview: prefer the tiniest looping format. - let previewStr = media_formats["nanogif"]?.url - ?? media_formats["tinygif"]?.url - ?? media_formats["gif"]?.url - // Send: tinygif keeps the notch payload light. - let sendStr = media_formats["tinygif"]?.url - ?? media_formats["nanogif"]?.url - ?? media_formats["gif"]?.url - guard let previewStr, let sendStr, - let preview = URL(string: previewStr), let send = URL(string: sendStr) else { - return nil - } - return NudgeGif( - id: id, - previewURL: preview, - sendURL: send, - description: content_description ?? "GIF" - ) + /// Last resort: first `.gif` URL found anywhere in the file tree. + private static func anyGifURL(_ obj: Any) -> String? { + if let s = obj as? String, s.hasPrefix("http"), s.contains(".gif") { return s } + if let dict = obj as? [String: Any] { + for v in dict.values { if let f = anyGifURL(v) { return f } } } + if let arr = obj as? [Any] { + for v in arr { if let f = anyGifURL(v) { return f } } + } + return nil } - - private struct Format: Decodable { let url: String } } diff --git a/Nudge/components/GifPicker/NudgeGifPickerView.swift b/Nudge/components/GifPicker/NudgeGifPickerView.swift index ad7097d..9c40e8c 100644 --- a/Nudge/components/GifPicker/NudgeGifPickerView.swift +++ b/Nudge/components/GifPicker/NudgeGifPickerView.swift @@ -2,7 +2,7 @@ // NudgeGifPickerView.swift // Nudge // -// Search Tenor, pick a GIF, choose a teammate, optionally add a message, +// Search KLIPY, pick a GIF, choose a teammate, optionally add a message, // and send. Lives in its own panel (see GifPickerWindowController) so the // ephemeral notch doesn't dismiss it mid-search. // @@ -66,9 +66,9 @@ struct NudgeGifPickerView: View { Image(systemName: "key.horizontal") .font(.system(size: 40)) .foregroundStyle(.secondary) - Text("No Tenor API key yet") + Text("No Klipy API key yet") .font(.title3.weight(.semibold)) - Text("Paste a Tenor API key in Settings → GIFs to search and send GIFs.") + Text("Paste a Klipy API key in Settings → GIFs to search and send GIFs.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) Button("Open Settings") { diff --git a/Nudge/components/Settings/SettingsView.swift b/Nudge/components/Settings/SettingsView.swift index d778dc1..f05d493 100644 --- a/Nudge/components/Settings/SettingsView.swift +++ b/Nudge/components/Settings/SettingsView.swift @@ -51,7 +51,7 @@ struct NudgeSettingsPane: View { @ObservedObject var roster = NudgeRoster.shared @Default(.nudgePlaySoundOnReceive) var playSoundOnReceive @Default(.nudgeShowBackupNotification) var showBackupNotification - @Default(.tenorApiKey) var tenorApiKey + @Default(.klipyApiKey) var klipyApiKey @State private var nameDraft: String = NudgeIdentity.shared.current ?? "" @State private var nameError: String? @@ -234,12 +234,12 @@ struct NudgeSettingsPane: View { } Section { - SecureField("Tenor API key", text: $tenorApiKey) + SecureField("Klipy API key", text: $klipyApiKey) .textFieldStyle(.roundedBorder) } header: { Text("GIFs") } footer: { - Text("A shared team key ships with the app, so GIFs work out of the box. Paste your own Tenor key here only if you want to override it. Get one free at developers.google.com/tenor — stored locally, never sent to teammates.") + Text("A shared team key ships with the app, so GIFs work out of the box. Paste your own Klipy key here only if you want to override it. Get one free at klipy.com/developers — stored locally, never sent to teammates.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Nudge/models/Constants.swift b/Nudge/models/Constants.swift index 6264b1c..4ec2615 100644 --- a/Nudge/models/Constants.swift +++ b/Nudge/models/Constants.swift @@ -174,6 +174,6 @@ extension Defaults.Keys { // MARK: Nudge static let nudgePlaySoundOnReceive = Key("nudgePlaySoundOnReceive", default: true) static let nudgeShowBackupNotification = Key("nudgeShowBackupNotification", default: true) - // Tenor (GIF search) API key — paste your own in Settings. - static let tenorApiKey = Key("tenorApiKey", default: "") + // Klipy (GIF search) API key — paste your own in Settings. + static let klipyApiKey = Key("klipyApiKey", default: "") } From f545f25ef0145d05af5ca7c2fdf11a7b8377d6fa Mon Sep 17 00:00:00 2001 From: Maximilian Arnold Date: Wed, 15 Jul 2026 14:19:21 +0200 Subject: [PATCH 5/5] ci: drop static.yml (Pages deploy of the removed Sparkle appcast) It deployed updater/ (the Sparkle appcast) to GitHub Pages and had been failing on every push since mid-May. Sparkle was removed in the rebrand, so the feed is dead. Removes the last red check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/static.yml | 44 ------------------------------------ 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/static.yml diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml deleted file mode 100644 index d70b6c4..0000000 --- a/.github/workflows/static.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: - - main - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Setup Pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - name: Upload artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - # Upload entire repository - path: 'updater/' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0