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 {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
logoSection
serverStatusSection
urlSection
tagManagementSection
.id(AddBookmarkFieldFocus.labels)
titleSection
.id(AddBookmarkFieldFocus.title)
statusSection
Spacer(minLength: 100) // Space for button
}
if !viewModel.isConfigured || viewModel.sessionExpired {
// Show setup required screen
VStack(spacing: 24) {
logoSection
setupRequiredSection
Spacer()
}
.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)
.padding()
} else {
// Normal UI
ScrollViewReader { proxy in
ScrollView {
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))
.ignoresSafeArea(.keyboard, edges: .bottom)
@ -73,6 +84,31 @@ struct ShareBookmarkView: View {
.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
private var serverStatusSection: some View {
if !viewModel.isServerReachable {

View File

@ -11,6 +11,8 @@ class ShareBookmarkViewModel: ObservableObject {
@Published var isSaving: Bool = false
@Published var searchText: String = ""
@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 extensionContext: NSExtensionContext?
@ -21,6 +23,13 @@ class ShareBookmarkViewModel: ObservableObject {
init(extensionContext: NSExtensionContext?) {
self.extensionContext = extensionContext
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()
}
@ -174,6 +183,30 @@ class ShareBookmarkViewModel: ObservableObject {
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() {
logger.debug("Completing extension request")
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 {
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"
logger.error("Server error \(httpResponse.statusCode): \(msg)")
@ -129,6 +132,9 @@ class SimpleAPI {
DispatchQueue.main.async {
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"
logger.error("Server error \(httpResponse.statusCode): \(msg)")

View File

@ -57,15 +57,15 @@ class AppViewModel {
}
private func handleUnauthorizedResponse() async {
print("AppViewModel: Handling 401 Unauthorized - logging out user")
Logger.viewModel.info("Handling 401 Unauthorized - logging out user")
do {
try await factory.makeLogoutUseCase().execute()
loadSetupStatus()
print("AppViewModel: User successfully logged out due to 401 error")
Logger.viewModel.info("User successfully logged out due to 401 error")
} 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 {
// 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()
// Check if last sync was less than 2 minutes ago
if let lastSync = lastAppStartTagSyncTime,
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
}
// 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()
lastAppStartTagSyncTime = now
}
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
Task.detached(priority: .background) { [weak self] in
guard let self = self else { return }

View File

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

View File

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

View File

@ -86,12 +86,10 @@ struct SettingsContainerView: View {
LegalPrivacySettingsView()
// Debug-only Logging Configuration
#if DEBUG
if Bundle.main.isDebugBuild {
// Debug-only Settings Section
if !Bundle.main.isProduction {
debugSettingsSection
}
#endif
// App Info Section
appInfoSection
@ -104,7 +102,6 @@ struct SettingsContainerView: View {
}
}
#if DEBUG
@ViewBuilder
private var debugSettingsSection: some View {
Section {
@ -112,42 +109,16 @@ struct SettingsContainerView: View {
icon: "wrench.and.screwdriver.fill",
iconColor: .orange,
title: "Debug Menu",
subtitle: "Network simulation, data management & more"
subtitle: "Network simulation, logging & more"
) {
DebugMenuView()
}
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()
.environmentObject(AppSettings())
}
} header: {
HStack {
Text("Debug Settings")
Spacer()
Text("DEBUG BUILD")
Text(Bundle.main.isTestFlightBuild ? "TESTFLIGHT" : "DEBUG BUILD")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
@ -155,9 +126,10 @@ struct SettingsContainerView: View {
.foregroundColor(.orange)
.clipShape(Capsule())
}
} footer: {
Text("Debug menu is also accessible via shake gesture")
}
}
#endif
@ViewBuilder
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.")
}
#if DEBUG
Section {
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
} header: {
Text("Reading Settings")
}
if Bundle.main.isDebugBuild {
Section {
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
} header: {
Text("Reading Settings")
}
if let successMessage = viewModel.successMessage {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
if let successMessage = viewModel.successMessage {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.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) {
ReleaseNotesView()

View File

@ -16,37 +16,37 @@ struct SyncSettingsView: View {
var body: some View {
Group {
#if DEBUG
Section {
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
if viewModel.autoSyncEnabled {
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
if Bundle.main.isDebugBuild {
Section {
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
if viewModel.autoSyncEnabled {
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 {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
if let successMessage = viewModel.successMessage {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.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 {
await viewModel.loadGeneralSettings()

View File

@ -54,17 +54,17 @@ class LogConfiguration: ObservableObject {
private init() {
// First time setup: Enable logging in DEBUG builds with sensible defaults
#if DEBUG
if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil {
isLoggingEnabled = true
showPerformanceLogs = true
showTimestamps = true
includeSourceLocation = true
globalMinLevel = .debug
UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized")
saveConfiguration()
if Bundle.main.isDebugBuild {
if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil {
isLoggingEnabled = true
showPerformanceLogs = true
showTimestamps = true
includeSourceLocation = true
globalMinLevel = .debug
UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized")
saveConfiguration()
}
}
#endif
loadConfiguration()
}
@ -184,7 +184,7 @@ struct Logger {
// MARK: - Store Log
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 }
let entry = LogEntry(
level: level,
@ -197,7 +197,6 @@ struct Logger {
Task {
await LogStore.shared.addEntry(entry)
}
#endif
}
// MARK: - Convenience Methods