From a5fbec2f1cae5f1af1891d179397c33341a39635 Mon Sep 17 00:00:00 2001 From: aichy126 Date: Tue, 28 Apr 2026 20:19:44 +0800 Subject: [PATCH 1/2] feat:Improve shell resolution and PTY spawn diagnostics --- src/terminal-manager.mjs | 111 ++++++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 14 deletions(-) diff --git a/src/terminal-manager.mjs b/src/terminal-manager.mjs index b043916..3c07c95 100644 --- a/src/terminal-manager.mjs +++ b/src/terminal-manager.mjs @@ -8,18 +8,66 @@ import { TerminalSession } from './terminal-session.mjs'; import * as persistence from './persistence.mjs'; import { config } from './config.mjs'; +function isExecutable(filePath) { + if (!filePath) return false; + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function resolveShellByName(name) { + if (!name) return name; + if (path.isAbsolute(name)) return name; + const candidates = process.platform === 'darwin' + ? ['/opt/homebrew/bin', '/usr/local/bin', '/bin', '/usr/bin'] + : ['/usr/local/bin', '/usr/bin', '/bin']; + for (const dir of candidates) { + const candidate = path.join(dir, name); + if (isExecutable(candidate)) return candidate; + } + return name; +} + +function pickFirstExecutable(candidates) { + for (const candidate of candidates) { + if (candidate && isExecutable(candidate)) return candidate; + } + return null; +} + function resolveShell() { if (config.shell) { - return config.shell; + const configured = resolveShellByName(config.shell); + if (path.isAbsolute(configured) && isExecutable(configured)) { + return configured; + } + return configured; } if (process.platform === 'win32') { return process.env.COMSPEC || 'cmd.exe'; } - // Try to use Homebrew installed bash if available (newer version) - if (fs.existsSync('/opt/homebrew/bin/bash')) { - return '/opt/homebrew/bin/bash'; - } - return '/bin/bash'; + const envShell = process.env.SHELL; + const platformCandidates = process.platform === 'darwin' + ? [ + envShell, + '/opt/homebrew/bin/zsh', + '/usr/local/bin/zsh', + '/bin/zsh', + '/opt/homebrew/bin/bash', + '/usr/local/bin/bash', + '/bin/bash', + '/bin/sh' + ] + : [ + envShell, + '/usr/bin/bash', + '/bin/bash', + '/bin/sh' + ]; + return pickFirstExecutable(platformCandidates) || '/bin/sh'; } const historyLimit = config.historyLimit; @@ -230,7 +278,10 @@ export class TerminalManager { _createPtySession(options = {}) { const id = options.id || crypto.randomUUID(); - const shell = options.shell || resolveShell(); + const requestedShell = options.shell || resolveShell(); + const shell = path.isAbsolute(requestedShell) + ? requestedShell + : resolveShellByName(requestedShell); const initialCwd = options.cwd || process.env.TABMINAL_CWD || os.homedir(); @@ -238,6 +289,12 @@ export class TerminalManager { ...process.env, ...(options.env || {}) }; + if (!options.directSpawn + && !options.spawnCommand + && path.isAbsolute(shell) + && env.SHELL !== shell) { + env.SHELL = shell; + } let spawnShell = options.spawnCommand || shell; let args = Array.isArray(options.spawnArgs) ? options.spawnArgs : []; let initDirPath = null; @@ -308,6 +365,24 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker) const cols = Number.isFinite(options.cols) ? options.cols : this.lastCols; const rows = Number.isFinite(options.rows) ? options.rows : this.lastRows; + if (path.isAbsolute(spawnShell) && !isExecutable(spawnShell)) { + throw new Error( + `Shell binary not found or not executable: ${spawnShell}` + + ` (requested via "${requestedShell}")` + ); + } + let cwdStat; + try { + cwdStat = fs.statSync(initialCwd); + } catch (cwdErr) { + throw new Error( + `cwd unreadable: ${initialCwd} - ${cwdErr.message}` + ); + } + if (!cwdStat.isDirectory()) { + throw new Error(`cwd is not a directory: ${initialCwd}`); + } + let ptyProcess; try { const ptyOptions = { @@ -322,24 +397,32 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker) } ptyProcess = pty.spawn(spawnShell, args, ptyOptions); } catch (err) { + const errProps = {}; + if (err) { + for (const key of Object.getOwnPropertyNames(err)) { + try { errProps[key] = err[key]; } catch {} + } + } const spawnInfo = { shell: spawnShell, - requestedShell: shell, + requestedShell, args, cwd: initialCwd, + cwdExists: fs.existsSync(initialCwd), + shellExecutable: isExecutable(spawnShell), cols, rows, + platform: process.platform, + arch: process.arch, + nodeVersion: process.version, env: { SHELL: env.SHELL, TERM: env.TERM, PATH: env.PATH, - HOME: env.HOME + HOME: env.HOME, + ZDOTDIR: env.ZDOTDIR }, - error: { - message: err?.message, - code: err?.code, - errno: err?.errno - } + error: errProps }; console.error('[Manager] Failed to spawn PTY', spawnInfo); throw err; From de2323bb707e51abac60548f63f65225f615e2e5 Mon Sep 17 00:00:00 2001 From: aichy126 Date: Thu, 16 Jul 2026 23:03:39 +0800 Subject: [PATCH 2/2] fix: authenticate the Apple app with the challenge/login flow The Apple client still authenticated the way the backend worked before 2026-04-10: it sent SHA-256(password) as a reusable bearer token. The server hardened auth that day (ba7509a, a910f26, 6ecb8fe) and dropped password-hash tokens, but apps/Apple was last touched on 2026-03-23 and never followed. Every request has returned 401 since, so bootstrap died on its first heartbeat and the app could not reach any current Tabminal host. Implement the flow the backend actually expects: - TabminalMobileCore: add the challenge/login/refresh calls and the tabminal-hmac-sha256-login-v1 responder. The HMAC is keyed with the raw bytes of the password digest, not its hex text, and signs the exact expiresAt string the server sent, so the challenge keeps expiresAt as a raw String rather than a re-serialized Date. - MobileAppModel: exchange the password (or a stored refresh token) for a token pair, hold the access token on the endpoint, and rotate it 60s before its 15-minute expiry from the heartbeat loop, propagating the new token to the host record and every workspace that holds a copy of the endpoint. - Sub-hosts now log in against themselves. Inheriting the main host's token only made sense when a token was a password hash shared across hosts. - MainHostCredentialStore: persist the rotating refresh token instead of the password hash. The password is never stored. - project.yml: declare the deployment targets explicitly. xcodegen does not emit them for supportedDestinations targets, so builds defaulted to the SDK version and xcodebuild rejected any device older than the SDK. - run-device.sh: build, sign, install and launch on a connected device. Verified against a real host (MacMini, 3.0.40): the app completes login, heartbeat, session create and websocket streaming, and renders live PTY output. Package tests cover the HMAC against a vector generated from the server's own construction. Co-Authored-By: Claude Opus 4.8 --- apps/Apple/App/MainHostCredentialStore.swift | 3 +- apps/Apple/App/MobileAppModel.swift | 229 ++++++++++++++---- apps/Apple/App/ServerConnectionView.swift | 2 +- .../TabminalAPIClient.swift | 63 +++++ .../TabminalProtocolModels.swift | 92 +++++++ .../TabminalMobileCoreTests.swift | 51 ++++ apps/Apple/project.yml | 3 + apps/Apple/run-device.sh | 100 ++++++++ 8 files changed, 499 insertions(+), 44 deletions(-) create mode 100755 apps/Apple/run-device.sh diff --git a/apps/Apple/App/MainHostCredentialStore.swift b/apps/Apple/App/MainHostCredentialStore.swift index f3e7d0c..488a070 100644 --- a/apps/Apple/App/MainHostCredentialStore.swift +++ b/apps/Apple/App/MainHostCredentialStore.swift @@ -1,9 +1,10 @@ import Foundation import Security +/// Stores the main host's refresh token. The password is never persisted. struct MainHostCredentialStore { private static let service = "com.leask.tabminal.mobile.main-host" - private static let account = "auth-token" + private static let account = "refresh-token" func loadToken() -> String? { let query: [CFString: Any] = [ diff --git a/apps/Apple/App/MobileAppModel.swift b/apps/Apple/App/MobileAppModel.swift index aee0277..d190d8b 100644 --- a/apps/Apple/App/MobileAppModel.swift +++ b/apps/Apple/App/MobileAppModel.swift @@ -131,7 +131,11 @@ final class MobileAppModel { @ObservationIgnored private var heartbeatTask: Task? @ObservationIgnored - private var mainToken: String = "" + private var mainAccessToken: String = "" + @ObservationIgnored + private var mainRefreshToken: String = "" + @ObservationIgnored + private var mainAccessTokenExpiresAt: Date? @ObservationIgnored private var workspaces: [String: SessionWorkspaceModel] = [:] @ObservationIgnored @@ -228,24 +232,15 @@ final class MobileAppModel { let trimmedPassword = mainPassword.trimmingCharacters( in: .whitespacesAndNewlines ) - let token = !trimmedPassword.isEmpty - ? TabminalPasswordHasher.sha256Hex(trimmedPassword) - : credentialStore.loadToken() ?? "" + let storedRefreshToken = credentialStore.loadToken() ?? "" - guard !token.isEmpty else { + guard !trimmedPassword.isEmpty || !storedRefreshToken.isEmpty else { loginErrorMessage = "Password is required." return } loginErrorMessage = "" isAuthenticating = true - let mainEndpoint = TabminalServerEndpoint( - id: "main", - baseURL: parsedURL, - host: mainHostName, - token: token, - isPrimary: true - ) Task { defer { @@ -254,7 +249,21 @@ final class MobileAppModel { do { phase = .loading - mainToken = token + let tokens = try await authenticate( + baseURL: parsedURL, + host: mainHostName, + password: trimmedPassword, + refreshToken: storedRefreshToken + ) + applyMainTokens(tokens) + + let mainEndpoint = TabminalServerEndpoint( + id: "main", + baseURL: parsedURL, + host: mainHostName, + token: tokens.accessToken, + isPrimary: true + ) try await bootstrap(mainEndpoint: mainEndpoint) defaults.set( mainEndpoint.baseURL.absoluteString, @@ -264,7 +273,7 @@ final class MobileAppModel { mainEndpoint.host, forKey: Self.defaultsMainHostKey ) - credentialStore.saveToken(token) + mainPassword = "" phase = .ready } catch { phase = .login @@ -273,6 +282,46 @@ final class MobileAppModel { } } + /// Trades a password (or a stored refresh token) for a token pair. + /// + /// The password is used once and never stored: only the rotating refresh + /// token is persisted. + private func authenticate( + baseURL: URL, + host: String, + password: String, + refreshToken: String + ) async throws -> TabminalAuthTokens { + let anonymous = TabminalServerEndpoint( + id: "main", + baseURL: baseURL, + host: host, + token: "", + isPrimary: true + ) + + if !password.isEmpty { + return try await apiClient.login( + server: anonymous, + password: password + ) + } + + return try await apiClient.refreshTokens( + server: anonymous, + refreshToken: refreshToken + ) + } + + /// The server rotates the refresh token on every issue, so the new one must + /// replace the stored copy or the next launch will present a dead token. + private func applyMainTokens(_ tokens: TabminalAuthTokens) { + mainAccessToken = tokens.accessToken + mainAccessTokenExpiresAt = tokens.accessTokenExpiresAt + mainRefreshToken = tokens.refreshToken + credentialStore.saveToken(tokens.refreshToken) + } + func logout(clearCredentials: Bool = true) { stopHeartbeat() if clearCredentials { @@ -288,7 +337,7 @@ final class MobileAppModel { isSubmittingHostDraft = false phase = .login mainPassword = "" - mainToken = "" + clearMainTokens() for workspace in workspaces.values { workspace.setPresented(false) @@ -296,6 +345,12 @@ final class MobileAppModel { workspaces.removeAll() } + private func clearMainTokens() { + mainAccessToken = "" + mainRefreshToken = "" + mainAccessTokenExpiresAt = nil + } + func restoreMainHostSessionIfNeeded() { guard !didAttemptRestore else { return @@ -303,20 +358,12 @@ final class MobileAppModel { didAttemptRestore = true guard let parsedURL = URL(string: mainServerURL), - let token = credentialStore.loadToken(), - !token.isEmpty + let refreshToken = credentialStore.loadToken(), + !refreshToken.isEmpty else { return } - let mainEndpoint = TabminalServerEndpoint( - id: "main", - baseURL: parsedURL, - host: mainHostName, - token: token, - isPrimary: true - ) - phase = .loading loginErrorMessage = "" isAuthenticating = true @@ -327,17 +374,31 @@ final class MobileAppModel { } do { - mainToken = token - try await bootstrap(mainEndpoint: mainEndpoint) + let tokens = try await authenticate( + baseURL: parsedURL, + host: mainHostName, + password: "", + refreshToken: refreshToken + ) + applyMainTokens(tokens) + + try await bootstrap( + mainEndpoint: TabminalServerEndpoint( + id: "main", + baseURL: parsedURL, + host: mainHostName, + token: tokens.accessToken, + isPrimary: true + ) + ) phase = .ready } catch let TabminalClientError.invalidStatus(code, _) where code == 401 || code == 403 { credentialStore.clearToken() - mainToken = "" + clearMainTokens() phase = .login loginErrorMessage = "Saved login expired." } catch { - mainToken = token phase = .login loginErrorMessage = Self.displayMessage(for: error) } @@ -542,21 +603,19 @@ final class MobileAppModel { return } - let inheritedToken = mainToken - let tokenToUse: String - if !hostDraft.password.isEmpty { - tokenToUse = TabminalPasswordHasher.sha256Hex(hostDraft.password) - } else { - switch hostEditorMode { - case .add: - tokenToUse = inheritedToken - case .edit(let hostID), .reconnect(let hostID): - tokenToUse = hostRecord(for: hostID)?.endpoint.token - ?? inheritedToken - } + // Every host issues its own access token, so a password typed here is + // exchanged against that host. Reusing the main host's token would + // always be rejected. + let password = hostDraft.password + let existingToken: String + switch hostEditorMode { + case .add: + existingToken = "" + case .edit(let hostID), .reconnect(let hostID): + existingToken = hostRecord(for: hostID)?.endpoint.token ?? "" } - guard !tokenToUse.isEmpty else { + guard !password.isEmpty || !existingToken.isEmpty else { hostDraftErrorMessage = "Password is required for this host." return } @@ -570,6 +629,13 @@ final class MobileAppModel { } do { + let tokenToUse = try await subHostToken( + baseURL: parsedURL, + host: hostDraft.host, + password: password, + existingToken: existingToken + ) + switch hostEditorMode { case .add: try await addHost( @@ -593,6 +659,32 @@ final class MobileAppModel { } } + /// Only the main host's refresh token is persisted, so a sub-host access + /// token is not renewed in the background: once it lapses the host drops to + /// `needsAuth` and the reconnect sheet asks for the password again. + private func subHostToken( + baseURL: URL, + host: String, + password: String, + existingToken: String + ) async throws -> String { + guard !password.isEmpty else { + return existingToken + } + + let candidate = TabminalServerEndpoint( + id: "candidate", + baseURL: baseURL, + host: host, + token: "", + isPrimary: false + ) + return try await apiClient.login( + server: candidate, + password: password + ).accessToken + } + func removeHost(_ hostID: String) { guard let host = hostRecord(for: hostID), !host.isPrimary else { return @@ -1026,6 +1118,7 @@ final class MobileAppModel { if Task.isCancelled { return } + await self.refreshMainTokensIfNeeded() await self.syncAllHosts(ensurePrimarySession: false) } } @@ -1036,6 +1129,57 @@ final class MobileAppModel { heartbeatTask = nil } + /// Access tokens live 15 minutes, so they are rotated shortly before expiry + /// rather than after a request has already failed. + /// + /// A failure here is left to the heartbeat's own `401` handling: it either + /// recovers on the next tick or ends in a clean logout. + private func refreshMainTokensIfNeeded() async { + guard !mainRefreshToken.isEmpty, + let expiry = mainAccessTokenExpiresAt, + expiry.timeIntervalSinceNow < Self.tokenRefreshLeadTime, + let record = hostRecord(for: "main") + else { + return + } + + do { + let tokens = try await apiClient.refreshTokens( + server: record.endpoint, + refreshToken: mainRefreshToken + ) + applyMainTokens(tokens) + propagateMainToken(tokens.accessToken) + } catch { + return + } + } + + /// The endpoint is a value type held by the host record, every workspace, + /// and each websocket feed, so a rotated token has to be pushed to all of + /// them or the next reconnect would present the old one. + private func propagateMainToken(_ token: String) { + guard let record = hostRecord(for: "main") else { + return + } + + let endpoint = TabminalServerEndpoint( + id: "main", + baseURL: record.endpoint.baseURL, + host: record.endpoint.host, + token: token, + isPrimary: true + ) + + updateHost("main") { current in + current.endpoint = endpoint + } + + for workspace in workspaces.values where workspace.hostID == "main" { + workspace.updateEndpoint(endpoint) + } + } + private func hostRecord(for hostID: String) -> HostRecord? { hosts.first { $0.id == hostID } } @@ -1121,6 +1265,7 @@ final class MobileAppModel { private static let defaultsMainURLKey = "tabminal.mobile.mainURL" private static let defaultsMainHostKey = "tabminal.mobile.mainHost" + private static let tokenRefreshLeadTime: TimeInterval = 60 } enum ConnectionError: LocalizedError { diff --git a/apps/Apple/App/ServerConnectionView.swift b/apps/Apple/App/ServerConnectionView.swift index 7d68d09..dd3b84d 100644 --- a/apps/Apple/App/ServerConnectionView.swift +++ b/apps/Apple/App/ServerConnectionView.swift @@ -133,7 +133,7 @@ struct ServerConnectionView: View { label: "Password", hint: model.hasStoredMainLogin ? "Optional. Leave empty to reuse the saved main-host login." - : "The app sends the same SHA-256 hash used by the web client." + : "Exchanged for a login token. The password is never stored or sent." ) { SecureField( "Password (optional, use saved login)", diff --git a/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift b/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift index a103c17..1026770 100644 --- a/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift +++ b/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift @@ -18,6 +18,69 @@ public actor TabminalAPIClient { self.encoder = TabminalJSONCoding.makeEncoder() } + /// Runs the full challenge/login handshake and returns freshly issued + /// tokens. The challenge is single-use and lives ~30s, so it is requested + /// and consumed inside this one call. + public func login( + server: TabminalServerEndpoint, + password: String + ) async throws -> TabminalAuthTokens { + let challenge = try await requestChallenge(server: server) + let response = TabminalLoginChallengeResponder.response( + passwordHash: TabminalPasswordHasher.sha256Hex(password), + challenge: challenge + ) + let request = try makeRequest( + server: server, + path: "/api/auth/login", + method: "POST", + body: TabminalLoginRequest( + challengeId: challenge.challengeId, + response: response + ) + ) + return try await send( + request, + server: server, + decodeAs: TabminalAuthTokens.self + ) + } + + /// Exchanges a refresh token for a new token pair. The server rotates the + /// refresh token on every success, so the caller must persist the result. + public func refreshTokens( + server: TabminalServerEndpoint, + refreshToken: String + ) async throws -> TabminalAuthTokens { + let request = try makeRequest( + server: server, + path: "/api/auth/refresh", + method: "POST", + body: TabminalRefreshRequest(refreshToken: refreshToken) + ) + return try await send( + request, + server: server, + decodeAs: TabminalAuthTokens.self + ) + } + + public func requestChallenge( + server: TabminalServerEndpoint + ) async throws -> TabminalAuthChallenge { + let request = try makeRequest( + server: server, + path: "/api/auth/challenge", + method: "POST", + body: [String: String]() + ) + return try await send( + request, + server: server, + decodeAs: TabminalAuthChallenge.self + ) + } + public func heartbeat( server: TabminalServerEndpoint, updates: [TabminalSessionUpdate] diff --git a/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift b/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift index c300dfb..49edb61 100644 --- a/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift +++ b/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift @@ -160,6 +160,98 @@ public enum TabminalPasswordHasher { } } +public struct TabminalAuthChallenge: Codable, Sendable { + public let challengeId: String + public let salt: String + /// Kept as the raw string the server sent. It is signed verbatim, so + /// round-tripping it through `Date` would change the bytes and the server + /// would reject the response. + public let expiresAt: String + public let algorithm: String + + public init( + challengeId: String, + salt: String, + expiresAt: String, + algorithm: String + ) { + self.challengeId = challengeId + self.salt = salt + self.expiresAt = expiresAt + self.algorithm = algorithm + } +} + +public struct TabminalAuthTokens: Codable, Sendable { + public let accessToken: String + public let accessTokenExpiresAt: Date + public let refreshToken: String + public let refreshTokenExpiresAt: Date +} + +public struct TabminalLoginRequest: Codable, Sendable { + public let challengeId: String + public let response: String +} + +public struct TabminalRefreshRequest: Codable, Sendable { + public let refreshToken: String +} + +/// Computes the one-time login response for `tabminal-hmac-sha256-login-v1`. +/// +/// The password hash itself is never sent: the server recomputes this HMAC from +/// its own configured hash and compares in constant time. +public enum TabminalLoginChallengeResponder { + public static let messagePrefix = "tabminal-login-v1" + + public static func response( + passwordHash: String, + challenge: TabminalAuthChallenge + ) -> String { + let message = [ + messagePrefix, + challenge.challengeId, + challenge.salt, + challenge.expiresAt + ].joined(separator: ":") + + // The server keys the HMAC with the digest's raw bytes + // (`Buffer.from(hash, 'hex')`), not with its hex text. + let key = SymmetricKey( + data: Data(hexEncoded: passwordHash.lowercased()) + ) + let code = HMAC.authenticationCode( + for: Data(message.utf8), + using: key + ) + return code.map { String(format: "%02x", $0) }.joined() + } +} + +extension Data { + init(hexEncoded string: String) { + var bytes = [UInt8]() + bytes.reserveCapacity(string.count / 2) + + var index = string.startIndex + while index < string.endIndex, + let next = string.index( + index, + offsetBy: 2, + limitedBy: string.endIndex + ) { + guard let byte = UInt8(string[index ..< next], radix: 16) else { + break + } + bytes.append(byte) + index = next + } + + self = Data(bytes) + } +} + public struct TabminalClusterPayload: Codable, Sendable { public let servers: [TabminalClusterServer] diff --git a/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift b/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift index e85fa33..9ff5c4f 100644 --- a/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift +++ b/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift @@ -50,6 +50,57 @@ func passwordHasherMatchesServerSha256Format() { ) } +@Test +func loginChallengeResponseMatchesServerHmac() { + // Vector produced with the server's own construction: + // HMAC-SHA256(key = raw bytes of SHA-256(password), + // message = "tabminal-login-v1:::") + let challenge = TabminalAuthChallenge( + challengeId: "9f8d1c2b-0000-4a1b-8c3d-5e6f70819293", + salt: "Zm9vYmFyLXNhbHQtdmFsdWU", + expiresAt: "2026-04-10T15:00:30.000Z", + algorithm: "tabminal-hmac-sha256-login-v1" + ) + let passwordHash = TabminalPasswordHasher.sha256Hex( + "correct horse battery staple" + ) + + #expect( + passwordHash + == "c4bbcb1fbec99d65bf59d85c8cb62ee2db963f0fe106f483d9afa73bd4e39a8a" + ) + #expect( + TabminalLoginChallengeResponder.response( + passwordHash: passwordHash, + challenge: challenge + ) == "8ae9f238b25725db9274c181fca261d65c9af71790434190d77db54b03b62844" + ) +} + +@Test +func authTokensDecodeFractionalISODates() throws { + let json = """ + { + "accessToken": "ta_abc", + "accessTokenExpiresAt": "2026-04-10T15:00:00.000Z", + "refreshToken": "tr_def", + "refreshTokenExpiresAt": "2026-07-09T15:00:00.000Z" + } + """ + + let tokens = try TabminalJSONCoding.makeDecoder().decode( + TabminalAuthTokens.self, + from: Data(json.utf8) + ) + + #expect(tokens.accessToken == "ta_abc") + #expect(tokens.refreshToken == "tr_def") + #expect( + tokens.accessTokenExpiresAt + == Date(timeIntervalSince1970: 1_775_833_200) + ) +} + @Test func clusterPayloadDecodesBackendBaseUrlKey() throws { let json = """ diff --git a/apps/Apple/project.yml b/apps/Apple/project.yml index 965a36a..b5aa174 100644 --- a/apps/Apple/project.yml +++ b/apps/Apple/project.yml @@ -34,6 +34,9 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.leask.tabminal.mobile PRODUCT_NAME: Tabminal Mobile + IPHONEOS_DEPLOYMENT_TARGET: "18.0" + MACOSX_DEPLOYMENT_TARGET: "15.0" + XROS_DEPLOYMENT_TARGET: "2.0" GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDarkContent diff --git a/apps/Apple/run-device.sh b/apps/Apple/run-device.sh new file mode 100755 index 0000000..e86ce0a --- /dev/null +++ b/apps/Apple/run-device.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_PATH="${ROOT_DIR}/TabminalMobileApp.xcodeproj" +SCHEME="TabminalMobileApp" +APP_BUNDLE="${ROOT_DIR}/build/Build/Products/Debug-iphoneos/Tabminal Mobile.app" +DEVELOPMENT_TEAM="${TABMINAL_DEVELOPMENT_TEAM:-258X46W652}" +APP_ID="${TABMINAL_BUNDLE_ID:-com.miramiao.tabminal.mobile}" +source "${ROOT_DIR}/xcodebuild-lock.sh" + +cd "${ROOT_DIR}" + +tabminal_acquire_xcodebuild_lock + +xcodegen generate >/dev/null + +DEVICE_UDID="${1:-${TABMINAL_DEVICE_UDID:-}}" + +if [[ -z "${DEVICE_UDID}" ]]; then + DEVICE_JSON="$(mktemp -t tabminal-devices)" + trap 'rm -f "${DEVICE_JSON}"' EXIT + xcrun devicectl list devices --quiet --json-output "${DEVICE_JSON}" + DEVICE_UDID="$( + python3 -c ' +import json +import sys + +with open(sys.argv[1]) as handle: + payload = json.load(handle) + +for device in payload["result"]["devices"]: + if device["hardwareProperties"]["platform"] != "iOS": + continue + if device["connectionProperties"]["tunnelState"] == "unavailable": + continue + print(device["hardwareProperties"]["udid"]) + break +' "${DEVICE_JSON}" + )" +fi + +if [[ -z "${DEVICE_UDID}" ]]; then + echo "No connected iOS device found." >&2 + exit 1 +fi + +# ghostty-build-settings.sh needs bash 4 namerefs, so resolve the artifact here +# instead. Without a slice the app falls back to its text renderer. +GHOSTTY_XCFRAMEWORK="" +for candidate in \ + "${TABMINAL_GHOSTTY_XCFRAMEWORK_PATH:-}" \ + "${TABMINAL_GHOSTTY_REPO_PATH:+${TABMINAL_GHOSTTY_REPO_PATH}/macos/GhosttyKit.xcframework}" \ + "${ROOT_DIR}/Vendor/Ghostty/GhosttyKit.xcframework"; do + if [[ -n "${candidate}" && -d "${candidate}" ]]; then + GHOSTTY_XCFRAMEWORK="${candidate}" + break + fi +done + +XCODEBUILD_ARGS=() +if [[ -n "${GHOSTTY_XCFRAMEWORK}" ]]; then + for lib in \ + "${GHOSTTY_XCFRAMEWORK}/ios-arm64/libghostty-fat.a" \ + "${GHOSTTY_XCFRAMEWORK}/ios-arm64/libghostty.a"; do + if [[ -f "${lib}" ]]; then + echo "[ghostty] Linking ${lib}" >&2 + XCODEBUILD_ARGS+=( + "OTHER_LDFLAGS=\$(inherited) -force_load ${lib} -lc++" + ) + break + fi + done +fi + +if [[ ${#XCODEBUILD_ARGS[@]} -eq 0 ]]; then + echo "[ghostty] No iphoneos slice found; using text fallback." >&2 + XCODEBUILD_ARGS+=("GCC_PREPROCESSOR_DEFINITIONS=\$(inherited)") +fi + +xcodebuild \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" \ + -sdk iphoneos \ + -destination "id=${DEVICE_UDID}" \ + -derivedDataPath "${ROOT_DIR}/build" \ + -allowProvisioningUpdates \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + CODE_SIGN_STYLE=Automatic \ + CODE_SIGN_IDENTITY="Apple Development" \ + DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM}" \ + PRODUCT_BUNDLE_IDENTIFIER="${APP_ID}" \ + IPHONEOS_DEPLOYMENT_TARGET=18.0 \ + "${XCODEBUILD_ARGS[@]}" \ + build + +xcrun devicectl device install app --device "${DEVICE_UDID}" "${APP_BUNDLE}" +xcrun devicectl device process launch --device "${DEVICE_UDID}" "${APP_ID}"