diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/LaravelEnvironment.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/LaravelEnvironment.kt index 7c6885b5..56478aba 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/LaravelEnvironment.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/LaravelEnvironment.kt @@ -9,8 +9,6 @@ import java.io.FileInputStream import java.io.BufferedInputStream import java.util.zip.ZipEntry import java.util.zip.ZipInputStream -import java.net.HttpURLConnection -import java.net.URL import org.json.JSONObject import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -73,6 +71,7 @@ class LaravelEnvironment(private val context: Context) { // Directory paths private const val DIR_LARAVEL = "laravel" + private const val DIR_UPDATES = "updates" private const val DIR_PERSISTED = "persisted_data" private const val DIR_STORAGE = "persisted_data/storage" private const val DIR_FRAMEWORK = "persisted_data/storage/framework" @@ -85,12 +84,8 @@ class LaravelEnvironment(private val context: Context) { private const val DIR_DATABASE = "persisted_data/database/" private const val DIR_PHP_SESSIONS = "php_sessions" - // API URLs - private const val BIFROST_API_BASE = "https://bifrost.nativephp.com/api/apps" - // Version constants private const val VERSION_DEBUG = "DEBUG" - private const val VERSION_DEFAULT = "0.0.0" // Environment variable regex patterns private const val REGEX_APP_VERSION = "(?m)^NATIVEPHP_APP_VERSION=(.+)$" @@ -189,14 +184,8 @@ class LaravelEnvironment(private val context: Context) { setupDirectories() - // OTA check commented out — adds ~300ms network latency on every cold boot - // TODO: Re-enable when OTA is ready for production - // val didExtract = if (checkAndApplyOTAUpdate()) { - // Log.d(TAG, "✅ OTA update applied successfully") - // true - // } else { - // extractLaravelBundle() - // } + // OTA check/download lives in the mobile-ota plugin. Core only + // applies pending zips from {appStorageDir}/updates on boot. // Hold the lock across extraction AND the post-extraction steps // (.env writes + classic artisan). A second activity's init thread @@ -205,7 +194,9 @@ class LaravelEnvironment(private val context: Context) { // while THIS thread is still cycling classic embeds — see the // extractionLock comment for the failure that causes. extractionLock.withLock { - val didExtract = extractLaravelBundleUnlocked() + val didBundle = extractLaravelBundleUnlocked() + val didPending = applyPendingUpdatesUnlocked() + val didExtract = didBundle || didPending setupEnvironment(didExtract) @@ -230,32 +221,31 @@ class LaravelEnvironment(private val context: Context) { * either path; the isUpToDate check inside short-circuits repeat callers. */ private fun extractLaravelBundle(): Boolean = extractionLock.withLock { - extractLaravelBundleUnlocked() + val didBundle = extractLaravelBundleUnlocked() + val didPending = applyPendingUpdatesUnlocked() + didBundle || didPending } private fun extractLaravelBundleUnlocked(): Boolean { val laravelDir = File(appStorageDir, DIR_LARAVEL) val otaMarkerFile = File(laravelDir, OTA_MARKER) - // Check if OTA is configured in both bundled and extracted versions - val bundledBifrostId = getBifrostAppId() - val extractedBifrostId = getBifrostAppIdFromExtracted() - val isBundledOtaConfigured = !bundledBifrostId.isNullOrEmpty() - val isExtractedOtaConfigured = !extractedBifrostId.isNullOrEmpty() - - // If OTA marker exists but bundled version no longer has OTA configured, remove marker and force extraction - if (otaMarkerFile.exists() && !isBundledOtaConfigured) { - val otaVersion = otaMarkerFile.readText().trim() - Log.d(TAG, "🔄 OTA removed from bundled version, rolling back from OTA version $otaVersion to bundled version") - Log.d(TAG, "🔍 Bundled BIFROST_APP_ID: '$bundledBifrostId', Extracted BIFROST_APP_ID: '$extractedBifrostId'") - otaMarkerFile.delete() - // Continue with extraction to rollback to bundled version - } - // If OTA marker exists and bundled version still has OTA configured, skip extraction - else if (otaMarkerFile.exists() && isBundledOtaConfigured) { - val otaVersion = otaMarkerFile.readText().trim() - Log.d(TAG, "✅ OTA update version $otaVersion is active, skipping bundle extraction") - return false + // .ota_applied records the bundled identity the pending zip was applied over. + // Same identity → keep the OTA tree (do not re-extract the older bundled zip). + // Different identity or DEBUG → store build changed; roll back to the bundle. + if (otaMarkerFile.exists()) { + val recordedBundledId = otaMarkerFile.readText().trim() + val markerMeta = readBundleMetadata() + val embeddedId = buildVersionId(markerMeta.version, markerMeta.versionCode) + val isDebug = embeddedId?.equals(VERSION_DEBUG, ignoreCase = true) == true + + if (isDebug || (embeddedId != null && embeddedId != recordedBundledId)) { + Log.d(TAG, "🔄 Bundled identity changed ($recordedBundledId → ${embeddedId ?: "none"}); rolling back OTA to bundle") + otaMarkerFile.delete() + } else { + Log.d(TAG, "✅ OTA update is active (applied over $recordedBundledId), skipping bundle extraction") + return false + } } // Build composite "version+b+versionCode" identity from bundle metadata. @@ -450,59 +440,6 @@ class LaravelEnvironment(private val context: Context) { return Pair(id.substring(0, sepIndex), id.substring(sepIndex + 1)) } - private fun checkAndApplyOTAUpdate(): Boolean { - // Check if BIFROST_APP_ID exists in environment or app metadata - val bifrostAppId = getBifrostAppId() - if (bifrostAppId.isNullOrEmpty()) { - Log.d(TAG, "â„šī¸ No BIFROST_APP_ID found, skipping OTA check") - return false - } - - val laravelDir = File(appStorageDir, DIR_LARAVEL) - - // Get current version from existing .env if available, otherwise from bundled .env - val currentVersion = if (laravelDir.exists()) { - val envFile = File(laravelDir, ENV_FILE) - if (envFile.exists()) { - getVersionFromEnvFile(envFile) - } else { - getVersionFromBundledEnv() - } - } else { - getVersionFromBundledEnv() - } ?: VERSION_DEFAULT - - // Special case: DEBUG version means skip OTA - if (currentVersion == VERSION_DEBUG) { - Log.d(TAG, "â„šī¸ DEBUG version detected, skipping OTA update") - return false - } - - Log.d(TAG, "🔄 Checking for OTA updates...") - Log.d(TAG, "📱 Current version: $currentVersion") - Log.d(TAG, "🆔 Bifrost App ID: $bifrostAppId") - - return try { - val updateInfo = checkForUpdate(bifrostAppId, currentVersion) - if (updateInfo != null && !updateInfo.optBoolean("upToDate", true)) { - val newVersion = updateInfo.optString("current_version", "") - val downloadUrl = updateInfo.optString("download_url", "") - - Log.d(TAG, "đŸ“Ĩ Update available: $currentVersion → $newVersion") - - if (downloadUrl.isNotEmpty() && newVersion != currentVersion) { - return downloadAndApplyUpdate(downloadUrl, newVersion) - } - } else { - Log.d(TAG, "✅ App is up to date") - } - false - } catch (e: Exception) { - Log.e(TAG, "❌ OTA update check failed", e) - false - } - } - private fun getVersionFromEnvFile(envFile: File): String? { return try { val envContent = envFile.readText() @@ -523,161 +460,96 @@ class LaravelEnvironment(private val context: Context) { } } - private fun getVersionFromBundledEnv(): String? { - // Use cached metadata instead of reading ZIP again - return readBundleMetadata().version + /** + * Next-boot OTA apply: any *.zip in {appStorageDir}/updates/ is extracted + * into laravel (same unzip as the bundled payload). One zip per boot. + * The running .env is stashed and restored verbatim; no key merge. + */ + private fun isDebugBundle(): Boolean { + val embeddedId = buildVersionId( + readBundleMetadata().version, + readBundleMetadata().versionCode + ) + return embeddedId?.equals(VERSION_DEBUG, ignoreCase = true) == true } - - private fun getBifrostAppId(): String? { - // Use cached metadata instead of reading ZIP again - val bifrostId = readBundleMetadata().bifrostAppId - if (!bifrostId.isNullOrEmpty()) { - Log.d(TAG, "Found BIFROST_APP_ID in bundled .env: $bifrostId") - } else { - Log.d(TAG, "No BIFROST_APP_ID found in bundled .env") + private fun applyPendingUpdatesUnlocked(): Boolean { + // DEBUG is the native:run payload. Always extract it (extractLaravelBundleUnlocked) + // and do not apply a leftover pending OTA on top — otherwise developers + // never see the PHP they just shipped. Versioned builds still apply pending. + if (isDebugBundle()) { + Log.d(TAG, "🚧 DEBUG bundle: skipping pending OTA apply so local changes show") + return false } - return bifrostId - } - - private fun getBifrostAppIdFromExtracted(): String? { - // Read from extracted .env file - val laravelDir = File(appStorageDir, DIR_LARAVEL) - val envFile = File(laravelDir, ENV_FILE) + val updatesDir = File(appStorageDir, DIR_UPDATES) + if (!updatesDir.exists()) return false - if (!envFile.exists()) { - return null - } + val zipFiles = updatesDir.listFiles { file -> + file.isFile && file.name.endsWith(".zip", ignoreCase = true) + } ?: return false - try { - val envContent = envFile.readText() - val bifrostIdMatch = Regex(REGEX_BIFROST_ID).find(envContent) - val bifrostId = bifrostIdMatch?.groupValues?.get(1)?.trim() + if (zipFiles.isEmpty()) return false - if (!bifrostId.isNullOrEmpty()) { - Log.d(TAG, "Found BIFROST_APP_ID in extracted .env: $bifrostId") - return bifrostId - } - } catch (e: Exception) { - Log.e(TAG, "Failed to read BIFROST_APP_ID from extracted .env", e) - } - - Log.d(TAG, "No BIFROST_APP_ID found in extracted .env") - return null + val zipFile = zipFiles.firstOrNull { it.name == "pending.zip" } ?: zipFiles[0] + return installPendingUpdate(zipFile) } - - private fun checkForUpdate(appId: String, currentVersion: String): JSONObject? { - return try { - val url = URL("$BIFROST_API_BASE/$appId/ota?installed=$currentVersion") - val connection = url.openConnection() as HttpURLConnection - - connection.requestMethod = "GET" - connection.connectTimeout = 10000 - connection.readTimeout = 10000 - connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("User-Agent", "NativePHP-Android/${android.os.Build.VERSION.RELEASE}") - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val response = connection.inputStream.bufferedReader().use { it.readText() } - JSONObject(response) - } else { - Log.e(TAG, "OTA check failed with status: $responseCode") - null - } - } catch (e: Exception) { - Log.e(TAG, "Failed to check for updates", e) - null - } - } - - private fun downloadAndApplyUpdate(downloadUrl: String, newVersion: String): Boolean { - val tempFile = File(context.cacheDir, "ota_update_$newVersion.zip") - + + private fun installPendingUpdate(zipFile: File): Boolean { + val laravelDir = File(appStorageDir, DIR_LARAVEL) + val envFile = File(laravelDir, ENV_FILE) + val stashFile = File(appStorageDir, "$DIR_UPDATES/.env.stash") + return try { - // Download the update - Log.d(TAG, "đŸ“Ĩ Downloading update from: $downloadUrl") - val url = URL(downloadUrl) - val connection = url.openConnection() as HttpURLConnection - connection.connectTimeout = 30000 - connection.readTimeout = 30000 - - connection.inputStream.use { input -> - FileOutputStream(tempFile).use { output -> - val buffer = ByteArray(8192) - var bytesRead: Int - var totalBytes = 0L - - while (input.read(buffer).also { bytesRead = it } != -1) { - output.write(buffer, 0, bytesRead) - totalBytes += bytesRead - - // Log progress every 1MB - if (totalBytes % (1024 * 1024) == 0L) { - Log.d(TAG, "đŸ“Ĩ Downloaded ${totalBytes / (1024 * 1024)}MB...") - } - } - - Log.d(TAG, "✅ Download complete: ${totalBytes / 1024}KB") - } + var hadEnv = false + if (envFile.exists()) { + stashFile.parentFile?.mkdirs() + envFile.copyTo(stashFile, overwrite = true) + hadEnv = true + Log.d(TAG, "đŸ“Ļ Stashed existing .env before pending update") } - - // Apply the update - val laravelDir = File(appStorageDir, DIR_LARAVEL) - // Delete entire laravel directory - persisted_data is separate and safe if (laravelDir.exists()) { - Log.d(TAG, "đŸ—‘ī¸ Removing old Laravel directory for OTA update (persisted_data is safe)") - laravelDir.deleteRecursively() + Log.d(TAG, "đŸ—‘ī¸ Removing Laravel directory for pending OTA (persisted_data is safe)") + try { + val process = Runtime.getRuntime().exec(arrayOf("rm", "-rf", laravelDir.absolutePath)) + process.waitFor() + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to remove Laravel directory: ${e.message}") + laravelDir.listFiles()?.forEach { it.delete() } + } } - laravelDir.mkdirs() - // Extract the update - Log.d(TAG, "đŸ“Ļ Extracting OTA update...") - FileInputStream(tempFile).use { fileInput -> - unzip(fileInput, laravelDir) - } + FileInputStream(zipFile).use { unzip(it, laravelDir) } - // Update the NATIVEPHP_APP_VERSION in .env file - val envFile = File(laravelDir, ENV_FILE) - if (envFile.exists()) { - var envContent = envFile.readText() - - // Update or add NATIVEPHP_APP_VERSION - if (envContent.contains(Regex("NATIVEPHP_APP_VERSION=.*"))) { - envContent = envContent.replace( - Regex("NATIVEPHP_APP_VERSION=.*"), - "NATIVEPHP_APP_VERSION=$newVersion" - ) - } else { - // Add it if not present - envContent += "\nNATIVEPHP_APP_VERSION=$newVersion" - } - - envFile.writeText(envContent) - Log.d(TAG, "✅ Updated NATIVEPHP_APP_VERSION to $newVersion in .env") + if (hadEnv && stashFile.exists()) { + stashFile.copyTo(File(laravelDir, ENV_FILE), overwrite = true) + stashFile.delete() + Log.d(TAG, "đŸ“Ļ Restored stashed .env over extracted payload") } - - // Write version marker file to prevent re-extraction of old bundle - val otaMarkerFile = File(laravelDir, OTA_MARKER) - otaMarkerFile.writeText(newVersion) - - // Clean up - tempFile.delete() - - Log.d(TAG, "✅ OTA update applied successfully to version $newVersion") + + // Record the bundled identity this OTA was applied over so the next + // boot skips re-extracting the older bundle, while a newer store + // build (different embedded id) still rolls back to the bundle. + val bundledId = buildVersionId( + readBundleMetadata().version, + readBundleMetadata().versionCode + ) ?: "ota" + File(laravelDir, OTA_MARKER).writeText(bundledId) + + File(laravelDir, "storage/framework").mkdirs() + File(laravelDir, "bootstrap/cache").mkdirs() + + zipFile.delete() + + Log.d(TAG, "✅ Pending OTA zip applied; .ota_applied=$bundledId") true - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to download or apply OTA update", e) - - // Clean up on failure - if (tempFile.exists()) { - tempFile.delete() + Log.e(TAG, "❌ Failed to apply pending OTA zip", e) + if (stashFile.exists()) { + stashFile.delete() } - false } } diff --git a/resources/xcode/NativePHP/AppUpdateManager.swift b/resources/xcode/NativePHP/AppUpdateManager.swift index dcbca0a4..c5ca3ff1 100644 --- a/resources/xcode/NativePHP/AppUpdateManager.swift +++ b/resources/xcode/NativePHP/AppUpdateManager.swift @@ -39,8 +39,12 @@ class AppUpdateManager { didExtract = true } - // Check for and apply any pending updates - if applyPendingUpdates() { + // DEBUG is the native:run payload. Always extract it (shouldUpdateFromBundle) + // and do not apply a leftover pending OTA on top — otherwise developers + // never see the PHP they just shipped. Versioned builds still apply pending. + if isDebugBundle() { + print("🚧 DEBUG bundle: skipping pending OTA apply so local changes show") + } else if applyPendingUpdates() { didExtract = true } @@ -215,8 +219,21 @@ class AppUpdateManager { print("đŸ“Ļ Installing app update from: \(zipPath)") let extractPath = updatesPath + "/extracted_" + UUID().uuidString + let envStashPath = updatesPath + "/.env.stash" + let currentEnvPath = appPath + "/.env" do { + // Stash the running .env so device-local values survive the payload replace. + // Restore it verbatim over the extracted .env; no key merge. Skip if none. + let hadEnv = FileManager.default.fileExists(atPath: currentEnvPath) + if hadEnv { + if FileManager.default.fileExists(atPath: envStashPath) { + try FileManager.default.removeItem(atPath: envStashPath) + } + try FileManager.default.copyItem(atPath: currentEnvPath, toPath: envStashPath) + print("đŸ“Ļ Stashed existing .env before pending update") + } + // Create extraction directory try FileManager.default.createDirectory(atPath: extractPath, withIntermediateDirectories: true) @@ -228,6 +245,7 @@ class AppUpdateManager { try FileManager.default.unzipItem(at: sourceURL, to: destinationURL) } catch { print("❌ Failed to extract zip file: \(error)") + try? FileManager.default.removeItem(atPath: envStashPath) return false } @@ -235,6 +253,7 @@ class AppUpdateManager { guard isValidApp(at: extractPath) else { print("❌ Invalid app structure in zip") try? FileManager.default.removeItem(atPath: extractPath) + try? FileManager.default.removeItem(atPath: envStashPath) return false } @@ -245,6 +264,17 @@ class AppUpdateManager { // Move new app into place try FileManager.default.moveItem(atPath: extractPath, toPath: appPath) + // Restore the stashed .env over whatever the zip extracted + if hadEnv, FileManager.default.fileExists(atPath: envStashPath) { + let newEnvPath = appPath + "/.env" + if FileManager.default.fileExists(atPath: newEnvPath) { + try FileManager.default.removeItem(atPath: newEnvPath) + } + try FileManager.default.copyItem(atPath: envStashPath, toPath: newEnvPath) + try? FileManager.default.removeItem(atPath: envStashPath) + print("đŸ“Ļ Restored stashed .env over extracted payload") + } + // Create installed.version file for the new version createInstalledVersionFile() @@ -254,6 +284,7 @@ class AppUpdateManager { // Cleanup try? FileManager.default.removeItem(atPath: extractPath) try? FileManager.default.removeItem(atPath: zipPath) + try? FileManager.default.removeItem(atPath: envStashPath) // Keep only the latest backup cleanupOldBackups() @@ -266,6 +297,7 @@ class AppUpdateManager { // Cleanup on failure try? FileManager.default.removeItem(atPath: extractPath) + try? FileManager.default.removeItem(atPath: envStashPath) return false } } @@ -285,7 +317,13 @@ class AppUpdateManager { let updateFiles = (try? FileManager.default.contentsOfDirectory(atPath: updatesPath)) ?? [] let zipFiles = updateFiles.filter { $0.hasSuffix(".zip") } - for zipFile in zipFiles { + // Prefer a stable pending.zip (plugin download target) so leftover + // timestamped zips don't win. Still only apply one zip per boot. + let ordered = zipFiles.contains("pending.zip") + ? ["pending.zip"] + zipFiles.filter { $0 != "pending.zip" } + : zipFiles + + for zipFile in ordered { let zipPath = updatesPath + "/" + zipFile if installUpdate(from: zipPath) { // Only install one update at a time @@ -404,9 +442,22 @@ class AppUpdateManager { return shouldUpdateWithVersion(bundledId) } + private func isDebugIdentity(_ id: String?) -> Bool { + guard let id else { return false } + return id.caseInsensitiveCompare("DEBUG") == .orderedSame + } + + private func isDebugBundle() -> Bool { + if let fast = getBundledAppVersionFast(), isDebugIdentity(fast) { + return true + } + return isDebugIdentity(getBundledAppVersion()) + } + private func shouldUpdateWithVersion(_ bundledId: String) -> Bool { - // Special case: If bundled version is DEBUG, always update from bundle (for development) - if bundledId == "DEBUG" { + // DEBUG is what native:run ships. Always re-extract so developers see + // PHP changes without bumping a version (same as Android). + if isDebugIdentity(bundledId) { print("🚧 DEBUG version detected, updating from bundle") return true } @@ -493,205 +544,10 @@ class AppUpdateManager { } } - // MARK: - OTA Updates (Bifrost API) - - func checkForUpdates() { - // Skip OTA checks for DEBUG version - let currentVersion = getAppVersion() ?? "unknown" - if currentVersion == "DEBUG" { - print("🚧 DEBUG version detected, skipping OTA checks") - return - } - - // Get BIFROST_APP_ID from Laravel environment - guard let bifrostAppId = getBifrostAppId() else { - print("🔍 No BIFROST_APP_ID configured, skipping OTA checks") - return - } - - // Build Bifrost API URL - let urlString = "https://bifrost.nativephp.com/api/app/\(bifrostAppId)/ota?installed=\(currentVersion)" - guard let updateURL = URL(string: urlString) else { - print("❌ Invalid update URL: \(urlString)") - return - } - - print("🔍 Checking for updates at: \(urlString)") - - var request = URLRequest(url: updateURL) - request.timeoutInterval = 10.0 // 10 second timeout for check - - let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in - if let error = error { - print("❌ Update check failed: \(error.localizedDescription)") - return - } - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let data = data else { - print("❌ Invalid update response") - return - } - - // Parse Bifrost response - do { - if let updateInfo = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - let upToDate = updateInfo["upToDate"] as? Bool ?? true - - if !upToDate, - let downloadURL = updateInfo["download_url"] as? String, - let newVersion = updateInfo["current_version"] as? String { - - // Check if this is a compatible version update (patch/minor only) - if self?.isCompatibleUpdate(from: currentVersion, to: newVersion) == true { - print("🆕 Compatible update available: \(currentVersion) → \(newVersion)") - self?.downloadUpdate(from: downloadURL, version: newVersion) - } else { - print("âš ī¸ Major version update detected (\(currentVersion) → \(newVersion)) - requires app store update") - } - } else { - print("📱 App is up to date") - } - } - } catch { - print("❌ Failed to parse update info: \(error)") - } - } - - task.resume() - } - - private func isCompatibleUpdate(from currentVersion: String, to newVersion: String) -> Bool { - // Parse semver versions - let currentSemver = parseSemver(currentVersion) - let newSemver = parseSemver(newVersion) - - guard let current = currentSemver, let new = newSemver else { - print("âš ī¸ Unable to parse semver versions: \(currentVersion) → \(newVersion)") - // If we can't parse versions, allow the update (fallback behavior) - return true - } - - // Only allow patch and minor version updates (same major version) - if new.major != current.major { - print("❌ Major version change detected: \(current.major) → \(new.major)") - return false - } - - // Allow minor and patch updates - if new.minor > current.minor || (new.minor == current.minor && new.patch > current.patch) { - print("✅ Compatible update: \(currentVersion) → \(newVersion)") - return true - } - - // Don't allow downgrades - print("âš ī¸ Version downgrade or same version: \(currentVersion) → \(newVersion)") - return false - } - - private func parseSemver(_ version: String) -> (major: Int, minor: Int, patch: Int)? { - // Remove 'v' prefix if present - let cleanVersion = version.hasPrefix("v") ? String(version.dropFirst()) : version - - // Split by dots and parse - let parts = cleanVersion.components(separatedBy: ".") - - // Handle different semver formats - if parts.count >= 3 { - // Full semver: 1.2.3 - guard let major = Int(parts[0]), - let minor = Int(parts[1]), - let patch = Int(parts[2]) else { - return nil - } - return (major, minor, patch) - } else if parts.count == 2 { - // Missing patch: 1.2 -> 1.2.0 - guard let major = Int(parts[0]), - let minor = Int(parts[1]) else { - return nil - } - return (major, minor, 0) - } else if parts.count == 1 { - // Only major: 1 -> 1.0.0 - guard let major = Int(parts[0]) else { - return nil - } - return (major, 0, 0) - } - - return nil - } - - private func getBifrostAppId() -> String? { - // Read BIFROST_APP_ID from Info.plist - return Bundle.main.object(forInfoDictionaryKey: "BIFROST_APP_ID") as? String - } - - private func downloadUpdate(from urlString: String, version: String) { - guard let url = URL(string: urlString) else { - print("❌ Invalid download URL: \(urlString)") - return - } - - print("âŦ‡ī¸ Downloading update \(version) from: \(urlString)") - - var request = URLRequest(url: url) - request.timeoutInterval = 30.0 // 30 second timeout for download - - let task = URLSession.shared.downloadTask(with: request) { [weak self] tempURL, response, error in - if let error = error { - print("❌ Download failed: \(error.localizedDescription)") - return - } - - guard let tempURL = tempURL else { - print("❌ No download file received") - return - } - - // Move downloaded file to updates directory - let filename = "update_\(version)_\(Int(Date().timeIntervalSince1970)).zip" - let finalPath = (self?.updatesPath ?? "") + "/" + filename - - do { - if FileManager.default.fileExists(atPath: finalPath) { - try FileManager.default.removeItem(atPath: finalPath) - } - try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: finalPath)) - - print("✅ Update downloaded: \(filename)") - - // Automatically install the update - DispatchQueue.main.async { - if self?.installUpdate(from: finalPath) == true { - self?.notifyUpdateInstalled(version: version) - } - } - - } catch { - print("❌ Failed to save downloaded update: \(error)") - } - } - - task.resume() - } - - private func notifyUpdateInstalled(version: String) { - // Notify the Laravel app that an update was installed - LaravelBridge.shared.send?( - "Native\\Mobile\\Events\\App\\UpdateInstalled", - ["version": version, "timestamp": Int(Date().timeIntervalSince1970)] - ) - - // Optionally show a toast or reload the WebView - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - NotificationCenter.default.post(name: .reloadWebViewNotification, object: nil) - } - } - // MARK: - Fast Version Checking + // + // OTA check/download lives in the mobile-ota plugin. Core only applies + // pending zips from Documents/updates on boot via applyPendingUpdates(). private func getBundledAppVersionFast() -> String? { guard let bundlePath = Bundle.main.path(forResource: "bundled", ofType: "version") else { diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift index 51a35310..df22221c 100644 --- a/resources/xcode/NativePHP/NativePHPApp.swift +++ b/resources/xcode/NativePHP/NativePHPApp.swift @@ -124,21 +124,16 @@ struct NativePHPApp: App { // 6. Reload handling + hot reload server. The coordinator must be // registered before anything can post reloadWebViewNotification — - // HotReloadServer triggers (DEBUG) or AppUpdateManager after an OTA - // update (production) — and independently of the WebView, which a - // native-direct boot never mounts. + // HotReloadServer triggers (DEBUG) — and independently of the WebView, + // which a native-direct boot never mounts. HotReloadCoordinator.shared.activate() #if DEBUG HotReloadServer.shared.start() #endif - // 7. OTA check commented out — parity with Android, where the boot-time - // Bifrost request was disabled for its network latency on cold boot. - // TODO: Re-enable on BOTH platforms together when OTA is ready for - // production, as an async check after first content — never on the - // boot path. - // NSLog("[NativePHP] checkForUpdates START") - // AppUpdateManager.shared.checkForUpdates() + // 7. OTA check/download lives in the mobile-ota plugin. Core only + // applies pending zips from Documents/updates on boot (ensureAppExists + // → applyPendingUpdates). Do not re-enable a Bifrost client here. // 8. Defer queue worker boot — start AFTER critical path completes // so it doesn't compete for CPU/memory during first page render