Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions config/nativephp.php
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,31 @@
*/
'ipad' => false,

/*
|--------------------------------------------------------------------------
| RTL Support
|--------------------------------------------------------------------------
|
| When enabled, the app will respect the device locale for RTL languages
| (Arabic, Hebrew, etc.) and automatically mirror the layout direction.
| When disabled (default), the app always uses LTR regardless of locale.
|
*/
'rtl_support' => false,

/*
|--------------------------------------------------------------------------
| Localizations
|--------------------------------------------------------------------------
|
| The list of language codes your app supports. These values populate the
| CFBundleLocalizations array in Info.plist for iOS builds, telling the
| system which localizations your app provides.
|
*/

'localizations' => ['en'],

/*
|--------------------------------------------------------------------------
| Device Orientation Support
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ object EdgeFunctions {
return mapOf("error" to "No components provided")
}

// Extract _meta.rtl_support flag
@Suppress("UNCHECKED_CAST")
val meta = parameters["_meta"] as? Map<String, Any>
val rtlSupport = meta?.get("rtl_support") as? Boolean
if (rtlSupport != null) {
NativeUIState.setRtlSupport(rtlSupport)
}

Log.d("EdgeFunctions.Set", "🎨 Edge.Set called")

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
Expand Down Expand Up @@ -125,7 +126,21 @@ class MainActivity : FragmentActivity(), WebViewProvider {

// Set up Compose UI
setContent {
MainScreen()
val rtlSupport by NativeUIState.rtlSupport
val layoutDirection = if (rtlSupport) {
// Let the system determine direction from locale
androidx.compose.ui.unit.LayoutDirection.Rtl.takeIf {
resources.configuration.layoutDirection == android.view.View.LAYOUT_DIRECTION_RTL
} ?: androidx.compose.ui.unit.LayoutDirection.Ltr
} else {
androidx.compose.ui.unit.LayoutDirection.Ltr
}

CompositionLocalProvider(
androidx.compose.ui.platform.LocalLayoutDirection provides layoutDirection
) {
MainScreen()
}
}

initializeEnvironmentAsync {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ object NativeUIState {
private val _topBarData = mutableStateOf<TopBarData?>(null)
val topBarData: State<TopBarData?> = _topBarData

// RTL support flag — controlled by nativephp.rtl_support config
private val _rtlSupport = mutableStateOf(false)
val rtlSupport: State<Boolean> = _rtlSupport

// Keyboard visibility state - used to hide bottom nav when keyboard is open
private val _isKeyboardVisible = mutableStateOf(false)
val isKeyboardVisible: State<Boolean> = _isKeyboardVisible
Expand All @@ -35,6 +39,14 @@ object NativeUIState {
var drawerState: DrawerState? = null
var drawerScope: CoroutineScope? = null

/**
* Update RTL support flag from Edge.Set _meta payload
*/
fun setRtlSupport(enabled: Boolean) {
_rtlSupport.value = enabled
Log.d(TAG, "RTL support set to: $enabled")
}

/**
* Update keyboard visibility state
*/
Expand Down
52 changes: 36 additions & 16 deletions resources/xcode/NativePHP/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,31 @@ extension Notification.Name {
class AppDelegate: NSObject, UIApplicationDelegate {
static let shared = AppDelegate()

/// Apply or remove RTL appearance for all UIKit components.
/// Called from NativeUIState when rtlSupport changes.
static func applyRTLAppearance(_ isRTL: Bool) {
let direction: UISemanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight

UIView.appearance().semanticContentAttribute = direction
UINavigationBar.appearance().semanticContentAttribute = direction
UITabBar.appearance().semanticContentAttribute = direction
UITableView.appearance().semanticContentAttribute = direction
UICollectionView.appearance().semanticContentAttribute = direction
UITextField.appearance().textAlignment = .natural
UILabel.appearance().textAlignment = .natural
}

// Called when the app is launched
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {

// RTL appearance is applied conditionally via applyRTLAppearance()
// when NativeUIState.rtlSupport is set from the Edge.Set payload.
// Initial state is LTR until the config flag arrives.
AppDelegate.applyRTLAppearance(false)

// Check if the app was launched from a URL (custom scheme)
if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL {
DebugLogger.shared.log("📱 AppDelegate: Cold start with custom scheme URL: \(url)")
Expand All @@ -45,9 +65,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {

// Check if the app was launched from a Universal Link
if let userActivityDictionary = launchOptions?[UIApplication.LaunchOptionsKey.userActivityDictionary] as? [String: Any],
let userActivity = userActivityDictionary["UIApplicationLaunchOptionsUserActivityKey"] as? NSUserActivity,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
let userActivity = userActivityDictionary["UIApplicationLaunchOptionsUserActivityKey"] as? NSUserActivity,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
DebugLogger.shared.log("📱 AppDelegate: Cold start with Universal Link: \(url)")
// Pass the URL to the DeepLinkRouter
DeepLinkRouter.shared.handle(url: url)
Expand All @@ -58,13 +78,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {

// Called for Universal Links
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
// Check if this is a Universal Link
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
let url = userActivity.webpageURL {
// Pass the URL to the DeepLinkRouter
DeepLinkRouter.shared.handle(url: url)
return true
Expand All @@ -76,8 +96,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// MARK: - Push Notification Token Handling (forwards to plugins via NotificationCenter)

func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
NotificationCenter.default.post(
name: .didRegisterForRemoteNotifications,
Expand All @@ -87,8 +107,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
}

func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
NotificationCenter.default.post(
name: .didFailToRegisterForRemoteNotifications,
Expand All @@ -99,9 +119,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {

// Handle deeplinks
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
// Pass the URL to the DeepLinkRouter
DeepLinkRouter.shared.handle(url: url)
Expand Down
12 changes: 12 additions & 0 deletions resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ enum EdgeFunctions {
return ["error": "No components array provided"]
}

// Extract _meta.rtl_support flag
if let meta = parameters["_meta"] as? [String: Any],
let rtlSupport = meta["rtl_support"] as? Bool {
if Thread.isMainThread {
NativeUIState.shared.rtlSupport = rtlSupport
} else {
DispatchQueue.main.sync {
NativeUIState.shared.rtlSupport = rtlSupport
}
}
}

print("🎨 Edge.Set called with \(components.count) component(s)")
print("🎨 Edge.Set components: \(components)")

Expand Down
4 changes: 4 additions & 0 deletions resources/xcode/NativePHP/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ struct WebView: UIViewRepresentable {
let isDarkMode = windowScene?.windows.first?.traitCollection.userInterfaceStyle == .dark
let colorScheme = isDarkMode ? "dark" : "light"

let isRTL = NativeUIState.shared.isRTL
let dir = isRTL ? "rtl" : "ltr"

let js = """
(function() {
// Set CSS variables directly on documentElement for immediate availability
Expand All @@ -259,6 +262,7 @@ struct WebView: UIViewRepresentable {
document.documentElement.style.setProperty('--inset-bottom', '\(insets.bottom)px');
document.documentElement.style.setProperty('--inset-left', '\(insets.left)px');
document.documentElement.style.setProperty('--native-color-scheme', '\(colorScheme)');
document.documentElement.setAttribute('dir', '\(dir)');
}
})();
"""
Expand Down
3 changes: 3 additions & 0 deletions resources/xcode/NativePHP/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
</array>
</dict>
</array>
<key>CFBundleLocalizations</key>
<array>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
Expand Down
47 changes: 26 additions & 21 deletions resources/xcode/NativePHP/NativePHPApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,22 @@ struct NativePHPApp: App {
// Splash overlays until WebView finishes loading (Phase 3)
if !appState.isInitialized {
SplashView()
.transition(.opacity)
.onAppear {
// Phase 1: Start deferred initialization on a background thread
// This runs AFTER the splash view is visible, avoiding watchdog timeout
DispatchQueue.global(qos: .userInitiated).async {
performDeferredInitialization()
}
.transition(.opacity)
.onAppear {
// Phase 1: Start deferred initialization on a background thread
// This runs AFTER the splash view is visible, avoiding watchdog timeout
DispatchQueue.global(qos: .userInitiated).async {
performDeferredInitialization()
}
}
}
}
.animation(.easeInOut(duration: 0.3), value: appState.isInitialized)
// Apply layout direction based on rtl_support config and device locale.
// When rtl_support is enabled, RTL languages get .rightToLeft; otherwise always LTR.
// UIKit components (UITabBar, UINavigationBar) are handled separately
// via AppDelegate.applyRTLAppearance().
.environment(\.layoutDirection, NativeUIState.shared.isRTL ? .rightToLeft : .leftToRight)
.onOpenURL { url in
// Only handle if not already handled by AppDelegate during cold start
if !DeepLinkRouter.shared.hasPendingURL() {
Expand Down Expand Up @@ -197,19 +202,19 @@ struct NativePHPApp: App {
let envPath = URL(fileURLWithPath: appPath).appendingPathComponent(".env")

guard FileManager.default.fileExists(atPath: envPath.path),
let envContent = try? String(contentsOf: envPath, encoding: .utf8) else {
let envContent = try? String(contentsOf: envPath, encoding: .utf8) else {
DebugLogger.shared.log("⚙️ No .env file found, using default start URL")
return "/"
}

// Use regex to find NATIVEPHP_START_URL value
let pattern = #"NATIVEPHP_START_URL\s*=\s*([^\r\n]+)"#
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: envContent, range: NSRange(envContent.startIndex..., in: envContent)),
let valueRange = Range(match.range(at: 1), in: envContent) {
let match = regex.firstMatch(in: envContent, range: NSRange(envContent.startIndex..., in: envContent)),
let valueRange = Range(match.range(at: 1), in: envContent) {
var value = String(envContent[valueRange])
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))

if !value.isEmpty {
// Ensure path starts with /
Expand All @@ -229,9 +234,9 @@ struct NativePHPApp: App {
let caPath = Bundle.main.path(forResource: "cacert", ofType: "pem") ?? "Path not found"

let phpIni = """
curl.cainfo="\(caPath)"
openssl.cafile="\(caPath)"
"""
curl.cainfo="\(caPath)"
openssl.cafile="\(caPath)"
"""

let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let path = supportDir.appendingPathComponent("php.ini")
Expand All @@ -251,7 +256,7 @@ struct NativePHPApp: App {
let fileManager = FileManager.default

let databaseFileURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
.appendingPathComponent("database/database.sqlite")
.appendingPathComponent("database/database.sqlite")

if !fileManager.fileExists(atPath: databaseFileURL.path) {
// Create an empty SQLite file
Expand Down Expand Up @@ -341,12 +346,12 @@ struct NativePHPApp: App {

for (header, value) in request.headers {
let formattedKey = "HTTP_" + header
.replacingOccurrences(of: "-", with: "_")
.uppercased()
.replacingOccurrences(of: "-", with: "_")
.uppercased()

// Convert Swift strings to C strings
guard let cKey = formattedKey.cString(using: .utf8),
let cValue = value.cString(using: .utf8) else {
let cValue = value.cString(using: .utf8) else {
print("Failed to convert \(header) or its value to C string.")
continue
}
Expand Down Expand Up @@ -430,8 +435,8 @@ struct NativePHPApp: App {
let status = SecItemCopyMatching(query as CFDictionary, &result)

if status == errSecSuccess,
let data = result as? Data,
let existingKey = String(data: data, encoding: .utf8) {
let data = result as? Data,
let existingKey = String(data: data, encoding: .utf8) {
DebugLogger.shared.log("🔑 Retrieved existing APP_KEY from Keychain")
return existingKey
}
Expand Down
Loading