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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions BookPlayer.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions BookPlayer/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,9 @@ extension AppDelegate {

self.coreServices = coreServices

// Wire up accountService for Watch auth transfer
watchService.setAccountService(accountService)

return coreServices
}
}
Expand Down
36 changes: 36 additions & 0 deletions BookPlayer/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@
"siri_activity_title" = "Continue last played book";
"watchapp_connect_error_title" = "Connectivity Error";
"watchapp_connect_error_description" = "There's a problem connecting to your phone, please try again later";
"watch_signin_with_iphone" = "Sign in with iPhone";
"watch_signin_phone_required" = "Please sign in on your iPhone first, then try again.";
"watch_signin_failed" = "Sign in failed";
"sleep_remaining_title" = "%@ remaining until sleep";
"audio_source_title" = "Audio Source";
"speed_title" = "speed";
Expand Down Expand Up @@ -255,6 +258,10 @@
"logout_title" = "Log out";
"delete_account_title" = "Delete Account";
"account_title" = "Account";
"account_passkeys_title" = "Passkeys";
"account_passkeys_description" = "Passkeys let you sign in securely with Face ID or Touch ID instead of a password.";
"account_passkey_configured" = "Your passkey is synced via iCloud Keychain and works across all your Apple devices.";
"account_add_passkey_title" = "Add a Passkey";
"benefits_cloudsync_title" = "Cloud sync (Beta)";
"benefits_themesicons_title" = "Themes & Icons";
"benefits_supportus_title" = "Support us";
Expand Down Expand Up @@ -395,3 +402,32 @@ We're working hard on providing a seamless experience, if possible, please conta
"database_no_backup_message" = "The database is corrupted and no backup is available. The library will need to be reset and rebuilt from your audio files.";
"settings_smartrewind_max_interval_title" = "Smart Rewind Limit";
"settings_support_discord_title" = "Join our Discord server";

// Passkey Authentication
"passkey_unnamed_device" = "Unnamed Device";
"passkey_delete_title" = "Delete Passkey";
"passkey_delete_message" = "Are you sure you want to delete this passkey? You won't be able to use it to sign in anymore.";
"passkey_continue_button" = "Continue with Passkey";
"passkey_signin_button" = "Sign in with Passkey";
"apple_signin_link" = "Sign in with Apple";
"apple_signin_title" = "Sign in with Apple";
"apple_signin_subtitle" = "Use your existing Apple ID to sign in or create an account.";
"email_title" = "Email";
"passkey_registration_title" = "Create Account";
"auth_methods_section_title" = "Sign-in Methods";
"passkey_created" = "Created";
"auth_method_added" = "Added";
"auth_method_primary" = "Primary";

/* Email Verification */
"verify_email_title" = "Verify Your Email";
"verify_email_subtitle" = "Enter the 6-digit code sent to %@";
"verify_button" = "Verify";
"verify_didnt_receive" = "Didn't receive the code?";
"verify_resend_button" = "Resend Code";
"verify_resend_wait" = "Resend in %d seconds";
"continue_title" = "Continue";
"passkey_creating" = "Creating your passkey...";
"passkey_email_exists_title" = "Account Exists";
"passkey_email_exists_message" = "An account with this email already exists. Please sign in with your existing passkey or Apple ID instead.";
"passkey_signin_existing" = "Sign in with existing passkey";
4 changes: 4 additions & 0 deletions BookPlayer/BookPlayer.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:bookplayer.app</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.$(CFBundleIdentifier)</string>
Expand Down
12 changes: 6 additions & 6 deletions BookPlayer/Generated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,13 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol {
var insertItemsFromReceivedFiles: [URL]?
var insertItemsFromReceivedInvocations: [[URL]] = []
var insertItemsFromReturnValue: [SimpleLibraryItem]!
var insertItemsFromClosure: (([URL]) -> [SimpleLibraryItem])?
func insertItems(from files: [URL]) -> [SimpleLibraryItem] {
var insertItemsFromClosure: (([URL]) async -> [SimpleLibraryItem])?
func insertItems(from files: [URL]) async -> [SimpleLibraryItem] {
insertItemsFromCallsCount += 1
insertItemsFromReceivedFiles = files
insertItemsFromReceivedInvocations.append(files)
if let insertItemsFromClosure = insertItemsFromClosure {
return insertItemsFromClosure(files)
return await insertItemsFromClosure(files)
} else {
return insertItemsFromReturnValue
}
Expand Down Expand Up @@ -465,13 +465,13 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol {
var createBookFromReceivedUrl: URL?
var createBookFromReceivedInvocations: [URL] = []
var createBookFromReturnValue: Book!
var createBookFromClosure: ((URL) -> Book)?
func createBook(from url: URL) -> Book {
var createBookFromClosure: ((URL) async -> Book)?
func createBook(from url: URL) async -> Book {
createBookFromCallsCount += 1
createBookFromReceivedUrl = url
createBookFromReceivedInvocations.append(url)
if let createBookFromClosure = createBookFromClosure {
return createBookFromClosure(url)
return await createBookFromClosure(url)
} else {
return createBookFromReturnValue
}
Expand Down
2 changes: 1 addition & 1 deletion BookPlayer/Player/PlayerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject {
return item.publisher(for: \.percentCompleted, options: [.initial, .new])
.map { percentCompleted in
let progress = item.isFinished ? 1.0 : percentCompleted / 100
return (item.relativePath, progress )
return (item.relativePath, progress)
}
.eraseToAnyPublisher()
}
Expand Down
169 changes: 169 additions & 0 deletions BookPlayer/Profile/Account/AccountPasskeySectionView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//
// AccountPasskeySectionView.swift
// BookPlayer
//
// Created by Claude on 1/10/25.
// Copyright © 2025 BookPlayer LLC. All rights reserved.
//

import BookPlayerKit
import SwiftUI

struct AccountPasskeySectionView: View {
@State private var passkey: PasskeyInfo?
@State private var isLoading = false
@State private var loadFailed = false
@State private var error: Error?
@State private var showDeleteConfirmation = false

@EnvironmentObject private var theme: ThemeViewModel
@Environment(\.accountService) private var accountService
@Environment(\.passkeyService) private var passkeyService

var body: some View {
Section {
if isLoading {
loadingView
} else if let passkey = passkey {
passkeyRow(passkey)
} else if loadFailed {
retryButton
} else {
addButton
}
} header: {
Text("Passkey")
.foregroundStyle(theme.secondaryColor)
}
.onAppear {
Task {
await loadPasskey()
}
}
.errorAlert(error: $error)
.alert("passkey_delete_title".localized, isPresented: $showDeleteConfirmation) {
Button("cancel_button".localized, role: .cancel) {}
Button("delete_button".localized, role: .destructive) {
if let passkey = passkey {
Task {
await deletePasskey(id: passkey.id)
}
}
}
} message: {
Text("passkey_delete_message".localized)
}
}

@ViewBuilder
private var loadingView: some View {
HStack {
Spacer()
ProgressView()
Spacer()
}
}

@ViewBuilder
private func passkeyRow(_ passkey: PasskeyInfo) -> some View {
HStack(spacing: Spacing.S) {
Image(systemName: "person.badge.key")
.font(.title2)
.foregroundStyle(theme.linkColor)

VStack(alignment: .leading, spacing: 4) {
Text(passkey.deviceName ?? "passkey_unnamed_device".localized)
.font(.body)
.foregroundStyle(theme.primaryColor)

Text("passkey_created".localized + " " + passkey.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(theme.secondaryColor)
}

Spacer()

Menu {
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("delete_button".localized, systemImage: "trash")
}
.tint(.red)
} label: {
Image(systemName: "ellipsis.circle")
.font(.title2)
.foregroundStyle(theme.linkColor)
.frame(width: 44, height: 44)
}
}
}

@ViewBuilder
private var retryButton: some View {
Button {
Task {
await loadPasskey()
}
} label: {
Label("network_error_title".localized, systemImage: "arrow.clockwise")
.foregroundStyle(theme.linkColor)
}
}

@ViewBuilder
private var addButton: some View {
Button {
addPasskey()
} label: {
Label("account_add_passkey_title".localized, systemImage: "person.badge.key")
.foregroundStyle(theme.linkColor)
}
}

private func loadPasskey() async {
isLoading = true
loadFailed = false
do {
let passkeys = try await passkeyService.listPasskeys()
// Only use the first passkey (we only support one per account)
passkey = passkeys.first
} catch {
loadFailed = true
}
isLoading = false
}

private func addPasskey() {
// Don't allow adding if one already exists
guard passkey == nil else { return }

Task {
do {
let deviceName = UIDevice.current.name
let email = accountService.getAccount()?.email
try await passkeyService.addPasskeyToAccount(deviceName: deviceName, email: email!)
Comment on lines +144 to +145
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

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

Force unwrapping the email with '!' is unsafe. If the account's email is nil, this will crash the app. Consider adding proper error handling or using optional binding to safely unwrap the email value.

Suggested change
let email = accountService.getAccount()?.email
try await passkeyService.addPasskeyToAccount(deviceName: deviceName, email: email!)
guard let email = accountService.getAccount()?.email else {
return
}
try await passkeyService.addPasskeyToAccount(deviceName: deviceName, email: email)

Copilot uses AI. Check for mistakes.
await loadPasskey()
} catch PasskeyError.userCancelled {
// User cancelled, do nothing
} catch {
self.error = error
}
}
}

private func deletePasskey(id: Int) async {
do {
try await passkeyService.deletePasskey(id: id)
await loadPasskey()
} catch {
self.error = error
}
}
}

#Preview {
Form {
AccountPasskeySectionView()
}
}
1 change: 1 addition & 0 deletions BookPlayer/Profile/Account/AccountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct AccountView: View {
}
}
AccountTermsConditionsSectionView()
AccountPasskeySectionView()
AccountLogoutSectionView()
AccountDeleteSectionView(showAlert: $showDeleteAlert)
}
Expand Down
68 changes: 68 additions & 0 deletions BookPlayer/Profile/Login/AppleSignInLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// AppleSignInLink.swift
// BookPlayer
//
// Created by Claude on 1/10/25.
// Copyright © 2025 BookPlayer LLC. All rights reserved.
//

import AuthenticationServices
import BookPlayerKit
import SwiftUI

struct AppleSignInLink: View {
@Environment(\.loadingState) private var loadingState
@Environment(\.accountService) private var accountService
@EnvironmentObject private var theme: ThemeViewModel

@State private var showAppleSignIn = false

var handleSignIn: (_ hasSubscription: Bool) -> Void

var body: some View {
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.email]
} onCompletion: { result in
switch result {
case .success(let authorization):
Task {
do {
loadingState.show = true

guard
let creds = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = creds.identityToken,
let token = String(data: tokenData, encoding: .utf8)
else {
throw AccountError.missingToken
}

let account = try await accountService.login(
with: token,
userId: creds.user
)

loadingState.show = false

handleSignIn(account?.hasSubscription == true)
} catch {
loadingState.show = false
loadingState.error = error
}
}
case .failure(let error):
if (error as? ASAuthorizationError)?.code != .canceled {
loadingState.error = error
}
}
}
.frame(height: 46)
.signInWithAppleButtonStyle(theme.useDarkVariant ? .white : .black)
.padding(.horizontal, Spacing.M)
}
}

#Preview {
AppleSignInLink { _ in }
.environmentObject(ThemeViewModel())
}
34 changes: 34 additions & 0 deletions BookPlayer/Profile/Login/ContinueWithPasskeyButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// ContinueWithPasskeyButton.swift
// BookPlayer
//
// Created by Claude on 1/10/25.
// Copyright © 2025 BookPlayer LLC. All rights reserved.
//

import BookPlayerKit
import SwiftUI

struct ContinueWithPasskeyButton: View {
@EnvironmentObject private var theme: ThemeViewModel

var action: () -> Void

var body: some View {
Button(action: action) {
HStack(spacing: Spacing.S) {
Image(systemName: "person.badge.key.fill")
Text("passkey_continue_button".localized)
}
.bpFont(Fonts.body)
.frame(maxWidth: .infinity)
.foregroundColor(theme.linkColor)
}
.padding(.horizontal, Spacing.M)
}
}

#Preview {
ContinueWithPasskeyButton { }
.environmentObject(ThemeViewModel())
}
Loading