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,6 +14,16 @@ struct ShareBookmarkView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if !viewModel.isConfigured || viewModel.sessionExpired {
// Show setup required screen
VStack(spacing: 24) {
logoSection
setupRequiredSection
Spacer()
}
.padding()
} else {
// Normal UI
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -41,6 +51,7 @@ struct ShareBookmarkView: View {
saveButtonSection saveButtonSection
} }
}
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.ignoresSafeArea(.keyboard, edges: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom)
.contentShape(Rectangle()) .contentShape(Rectangle())
@ -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,7 +249,7 @@ 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: {
@ -259,7 +259,7 @@ struct BookmarkDetailView2: View {
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
} }
} }
#endif }
// Top toolbar (right) // Top toolbar (right)
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {

View File

@ -136,7 +136,7 @@ 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) {
@ -167,7 +167,7 @@ struct OfflineReadingDetailView: View {
.foregroundColor(.primary) .foregroundColor(.primary)
.textCase(nil) .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,7 +45,7 @@ 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)
@ -73,7 +73,7 @@ struct SettingsGeneralView: View {
} }
} }
} }
#endif }
} }
.sheet(isPresented: $showReleaseNotes) { .sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView() ReleaseNotesView()

View File

@ -16,7 +16,7 @@ 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 {
@ -46,7 +46,7 @@ struct SyncSettingsView: View {
} }
} }
} }
#endif }
} }
.task { .task {
await viewModel.loadGeneralSettings() await viewModel.loadGeneralSettings()

View File

@ -54,7 +54,7 @@ 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
@ -64,7 +64,7 @@ class LogConfiguration: ObservableObject {
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