fix: Prevent sync during onboarding and improve Share Extension UX

- Add guard checks in AppViewModel to prevent sync when hasFinishedSetup is false
- Share Extension now shows friendly UI when app not configured or session expired
- Check token existence via KeychainHelper instead of attempting API calls
- Improve 401 error messages: "Session expired. Please log in via the Readeck app."
- Replace #if DEBUG with BuildEnvironment.isDebugBuild for runtime checks
- Enable Debug Menu in both DEBUG and TestFlight builds
- Replace print() statements with Logger.sync/Logger.viewModel
This commit is contained in:
Ilyas Hallak 2025-12-19 21:34:22 +01:00
parent 997d740597
commit 03cd32dd4d
10 changed files with 225 additions and 163 deletions

View File

@ -14,32 +14,43 @@ struct ShareBookmarkView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollViewReader { proxy in if !viewModel.isConfigured || viewModel.sessionExpired {
ScrollView { // Show setup required screen
VStack(spacing: 0) { VStack(spacing: 24) {
logoSection logoSection
serverStatusSection setupRequiredSection
urlSection Spacer()
tagManagementSection
.id(AddBookmarkFieldFocus.labels)
titleSection
.id(AddBookmarkFieldFocus.title)
statusSection
Spacer(minLength: 100) // Space for button
}
} }
.padding(.bottom, max(0, keyboardHeight - 120)) .padding()
.onChange(of: focusedField) { newField, _ in } else {
guard let field = newField else { return } // Normal UI
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { ScrollViewReader { proxy in
withAnimation(.easeInOut(duration: 0.25)) { ScrollView {
proxy.scrollTo(field, anchor: .center) VStack(spacing: 0) {
logoSection
serverStatusSection
urlSection
tagManagementSection
.id(AddBookmarkFieldFocus.labels)
titleSection
.id(AddBookmarkFieldFocus.title)
statusSection
Spacer(minLength: 100) // Space for button
}
}
.padding(.bottom, max(0, keyboardHeight - 120))
.onChange(of: focusedField) { newField, _ in
guard let field = newField else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(field, anchor: .center)
}
} }
} }
} }
}
saveButtonSection saveButtonSection
}
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.ignoresSafeArea(.keyboard, edges: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom)
@ -73,6 +84,31 @@ struct ShareBookmarkView: View {
.opacity(0.9) .opacity(0.9)
} }
@ViewBuilder
private var setupRequiredSection: some View {
VStack(spacing: 20) {
Image(systemName: viewModel.sessionExpired ? "person.crop.circle.badge.exclamationmark" : "exclamationmark.triangle")
.font(.system(size: 60))
.foregroundColor(.orange)
.padding(.top, 40)
VStack(spacing: 12) {
Text(viewModel.sessionExpired ? "Session Expired" : "Setup Required")
.font(.system(size: 24, weight: .bold))
.foregroundColor(.primary)
Text(viewModel.sessionExpired
? "Please log in via the Readeck app to continue saving bookmarks."
: "Please complete the setup in the Readeck app before using the share extension.")
.font(.system(size: 16))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
}
.frame(maxWidth: .infinity)
}
@ViewBuilder @ViewBuilder
private var serverStatusSection: some View { private var serverStatusSection: some View {
if !viewModel.isServerReachable { if !viewModel.isServerReachable {

View File

@ -11,6 +11,8 @@ class ShareBookmarkViewModel: ObservableObject {
@Published var isSaving: Bool = false @Published var isSaving: Bool = false
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var isServerReachable: Bool = true @Published var isServerReachable: Bool = true
@Published var isConfigured: Bool = true
@Published var sessionExpired: Bool = false
let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount let tagSortOrder: TagSortOrder = .byCount // Share Extension always uses byCount
let extensionContext: NSExtensionContext? let extensionContext: NSExtensionContext?
@ -21,6 +23,13 @@ class ShareBookmarkViewModel: ObservableObject {
init(extensionContext: NSExtensionContext?) { init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext self.extensionContext = extensionContext
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)") logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
// Check if app is configured by verifying token exists
checkConfiguration()
// Setup notification observer for 401 errors
setupNotificationObservers()
extractSharedContent() extractSharedContent()
} }
@ -174,6 +183,30 @@ class ShareBookmarkViewModel: ObservableObject {
searchText = "" searchText = ""
} }
private func checkConfiguration() {
let token = KeychainHelper.shared.loadToken()
let endpoint = KeychainHelper.shared.loadEndpoint()
if token == nil || token?.isEmpty == true || endpoint == nil || endpoint?.isEmpty == true {
logger.warning("Share extension opened but app is not configured (missing token or endpoint)")
isConfigured = false
} else {
logger.info("Share extension opened with valid configuration")
isConfigured = true
}
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(
forName: .unauthorizedAPIResponse,
object: nil,
queue: .main
) { [weak self] _ in
self?.logger.warning("Received 401 Unauthorized - session expired")
self?.sessionExpired = true
}
}
private func completeExtensionRequest() { private func completeExtensionRequest() {
logger.debug("Completing extension request") logger.debug("Completing extension request")
guard let context = extensionContext else { guard let context = extensionContext else {
@ -189,4 +222,8 @@ class ShareBookmarkViewModel: ObservableObject {
} }
} }
} }
deinit {
NotificationCenter.default.removeObserver(self)
}
} }

View File

@ -76,6 +76,9 @@ class SimpleAPI {
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
} }
logger.error("Authentication failed: 401 Unauthorized")
showStatus("Session expired. Please log in via the Readeck app.", true)
return
} }
let msg = String(data: data, encoding: .utf8) ?? "Unknown error" let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)") logger.error("Server error \(httpResponse.statusCode): \(msg)")
@ -129,6 +132,9 @@ class SimpleAPI {
DispatchQueue.main.async { DispatchQueue.main.async {
NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil)
} }
logger.error("Authentication failed: 401 Unauthorized")
showStatus("Session expired. Please log in via the Readeck app.", true)
return nil
} }
let msg = String(data: data, encoding: .utf8) ?? "Unknown error" let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
logger.error("Server error \(httpResponse.statusCode): \(msg)") logger.error("Server error \(httpResponse.statusCode): \(msg)")

View File

@ -57,15 +57,15 @@ class AppViewModel {
} }
private func handleUnauthorizedResponse() async { private func handleUnauthorizedResponse() async {
print("AppViewModel: Handling 401 Unauthorized - logging out user") Logger.viewModel.info("Handling 401 Unauthorized - logging out user")
do { do {
try await factory.makeLogoutUseCase().execute() try await factory.makeLogoutUseCase().execute()
loadSetupStatus() loadSetupStatus()
print("AppViewModel: User successfully logged out due to 401 error") Logger.viewModel.info("User successfully logged out due to 401 error")
} catch { } catch {
print("AppViewModel: Error during logout: \(error)") Logger.viewModel.error("Error during logout: \(error.localizedDescription)")
} }
} }
@ -106,22 +106,34 @@ class AppViewModel {
} }
private func syncTagsOnAppStart() async { private func syncTagsOnAppStart() async {
// Don't sync if onboarding is not complete (no token/endpoint available)
guard settingsRepository.hasFinishedSetup else {
Logger.sync.debug("Skipping tag sync - onboarding not completed")
return
}
let now = Date() let now = Date()
// Check if last sync was less than 2 minutes ago // Check if last sync was less than 2 minutes ago
if let lastSync = lastAppStartTagSyncTime, if let lastSync = lastAppStartTagSyncTime,
now.timeIntervalSince(lastSync) < 120 { now.timeIntervalSince(lastSync) < 120 {
print("AppViewModel: Skipping tag sync - last sync was less than 2 minutes ago") Logger.sync.debug("Skipping tag sync - last sync was less than 2 minutes ago")
return return
} }
// Sync tags from server to Core Data // Sync tags from server to Core Data
print("AppViewModel: Syncing tags on app start") Logger.sync.info("Syncing tags on app start")
try? await syncTagsUseCase.execute() try? await syncTagsUseCase.execute()
lastAppStartTagSyncTime = now lastAppStartTagSyncTime = now
} }
private func syncOfflineArticlesIfNeeded() { private func syncOfflineArticlesIfNeeded() {
// Don't sync if onboarding is not complete (no token/endpoint available)
guard settingsRepository.hasFinishedSetup else {
Logger.sync.debug("Skipping offline sync - onboarding not completed")
return
}
// Run offline sync in background without blocking app start // Run offline sync in background without blocking app start
Task.detached(priority: .background) { [weak self] in Task.detached(priority: .background) { [weak self] in
guard let self = self else { return } guard let self = self else { return }

View File

@ -249,17 +249,17 @@ struct BookmarkDetailView2: View {
@ToolbarContentBuilder @ToolbarContentBuilder
private var toolbarContent: some ToolbarContent { private var toolbarContent: some ToolbarContent {
#if DEBUG if Bundle.main.isDebugBuild {
// Toggle button (left) // Toggle button (left)
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button(action: { Button(action: {
useNativeWebView.toggle() useNativeWebView.toggle()
}) { }) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
}
} }
} }
#endif
// Top toolbar (right) // Top toolbar (right)
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {

View File

@ -136,38 +136,38 @@ struct OfflineReadingDetailView: View {
.textCase(nil) .textCase(nil)
} }
#if DEBUG if Bundle.main.isDebugBuild {
Section { Section {
// Debug: Toggle offline mode simulation // Debug: Toggle offline mode simulation
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Toggle(isOn: Binding( Toggle(isOn: Binding(
get: { !appSettings.isNetworkConnected }, get: { !appSettings.isNetworkConnected },
set: { isOffline in set: { isOffline in
appSettings.isNetworkConnected = !isOffline appSettings.isNetworkConnected = !isOffline
} }
)) { )) {
HStack { HStack {
Image(systemName: "airplane") Image(systemName: "airplane")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Simulate Offline Mode".localized)
.foregroundColor(.orange) .foregroundColor(.orange)
Text("DEBUG: Toggle network status".localized)
.font(.caption) VStack(alignment: .leading, spacing: 2) {
.foregroundColor(.secondary) Text("Simulate Offline Mode".localized)
.foregroundColor(.orange)
Text("DEBUG: Toggle network status".localized)
.font(.caption)
.foregroundColor(.secondary)
}
} }
} }
} }
} header: {
Text("Debug".localized)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.textCase(nil)
} }
} header: {
Text("Debug".localized)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.textCase(nil)
} }
#endif
} }
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)

View File

@ -86,12 +86,10 @@ struct SettingsContainerView: View {
LegalPrivacySettingsView() LegalPrivacySettingsView()
// Debug-only Logging Configuration // Debug-only Settings Section
#if DEBUG if !Bundle.main.isProduction {
if Bundle.main.isDebugBuild {
debugSettingsSection debugSettingsSection
} }
#endif
// App Info Section // App Info Section
appInfoSection appInfoSection
@ -104,7 +102,6 @@ struct SettingsContainerView: View {
} }
} }
#if DEBUG
@ViewBuilder @ViewBuilder
private var debugSettingsSection: some View { private var debugSettingsSection: some View {
Section { Section {
@ -112,42 +109,16 @@ struct SettingsContainerView: View {
icon: "wrench.and.screwdriver.fill", icon: "wrench.and.screwdriver.fill",
iconColor: .orange, iconColor: .orange,
title: "Debug Menu", title: "Debug Menu",
subtitle: "Network simulation, data management & more" subtitle: "Network simulation, logging & more"
) { ) {
DebugMenuView() DebugMenuView()
} .environmentObject(AppSettings())
SettingsRowNavigationLink(
icon: "list.bullet.rectangle",
iconColor: .blue,
title: "Debug Logs",
subtitle: "View all debug messages"
) {
DebugLogViewer()
}
SettingsRowNavigationLink(
icon: "slider.horizontal.3",
iconColor: .purple,
title: "Logging Configuration",
subtitle: "Configure log levels and categories"
) {
LoggingConfigurationView()
}
SettingsRowNavigationLink(
icon: "textformat",
iconColor: .green,
title: "Font Debug",
subtitle: "View available fonts"
) {
FontDebugView()
} }
} header: { } header: {
HStack { HStack {
Text("Debug Settings") Text("Debug Settings")
Spacer() Spacer()
Text("DEBUG BUILD") Text(Bundle.main.isTestFlightBuild ? "TESTFLIGHT" : "DEBUG BUILD")
.font(.caption2) .font(.caption2)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
@ -155,9 +126,10 @@ struct SettingsContainerView: View {
.foregroundColor(.orange) .foregroundColor(.orange)
.clipShape(Capsule()) .clipShape(Capsule())
} }
} footer: {
Text("Debug menu is also accessible via shake gesture")
} }
} }
#endif
@ViewBuilder @ViewBuilder
private var appInfoSection: some View { private var appInfoSection: some View {

View File

@ -45,35 +45,35 @@ struct SettingsGeneralView: View {
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.") Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
} }
#if DEBUG if Bundle.main.isDebugBuild {
Section { Section {
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead) Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
} header: { } header: {
Text("Reading Settings") Text("Reading Settings")
} }
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
Section { Section {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
Text(successMessage) Text(successMessage)
.foregroundColor(.green) .foregroundColor(.green)
}
}
}
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
}
} }
} }
} }
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
}
}
}
#endif
} }
.sheet(isPresented: $showReleaseNotes) { .sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView() ReleaseNotesView()

View File

@ -16,37 +16,37 @@ struct SyncSettingsView: View {
var body: some View { var body: some View {
Group { Group {
#if DEBUG if Bundle.main.isDebugBuild {
Section { Section {
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
if viewModel.autoSyncEnabled { if viewModel.autoSyncEnabled {
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
} header: {
Text("Sync Settings")
} }
} header: {
Text("Sync Settings")
}
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
Section { Section {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
Text(successMessage) Text(successMessage)
.foregroundColor(.green) .foregroundColor(.green)
}
}
}
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
}
} }
} }
} }
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
}
}
}
#endif
} }
.task { .task {
await viewModel.loadGeneralSettings() await viewModel.loadGeneralSettings()

View File

@ -54,17 +54,17 @@ class LogConfiguration: ObservableObject {
private init() { private init() {
// First time setup: Enable logging in DEBUG builds with sensible defaults // First time setup: Enable logging in DEBUG builds with sensible defaults
#if DEBUG if Bundle.main.isDebugBuild {
if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil { if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil {
isLoggingEnabled = true isLoggingEnabled = true
showPerformanceLogs = true showPerformanceLogs = true
showTimestamps = true showTimestamps = true
includeSourceLocation = true includeSourceLocation = true
globalMinLevel = .debug globalMinLevel = .debug
UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized") UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized")
saveConfiguration() saveConfiguration()
}
} }
#endif
loadConfiguration() loadConfiguration()
} }
@ -184,7 +184,7 @@ struct Logger {
// MARK: - Store Log // MARK: - Store Log
private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) { private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) {
#if DEBUG guard Bundle.main.isDebugBuild else { return }
guard config.isLoggingEnabled else { return } guard config.isLoggingEnabled else { return }
let entry = LogEntry( let entry = LogEntry(
level: level, level: level,
@ -197,7 +197,6 @@ struct Logger {
Task { Task {
await LogStore.shared.addEntry(entry) await LogStore.shared.addEntry(entry)
} }
#endif
} }
// MARK: - Convenience Methods // MARK: - Convenience Methods