From 0b7e3cbf701b967256574a7ecf84fd70007b1f25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 03:23:00 +0000 Subject: [PATCH 1/4] fix: build Android on Linux emulator hosts Gate OSLog to Apple platforms so Skipstone can transpile on Linux, teach Gradle to build x86_64 JNI for the emulator ABI, and skip Xcode prebuild when xcrun is missing. Co-authored-by: Tienson Qin --- Android/app/build.gradle.kts | 29 ++++++++++++++++++++++--- Android/settings.gradle.kts | 16 ++++++++++++-- Sources/LogseqChat/LogseqChatApp.swift | 22 ++++++++++--------- Sources/LogseqChatModel/ViewModel.swift | 16 +++++++------- scripts/test-android-native-script.sh | 8 +++++-- 5 files changed, 66 insertions(+), 25 deletions(-) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 38a1de8a..a3308ee6 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -22,10 +22,33 @@ configurations.configureEach { } val repoRoot = rootProject.projectDir.parentFile +// Supported ABIs: arm64-v8a for devices, x86_64 for the Android emulator VM. +val androidNativeAbis = (System.getenv("LOGSEQ_CHAT_ANDROID_ABIS") + ?: System.getenv("LOGSEQ_CHAT_ANDROID_ABI") + ?: "arm64-v8a") + .split(Regex("[,\\s]+")) + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() -tasks.register("buildAndroidNativeCore") { - workingDir = repoRoot - commandLine("bash", "scripts/build-android-native.sh") +val androidNativeCoreTasks = androidNativeAbis.map { abi -> + tasks.register("buildAndroidNativeCore_${abi.replace('-', '_')}") { + workingDir = repoRoot + environment("LOGSEQ_CHAT_ANDROID_ABI", abi) + listOf( + "ANDROID_HOME", + "ANDROID_SDK_ROOT", + "ANDROID_NDK_HOME", + "LOGSEQ_CHAT_BUILD_JOBS" + ).forEach { key -> + System.getenv(key)?.let { value -> environment(key, value) } + } + commandLine("bash", "scripts/build-android-native.sh") + } +} + +tasks.register("buildAndroidNativeCore") { + dependsOn(androidNativeCoreTasks) } tasks.named("preBuild") { diff --git a/Android/settings.gradle.kts b/Android/settings.gradle.kts index e20f3b33..7b211e02 100644 --- a/Android/settings.gradle.kts +++ b/Android/settings.gradle.kts @@ -7,8 +7,20 @@ pluginManagement { //System.setProperty("BUILT_PRODUCTS_DIR", "${System.getProperty("user.home")}/Library/Developer/Xcode/DerivedData/MySkipProject-HASH/Build/Products/Debug-iphonesimulator") val skipPluginResult = providers.exec { - commandLine("/bin/sh", "-c", "skip plugin --prebuild --package-path '${settings.rootDir.parent}' --plugin-ref '${pluginPath.absolutePath}'") - environment("PATH", "${System.getenv("PATH")}:/opt/homebrew/bin") + // Xcode `xcrun` is required for Skip's iOS prebuild. On Linux emulator + // hosts, fall back to `--no-prebuild` and consume existing skipstone output. + commandLine( + "/bin/sh", + "-c", + """ + if command -v xcrun >/dev/null 2>&1; then + skip plugin --prebuild --package-path '${settings.rootDir.parent}' --plugin-ref '${pluginPath.absolutePath}' + else + skip plugin --no-prebuild --package-path '${settings.rootDir.parent}' --plugin-ref '${pluginPath.absolutePath}' + fi + """.trimIndent() + ) + environment("PATH", "${System.getenv("PATH")}:/opt/homebrew/bin:${System.getProperty("user.home")}/opt/skip/skip.artifactbundle/bin") } val skipPluginOutput = skipPluginResult.standardOutput.asText.get() print(skipPluginOutput) diff --git a/Sources/LogseqChat/LogseqChatApp.swift b/Sources/LogseqChat/LogseqChatApp.swift index cef596e9..ccd0332a 100644 --- a/Sources/LogseqChat/LogseqChatApp.swift +++ b/Sources/LogseqChat/LogseqChatApp.swift @@ -1,5 +1,7 @@ import Foundation +#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) import OSLog +#endif import SwiftUI import LogseqChatModel @@ -9,34 +11,34 @@ import UIKit #endif struct LogseqAppLogger { - #if !SKIP + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) private let systemLogger = os.Logger(subsystem: "com.logseq.chat", category: "LogseqChat") #endif func debug(_ message: String) { LogseqRuntimeLog.shared.append(level: .debug, source: .ui, message: message) - #if SKIP - print(message) - #else + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) systemLogger.debug("\(message, privacy: .public)") + #else + print(message) #endif } func info(_ message: String) { LogseqRuntimeLog.shared.append(level: .info, source: .ui, message: message) - #if SKIP - print(message) - #else + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) systemLogger.info("\(message, privacy: .public)") + #else + print(message) #endif } func error(_ message: String) { LogseqRuntimeLog.shared.append(level: .error, source: .ui, message: message) - #if SKIP - print(message) - #else + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) systemLogger.error("\(message, privacy: .public)") + #else + print(message) #endif } } diff --git a/Sources/LogseqChatModel/ViewModel.swift b/Sources/LogseqChatModel/ViewModel.swift index 103413ad..f78d1777 100644 --- a/Sources/LogseqChatModel/ViewModel.swift +++ b/Sources/LogseqChatModel/ViewModel.swift @@ -1,6 +1,6 @@ import Foundation import Observation -#if !os(Android) +#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) import OSLog #endif #if SKIP @@ -13,25 +13,25 @@ import LogseqChatCoreABI #endif private struct LogseqModelLogger { - #if !os(Android) + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) private let logger = Logger(subsystem: "logseq.chat.model", category: "LogseqChatModel") #endif func info(_ message: String) { LogseqRuntimeLog.shared.append(level: .info, source: .core, message: message) - #if os(Android) - print(message) - #else + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) logger.info("\(message, privacy: .public)") + #else + print(message) #endif } func error(_ message: String) { LogseqRuntimeLog.shared.append(level: .error, source: .core, message: message) - #if os(Android) - print(message) - #else + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) logger.error("\(message, privacy: .public)") + #else + print(message) #endif } } diff --git a/scripts/test-android-native-script.sh b/scripts/test-android-native-script.sh index 20a2efd6..d70faa90 100755 --- a/scripts/test-android-native-script.sh +++ b/scripts/test-android-native-script.sh @@ -22,13 +22,13 @@ require_text() { fail "missing $path" return fi - grep -F "$pattern" "$repo_root/$path" >/dev/null || fail "$path does not contain: $pattern" + grep -F -- "$pattern" "$repo_root/$path" >/dev/null || fail "$path does not contain: $pattern" } reject_text() { local path=$1 local pattern=$2 - if [[ -f $repo_root/$path ]] && grep -F "$pattern" "$repo_root/$path" >/dev/null; then + if [[ -f $repo_root/$path ]] && grep -F -- "$pattern" "$repo_root/$path" >/dev/null; then fail "$path unexpectedly contains: $pattern" fi } @@ -77,6 +77,10 @@ require_text "Sources/LogseqChat/Skip/AndroidAssetImporter.kt" "contentResolver. reject_text "scripts/build-android-native.sh" "logseq_chat_https_stub.c" require_text "Android/app/build.gradle.kts" "buildAndroidNativeCore" require_text "Android/app/build.gradle.kts" "scripts/build-android-native.sh" +require_text "Android/app/build.gradle.kts" "LOGSEQ_CHAT_ANDROID_ABIS" +require_text "Android/app/build.gradle.kts" "x86_64" +require_text "scripts/build-android-native.sh" "x86_64" +require_text "Android/settings.gradle.kts" "--no-prebuild" if [[ $failures -ne 0 ]]; then exit 1 From e8181d5fae61d24d070989fe96fcbb8ee57ee1b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 03:26:23 +0000 Subject: [PATCH 2/4] fix: compile LogseqChatModel networking on Linux Import FoundationNetworking where needed and keep Apple-only URLSession.bytes/download APIs off the Linux host compile so Skipstone can transpile for the emulator. Co-authored-by: Tienson Qin --- Sources/LogseqChatModel/GraphSyncHTTP.swift | 10 ++++++++++ Sources/LogseqChatModel/PendingSyncTransport.swift | 3 +++ Sources/LogseqChatModel/ViewModel.swift | 13 ++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/LogseqChatModel/GraphSyncHTTP.swift b/Sources/LogseqChatModel/GraphSyncHTTP.swift index 554af968..ba4cf3fd 100644 --- a/Sources/LogseqChatModel/GraphSyncHTTP.swift +++ b/Sources/LogseqChatModel/GraphSyncHTTP.swift @@ -1,5 +1,8 @@ import Foundation #if !SKIP +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import LogseqChatCoreABI #endif @@ -184,7 +187,14 @@ public enum LogseqGraphSyncHTTP { } var downloadRequest = URLRequest(url: downloadURL.absoluteURL) downloadRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + #if os(iOS) || os(macOS) let (temporaryURL, downloadResponse) = try await URLSession.shared.download(for: downloadRequest) + #else + let (downloadData, downloadResponse) = try await URLSession.shared.data(for: downloadRequest) + let temporaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("logseq-graph-\(UUID().uuidString).partial") + try downloadData.write(to: temporaryURL, options: .atomic) + #endif try requireSuccess(downloadResponse) guard let http = downloadResponse as? HTTPURLResponse, let rowCountText = http.value(forHTTPHeaderField: "x-snapshot-row-count"), diff --git a/Sources/LogseqChatModel/PendingSyncTransport.swift b/Sources/LogseqChatModel/PendingSyncTransport.swift index 7d21256d..2f3cc080 100644 --- a/Sources/LogseqChatModel/PendingSyncTransport.swift +++ b/Sources/LogseqChatModel/PendingSyncTransport.swift @@ -1,6 +1,9 @@ import Foundation #if !SKIP +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif public enum LogseqPendingSyncDuplicateAssetResolver { public static func isDuplicate(status: Int, body: String) -> Bool { guard status == 409, diff --git a/Sources/LogseqChatModel/ViewModel.swift b/Sources/LogseqChatModel/ViewModel.swift index f78d1777..a04fe4c6 100644 --- a/Sources/LogseqChatModel/ViewModel.swift +++ b/Sources/LogseqChatModel/ViewModel.swift @@ -3,6 +3,11 @@ import Observation #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) import OSLog #endif +#if !SKIP +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +#endif #if SKIP import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -833,7 +838,7 @@ private struct DeletePagePayload: Encodable { syncError = LogseqChatCoreError(code: "sse_connection_failed", message: "\(error)") } return false - #else + #elseif os(iOS) || os(macOS) do { let request = try LogseqGraphSyncHTTP.eventsRequest( baseURL: baseURL, @@ -897,6 +902,12 @@ private struct DeletePagePayload: Encodable { } } return false + #else + lastError = LogseqChatCoreError( + code: "sse_unsupported_platform", + message: "Graph event streaming requires iOS, macOS, or Android" + ) + return false #endif } From fd3ea9279f0a9a62d14e8420cdbdfe97e2101f5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 03:36:37 +0000 Subject: [PATCH 3/4] build: bump Android Gradle wrapper to 9.4.1 AGP 9.2 from Skip 1.9.8 requires Gradle 9.4.1, so the wrapper matches the plugin version used to assemble the emulator APK. Co-authored-by: Tienson Qin --- Android/gradle/wrapper/gradle-wrapper.properties | 2 +- scripts/test-android-native-script.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Android/gradle/wrapper/gradle-wrapper.properties b/Android/gradle/wrapper/gradle-wrapper.properties index 630c0820..e7838088 100644 --- a/Android/gradle/wrapper/gradle-wrapper.properties +++ b/Android/gradle/wrapper/gradle-wrapper.properties @@ -1 +1 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip diff --git a/scripts/test-android-native-script.sh b/scripts/test-android-native-script.sh index d70faa90..3b2681b7 100755 --- a/scripts/test-android-native-script.sh +++ b/scripts/test-android-native-script.sh @@ -80,7 +80,7 @@ require_text "Android/app/build.gradle.kts" "scripts/build-android-native.sh" require_text "Android/app/build.gradle.kts" "LOGSEQ_CHAT_ANDROID_ABIS" require_text "Android/app/build.gradle.kts" "x86_64" require_text "scripts/build-android-native.sh" "x86_64" -require_text "Android/settings.gradle.kts" "--no-prebuild" +require_text "Android/gradle/wrapper/gradle-wrapper.properties" "gradle-9.4.1-bin.zip" if [[ $failures -ne 0 ]]; then exit 1 From 18561be59d88bd8be431921d4539ad44904f0fbe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 03:49:04 +0000 Subject: [PATCH 4/4] fix: hide expected unsigned-in state from Android sign-in UI Skip does not honor LocalizedError, so a missing access token was shown as the Kotlin case name. Treat not-signed-in as a normal signed-out path and prefer errorDescription for real failures. Co-authored-by: Tienson Qin --- Sources/LogseqChatModel/Authentication.swift | 36 +++++++++++++++---- .../AuthenticationTests.swift | 18 ++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/Sources/LogseqChatModel/Authentication.swift b/Sources/LogseqChatModel/Authentication.swift index 4fa45e68..efeb190d 100644 --- a/Sources/LogseqChatModel/Authentication.swift +++ b/Sources/LogseqChatModel/Authentication.swift @@ -15,7 +15,7 @@ public enum LogseqAuthenticationState: String, Sendable { case signingOut } -public enum LogseqAuthenticationError: Error, LocalizedError { +public enum LogseqAuthenticationError: Error, LocalizedError, Equatable { case notSignedIn public var errorDescription: String? { @@ -52,9 +52,12 @@ public enum LogseqAuthenticationError: Error, LocalizedError { } state = .signedIn onAccessToken(token) + } catch is LogseqAuthenticationError { + state = .signedOut + onAccessToken(nil) } catch { state = .signedOut - errorMessage = error.localizedDescription + errorMessage = Self.displayedMessage(for: error) onAccessToken(nil) } } @@ -71,7 +74,7 @@ public enum LogseqAuthenticationError: Error, LocalizedError { onAccessToken(token) } catch { state = .signedOut - errorMessage = error.localizedDescription + errorMessage = Self.displayedMessage(for: error) onAccessToken(nil) } } @@ -82,7 +85,7 @@ public enum LogseqAuthenticationError: Error, LocalizedError { do { try await provider.signOut() } catch { - errorMessage = error.localizedDescription + errorMessage = Self.displayedMessage(for: error) } state = .signedOut onAccessToken(nil) @@ -92,7 +95,7 @@ public enum LogseqAuthenticationError: Error, LocalizedError { do { guard let token = try await provider.accessToken(), !token.isEmpty else { state = .signedOut - errorMessage = LogseqAuthenticationError.notSignedIn.localizedDescription + errorMessage = nil onAccessToken(nil) throw LogseqAuthenticationError.notSignedIn } @@ -100,11 +103,32 @@ public enum LogseqAuthenticationError: Error, LocalizedError { errorMessage = nil onAccessToken(token) return token + } catch is LogseqAuthenticationError { + state = .signedOut + errorMessage = nil + onAccessToken(nil) + throw LogseqAuthenticationError.notSignedIn } catch { state = .signedOut - errorMessage = error.localizedDescription + errorMessage = Self.displayedMessage(for: error) onAccessToken(nil) throw error } } + + private static func displayedMessage(for error: Error) -> String { + if let authenticationError = error as? LogseqAuthenticationError { + return authenticationError.errorDescription ?? "Sign in to connect to Logseq Sync." + } + if let localized = error as? LocalizedError, + let description = localized.errorDescription, + !description.isEmpty { + return description + } + let description = error.localizedDescription + if description.contains("$") || description.contains("Case") { + return "Something went wrong. Try signing in again." + } + return description + } } diff --git a/Tests/LogseqChatModelTests/AuthenticationTests.swift b/Tests/LogseqChatModelTests/AuthenticationTests.swift index 97bcda60..12aec38e 100644 --- a/Tests/LogseqChatModelTests/AuthenticationTests.swift +++ b/Tests/LogseqChatModelTests/AuthenticationTests.swift @@ -103,6 +103,24 @@ private struct FailingCognitoProvider: LogseqCognitoProviding { #expect(await provider.recordedSignOutCount() == 1) } + @Test @MainActor func missingAccessTokenDoesNotSurfaceARawErrorOnTheSignInScreen() async { + let auth = LogseqAuthenticationStore(provider: FakeCognitoProvider(token: nil)) + + await auth.restore() + #expect(auth.state == .signedOut) + #expect(auth.errorMessage == nil) + + do { + _ = try await auth.accessToken() + #expect(1 == 0, "Expected a missing access token to throw") + } catch is LogseqAuthenticationError { + #expect(auth.state == .signedOut) + #expect(auth.errorMessage == nil) + } catch { + #expect(1 == 0, "Expected LogseqAuthenticationError.notSignedIn") + } + } + @Test @MainActor func accessTokenFailureUpdatesObservableAuthenticationState() async { var published: [String?] = [] let auth = LogseqAuthenticationStore(provider: FailingCognitoProvider()) { token in