feat(iOS): 完善iOS平台WiFi配对与权限本地化适配

1. 新增iOSWiFiHelper类实现异步获取WiFi SSID,自动请求位置权限
2. 新增iOS权限描述本地化处理器,实现中英文权限文案自动切换
3. 更新所有iOS权限描述为更清晰的英文/中文说明
4. 优化iOS编译后处理脚本优先级,避免被其他插件覆盖
5. 在连接设备页面和WiFi设置页面自动填充当前WiFi名称
6. 更新应用版本号与构建号
This commit is contained in:
yuqucheng 2026-08-07 16:14:10 +08:00
parent 954cb33e71
commit 9316f8f331
11 changed files with 354 additions and 21 deletions

View File

@ -0,0 +1,90 @@
#if UNITY_IOS
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
using System.IO;
using System.Text;
using UnityEngine;
/// <summary>
/// iOS 权限描述双语本地化:
/// - Info.plist 写入英文描述作为默认(所有非中文用户看到英文)
/// - 生成 zh-Hans.lproj/InfoPlist.strings中文系统用户自动看到中文
/// 最高优先级执行,确保覆盖其他插件写入的权限描述。
/// </summary>
public class iOSLocalizedPermissionPostProcessor
{
// 权限描述中英文对照
private static readonly (string key, string en, string zhHans)[] PERMISSIONS =
{
("NSCameraUsageDescription",
"Camera access is used to scan the QR code on your device for pairing. For example, scan the QR code on the device body to complete the connection.",
"需要相机权限来扫描设备上的二维码完成配对。例如:扫描设备机身上的二维码后完成连接。"),
("NSLocationWhenInUseUsageDescription",
"Location access is used to detect the current WiFi network so we can display the connected WiFi name.",
"需要位置权限来获取当前 WiFi 信息,用于展示已连接的 WiFi 名称。"),
("NSBluetoothAlwaysUsageDescription",
"Bluetooth is used to scan for and connect to your device. For example, after pairing with the device, we send control commands to it.",
"需要蓝牙权限来搜索并连接你的设备。例如:设备配对成功后向其发送控制指令。"),
("NSBluetoothPeripheralUsageDescription",
"Bluetooth is used to scan for and connect to your device. For example, after pairing with the device, we send control commands to it.",
"需要蓝牙权限来搜索并连接你的设备。例如:设备配对成功后向其发送控制指令。"),
("NSPhotoLibraryUsageDescription",
"Allows you to choose photos from your library to set as your profile picture or attach to feedback reports. For example, select a photo when you tap your avatar in the profile page.",
"允许你从相册选择照片设置头像或上传问题反馈截图。例如:在个人主页点击头像时选择照片进行更换。"),
("NSPhotoLibraryAddUsageDescription",
"Allows saving generated QR code images and downloaded videos to your photo library. For example, save a QR code to your library so you can share it with friends.",
"允许将生成的二维码图片和下载的视频保存到你的相册。例如:将二维码保存到相册后分享给好友。"),
};
[PostProcessBuild(2000)]
public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject)
{
if (target != BuildTarget.iOS) return;
// 1. Info.plist 写入英文描述(默认回退语言)
string plistPath = Path.Combine(pathToBuiltProject, "Info.plist");
PlistDocument plist = new PlistDocument();
plist.ReadFromString(File.ReadAllText(plistPath));
foreach (var p in PERMISSIONS)
{
plist.root.SetString(p.key, p.en);
}
File.WriteAllText(plistPath, plist.WriteToString());
// 2. 生成中文本地化文件 InfoPlist.strings
WriteChineseStringsFile(pathToBuiltProject);
Debug.Log("[iOSLocalizedPermissionPostProcessor] 权限描述已本地化 (默认英文 / 中文自动切换)");
}
private static void WriteChineseStringsFile(string buildPath)
{
string lprojDir = Path.Combine(buildPath, "zh-Hans.lproj");
Directory.CreateDirectory(lprojDir);
var sb = new StringBuilder();
foreach (var p in PERMISSIONS)
{
sb.AppendLine($"\"{p.key}\" = \"{p.zhHans}\";");
}
// 使用带 BOM 的 UTF-8 编码,兼容 Xcode
string filePath = Path.Combine(lprojDir, "InfoPlist.strings");
File.WriteAllText(filePath, sb.ToString(), new UTF8Encoding(true));
// 3. 将 InfoPlist.strings 添加到 Xcode 工程的 Resources 构建阶段
string pbxPath = PBXProject.GetPBXProjectPath(buildPath);
PBXProject proj = new PBXProject();
proj.ReadFromString(File.ReadAllText(pbxPath));
string targetGuid = proj.GetUnityMainTargetGuid();
string fileGuid = proj.AddFile(filePath, Path.Combine("zh-Hans.lproj", "InfoPlist.strings"), PBXSourceTree.Source);
proj.AddFileToBuild(targetGuid, fileGuid);
File.WriteAllText(pbxPath, proj.WriteToString());
Debug.Log($"[iOSLocalizedPermissionPostProcessor] 已添加中文本地化: {filePath}");
}
}
#endif

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ebd0bc243972540f0bd8a732dc3ec572
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -18,7 +18,7 @@ public class iOSPermissionPostProcessor
PlistElementDict rootDict = plist.root;
// 添加摄像头权限描述
rootDict.SetString("NSCameraUsageDescription", "需要摄像头权限来扫描二维码连接设备");
rootDict.SetString("NSCameraUsageDescription", "Camera access is used to scan the QR code on your device for pairing. For example, scan the QR code on the device body to complete the connection.");
File.WriteAllText(plistPath, plist.WriteToString());
}

View File

@ -8,7 +8,8 @@ public class iOSWiFiBuildPostProcessor
{
private const string WIFI_INFO_ENTITLEMENT = "com.apple.developer.networking.wifi-info";
[PostProcessBuild]
// 高于默认顺序执行,确保覆盖其他插件写入的蓝牙描述
[PostProcessBuild(1000)]
public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject)
{
if (target != BuildTarget.iOS) return;
@ -21,11 +22,11 @@ public class iOSWiFiBuildPostProcessor
PlistElementDict rootDict = plist.root;
// 添加位置权限描述iOS 13+ 获取 WiFi 需要)
rootDict.SetString("NSLocationWhenInUseUsageDescription", "需要位置权限来获取当前 WiFi 信息");
rootDict.SetString("NSLocationWhenInUseUsageDescription", "Location access is used to detect the current WiFi network so we can display the connected WiFi name.");
// 添加蓝牙权限描述
rootDict.SetString("NSBluetoothAlwaysUsageDescription", "需要蓝牙权限来连接设备");
rootDict.SetString("NSBluetoothPeripheralUsageDescription", "需要蓝牙权限来连接设备");
rootDict.SetString("NSBluetoothAlwaysUsageDescription", "Bluetooth is used to scan for and connect to your device. For example, after pairing with the device, we send control commands to it.");
rootDict.SetString("NSBluetoothPeripheralUsageDescription", "Bluetooth is used to scan for and connect to your device. For example, after pairing with the device, we send control commands to it.");
// 添加后台模式
PlistElementArray bgModes = rootDict.CreateArray("UIBackgroundModes");

View File

@ -14,8 +14,8 @@ namespace NativeGalleryNamespace
private const string SAVE_PATH = "ProjectSettings/NativeGallery.json";
public bool AutomatedSetup = true;
public string PhotoLibraryUsageDescription = "The app requires access to Photos to interact with it.";
public string PhotoLibraryAdditionsUsageDescription = "The app requires access to Photos to save media to it.";
public string PhotoLibraryUsageDescription = "Allows you to choose photos from your library to set as your profile picture or attach to feedback reports. For example, select a photo when you tap your avatar in the profile page.";
public string PhotoLibraryAdditionsUsageDescription = "Allows saving generated QR code images and downloaded videos to your photo library. For example, save a QR code to your library so you can share it with friends.";
public bool DontAskLimitedPhotosPermissionAutomaticallyOnIos14 = true; // See: https://mackuba.eu/2020/07/07/photo-library-changes-ios-14/
private static Settings m_instance = null;

View File

@ -2,10 +2,119 @@
#import <SystemConfiguration/CaptiveNetwork.h>
#import <NetworkExtension/NetworkExtension.h>
#import <CoreLocation/CoreLocation.h>
#import <string.h>
static CLLocationManager *locationManager = nil;
// UnitySendMessage 声明
extern void UnitySendMessage(const char *obj, const char *method, const char *msg);
// 授权变化回调目标(通过 UnitySendMessage 通知 C#
static const char *callbackGameObject = nil;
static const char *callbackMethod = nil;
// GetWiFiSSID 前向声明(定义在 extern "C" 块中)
extern "C" const char* GetWiFiSSID();
// 是否正在等待定位更新(收到第一个位置后立即获取 SSID
static BOOL pendingSSIDFetch = NO;
// 延迟获取 SSID 并回调 C#
static void FetchSSIDAndCallback() {
char *ssid = (char *)GetWiFiSSID();
if (callbackGameObject != nil && callbackMethod != nil) {
if (ssid != NULL) {
UnitySendMessage(callbackGameObject, callbackMethod, ssid);
free(ssid);
} else {
UnitySendMessage(callbackGameObject, callbackMethod, "");
}
}
}
@interface WiFiHelperLocationDelegate : NSObject <CLLocationManagerDelegate>
@end
@implementation WiFiHelperLocationDelegate
- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager {
if (@available(iOS 14.0, *)) {
// iOS 14+ 新回调:参数是 manager需自行查询授权状态
CLAuthorizationStatus status = [CLLocationManager authorizationStatus];
if (status == kCLAuthorizationStatusAuthorizedWhenInUse ||
status == kCLAuthorizationStatusAuthorizedAlways) {
NSLog(@"[WiFiHelper] Location permission granted after request");
[WiFiHelperLocationDelegate startSSIDFetchFlow];
} else if (status == kCLAuthorizationStatusDenied ||
status == kCLAuthorizationStatusRestricted) {
NSLog(@"[WiFiHelper] Location permission denied after request");
if (callbackGameObject != nil && callbackMethod != nil) {
UnitySendMessage(callbackGameObject, callbackMethod, "");
}
}
}
}
- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status {
if (@available(iOS 14.0, *)) return; // iOS 14+ 由上面的新回调处理
// iOS 13 及以下用旧回调方法
if (status == kCLAuthorizationStatusAuthorizedWhenInUse ||
status == kCLAuthorizationStatusAuthorizedAlways) {
NSLog(@"[WiFiHelper] Location permission granted after request (legacy callback)");
[WiFiHelperLocationDelegate startSSIDFetchFlow];
} else if (status == kCLAuthorizationStatusDenied ||
status == kCLAuthorizationStatusRestricted) {
NSLog(@"[WiFiHelper] Location permission denied after request (legacy callback)");
if (callbackGameObject != nil && callbackMethod != nil) {
UnitySendMessage(callbackGameObject, callbackMethod, "");
}
}
}
// 收到第一个位置更新后再获取 SSIDNEHotspotNetwork.fetchCurrent 的隐式要求)
- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray<CLLocation *> *)locations {
if (pendingSSIDFetch) {
NSLog(@"[WiFiHelper] First location update received, fetching SSID now");
pendingSSIDFetch = NO;
[manager stopUpdatingLocation];
FetchSSIDAndCallback();
}
}
// 定位失败时也兜底尝试获取一次
- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error {
NSLog(@"[WiFiHelper] Location manager failed: %@", error.localizedDescription);
if (pendingSSIDFetch) {
pendingSSIDFetch = NO;
[manager stopUpdatingLocation];
FetchSSIDAndCallback();
}
}
// 启动定位并等待第一个位置更新,超时则直接获取
+ (void)startSSIDFetchFlow {
if (![CLLocationManager locationServicesEnabled]) {
NSLog(@"[WiFiHelper] Location services disabled on device, fetching SSID anyway");
FetchSSIDAndCallback();
return;
}
if (locationManager == nil) {
locationManager = [[CLLocationManager alloc] init];
}
pendingSSIDFetch = YES;
[locationManager startUpdatingLocation];
// 超时兜底定位迟迟无响应如首次定位慢3 秒后直接获取
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 3.0 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
if (pendingSSIDFetch) {
NSLog(@"[WiFiHelper] Timeout waiting for location update, fetching SSID anyway");
pendingSSIDFetch = NO;
[locationManager stopUpdatingLocation];
FetchSSIDAndCallback();
}
});
}
@end
static WiFiHelperLocationDelegate *locationDelegate = nil;
extern "C" {
void RequestLocationPermission() {
if (locationManager != nil) return;
@ -31,12 +140,52 @@ extern "C" {
NSLog(@"[WiFiHelper] Location permission denied/restricted (status: %d)", (int)status);
}
}
const char* GetWiFiSSID() {
// 确保位置已启动至少一次NEHotspotNetwork 的隐式要求)
if (locationManager != nil && [CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorizedWhenInUse) {
[locationManager startUpdatingLocation];
/// <summary>
/// 请求位置权限(如未授权),授权完成后自动获取 WiFi 名称并回调 C#。
/// 已授权时立即获取并回调。
/// </summary>
void RequestWiFiSSIDNative(const char *gameObjectName, const char *methodName) {
if (callbackGameObject != nil) {
free((void *)callbackGameObject);
}
if (callbackMethod != nil) {
free((void *)callbackMethod);
}
callbackGameObject = gameObjectName ? strdup(gameObjectName) : nil;
callbackMethod = methodName ? strdup(methodName) : nil;
if (locationManager == nil) {
locationManager = [[CLLocationManager alloc] init];
}
if (locationDelegate == nil) {
locationDelegate = [[WiFiHelperLocationDelegate alloc] init];
}
locationManager.delegate = locationDelegate;
CLAuthorizationStatus status = [CLLocationManager authorizationStatus];
if (status == kCLAuthorizationStatusAuthorizedWhenInUse ||
status == kCLAuthorizationStatusAuthorizedAlways) {
NSLog(@"[WiFiHelper] Location already granted, fetching SSID directly");
[WiFiHelperLocationDelegate startSSIDFetchFlow];
return;
}
if (status == kCLAuthorizationStatusNotDetermined) {
NSLog(@"[WiFiHelper] Requesting location permission, will fetch SSID on grant");
[locationManager requestWhenInUseAuthorization];
} else {
NSLog(@"[WiFiHelper] Location permission denied/restricted (status: %d)", (int)status);
if (callbackGameObject != nil && callbackMethod != nil) {
UnitySendMessage(callbackGameObject, callbackMethod, "");
}
}
}
const char* GetWiFiSSID() {
CLAuthorizationStatus status = [CLLocationManager authorizationStatus];
BOOL locationEnabled = [CLLocationManager locationServicesEnabled];
NSLog(@"[WiFiHelper] GetWiFiSSID: auth=%d servicesEnabled=%d", (int)status, (int)locationEnabled);
__block NSString *resultSSID = nil;
__block BOOL finished = NO;
@ -47,6 +196,8 @@ extern "C" {
[NEHotspotNetwork fetchCurrentWithCompletionHandler:^(NEHotspotNetwork * _Nullable network) {
if (network && network.SSID.length > 0) {
resultSSID = network.SSID;
} else {
NSLog(@"[WiFiHelper] NEHotspotNetwork fetchCurrent returned nil");
}
finished = YES;
}];
@ -79,10 +230,6 @@ extern "C" {
}
}
if (locationManager != nil) {
[locationManager stopUpdatingLocation];
}
if (resultSSID == nil) {
NSLog(@"[WiFiHelper] No WiFi SSID found.");
return NULL;

View File

@ -154,9 +154,21 @@ public class ConnectDevicePageCtrl : MonoBehaviour
g.gameObject.SetActive(false);
sonPages[3].SetActive(true);
#if UNITY_IOS && !UNITY_EDITOR
// iOS异步请求位置权限授权后自动填入 WiFi 名称
wifiNameInput.text = "";
iOSWiFiHelper.RequestWiFiSSID((ssid) =>
{
if (!string.IsNullOrEmpty(ssid))
{
wifiNameInput.text = ssid;
}
});
#else
// 获取当前 WiFi 名称
string wifiName = GetCurrentWiFiName();
wifiNameInput.text = wifiName ?? "";
#endif
wifiPasswordInput.text="";

View File

@ -26,8 +26,21 @@ namespace Kill.UI.Pages
backButton.onClick.AddListener(Back);
confirmButton.onClick.RemoveAllListeners();
confirmButton.onClick.AddListener(OnWifiConfirmClick);
#if UNITY_IOS && !UNITY_EDITOR
// iOS异步请求位置权限授权后自动填入 WiFi 名称(无需重新打开页面)
ssidInput.text = "";
iOSWiFiHelper.RequestWiFiSSID((ssid) =>
{
if (!string.IsNullOrEmpty(ssid))
{
ssidInput.text = ssid;
CheckWifiNameAndPassword();
}
});
#else
string wifiName = GetCurrentWiFiName();
ssidInput.text = wifiName ?? "";
#endif
passwordInput.text = "";
CheckWifiNameAndPassword();
ssidInput.onEndEdit.RemoveAllListeners();

View File

@ -0,0 +1,48 @@
using System;
using System.Runtime.InteropServices;
using UnityEngine;
/// <summary>
/// iOS WiFi 名称异步获取辅助类。
/// 自动请求位置权限iOS 14+ NEHotspotNetwork 需要),
/// 用户授权后回调返回当前连接的 WiFi 名称,无需重新打开页面。
/// </summary>
public class iOSWiFiHelper : MonoBehaviour
{
private static iOSWiFiHelper _instance;
private static Action<string> _callback;
/// <summary>
/// 请求位置权限并获取当前 WiFi 名称,授权后异步回调。
/// </summary>
/// <param name="callback">回调参数为 WiFi SSID获取失败或用户拒绝时为 null</param>
public static void RequestWiFiSSID(Action<string> callback)
{
_callback = callback;
#if UNITY_IOS && !UNITY_EDITOR
EnsureInstance();
RequestWiFiSSIDNative(_instance.name, "OnWiFiSSIDResult");
#endif
}
private static void EnsureInstance()
{
if (_instance != null) return;
var go = new GameObject("iOSWiFiHelper");
DontDestroyOnLoad(go);
_instance = go.AddComponent<iOSWiFiHelper>();
}
/// <summary>原生层通过 UnitySendMessage 回调此方法</summary>
public void OnWiFiSSIDResult(string ssid)
{
var cb = _callback;
_callback = null;
cb?.Invoke(string.IsNullOrEmpty(ssid) ? null : ssid);
}
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
private static extern void RequestWiFiSSIDNative(string gameObjectName, string methodName);
#endif
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12a95f347f56e4858946e8ed0fe12caa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -145,7 +145,7 @@ PlayerSettings:
loadStoreDebugModeEnabled: 0
visionOSBundleVersion: 1.0
tvOSBundleVersion: 1.0
bundleVersion: 1.0.2
bundleVersion: 1.0.3
preloadedAssets:
- {fileID: 7756680987677051820, guid: 0488490a53b3eb2409be4727abdf829a, type: 2}
- {fileID: 11400000, guid: d990b2e2bc28cca43863c18940a862a3, type: 2}
@ -174,7 +174,7 @@ PlayerSettings:
buildNumber:
Standalone: 0
VisionOS: 0
iPhone: 3
iPhone: 0
tvOS: 0
overrideDefaultApplicationIdentifier: 1
AndroidBundleVersionCode: 3
@ -565,10 +565,10 @@ PlayerSettings:
enableInternalProfiler: 0
logObjCUncaughtExceptions: 1
enableCrashReportAPI: 0
cameraUsageDescription: "\u7528\u4E8E\u626B\u63CF\u4E8C\u7EF4\u7801"
locationUsageDescription: "\u7528\u4E8E\u84DD\u7259\u8BBE\u5907\u901A\u4FE1"
cameraUsageDescription: "Camera access is used to scan the QR code on your device for pairing."
locationUsageDescription: "Location access is used to detect the current WiFi network."
microphoneUsageDescription:
bluetoothUsageDescription:
bluetoothUsageDescription: "Bluetooth is used to scan for and connect to your device."
macOSTargetOSVersion: 10.13.0
switchNMETAOverride:
switchNetLibKey: