From 565f601ee6bda760b09e5916e39808d6758f953a Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Wed, 14 Oct 2026 18:35:08 +0530 Subject: [PATCH 1/6] fix: ios review issues --- dev/sync.js | 2 +- package.json | 2 +- platforms/ios/runner/Info.plist | 2 + .../runner/lib/network/AppAPIHandler.swift | 60 +++++---- .../lib/security/AppAuthentication.swift | 69 ++++++++--- .../lib/security/AuthenticatorService.swift | 30 +++-- .../ios/runnerTests/AuthenticationTests.swift | 100 +++++++++++---- .../ios/runnerTests/IOSBetaUITests.swift | 6 +- platforms/ios/runnerTests/SplashTests.swift | 2 - src/components/sidebar/index.js | 36 +++++- src/index.html | 21 ---- src/lib/auth.js | 38 ++++-- src/pages/changelog/changelog.js | 6 +- src/pages/welcome/welcome.js | 5 +- src/pages/welcome/welcome.scss | 12 -- src/platforms/ios/api.ts | 3 +- src/platforms/ios/apiXHR.ts | 5 + tests/unit/auth.test.js | 116 ++++++++++++++++++ tests/unit/iosApi.test.ts | 21 +++- tests/unit/iosBetaBadge.test.js | 40 ------ tests/unit/sync.test.js | 5 +- 21 files changed, 403 insertions(+), 178 deletions(-) create mode 100644 tests/unit/auth.test.js delete mode 100644 tests/unit/iosBetaBadge.test.js diff --git a/dev/sync.js b/dev/sync.js index c36fe79..c7fc7e7 100644 --- a/dev/sync.js +++ b/dev/sync.js @@ -57,7 +57,7 @@ function patchProjectFile(pkg) { function patchDisplayName(pkg) { patchFile(infoPlistFile, (content) => content.replace( - /(CFBundleDisplayName<\/key>\s*\n\s*)[^<]*(<\/string>)/, + /(CFBundle(?:Display)?Name<\/key>\s*\n\s*)[^<]*(<\/string>)/g, `$1${pkg.displayName}$2`, ), ); diff --git a/package.json b/package.json index 66632b2..4cbce72 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "acode-app", "displayName": "Acode", "version": "1.13.6", - "versionCode": 1016, + "versionCode": 1017, "androidPackageId": "com.foxdebug.acode", "appleAppId": "app.acode", "description": "Powerful code editor and terminal for IOS and Android", diff --git a/platforms/ios/runner/Info.plist b/platforms/ios/runner/Info.plist index fde681c..c8aef10 100644 --- a/platforms/ios/runner/Info.plist +++ b/platforms/ios/runner/Info.plist @@ -14,6 +14,8 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleName + Acode CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString diff --git a/platforms/ios/runner/lib/network/AppAPIHandler.swift b/platforms/ios/runner/lib/network/AppAPIHandler.swift index 9c4e784..6afe5e0 100644 --- a/platforms/ios/runner/lib/network/AppAPIHandler.swift +++ b/platforms/ios/runner/lib/network/AppAPIHandler.swift @@ -13,33 +13,12 @@ final class AppAPIHandler { func start(_ task: WKURLSchemeTask) { let id = ObjectIdentifier(task) do { - guard let source = task.request.url, source.scheme == "acode", source.host == "localhost", source.path.hasPrefix("/__api__/") else { throw URLError(.badURL) } - var destination = URLComponents(url: origin, resolvingAgainstBaseURL: false)! - let incoming = URLComponents(url: source, resolvingAgainstBaseURL: false)! - destination.percentEncodedPath = "/api/" + incoming.percentEncodedPath.dropFirst("/__api__/".count) - destination.percentEncodedQuery = incoming.percentEncodedQuery - guard let url = destination.url else { throw URLError(.badURL) } - var request = task.request - request.url = url - request.mainDocumentURL = origin - let credentials = request.value(forHTTPHeaderField: "X-Acode-Credentials") == "include" - let redirects = request.value(forHTTPHeaderField: "X-Acode-Redirect") ?? "follow" - request.httpShouldHandleCookies = credentials - for key in (request.allHTTPHeaderFields ?? [:]).keys where ["host", "origin", "referer", "cookie", "content-length"].contains(key.lowercased()) || key.lowercased().hasPrefix("x-acode-") || key.lowercased().hasPrefix("sec-") { - request.setValue(nil, forHTTPHeaderField: key) - } - request.setValue("https://localhost", forHTTPHeaderField: "Origin") - request.setValue("https://localhost/", forHTTPHeaderField: "Referer") - if credentials, AppAuthentication.accepts(url) { - let token = try auth.secrets.get("auth_token") - if !token.isEmpty { - var cookies = (HTTPCookieStorage.shared.cookies(for: url) ?? []).filter { $0.name != "token" } - if let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: url.host!, .path: "/", .secure: "TRUE"]) { cookies.append(cookie) } - request.setValue(HTTPCookie.requestHeaderFields(with: cookies)["Cookie"], forHTTPHeaderField: "Cookie") - } - } + let request = try request(for: task.request) + let source = task.request.url! + let url = request.url! + let redirects = task.request.value(forHTTPHeaderField: "X-Acode-Redirect") ?? "follow" let transport = HTTPTransport(request: request, followRedirects: redirects == "follow", connectTimeout: 30, readTimeout: 60, cookieStorage: .shared) - transport.allowRedirect = { [origin] next in next.scheme == origin.scheme && next.host == origin.host && next.port == origin.port && next.user == nil && next.password == nil } + transport.allowRedirect = { next in next.scheme == url.scheme && next.host == url.host && next.port == url.port && next.user == nil && next.password == nil } transport.onResponse = { response in if redirects == "error", (300..<400).contains(response.statusCode) { throw URLError(.redirectToNonExistentLocation) } var headers: [String: String] = [:] @@ -66,4 +45,33 @@ final class AppAPIHandler { } func stop(_ task: WKURLSchemeTask) { requests.removeValue(forKey: ObjectIdentifier(task))?.cancel() } + + func request(for incomingRequest: URLRequest) throws -> URLRequest { + guard let source = incomingRequest.url, source.scheme == "acode", source.host == "localhost", source.path.hasPrefix("/__api__/") else { throw URLError(.badURL) } + let origin = try incomingRequest.value(forHTTPHeaderField: "X-Acode-API-Origin").map(AppAuthentication.baseURL) ?? self.origin + var destination = URLComponents(url: origin, resolvingAgainstBaseURL: false)! + let incoming = URLComponents(url: source, resolvingAgainstBaseURL: false)! + destination.percentEncodedPath = "/api/" + incoming.percentEncodedPath.dropFirst("/__api__/".count) + destination.percentEncodedQuery = incoming.percentEncodedQuery + guard let url = destination.url else { throw URLError(.badURL) } + var request = incomingRequest + request.url = url + request.mainDocumentURL = origin + let credentials = request.value(forHTTPHeaderField: "X-Acode-Credentials") == "include" + let trusted = AppAuthentication.accepts(url) + request.httpShouldHandleCookies = credentials && !trusted + for key in (request.allHTTPHeaderFields ?? [:]).keys where ["host", "origin", "referer", "cookie", "content-length"].contains(key.lowercased()) || key.lowercased().hasPrefix("x-acode-") || key.lowercased().hasPrefix("sec-") { + request.setValue(nil, forHTTPHeaderField: key) + } + request.setValue("https://localhost", forHTTPHeaderField: "Origin") + request.setValue("https://localhost/", forHTTPHeaderField: "Referer") + if credentials, trusted { + // A parent-domain cookie must not override the token for the selected environment. + var cookies = (HTTPCookieStorage.shared.cookies(for: url) ?? []).filter { $0.name != "token" } + let token = try auth.token(for: url) + if !token.isEmpty, let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: url.host!, .path: "/", .secure: "TRUE"]) { cookies.append(cookie) } + request.setValue(HTTPCookie.requestHeaderFields(with: cookies)["Cookie"], forHTTPHeaderField: "Cookie") + } + return request + } } diff --git a/platforms/ios/runner/lib/security/AppAuthentication.swift b/platforms/ios/runner/lib/security/AppAuthentication.swift index e793be4..cf3a620 100644 --- a/platforms/ios/runner/lib/security/AppAuthentication.swift +++ b/platforms/ios/runner/lib/security/AppAuthentication.swift @@ -4,25 +4,24 @@ import WebKit struct AppAuthentication { static let origin = "https://acode.app" + static let hosts = ["acode.app", "dev.acode.app"] let secrets: SecretStore init(namespace: String = "acode_auth_secure") { secrets = SecretStore(namespace: namespace) } static func accepts(_ url: URL) -> Bool { - url.scheme == "https" && url.host == "acode.app" && (url.port == nil || url.port == 443) && url.user == nil && url.password == nil + url.scheme == "https" && hosts.contains(url.host ?? "") && (url.port == nil || url.port == 443) && url.user == nil && url.password == nil } func begin(baseURL: String, version: Int) throws -> URL { - guard let base = URL(string: baseURL), Self.accepts(base), ["", "/"].contains(base.path), base.query == nil, base.fragment == nil else { - throw SecretFailure("Unsupported sign-in URL") - } + let base = try Self.baseURL(baseURL) let state = try SecureToken.generate(bytes: 24) let verifier = try SecureToken.generate() let challenge = SHA256.hash(data: Data(verifier.utf8)).map { String(format: "%02x", $0) }.joined() - let pending = try JSONSerialization.data(withJSONObject: ["state": state, "verifier": verifier]) + let pending = try JSONSerialization.data(withJSONObject: ["state": state, "verifier": verifier, "origin": base.absoluteString]) try secrets.set("pending_login", value: String(decoding: pending, as: UTF8.self)) - var url = URLComponents(string: Self.origin + "/login")! - url.queryItems = ["redirect": "app", "authFlow": "app-code", "state": state, "challenge": challenge, "appVersionCode": String(version)].map { URLQueryItem(name: $0.key, value: $0.value) } + var url = URLComponents(url: base.appendingPathComponent("login"), resolvingAgainstBaseURL: false)! + url.queryItems = ["redirect": "app", "authFlow": "app-code", "state": state, "challenge": challenge, "appVersionCode": String(version), "src": "ios"].map { URLQueryItem(name: $0.key, value: $0.value) } return url.url! } @@ -36,8 +35,9 @@ struct AppAuthentication { SecureToken.matches(state, pending["state"]), let verifier = pending["verifier"], !verifier.isEmpty else { throw SecretFailure("Invalid sign-in callback") } + let base = try Self.baseURL(pending["origin"] ?? Self.origin) try cancel() - var request = URLRequest(url: URL(string: Self.origin + "/api/user/app-token/exchange")!) + var request = URLRequest(url: base.appendingPathComponent("api/user/app-token/exchange")) request.httpMethod = "POST" request.httpShouldHandleCookies = false request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -45,28 +45,63 @@ struct AppAuthentication { return request } - func save(_ token: String) throws { + func token(for url: URL) throws -> String { try secrets.get(Self.tokenKey(for: url)) } + + static func accountURL(baseURL: String) throws -> URL { + let base = try Self.baseURL(baseURL) + var url = URLComponents(url: base.appendingPathComponent("profile/edit"), resolvingAgainstBaseURL: false)! + url.queryItems = [URLQueryItem(name: "src", value: "ios")] + return url.url! + } + + func save(_ token: String, for url: URL = URL(string: Self.origin)!) throws { guard !token.isEmpty, token.utf8.allSatisfy({ $0 == 0x21 || (0x23...0x2b).contains($0) || (0x2d...0x3a).contains($0) || (0x3c...0x5b).contains($0) || (0x5d...0x7e).contains($0) }) else { throw SecretFailure("Invalid sign-in token") } - try secrets.set("auth_token", value: token) + try secrets.set(Self.tokenKey(for: url), value: token) } func cancel() throws { try secrets.remove("pending_login") } - func logout() throws { try cancel(); try secrets.remove("auth_token") } + func logout() throws { try cancel(); try secrets.remove("auth_token"); try secrets.remove("auth_token_dev") } @MainActor func synchronizeCookies(_ store: WKHTTPCookieStore) async throws { - for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == "token" && ["acode.app", ".acode.app", "dev.acode.app"].contains(cookie.domain) { + for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == "token" && Self.hosts.contains(cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) { HTTPCookieStorage.shared.deleteCookie(cookie) } - for cookie in await store.allCookies() where cookie.name == "token" && ["acode.app", ".acode.app", "dev.acode.app"].contains(cookie.domain) { + for cookie in await store.allCookies() where cookie.name == "token" && Self.hosts.contains(cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) { await store.deleteCookie(cookie) } - let token = try secrets.get("auth_token") - if !token.isEmpty, let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: ".acode.app", .path: "/", .secure: "TRUE", .expires: Date.distantFuture, HTTPCookiePropertyKey("HttpOnly"): "TRUE", HTTPCookiePropertyKey("SameSite"): "None"]) { - await store.setCookie(cookie) - HTTPCookieStorage.shared.setCookie(cookie) + for host in Self.hosts { + let token = try token(for: URL(string: "https://" + host)!) + if !token.isEmpty, let cookie = HTTPCookie(properties: [.name: "token", .value: token, .domain: host, .path: "/", .secure: "TRUE", .expires: Date.distantFuture, HTTPCookiePropertyKey("HttpOnly"): "TRUE", HTTPCookiePropertyKey("SameSite"): "None"]) { + await store.setCookie(cookie) + HTTPCookieStorage.shared.setCookie(cookie) + } } } + + static func baseURL(_ value: String) throws -> URL { + guard let url = URL(string: value), accepts(url), ["", "/"].contains(url.path), url.query == nil, url.fragment == nil else { + throw SecretFailure("Unsupported sign-in URL") + } + return URL(string: "https://" + url.host!)! + } + + static func exchangeFailure(_ response: HTTPURLResponse) -> SecretFailure { + if response.statusCode == 429 { + if let seconds = Int(response.value(forHTTPHeaderField: "Retry-After") ?? ""), seconds > 0 { + let minutes = (min(seconds, 86400) + 59) / 60 + return SecretFailure("Too many sign-in attempts. Please wait \(minutes) minute(s) and try again.") + } + return SecretFailure("Too many sign-in attempts. Please wait before trying again.") + } + if [400, 401].contains(response.statusCode) { return SecretFailure("Sign-in expired. Please try again.") } + return SecretFailure("The sign-in service is unavailable. Please try again later.") + } + + private static func tokenKey(for url: URL) throws -> String { + guard accepts(url) else { throw SecretFailure("Unsupported sign-in URL") } + return url.host == "acode.app" ? "auth_token" : "auth_token_dev" + } } diff --git a/platforms/ios/runner/lib/security/AuthenticatorService.swift b/platforms/ios/runner/lib/security/AuthenticatorService.swift index e319bed..f804868 100644 --- a/platforms/ios/runner/lib/security/AuthenticatorService.swift +++ b/platforms/ios/runner/lib/security/AuthenticatorService.swift @@ -36,23 +36,35 @@ private final class SignInCoordinator: NSObject, ASWebAuthenticationPresentation func exec(action: String, args: [Any], callback: Callback) { switch action { - case "login": + case "login", "openAccount": cancel() do { let options = args[safe: 0] as? [String: Any] ?? [:] - let url = try auth.begin(baseURL: options["baseUrl"] as? String ?? AppAuthentication.origin, version: options["appVersionCode"] as? Int ?? 0) + let account = action == "openAccount" + let baseURL = options["baseUrl"] as? String ?? AppAuthentication.origin + let url = try account ? AppAuthentication.accountURL(baseURL: baseURL) : auth.begin(baseURL: baseURL, version: options["appVersionCode"] as? Int ?? 0) self.callback = callback let id = operation let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "acode") { [weak self] url, error in Task { @MainActor in guard let self, self.operation == id else { return } - if let url { self.receive(url) } + if account { + if let error, (error as? ASWebAuthenticationSessionError)?.code != .canceledLogin { + self.fail(error.localizedDescription); return + } + self.callback = nil; self.session = nil + callback.success() + } + else if let url { self.receive(url) } + else if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { + self.fail("Sign-in was cancelled. You can try again.") + } else { self.fail(error?.localizedDescription ?? "Sign-in cancelled") } } } session.presentationContextProvider = self self.session = session - if !session.start() { fail("Unable to open sign-in") } + if !session.start() { fail(account ? "Unable to open account settings" : "Unable to open sign-in") } } catch { self.callback = nil; callback.error(error.localizedDescription) } case "saveToken", "logout": cancel() @@ -76,7 +88,7 @@ private final class SignInCoordinator: NSObject, ASWebAuthenticationPresentation let transport = HTTPTransport(request: request, followRedirects: false, connectTimeout: 15, readTimeout: 30) var data = Data() transport.onResponse = { response in - guard (200..<300).contains(response.statusCode) else { throw SecretFailure("Unable to complete sign-in") } + guard (200..<300).contains(response.statusCode) else { throw AppAuthentication.exchangeFailure(response) } } transport.onData = { bytes in guard data.count + bytes.count <= 65536 else { throw SecretFailure("Invalid sign-in response") } @@ -86,15 +98,17 @@ private final class SignInCoordinator: NSObject, ASWebAuthenticationPresentation Task { @MainActor in guard let self, self.operation == id else { return } do { - if error != nil { throw SecretFailure("Unable to complete sign-in") } + if let error { throw error } let response = try JSONSerialization.jsonObject(with: data) as? [String: Any] - try self.auth.save(response?["token"] as? String ?? "") + try self.auth.save(response?["token"] as? String ?? "", for: request.url!) if let store = self.bridge?.webView?.configuration.websiteDataStore.httpCookieStore { try await self.auth.synchronizeCookies(store) } guard self.operation == id else { return } let callback = self.callback self.callback = nil; self.exchange = nil; self.session = nil callback?.success() - } catch { self.fail("Unable to complete sign-in") } + } catch { + self.fail((error as? SecretFailure)?.message ?? "Sign-in could not be completed. Please check your connection and try again.") + } } } exchange = transport diff --git a/platforms/ios/runnerTests/AuthenticationTests.swift b/platforms/ios/runnerTests/AuthenticationTests.swift index 3a6f42a..ad31877 100644 --- a/platforms/ios/runnerTests/AuthenticationTests.swift +++ b/platforms/ios/runnerTests/AuthenticationTests.swift @@ -1,4 +1,5 @@ import CryptoKit +import WebKit import XCTest @testable import runner @@ -24,30 +25,34 @@ final class AuthenticationTests: XCTestCase { } func testLoginChallengeAndOneTimeExchangeMatchTheExistingServerProtocol() throws { - let url = try auth.begin(baseURL: "https://acode.app", version: 1011) - let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - let values = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value!) }) - XCTAssertEqual(url.path, "/login") - XCTAssertEqual(values["authFlow"], "app-code") - XCTAssertEqual(values["redirect"], "app") - XCTAssertEqual(values["appVersionCode"], "1011") - XCTAssertEqual(values["state"]?.count, 48) - let callback = URL(string: "acode://auth/callback?state=\(values["state"]!)&code=test-code")! - let request = try auth.consume(callback) - let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: request.httpBody!) as? [String: String]) - XCTAssertEqual(request.url?.absoluteString, "https://acode.app/api/user/app-token/exchange") - XCTAssertEqual(request.httpMethod, "POST") - XCTAssertEqual(payload["code"], "test-code") - XCTAssertEqual(payload["state"], values["state"]) - XCTAssertEqual(payload["verifier"]?.count, 64) - let digest = SHA256.hash(data: Data(payload["verifier"]!.utf8)).map { String(format: "%02x", $0) }.joined() - XCTAssertEqual(values["challenge"], digest) - XCTAssertThrowsError(try auth.consume(callback)) - XCTAssertEqual(try auth.secrets.get("pending_login"), "") + for origin in ["https://acode.app", "https://dev.acode.app"] { + let url = try auth.begin(baseURL: origin, version: 1011) + let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + let values = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value!) }) + XCTAssertEqual(url.host, URL(string: origin)?.host) + XCTAssertEqual(url.path, "/login") + XCTAssertEqual(values["authFlow"], "app-code") + XCTAssertEqual(values["redirect"], "app") + XCTAssertEqual(values["src"], "ios") + XCTAssertEqual(values["appVersionCode"], "1011") + XCTAssertEqual(values["state"]?.count, 48) + let callback = URL(string: "acode://auth/callback?state=\(values["state"]!)&code=test-code")! + let request = try auth.consume(callback) + let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: request.httpBody!) as? [String: String]) + XCTAssertEqual(request.url?.absoluteString, origin + "/api/user/app-token/exchange") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(payload["code"], "test-code") + XCTAssertEqual(payload["state"], values["state"]) + XCTAssertEqual(payload["verifier"]?.count, 64) + let digest = SHA256.hash(data: Data(payload["verifier"]!.utf8)).map { String(format: "%02x", $0) }.joined() + XCTAssertEqual(values["challenge"], digest) + XCTAssertThrowsError(try auth.consume(callback)) + XCTAssertEqual(try auth.secrets.get("pending_login"), "") + } } func testRejectsForgedCallbacksUntrustedOriginsAndInvalidCookieTokens() throws { - for value in ["http://acode.app", "https://acode.app.evil.test", "https://acode.app:8443", "https://user@acode.app", "https://acode.app/path"] { + for value in ["http://acode.app", "https://acode.app.evil.test", "https://acode.app:8443", "https://user@acode.app", "https://acode.app/path", "http://dev.acode.app", "https://dev.acode.app.evil.test", "https://dev.acode.app:8443", "https://dev.acode.app?next=other"] { XCTAssertThrowsError(try auth.begin(baseURL: value, version: 1)) } let url = try auth.begin(baseURL: "https://acode.app", version: 1) @@ -62,4 +67,57 @@ final class AuthenticationTests: XCTestCase { XCTAssertEqual(try auth.secrets.get("auth_token"), "") XCTAssertEqual(try auth.secrets.get("pending_login"), "") } + + @MainActor + func testAPIRoutingKeepsDevelopmentAndProductionCredentialsSeparate() async throws { + let production = URL(string: "https://acode.app")! + let development = URL(string: "https://dev.acode.app")! + let previous = (HTTPCookieStorage.shared.cookies ?? []).filter { $0.name == "token" && AppAuthentication.hosts.contains($0.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) } + let unrelated = HTTPCookie(properties: [.name: "review-fixture-" + UUID().uuidString, .value: "other", .domain: "acode.app", .path: "/"])! + HTTPCookieStorage.shared.setCookie(unrelated) + defer { + HTTPCookieStorage.shared.deleteCookie(unrelated) + for cookie in HTTPCookieStorage.shared.cookies ?? [] where cookie.name == "token" && AppAuthentication.hosts.contains(cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) { + HTTPCookieStorage.shared.deleteCookie(cookie) + } + previous.forEach(HTTPCookieStorage.shared.setCookie) + } + try auth.save("production-token") + try auth.save("development-token", for: development) + let dataStore = WKWebsiteDataStore.nonPersistent() + try await auth.synchronizeCookies(dataStore.httpCookieStore) + let webCookies = await dataStore.httpCookieStore.allCookies().filter { $0.name == "token" } + XCTAssertEqual(Set(webCookies.map(\.domain)), Set(AppAuthentication.hosts)) + let api = AppAPIHandler(auth: auth) + var source = URLRequest(url: URL(string: "acode://localhost/__api__/login?test=one%20two")!) + source.setValue("include", forHTTPHeaderField: "X-Acode-Credentials") + source.setValue("token=untrusted", forHTTPHeaderField: "Cookie") + for (origin, token) in [(production, "production-token"), (development, "development-token")] { + XCTAssertEqual((HTTPCookieStorage.shared.cookies(for: origin) ?? []).filter { $0.name == "token" }.map(\.value), [token]) + source.setValue(origin.absoluteString, forHTTPHeaderField: "X-Acode-API-Origin") + let request = try api.request(for: source) + XCTAssertEqual(request.url?.absoluteString, origin.absoluteString + "/api/login?test=one%20two") + let tokens = (request.value(forHTTPHeaderField: "Cookie") ?? "").components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) }.filter { $0.hasPrefix("token=") } + XCTAssertEqual(tokens, ["token=" + token]) + XCTAssertFalse(request.httpShouldHandleCookies) + XCTAssertNil(request.value(forHTTPHeaderField: "X-Acode-API-Origin")) + } + source.setValue("omit", forHTTPHeaderField: "X-Acode-Credentials") + XCTAssertNil(try api.request(for: source).value(forHTTPHeaderField: "Cookie")) + source.setValue("https://dev.acode.app.evil.test", forHTTPHeaderField: "X-Acode-API-Origin") + XCTAssertThrowsError(try api.request(for: source)) + source.setValue(nil, forHTTPHeaderField: "X-Acode-API-Origin") + XCTAssertEqual(try api.request(for: source).url?.host, production.host) + try auth.logout() + XCTAssertEqual(try auth.token(for: production), "") + XCTAssertEqual(try auth.token(for: development), "") + } + + func testExchangeErrorsExplainRetryTimingAndExpiredSignIn() { + let url = URL(string: "https://dev.acode.app/api/user/app-token/exchange")! + let limited = HTTPURLResponse(url: url, statusCode: 429, httpVersion: nil, headerFields: ["Retry-After": "61"])! + XCTAssertEqual(AppAuthentication.exchangeFailure(limited).message, "Too many sign-in attempts. Please wait 2 minute(s) and try again.") + let expired = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)! + XCTAssertEqual(AppAuthentication.exchangeFailure(expired).message, "Sign-in expired. Please try again.") + } } diff --git a/platforms/ios/runnerTests/IOSBetaUITests.swift b/platforms/ios/runnerTests/IOSBetaUITests.swift index 35af008..037ef85 100644 --- a/platforms/ios/runnerTests/IOSBetaUITests.swift +++ b/platforms/ios/runnerTests/IOSBetaUITests.swift @@ -4,17 +4,17 @@ import WebKit @MainActor final class IOSBetaUITests: BridgeTestCase { - func testHomeBadgeAndTerminalInfoFollowActiveTab() async throws { + func testHomeAndTerminalInfoFollowActiveTab() async throws { let webView = try await editorWebView() let home = try await webView.callAsyncJavaScript(""" await acode.exec('welcome'); editorManager.getFile('welcome-tab').makeActive(); const badge=editorManager.getFile('welcome-tab').content.shadowRoot.querySelector('.beta-badge'); - return badge?.textContent === 'Beta' && getComputedStyle(badge).display !== 'none' + return !badge && !editorManager.header.querySelector('[data-action="terminal-info"]'); """, arguments: [:], in: nil, contentWorld: .page) as? Bool XCTAssertEqual(home, true) - await capture(webView, name: "iOS home Beta badge") + await capture(webView, name: "iOS home") defer { webView.evaluateJavaScript("document.querySelector('.prompt.alert button')?.click();editorManager.getFile('ios-beta-terminal')?.remove(true);editorManager.getFile('ios-beta-editor')?.remove(true);editorManager.getFile('welcome-tab')?.makeActive();") diff --git a/platforms/ios/runnerTests/SplashTests.swift b/platforms/ios/runnerTests/SplashTests.swift index fda1ea3..5a234d9 100644 --- a/platforms/ios/runnerTests/SplashTests.swift +++ b/platforms/ios/runnerTests/SplashTests.swift @@ -54,8 +54,6 @@ final class SplashTests: BridgeTestCase { } let loading = try await webView.evaluateJavaScript("document.body.classList.contains('loading')") as? Bool XCTAssertEqual(loading, true) - let badge = try await webView.evaluateJavaScript("const badge=document.querySelector('.splash-beta');badge.textContent==='Beta'&&getComputedStyle(badge).display!=='none'") as? Bool - XCTAssertEqual(badge, true) XCTAssertEqual(controller.view.backgroundColor, color, saved) XCTAssertEqual(webView.backgroundColor, color, saved) XCTAssertEqual(webView.window?.backgroundColor, color, saved) diff --git a/src/components/sidebar/index.js b/src/components/sidebar/index.js index 670ea19..5b734c8 100644 --- a/src/components/sidebar/index.js +++ b/src/components/sidebar/index.js @@ -7,6 +7,8 @@ import Ref from "html-tag-js/ref"; import actionStack from "lib/actionStack"; import auth, { loginEvents } from "lib/auth"; import config from "lib/config"; +import customTab from "lib/customTab"; +import platform from "lib/platform"; import helpers from "utils/helpers"; import Url from "utils/Url"; @@ -73,7 +75,10 @@ function create($container, $toggler) {
- {/*
*/} +
+ + {strings.settings} +
{strings.logout} @@ -127,7 +132,7 @@ function create($container, $toggler) { await auth.login(); user = await auth.getLoggedInUser(); if (!user) { - return; + throw new Error("Sign-in did not complete. Please try again."); } } @@ -162,11 +167,34 @@ function create($container, $toggler) { } } catch (error) { console.error("Error checking login status:", error); + helpers.error(error); } finally { loader.destroy(); } } + async function handleAccountSettings() { + userContextMenu.el.classList.remove("active"); + document.removeEventListener("click", handleClickOutside); + try { + if (platform.isIOS) { + const user = await auth.openAccount(); + if (user === undefined) { + toast( + "Unable to refresh account status. Please check your connection.", + ); + return; + } + await updateSidebarAvatar(user); + if (!user) toast("You are signed out. Sign in again to continue."); + } else { + await customTab(`${config.BASE_URL}/profile/edit`); + } + } catch (error) { + helpers.error(error); + } + } + function handleClickOutside(e) { if ( !userContextMenu.el.contains(e.target) && @@ -206,9 +234,9 @@ function create($container, $toggler) { } } - async function updateSidebarAvatar() { + async function updateSidebarAvatar(user) { const defaultAvatar = ; - const user = await auth.getLoggedInUser(); + if (user === undefined) user = await auth.getLoggedInUser(); userAvatar.content = defaultAvatar; diff --git a/src/index.html b/src/index.html index 1bb9cf6..4ad64da 100644 --- a/src/index.html +++ b/src/index.html @@ -63,26 +63,6 @@ text-align: left; } - .splash-beta { - display: none; - position: absolute; - top: calc(50% + 110px); - left: 50%; - transform: translateX(-50%); - padding: 4px 12px; - border: 1px solid currentColor; - border-radius: 16px; - color: var(--primary-text-color, rgb(209, 209, 209)); - background-color: var(--secondary-color, #20202c); - font: 600 11px/1.4 sans-serif; - letter-spacing: 1px; - text-transform: uppercase; - } - - html[data-platform="ios"] .splash-beta { - display: inline-block; - } - .splash-message { position: absolute; bottom: 15px; @@ -226,7 +206,6 @@
- Beta
diff --git a/src/lib/auth.js b/src/lib/auth.js index 5ed9f2f..b3b4e81 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -1,4 +1,5 @@ import config from "./config"; +import platform from "./platform"; /** * @typedef {object} User @@ -59,7 +60,7 @@ class AuthService { clearTimeout(this.#loginTimeout); this.#loginTimeout = setTimeout(() => { for (const callback of this.#loginCallbacks) { - callback.reject("Login timed out"); + callback.reject(new Error("Sign-in timed out. Please try again.")); } this.#loginCallbacks.clear(); @@ -80,6 +81,8 @@ class AuthService { try { const res = await fetch(`${config.API_BASE}/login`, { method: "DELETE", + credentials: "include", + cache: "no-store", }); if (!res.ok) { throw new Error("Unable to logout."); @@ -100,6 +103,11 @@ class AuthService { } } + async openAccount() { + await this.#exec("openAccount", [{ baseUrl: config.BASE_URL }]); + return this.getLoggedInUser(true).catch(() => undefined); + } + /** * @param {boolean} forceFetch * @returns {Promise} @@ -108,7 +116,10 @@ class AuthService { if (loggedInUser && !forceFetch) return loggedInUser; try { - const res = await fetch(`${config.API_BASE}/login`); + const res = await fetch(`${config.API_BASE}/login`, { + credentials: "include", + cache: "no-store", + }); if (res.ok) { loggedInUser = await res.json(); @@ -119,13 +130,15 @@ class AuthService { } if (res.status === 401) { + loggedInUser = null; + clearTimeout(cacheTimeout); localStorage.removeItem(CACHE_USER_KEY); return null; } throw new Error("Unable to fetch user Info"); } catch (error) { - if (CACHE_USER_KEY in localStorage) { + if (!forceFetch && CACHE_USER_KEY in localStorage) { try { return JSON.parse(localStorage.getItem(CACHE_USER_KEY)); } catch {} @@ -139,19 +152,30 @@ class AuthService { return new Promise((resolve, reject) => { const callback = { resolve, reject }; this.#loginCallbacks.add(callback); + if (this.#loginCallbacks.size > 1) return; this.#exec("login", [ { baseUrl: config.BASE_URL, appVersionCode: window.BuildInfo?.versionCode || 0, }, ]) - .then(() => { - loginEvents.emit(); + .then(async () => { + if (!this.#loginCallbacks.has(callback)) return; + if (platform.isIOS && !(await this.getLoggedInUser(true))) { + throw new Error("Sign-in did not complete. Please try again."); + } + if (this.#loginCallbacks.has(callback)) loginEvents.emit(); }) .catch((err) => { + if (!this.#loginCallbacks.has(callback)) return; console.error("Native login error", err); - this.#loginCallbacks.delete(callback); - reject("Failed to login"); + clearTimeout(this.#loginTimeout); + const error = + err instanceof Error + ? err + : new Error(String(err || "Sign-in failed. Please try again.")); + for (const pending of this.#loginCallbacks) pending.reject(error); + this.#loginCallbacks.clear(); }); }); } diff --git a/src/pages/changelog/changelog.js b/src/pages/changelog/changelog.js index fc694b8..42187ea 100644 --- a/src/pages/changelog/changelog.js +++ b/src/pages/changelog/changelog.js @@ -6,6 +6,7 @@ import toast from "components/toast"; import DOMPurify from "dompurify"; import Ref from "html-tag-js/ref"; import actionStack from "lib/actionStack"; +import platform from "lib/platform"; import markdownIt from "markdown-it"; import markdownItFootnote from "markdown-it-footnote"; import markdownItTaskLists from "markdown-it-task-lists"; @@ -52,9 +53,7 @@ export default async function Changelog() {
  • Latest Release
  • -
  • - Beta Version -
  • + ${platform.isIOS ? "" : `
  • Beta Version
  • `}
  • Full Changelog
  • @@ -186,6 +185,7 @@ export default async function Changelog() { await loadLatestRelease(); break; case "beta": + if (platform.isIOS) return; await loadBetaRelease(); break; case "full": diff --git a/src/pages/welcome/welcome.js b/src/pages/welcome/welcome.js index ef6c725..191e599 100644 --- a/src/pages/welcome/welcome.js +++ b/src/pages/welcome/welcome.js @@ -47,10 +47,7 @@ function createWelcomeContent() {
    -

    - Welcome to Acode - {platform.isIOS && Beta} -

    +

    Welcome to Acode

    Powerful code editor for {platform.isIOS ? "iOS" : "Android"}

    diff --git a/src/pages/welcome/welcome.scss b/src/pages/welcome/welcome.scss index 98e5280..7b0f647 100644 --- a/src/pages/welcome/welcome.scss +++ b/src/pages/welcome/welcome.scss @@ -37,18 +37,6 @@ letter-spacing: -0.3px; } - .beta-badge { - padding: 2px 7px; - border: 1px solid var(--active-color); - border-radius: 12px; - color: var(--active-color); - font-size: 10px; - font-weight: 600; - line-height: 1.4; - letter-spacing: 0.6px; - text-transform: uppercase; - } - .tagline { font-size: 13px; color: color-mix(in srgb, var(--secondary-text-color) 60%, transparent); diff --git a/src/platforms/ios/api.ts b/src/platforms/ios/api.ts index 3fa53df..868bd74 100644 --- a/src/platforms/ios/api.ts +++ b/src/platforms/ios/api.ts @@ -13,6 +13,7 @@ export default function installAPITransport() { if (request.mode === "same-origin") throw new TypeError("Cross-origin request is not allowed"); const headers = new Headers(request.headers); + headers.set("X-Acode-API-Origin", new URL(request.url).origin); headers.set("X-Acode-Credentials", request.credentials); headers.set("X-Acode-Redirect", request.redirect); const response = await fetch.call(window, target, { @@ -52,7 +53,7 @@ function localURL(value: string) { try { const url = new URL(value, location.href); if ( - url.origin !== "https://acode.app" || + !["https://acode.app", "https://dev.acode.app"].includes(url.origin) || url.username || url.password || !(url.pathname === "/api" || url.pathname.startsWith("/api/")) diff --git a/src/platforms/ios/apiXHR.ts b/src/platforms/ios/apiXHR.ts index 6dc1b79..b0de391 100644 --- a/src/platforms/ios/apiXHR.ts +++ b/src/platforms/ios/apiXHR.ts @@ -56,6 +56,11 @@ export default function installAPIXHR( "Binary API uploads require asynchronous XHR on iOS", "InvalidAccessError", ); + setRequestHeader.call( + this, + "X-Acode-API-Origin", + new URL(state.url, location.href).origin, + ); setRequestHeader.call( this, "X-Acode-Credentials", diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js new file mode 100644 index 0000000..94860b1 --- /dev/null +++ b/tests/unit/auth.test.js @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +vi.mock("../../src/lib/config", () => ({ default: { BASE_URL: "https://dev.acode.app", API_BASE: "https://dev.acode.app/api" } })); +vi.mock("../../src/lib/platform", () => ({ default: { isIOS: true } })); + +let auth; +let loginEvents; +let exec; +let request; + +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + vi.stubGlobal("document", new EventTarget()); + vi.stubGlobal("window", { BuildInfo: { versionCode: 1016 } }); + vi.stubGlobal("localStorage", { + getItem(key) { return this[key] ?? null; }, + setItem(key, value) { this[key] = value; }, + removeItem(key) { delete this[key]; }, + }); + exec = vi.fn(); + request = vi.fn(); + vi.stubGlobal("Bridge", { exec }); + vi.stubGlobal("fetch", request); + ({ default: auth, loginEvents } = await import("../../src/lib/auth")); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test("coalesces login and verifies a fresh native session before notifying listeners", async () => { + const completed = vi.fn(); + loginEvents.addListener(completed); + request.mockResolvedValue(new Response(JSON.stringify({ id: 7, name: "Reviewer" }))); + const first = auth.login(); + const second = auth.login(); + expect(exec).toHaveBeenCalledTimes(1); + exec.mock.calls[0][0](); + await Promise.all([first, second]); + expect(completed).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith("https://dev.acode.app/api/login", { credentials: "include", cache: "no-store" }); + expect((await auth.getLoggedInUser()).id).toBe(7); +}); + +test("a rejected session clears cached account state and never reports login success", async () => { + request.mockResolvedValueOnce(new Response(JSON.stringify({ id: 7 }))); + await auth.getLoggedInUser(); + const completed = vi.fn(); + loginEvents.addListener(completed); + request.mockImplementation(async () => new Response("{}", { status: 401 })); + const pending = auth.login(); + const rejected = expect(pending).rejects.toThrow("Sign-in did not complete"); + exec.mock.calls[0][0](); + await rejected; + expect(completed).not.toHaveBeenCalled(); + expect(await auth.getLoggedInUser()).toBeNull(); + expect(localStorage["cached-logged-in-user"]).toBeUndefined(); +}); + +test("preserves actionable native errors for every waiter and permits a new attempt", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const message = "Too many sign-in attempts. Please wait 2 minutes and try again."; + const first = expect(auth.login()).rejects.toThrow(message); + const second = expect(auth.login()).rejects.toThrow(message); + exec.mock.calls[0][1](message); + await Promise.all([first, second]); + const retry = expect(auth.login()).rejects.toThrow("Sign-in cancelled"); + expect(exec).toHaveBeenCalledTimes(2); + exec.mock.calls[1][1]("Sign-in cancelled"); + await retry; + + const timedOut = expect(auth.login()).rejects.toThrow("Sign-in timed out"); + document.dispatchEvent(new Event("resume")); + await vi.advanceTimersByTimeAsync(60_000); + await timedOut; + const nextAttempt = expect(auth.login()).rejects.toThrow("Current attempt failed"); + exec.mock.calls[2][1]("Previous attempt cancelled"); + await Promise.resolve(); + exec.mock.calls[3][1]("Current attempt failed"); + await nextAttempt; +}); + +test("verification cannot fall back to an offline cached account", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + localStorage.setItem("cached-logged-in-user", JSON.stringify({ id: 7 })); + request.mockRejectedValue(new Error("Connection failed")); + await expect(auth.getLoggedInUser()).resolves.toEqual({ id: 7 }); + const pending = expect(auth.login()).rejects.toThrow("Connection failed"); + exec.mock.calls[0][0](); + await pending; +}); + +test("account settings use the authentication browser and refresh after it closes", async () => { + request.mockResolvedValue(new Response("{}", { status: 401 })); + const pending = auth.openAccount(); + const [closed, , service, action, args] = exec.mock.calls[0]; + expect([service, action, args]).toEqual(["Authenticator", "openAccount", [{ baseUrl: "https://dev.acode.app" }]]); + expect(request).not.toHaveBeenCalled(); + closed(); + await expect(pending).resolves.toBeNull(); + expect(request).toHaveBeenCalledTimes(1); +}); + +test("closing account settings offline skips refresh without reporting a signed-out session", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + request.mockResolvedValueOnce(new Response(JSON.stringify({ id: 7 }))); + await auth.getLoggedInUser(); + await vi.advanceTimersByTimeAsync(600_000); + request.mockRejectedValue(new Error("Offline")); + const pending = auth.openAccount(); + exec.mock.calls[0][0](); + await expect(pending).resolves.toBeUndefined(); +}); diff --git a/tests/unit/iosApi.test.ts b/tests/unit/iosApi.test.ts index 3a934ed..9735b1d 100644 --- a/tests/unit/iosApi.test.ts +++ b/tests/unit/iosApi.test.ts @@ -48,14 +48,25 @@ test("normalizes multipart XHR and cancels pending body preparation", async () = afterEach(() => vi.unstubAllGlobals()); test("only routes Acode HTTPS API requests through the iOS transport", async () => { - for (const url of ["https://example.com/api/login", "https://acode.app.evil.test/api/login", "http://acode.app/api/login", "https://acode.app:8443/api/login", "https://acode.app/plugins", "acode://localhost/__cdvfile_cache__/file.txt"]) { + for (const url of ["https://example.com/api/login", "https://acode.app.evil.test/api/login", "http://acode.app/api/login", "https://acode.app:8443/api/login", "https://acode.app/plugins", "https://dev.acode.app.evil.test/api/login", "http://dev.acode.app/api/login", "https://dev.acode.app:8443/api/login", "acode://localhost/__cdvfile_cache__/file.txt"]) { await window.fetch(url); expect(transport.mock.calls.at(-1)?.[0]).toBe(url); } - await window.fetch("https://acode.app/api/login?test=one%20two", { credentials: "include" }); - const [url, options] = transport.mock.calls.at(-1)!; - expect(url).toBe("acode://localhost/__api__/login?test=one%20two"); - expect(options.headers.get("X-Acode-Credentials")).toBe("include"); + for (const origin of ["https://acode.app", "https://dev.acode.app"]) { + await window.fetch(`${origin}/api/login?test=one%20two`, { credentials: "include", headers: { "X-Acode-API-Origin": "https://example.com" } }); + const [url, options] = transport.mock.calls.at(-1)!; + expect(url).toBe("acode://localhost/__api__/login?test=one%20two"); + expect(options.headers.get("X-Acode-Credentials")).toBe("include"); + expect(options.headers.get("X-Acode-API-Origin")).toBe(origin); + const xhr = new XMLHttpRequest(); + xhr.open("GET", `${origin}/api/login`); + xhr.withCredentials = true; + xhr.send(); + const state = xhr as unknown as { openArgs: unknown[]; headers: Record }; + expect(state.openArgs[1]).toBe("acode://localhost/__api__/login"); + expect(state.headers["X-Acode-API-Origin"]).toBe(origin); + expect(state.headers["X-Acode-Credentials"]).toBe("include"); + } }); test("retains Request bodies, content types, cancellation and response URLs", async () => { diff --git a/tests/unit/iosBetaBadge.test.js b/tests/unit/iosBetaBadge.test.js deleted file mode 100644 index fe19a7d..0000000 --- a/tests/unit/iosBetaBadge.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "node:fs"; -import vm from "node:vm"; -import { Window } from "happy-dom"; -import { expect, test, vi } from "vitest"; - -const html = fs.readFileSync( - new URL("../../src/index.html", import.meta.url), - "utf8", -); - -test.each([ - false, - true, -])("shows the splash Beta badge only on iOS: %s", (ios) => { - const window = new Window(); - window.document.head.innerHTML = html.match(/