diff --git a/.gitignore b/.gitignore index cd426cf380..72a497f890 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ $null /docs .trae-html-share-packages/ .trae/specs/ +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md index 9ae51a8b6e..392bd6909c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,5 +152,5 @@ 3. 此项目使用 Xcode 15.4/16,iPhoneOS 17.5 SDK 构建,且最低系统支持为 iOS 14.0。 4. Git 提交时请使用 `herbrine8403` 用户名和 `weishixvn@outlook.com` 邮箱。 5. 项目支持多平台构建,包括 iOS、tvOS、iOS 模拟器和 visionOS,通过 `PLATFORM` 参数指定。 -6. 渲染器设置为 Auto 时将自动选择合适的渲染器,包括 MobileGlues。 -7. JVM 版本将根据游戏版本自动选择 (Java 8/17/21)。 \ No newline at end of file +6. 渲染器设置为 Auto 时固定解析为 ANGLE;其他渲染后端需手动明确选择。 +7. JVM 版本将根据游戏版本自动选择 (Java 8/17/21)。 diff --git a/Natives/AssetVersionViewController.h b/Natives/AssetVersionViewController.h index 1f7f880858..94e0d61635 100644 --- a/Natives/AssetVersionViewController.h +++ b/Natives/AssetVersionViewController.h @@ -34,6 +34,8 @@ typedef NS_ENUM(NSInteger, AssetVersionType) { @property (nonatomic, assign) AssetVersionType assetType; // 在线项目的 Modrinth/CurseForge ID @property (nonatomic, copy, nullable) NSString *projectID; +// 在线 API 来源:1=Modrinth,2=CurseForge;默认 1 +@property (nonatomic, assign) NSInteger apiSource; // 项目显示名(用于导航栏标题) @property (nonatomic, copy, nullable) NSString *projectDisplayName; // 代理 diff --git a/Natives/AssetVersionViewController.m b/Natives/AssetVersionViewController.m index 001766787e..98e1cdc761 100644 --- a/Natives/AssetVersionViewController.m +++ b/Natives/AssetVersionViewController.m @@ -5,12 +5,12 @@ // // 通用资源版本选择视图控制器实现 // 阶段3统一:重构为 FCL 风格 chips 筛选条(游戏版本 + 排序方式),与 ModVersionViewController 风格一致 -// 资产类型(资源包/数据包/世界)无加载器概念,故无加载器筛选行,无双源切换(仅 Modrinth) -// 复用 ModrinthAPI 的 getVersionsForModWithID: 方法(API 端点对所有 project_type 通用) +// 资产类型(资源包/数据包/世界)无加载器概念,故无加载器筛选行;版本源继承搜索结果。 // #import "AssetVersionViewController.h" #import "installer/modpack/ModrinthAPI.h" +#import "installer/modpack/CurseForgeAPI.h" #import "ModVersion.h" #import "ModVersionTableViewCell.h" #import "AssetDetailHeaderView.h" @@ -509,8 +509,7 @@ - (void)fetchVersions { } [self.activityIndicator startAnimating]; - // 复用 getVersionsForModWithID:(Modrinth /project//version 端点对所有 project_type 通用) - [[ModrinthAPI sharedInstance] getVersionsForModWithID:self.projectID completion:^(NSArray * _Nullable versions, NSError * _Nullable error) { + void (^completion)(NSArray * _Nullable, NSError * _Nullable) = ^(NSArray * _Nullable versions, NSError * _Nullable error) { dispatch_async(dispatch_get_main_queue(), ^{ [self.activityIndicator stopAnimating]; if (error) { @@ -526,7 +525,13 @@ - (void)fetchVersions { [self processFilters]; [self applyFiltersAndSort]; }); - }]; + }; + + if (self.apiSource == 2) { + [[CurseForgeAPI sharedInstance] getVersionsForModWithID:self.projectID completion:completion]; + } else { + [[ModrinthAPI sharedInstance] getVersionsForModWithID:self.projectID completion:completion]; + } } #pragma mark - 筛选 + 排序 diff --git a/Natives/DataPackItem.h b/Natives/DataPackItem.h index b8ab762e6b..2fbe66f192 100644 --- a/Natives/DataPackItem.h +++ b/Natives/DataPackItem.h @@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN // --- 在线数据包属性 --- @property (nonatomic, copy, nullable) NSString *onlineID; +/// 在线 API 来源:1=Modrinth,2=CurseForge。 +@property (nonatomic, assign) NSInteger apiSource; @property (nonatomic, copy, nullable) NSString *author; @property (nonatomic, strong, nullable) NSNumber *downloads; @property (nonatomic, strong, nullable) NSNumber *likes; diff --git a/Natives/DataPackItem.m b/Natives/DataPackItem.m index b3592e34b5..e1eb24788f 100644 --- a/Natives/DataPackItem.m +++ b/Natives/DataPackItem.m @@ -31,6 +31,7 @@ - (instancetype)initWithOnlineData:(NSDictionary *)data { if (self = [super init]) { // 来自 Modrinth 搜索结果 _onlineID = data[@"id"] ? [data[@"id"] description] : nil; + _apiSource = [data[@"apiSource"] integerValue] == 2 ? 2 : 1; _displayName = data[@"title"] ?: @""; _dataPackDescription = data[@"description"] ?: @""; _iconURL = data[@"imageUrl"] ?: @""; diff --git a/Natives/DataPackService.h b/Natives/DataPackService.h index a49677cd7d..76a41e4954 100644 --- a/Natives/DataPackService.h +++ b/Natives/DataPackService.h @@ -31,7 +31,7 @@ typedef void(^DataPackDownloadProgressHandler)(NSProgress * _Nullable downloadPr // --- 本地数据包管理 --- // 扫描指定 profile 的 datapacks 目录,返回 .zip 和 .zip.disabled 文件列表 -- (void)scanDataPacksForProfile:(NSString *)profileName completion:(DataPackListHandler)completion; +- (void)scanDataPacksForProfile:(NSString * _Nullable)profileName completion:(DataPackListHandler)completion; // 获取数据包元数据(解析 zip 内的 pack.mcmeta,获取 pack_format 和 description) - (void)fetchMetadataForDataPack:(DataPackItem *)item completion:(DataPackMetadataHandler)completion; // 启用/禁用数据包(加/去 .disabled 后缀) @@ -44,14 +44,14 @@ typedef void(^DataPackDownloadProgressHandler)(NSProgress * _Nullable downloadPr // 注意:Minecraft 要求数据包放在 /saves/<世界名>/datapacks/,但 iOS 上无法选择世界, // 因此默认下载到 /datapacks/,需用户手动移动到对应世界目录。 - (void)downloadDataPack:(DataPackItem *)item - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName progress:(DataPackDownloadProgressHandler _Nullable)progress completion:(DataPackDownloadCompletionHandler _Nullable)completion; // 下载数据包到指定世界的 datapacks 目录(/saves//datapacks/) // worldName 为 nil 时回退到 /datapacks/ - (void)downloadDataPack:(DataPackItem *)item - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName worldName:(nullable NSString *)worldName progress:(DataPackDownloadProgressHandler _Nullable)progress completion:(DataPackDownloadCompletionHandler _Nullable)completion; @@ -61,7 +61,7 @@ typedef void(^DataPackDownloadProgressHandler)(NSProgress * _Nullable downloadPr /// CurseForge hashes algo=1),传入即启用校验,校验失败由统一下载器按镜像/退避节奏重试; /// 为 nil 时不做 SHA1 校验,靠 zip EOCD 兜底校验保证完整性。 - (void)downloadDataPack:(DataPackItem *)item - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName worldName:(nullable NSString *)worldName expectedSHA1:(nullable NSString *)expectedSHA1 progress:(DataPackDownloadProgressHandler _Nullable)progress @@ -71,7 +71,7 @@ typedef void(^DataPackDownloadProgressHandler)(NSProgress * _Nullable downloadPr - (NSString *)iconCachePathForURL:(NSString *)urlString; /// 获取当前 profile 的 datapacks 目录,不存在时自动创建 -- (nullable NSString *)ensureDataPacksFolderForProfile:(NSString *)profileName error:(NSError **)error; +- (nullable NSString *)ensureDataPacksFolderForProfile:(NSString * _Nullable)profileName error:(NSError **)error; @end diff --git a/Natives/DataPackService.m b/Natives/DataPackService.m index 3ee1a32e75..d2ddf7f42b 100644 --- a/Natives/DataPackService.m +++ b/Natives/DataPackService.m @@ -131,84 +131,24 @@ - (void)parsePackMcmetaForItem:(DataPackItem *)item { // 解析 profile 的 gameDir,返回 gameDir 或 nil - (nullable NSString *)gameDirForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - return gameDir; - } - } - } @catch (NSException *ex) { } - - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - return [NSString stringWithUTF8String:gameDirC]; - } - return nil; + return [PLProfiles resolvedGameDirectoryForProfileName:profileName]; } #pragma mark - DataPacks folder detection & scan // 查找指定 profile 的 datapacks 目录(已存在时返回路径,否则返回 nil) - (nullable NSString *)existingDataPacksFolderForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - NSString *dataPacksPath = [gameDir stringByAppendingPathComponent:@"datapacks"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:dataPacksPath isDirectory:&isDir] && isDir) { - return dataPacksPath; - } - } - } - } @catch (NSException *ex) { } - - // 回退:读取 POJAV_GAME_DIR 环境变量 - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - NSString *dataPacksPath = [gameDir stringByAppendingPathComponent:@"datapacks"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:dataPacksPath isDirectory:&isDir] && isDir) { - return dataPacksPath; - } - } + NSString *dataPacksPath = [[self gameDirForProfile:profileName] stringByAppendingPathComponent:@"datapacks"]; + BOOL isDir = NO; + if ([fm fileExistsAtPath:dataPacksPath isDirectory:&isDir] && isDir) return dataPacksPath; return nil; } /// 获取当前 profile 的 datapacks 目录,不存在时自动创建 - (nullable NSString *)ensureDataPacksFolderForProfile:(NSString *)profileName error:(NSError **)error { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - NSString *dataPacksPath = nil; - - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - dataPacksPath = [gameDir stringByAppendingPathComponent:@"datapacks"]; - } - } - } @catch (NSException *ex) { } - - if (!dataPacksPath) { - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - dataPacksPath = [gameDir stringByAppendingPathComponent:@"datapacks"]; - } - } + NSString *dataPacksPath = [[self gameDirForProfile:profileName] stringByAppendingPathComponent:@"datapacks"]; if (!dataPacksPath) { if (error) { @@ -457,6 +397,9 @@ - (void)downloadDataPack:(DataPackItem *)item supportsResume:YES iconURL:item.iconURL]; taskItem.downloadURL = item.selectedVersionDownloadURL; + NSString *taskProfileName = [PLProfiles effectiveProfileNameForPreferredName:profileName]; + if (taskProfileName.length > 0) taskItem.userInfo[@"profileName"] = taskProfileName; + taskItem.userInfo[@"destinationPath"] = destinationPath; // redesign-download-ui Phase 3:单文件下载接入统一进度页—— // PLTaskStagesSingleFile 单阶段 + autoPresentDetail 自动弹出 PLTaskProgressViewController [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId stages:PLTaskStagesSingleFile()]; @@ -464,10 +407,11 @@ - (void)downloadDataPack:(DataPackItem *)item // retryHandler:FCL 风格重新下载,复用同一 taskItem,重新发起 PLDownloadClient 请求 __weak typeof(self) weakSelf = self; + __block PLDownloadRequest *retryRequest = nil; taskItem.retryHandler = ^id(DownloadTaskItem *taskItemRef) { __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return nil; - return [strongSelf restartPLDownloadForTaskId:taskItemRef.taskId]; + if (!strongSelf || !retryRequest) return nil; + return [strongSelf startPLDownloadWithRequest:retryRequest taskItem:taskItemRef progress:progress completion:completion]; }; PLDownloadRequest *request = [[PLDownloadRequest alloc] init]; @@ -486,6 +430,7 @@ - (void)downloadDataPack:(DataPackItem *)item request.taskIdentifier = taskItem.taskId; // 无 SHA1 时对 .zip 做 EOCD 兜底完整性校验 request.allowZipFallbackCheck = YES; + retryRequest = request; [self startPLDownloadWithRequest:request taskItem:taskItem progress:progress completion:completion]; @@ -514,7 +459,6 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest self.downloadAccumulatedBytes[taskId] = @(0); self.downloadTotalBytes[taskId] = @(-1); self.downloadLastSpeeds[taskId] = @(0.0); - [self.downloadStateLock unlock]; PLDownloadOperation *operation = [[PLDownloadClient sharedClient] startRequest:request progress:^(int64_t deltaBytes, int64_t totalExpectedBytes) { @@ -534,12 +478,11 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest }]; if (!operation) { // 参数错误:PLDownloadClient 会异步回调 completion(error),由统一失败路径收尾 + [self.downloadStateLock unlock]; return nil; } - [self.downloadStateLock lock]; self.downloadOperations[taskId] = operation; - [self.downloadStateLock unlock]; // rawTask 为 weak 引用:operation 由 PLDownloadClient 与本 Service 共同持有, // DownloadTaskManager 据此对 PLDownloadOperation 做 pause/resume/cancel @@ -550,6 +493,7 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest [[DownloadTaskManager sharedManager] updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusRunning]; + [self.downloadStateLock unlock]; return operation; } @@ -642,21 +586,21 @@ - (void)handlePLDownloadCompletion:(BOOL)success [self.downloadStateLock unlock]; DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + NSError *completionError = success ? nil : (error ?: [NSError errorWithDomain:@"DataPackServiceError" code:3 userInfo:@{NSLocalizedDescriptionKey: @"Data pack download failed."}]); if (success) { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusCompleted]; - [manager setTaskWithId:taskId state:DownloadTaskStateCompleted]; + [manager setTaskWithId:taskId completedWithError:nil]; } else if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) { // 用户取消(DownloadTaskManager 已置 Cancelled,这里幂等对齐) [manager setTaskWithId:taskId state:DownloadTaskStateCancelled]; } else { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusFailed]; - [manager updateTaskWithId:taskId error:error]; - [manager setTaskWithId:taskId state:DownloadTaskStateFailed]; + [manager setTaskWithId:taskId completedWithError:completionError]; } if (completion) { BOOL successFlag = success ? YES : NO; - NSError *capturedError = success ? nil : error; + NSError *capturedError = completionError; dispatch_async(dispatch_get_main_queue(), ^{ completion(successFlag, capturedError); }); diff --git a/Natives/DataPacksManagerViewController.m b/Natives/DataPacksManagerViewController.m index 45b45383f6..47944640c8 100644 --- a/Natives/DataPacksManagerViewController.m +++ b/Natives/DataPacksManagerViewController.m @@ -13,6 +13,8 @@ #import "DataPackItem.h" #import "ResourceCardTableViewCell.h" #import "DownloadViewController.h" +#import "DownloadTaskManager.h" +#import "PLProfiles.h" #import "utils.h" #pragma mark - 数据包卡片 Cell(继承 Air-Design 卡片基类,本文件内轻量子类) @@ -91,6 +93,7 @@ - (instancetype)init { - (void)viewDidLoad { [super viewDidLoad]; + self.profileName = [PLProfiles effectiveProfileNameForPreferredName:self.profileName]; // 在线下载入口已移至下载界面:固定本地模式(currentMode 等属性保留仅为兼容 .h 既有声明) self.currentMode = DataPacksManagerModeLocal; self.localItems = [NSMutableArray array]; @@ -123,6 +126,14 @@ - (void)viewDidLoad { // 顶部提示横幅(保留既有提示文案) [self setupTipHeaderView]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleDownloadTaskCompleted:) + name:DownloadTaskManagerTaskCompletedNotification + object:nil]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; [self refreshLocalList]; } @@ -340,6 +351,8 @@ - (void)updateEmptyState { - (void)openDownloadPage { // 在线下载入口已收敛到统一下载界面(未区分资源类型 Tab,进入默认页) DownloadViewController *downloadVC = [[DownloadViewController alloc] init]; + downloadVC.initialTabIndex = 4; + downloadVC.targetProfileName = self.profileName; if (self.navigationController) { [self.navigationController pushViewController:downloadVC animated:YES]; } else { @@ -350,6 +363,14 @@ - (void)openDownloadPage { } } +- (void)handleDownloadTaskCompleted:(NSNotification *)notification { + DownloadTaskItem *task = notification.userInfo[DownloadTaskManagerTaskKey]; + if (task.state != DownloadTaskStateCompleted || + ![task.resourceType isEqualToString:DownloadTaskResourceTypeDataPack] || + ![task.userInfo[@"profileName"] isEqualToString:self.profileName]) return; + [self refreshLocalList]; +} + #pragma mark - UITableView DataSource & Delegate - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { diff --git a/Natives/DownloadTaskItem.h b/Natives/DownloadTaskItem.h index e0b495ce8f..28125b548c 100644 --- a/Natives/DownloadTaskItem.h +++ b/Natives/DownloadTaskItem.h @@ -34,6 +34,10 @@ extern NSString * const DownloadTaskResourceTypeModpack; extern NSString * const DownloadTaskResourceTypeWorld; extern NSString * const DownloadTaskResourceTypeJavaRuntime; +/// 传输字节已完成,但任务仍在校验、落盘、解压或安装。 +/// 此阶段不应在 UI 中显示为“下载中 100%”。 +extern NSString * const DownloadTaskUserInfoTransferCompleteKey; + @class DownloadTaskItem; /// 重试回调类型:业务方注册任务时设置,DownloadTaskManager.retryTaskWithId: 会调用它重建底层 rawTask。 diff --git a/Natives/DownloadTaskItem.m b/Natives/DownloadTaskItem.m index ac421704b8..9bcd10627d 100644 --- a/Natives/DownloadTaskItem.m +++ b/Natives/DownloadTaskItem.m @@ -9,6 +9,7 @@ NSString * const DownloadTaskResourceTypeModpack = @"modpack"; NSString * const DownloadTaskResourceTypeWorld = @"world"; NSString * const DownloadTaskResourceTypeJavaRuntime = @"javaruntime"; +NSString * const DownloadTaskUserInfoTransferCompleteKey = @"transferComplete"; /// 快照取值辅助:仅接受非空 NSString,否则返回兜底值(快照内容不可信,需类型清洗) static NSString *PLSnapshotString(id value, NSString *fallback) { diff --git a/Natives/DownloadTaskManager.m b/Natives/DownloadTaskManager.m index a6e618890a..d7a0758240 100644 --- a/Natives/DownloadTaskManager.m +++ b/Natives/DownloadTaskManager.m @@ -12,6 +12,12 @@ /// 全局并发下载上限(同时 Downloading 的任务数) NSInteger const PLDownloadMaxConcurrentTasks = 3; +static BOOL PLDownloadTaskStateIsTerminal(DownloadTaskState state) { + return state == DownloadTaskStateCompleted || + state == DownloadTaskStateCancelled || + state == DownloadTaskStateFailed; +} + /// NSURLSession 断点续传失效错误码(即文档中的 NSURLErrorCannotResume, /// iOS SDK 头文件未导出该符号,此处按系统取值本地定义) static NSInteger const PLNSURLErrorCannotResume = -3004; @@ -326,11 +332,12 @@ - (void)holdRawTaskLocked:(DownloadTaskItem *)item { if (!rawTask) return; // rawTask 为 nil 时仅做状态层排队 if ([rawTask isKindOfClass:[PLDownloadOperation class]]) { - if (((PLDownloadOperation *)rawTask).state == PLDownloadOperationStateRunning) { - [[PLDownloadClient sharedClient] pauseOperation:rawTask]; - NSNumber *held = self.holdCounts[item.taskId]; - self.holdCounts[item.taskId] = @((held ? held.integerValue : 0) + 1); - } + // 不在此处预读 operation.state:pauseOperation 自身会在 PLDownloadClient + // 的串行 stateQueue 上做幂等检查。无条件入队可保证快速出队时形成 + // begin -> pause -> resume 的确定顺序,避免 TOCTOU 后永久停在 Paused。 + [[PLDownloadClient sharedClient] pauseOperation:rawTask]; + NSNumber *held = self.holdCounts[item.taskId]; + self.holdCounts[item.taskId] = @((held ? held.integerValue : 0) + 1); return; } @@ -355,10 +362,8 @@ - (void)releaseRawTaskHoldLocked:(DownloadTaskItem *)item { if (!rawTask) return; if ([rawTask isKindOfClass:[PLDownloadOperation class]]) { - PLDownloadOperation *operation = (PLDownloadOperation *)rawTask; - if (operation.state == PLDownloadOperationStatePaused) { - [[PLDownloadClient sharedClient] resumeOperation:operation]; - } + // 与 pause 同一串行队列排队;不要用异步更新前的 state 做判断。 + [[PLDownloadClient sharedClient] resumeOperation:rawTask]; return; } @@ -431,6 +436,10 @@ - (void)requestDownloadingStateForItem:(DownloadTaskItem *)item { if (!item) return; [self.lock lock]; + if (PLDownloadTaskStateIsTerminal(item.state)) { + [self.lock unlock]; + return; + } if (item.state == DownloadTaskStateDownloading) { [self.lock unlock]; return; // 已占用槽位,幂等返回 @@ -550,6 +559,17 @@ - (void)resumeTaskWithId:(NSString *)taskId { id rawTask = item.rawTask; + // NSURLSessionTask 在取消式暂停的 delegate 收尾后可能已释放;rawTask 是 weak, + // 此时会变成 nil。业务方提供了 retryHandler 时直接重建,避免“继续”把状态 + // 改回 Downloading 却没有任何底层任务在运行。 + if (!rawTask && state == DownloadTaskStatePaused && item.retryHandler) { + [self removeFromWaitQueueLocked:taskId]; + [self.lock unlock]; + [self deleteResumeDataForItem:item]; + [self retryTaskWithId:taskId]; + return; + } + // 底层任务已终止(此前 pause 经 cancelByProducingResumeData: 结束): // resumeData 无业务方 session 归属无法直接复用 → 清除断点,回退 retryHandler 从头下载 if ([rawTask isKindOfClass:[NSURLSessionTask class]] && @@ -648,6 +668,16 @@ - (void)retryTaskWithId:(NSString *)taskId { DownloadTaskItem *item = self.tasks[taskId]; if (!item) { [self.lock unlock]; return; } + // 重试只允许从可恢复的静止状态进入。否则快速双击“重试”或自动重试与 + // 人工重试重叠时,会同时重建多个 rawTask,较晚返回的旧任务还可能覆盖 + // 新任务,导致后续暂停/取消控制错对象。 + if (item.state != DownloadTaskStateFailed && + item.state != DownloadTaskStateCancelled && + item.state != DownloadTaskStatePaused) { + [self.lock unlock]; + return; + } + // 无 retryHandler 无法重建 if (!item.retryHandler) { [self.lock unlock]; @@ -678,6 +708,7 @@ - (void)retryTaskWithId:(NSString *)taskId { item.speed = 0.0; item.estimatedTimeRemaining = 0.0; item.errorInfo = nil; + [item.userInfo removeObjectForKey:DownloadTaskUserInfoTransferCompleteKey]; item.retryCount += 1; item.needsRecreate = NO; @@ -771,7 +802,22 @@ - (void)updateTaskWithId:(NSString *)taskId [self.lock lock]; DownloadTaskItem *item = self.tasks[taskId]; if (item) { - item.progress = progress; + if (PLDownloadTaskStateIsTerminal(item.state)) { + [self.lock unlock]; + return; // 忽略终态之后迟到的进度回调 + } + // 收到全部字节并不等于任务完成:之后还可能校验哈希、原子落盘或解压。 + // 任务进入终态前将可见进度封顶为 99%,避免显示“下载中 100%”。 + BOOL transferComplete = (progress >= 1.0 && + item.state != DownloadTaskStateCompleted && + item.state != DownloadTaskStateCancelled && + item.state != DownloadTaskStateFailed); + item.progress = transferComplete ? 0.99 : progress; + if (transferComplete) { + item.userInfo[DownloadTaskUserInfoTransferCompleteKey] = @YES; + } else { + [item.userInfo removeObjectForKey:DownloadTaskUserInfoTransferCompleteKey]; + } if (totalBytes >= 0) item.totalSize = totalBytes; item.downloadedSize = downloadedBytes; if (item.state == DownloadTaskStatePending && ![self isQueuedLocked:taskId]) { @@ -801,6 +847,10 @@ - (void)updateTaskWithId:(NSString *)taskId [self.lock lock]; DownloadTaskItem *item = self.tasks[taskId]; if (item) { + if (PLDownloadTaskStateIsTerminal(item.state)) { + [self.lock unlock]; + return; + } item.speed = speed; item.estimatedTimeRemaining = estimatedTimeRemaining; } @@ -836,6 +886,13 @@ - (void)setTaskWithId:(NSString *)taskId state:(DownloadTaskState)state { DownloadTaskState oldState = item.state; + // 终态不可逆。显式重试会在 retryTaskWithId: 内先直接重置为 Pending, + // 因此不会被这里拦截;迟到的启动/暂停回调则不能把完成任务写回下载中。 + if (PLDownloadTaskStateIsTerminal(oldState)) { + [self.lock unlock]; + return; + } + // 防御:manager 主动 pause/cancel(cancelByProducingResumeData / cancel)后, // 业务方 session delegate 会收到 NSURLErrorCancelled 并上报 Failed——此时保持 Paused/Cancelled 不被覆盖 if (state == DownloadTaskStateFailed && @@ -854,6 +911,9 @@ - (void)setTaskWithId:(NSString *)taskId state:(DownloadTaskState)state { } item.state = state; + if (state != DownloadTaskStateDownloading) { + [item.userInfo removeObjectForKey:DownloadTaskUserInfoTransferCompleteKey]; + } [self removeFromWaitQueueLocked:taskId]; self.holdCounts[taskId] = nil; @@ -883,6 +943,10 @@ - (void)setTaskWithId:(NSString *)taskId completedWithError:(nullable NSError *) [self.lock lock]; DownloadTaskItem *item = self.tasks[taskId]; if (!item) { [self.lock unlock]; return; } + if (PLDownloadTaskStateIsTerminal(item.state)) { + [self.lock unlock]; + return; + } // 防御:同 setTaskWithId:state:,抑制 cancelByProducingResumeData 触发的残留失败上报 if (error && @@ -909,6 +973,7 @@ - (void)setTaskWithId:(NSString *)taskId completedWithError:(nullable NSError *) item.speed = 0.0; item.estimatedTimeRemaining = 0.0; } + [item.userInfo removeObjectForKey:DownloadTaskUserInfoTransferCompleteKey]; BOOL didReleaseSlot = NO; if (wasDownloading) { @@ -995,13 +1060,16 @@ - (void)updateTaskWithId:(NSString *)taskId [self.lock lock]; DownloadTaskItem *item = self.tasks[taskId]; PLTaskStage *stage = [self stageForItem:item atIndex:index]; - if (stage) stage.status = status; + BOOL ignoredTerminal = (stage && PLDownloadTaskStateIsTerminal(item.state) && status == PLTaskStageStatusRunning); + if (stage && !ignoredTerminal) { + stage.status = status; + } [self.lock unlock]; - if (stage) { + if (stage && !ignoredTerminal) { [self postUpdateForTask:item]; [self schedulePersistSnapshot]; - } else if (item) { + } else if (item && !ignoredTerminal) { NSLog(@"[DownloadTaskManager] updateTaskWithId:stageAtIndex:status: invalid stage index %lu for task %@", (unsigned long)index, taskId); } } @@ -1015,16 +1083,22 @@ - (void)updateTaskWithId:(NSString *)taskId [self.lock lock]; DownloadTaskItem *item = self.tasks[taskId]; PLTaskStage *stage = [self stageForItem:item atIndex:index]; - if (stage) { - stage.progress = progress; + BOOL ignoredTerminal = (stage && PLDownloadTaskStateIsTerminal(item.state)); + if (stage && !ignoredTerminal) { + BOOL transferComplete = (progress >= 1.0 && + stage.status == PLTaskStageStatusRunning && + item.state != DownloadTaskStateCompleted && + item.state != DownloadTaskStateCancelled && + item.state != DownloadTaskStateFailed); + stage.progress = transferComplete ? 0.99 : progress; if (message) stage.message = [message copy]; // nil 表示保留原文案 } [self.lock unlock]; - if (stage) { + if (stage && !ignoredTerminal) { [self postProgressUpdateForTask:item]; [self schedulePersistSnapshotForProgress]; - } else if (item) { + } else if (item && !ignoredTerminal) { NSLog(@"[DownloadTaskManager] updateTaskWithId:stageAtIndex:progress:message: invalid stage index %lu for task %@", (unsigned long)index, taskId); } } diff --git a/Natives/DownloadTasksViewController.m b/Natives/DownloadTasksViewController.m index 6b028d2e6a..6c82e2975b 100644 --- a/Natives/DownloadTasksViewController.m +++ b/Natives/DownloadTasksViewController.m @@ -9,6 +9,7 @@ #import "DownloadHistoryViewController.h" #import "PLTaskStages.h" #import "PLTaskProgressViewController.h" +#include static NSString * const kTaskCellReuseIdentifier = @"DownloadTaskCell"; static NSString * const kEmptyStateReuseIdentifier = @"DownloadTaskEmptyCell"; @@ -310,8 +311,11 @@ - (void)configureWithTask:(DownloadTaskItem *)task { self.progressView.hidden = NO; break; case DownloadTaskStateDownloading: + if ([task.userInfo[DownloadTaskUserInfoTransferCompleteKey] boolValue]) { + self.speedLabel.text = localize(@"i18n_str_78", nil); + } // Phase 6 Task 6.1:多文件任务显示 "42/100 · 2.1MB/s"(文件计数 + 速率) - if (task.totalFileCount > 0) { + else if (task.totalFileCount > 0) { NSString *speedText = [self compactSpeedText:task.speed]; if (speedText.length > 0) { self.speedLabel.text = [NSString @@ -556,7 +560,10 @@ - (NSString *)compactSpeedText:(double)speed { - (NSString *)formattedProgress:(double)progress { if (progress < 0.0) return @"--"; - return [NSString stringWithFormat:@"%.1f%%", progress * 100.0]; + // 非终态卡片不会走本方法显示 100%;向下截断可避免 99.95% 四舍五入为 100.0%。 + double clamped = MIN(0.999, MAX(0.0, progress)); + double truncated = floor(clamped * 1000.0) / 10.0; + return [NSString stringWithFormat:@"%.1f%%", truncated]; } - (void)configureSourceTagWithSource:(NSString *)source { diff --git a/Natives/DownloadViewController.h b/Natives/DownloadViewController.h index b416287500..1b132a6add 100644 --- a/Natives/DownloadViewController.h +++ b/Natives/DownloadViewController.h @@ -7,4 +7,7 @@ /// 供资源管理界面"去下载"引导跳转时定位到对应资源类型 @property (nonatomic, assign) NSInteger initialTabIndex; +/// 资源下载目标档案。由资源管理页传入;为空时回退到当前或首个有效档案。 +@property (nonatomic, copy, nullable) NSString *targetProfileName; + @end diff --git a/Natives/DownloadViewController.m b/Natives/DownloadViewController.m index ba1343e47a..26472f12d8 100644 --- a/Natives/DownloadViewController.m +++ b/Natives/DownloadViewController.m @@ -485,10 +485,14 @@ @interface DownloadViewController () 0) { - filters[@"query"] = self.currentSearchQuery; + if (self.modSearchQuery.length > 0) { + filters[@"query"] = self.modSearchQuery; } if (self.currentGameVersion.length > 0) { filters[@"version"] = self.currentGameVersion; @@ -1928,23 +1937,28 @@ - (void)loadModList { dispatch_async(dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; - [strongSelf.loadingIndicator stopAnimating]; + if (generation != strongSelf.modRequestGeneration) return; + if (strongSelf.tabSegment.selectedSegmentIndex == 1) { + [strongSelf.loadingIndicator stopAnimating]; + } [strongSelf.modTableView.refreshControl endRefreshing]; - strongSelf.isLoadingMore = NO; + strongSelf.isLoadingMods = NO; if (results) { - if (strongSelf.currentModOffset == 0) { + if (requestOffset == 0) { [strongSelf.modList removeAllObjects]; } [strongSelf.modList addObjectsFromArray:results]; strongSelf.hasMoreMods = (results.count >= 30); - strongSelf.currentModOffset += results.count; + strongSelf.currentModOffset = requestOffset + results.count; [strongSelf.modTableView reloadData]; - strongSelf.emptyLabel.hidden = (strongSelf.modList.count > 0); - if (strongSelf.modList.count == 0) { - strongSelf.emptyLabel.text = localize(@"i18n_str_180", nil); - strongSelf.emptyLabel.hidden = NO; + if (strongSelf.tabSegment.selectedSegmentIndex == 1) { + strongSelf.emptyLabel.hidden = (strongSelf.modList.count > 0); + if (strongSelf.modList.count == 0) { + strongSelf.emptyLabel.text = localize(@"i18n_str_180", nil); + strongSelf.emptyLabel.hidden = NO; + } } } else if (error) { [strongSelf showError:error.localizedDescription]; @@ -1954,26 +1968,27 @@ - (void)loadModList { } - (void)searchMods:(NSString *)query { - self.currentSearchQuery = query; - self.currentModOffset = 0; - self.hasMoreMods = YES; - [self.modList removeAllObjects]; - [self.modTableView reloadData]; - [self loadModList]; + self.modSearchQuery = query; + [self refreshModList]; } #pragma mark - Shader Search & Loading - (void)refreshShaderList { + self.shaderRequestGeneration += 1; + self.isLoadingShaders = NO; self.currentShaderOffset = 0; self.hasMoreShaders = YES; [self.shaderList removeAllObjects]; + [self.shaderTableView reloadData]; [self loadShaderList]; } - (void)loadShaderList { - if (self.isLoadingMore) return; - self.isLoadingMore = YES; + if (self.isLoadingShaders) return; + self.isLoadingShaders = YES; + NSInteger generation = self.shaderRequestGeneration; + NSInteger requestOffset = self.currentShaderOffset; if (self.currentShaderOffset == 0) { [self.loadingIndicator startAnimating]; @@ -1981,11 +1996,11 @@ - (void)loadShaderList { NSMutableDictionary *filters = [NSMutableDictionary dictionary]; filters[@"limit"] = @30; - filters[@"offset"] = @(self.currentShaderOffset); + filters[@"offset"] = @(requestOffset); filters[@"projectType"] = @"shader"; - if (self.currentSearchQuery.length > 0) { - filters[@"query"] = self.currentSearchQuery; + if (self.shaderSearchQuery.length > 0) { + filters[@"query"] = self.shaderSearchQuery; } if (self.currentGameVersion.length > 0) { filters[@"version"] = self.currentGameVersion; @@ -1997,23 +2012,28 @@ - (void)loadShaderList { dispatch_async(dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; - [strongSelf.loadingIndicator stopAnimating]; + if (generation != strongSelf.shaderRequestGeneration) return; + if (strongSelf.tabSegment.selectedSegmentIndex == 2) { + [strongSelf.loadingIndicator stopAnimating]; + } [strongSelf.shaderTableView.refreshControl endRefreshing]; - strongSelf.isLoadingMore = NO; + strongSelf.isLoadingShaders = NO; if (results) { - if (strongSelf.currentShaderOffset == 0) { + if (requestOffset == 0) { [strongSelf.shaderList removeAllObjects]; } [strongSelf.shaderList addObjectsFromArray:results]; strongSelf.hasMoreShaders = (results.count >= 30); - strongSelf.currentShaderOffset += results.count; + strongSelf.currentShaderOffset = requestOffset + results.count; [strongSelf.shaderTableView reloadData]; - strongSelf.emptyLabel.hidden = (strongSelf.shaderList.count > 0); - if (strongSelf.shaderList.count == 0) { - strongSelf.emptyLabel.text = localize(@"i18n_str_181", nil); - strongSelf.emptyLabel.hidden = NO; + if (strongSelf.tabSegment.selectedSegmentIndex == 2) { + strongSelf.emptyLabel.hidden = (strongSelf.shaderList.count > 0); + if (strongSelf.shaderList.count == 0) { + strongSelf.emptyLabel.text = localize(@"i18n_str_181", nil); + strongSelf.emptyLabel.hidden = NO; + } } } else if (error) { [strongSelf showError:error.localizedDescription]; @@ -2023,12 +2043,8 @@ - (void)loadShaderList { } - (void)searchShaders:(NSString *)query { - self.currentSearchQuery = query; - self.currentShaderOffset = 0; - self.hasMoreShaders = YES; - [self.shaderList removeAllObjects]; - [self.shaderTableView reloadData]; - [self loadShaderList]; + self.shaderSearchQuery = query; + [self refreshShaderList]; } #pragma mark - Modpack Search & Loading @@ -2477,8 +2493,17 @@ - (void)showGameVersionPicker { /// 解析当前 profile 的 Minecraft 版本(用于模组下载版本预选) /// 复用 ModpackExportService.parseVersionId: 从 lastVersionId 反解 +- (NSString *)effectiveTargetProfileName { + return [PLProfiles effectiveProfileNameForPreferredName:self.targetProfileName]; +} + +- (NSDictionary *)effectiveTargetProfile { + NSString *profileName = [self effectiveTargetProfileName]; + return profileName.length > 0 ? PLProfiles.current.profiles[profileName] : nil; +} + - (NSString *)currentProfileMinecraftVersion { - NSDictionary *profile = PLProfiles.current.selectedProfile; + NSDictionary *profile = [self effectiveTargetProfile]; NSString *lastVersionId = profile[@"lastVersionId"]; if (lastVersionId.length == 0) return nil; NSDictionary *parsed = [ModpackExportService parseVersionId:lastVersionId]; @@ -2489,7 +2514,7 @@ - (NSString *)currentProfileMinecraftVersion { /// 解析当前 profile 的模组加载器(fabric/forge/neoforge/quilt) /// 复用 ModpackExportService.parseVersionId: 从 lastVersionId 反解 - (NSString *)currentProfileLoader { - NSDictionary *profile = PLProfiles.current.selectedProfile; + NSDictionary *profile = [self effectiveTargetProfile]; NSString *lastVersionId = profile[@"lastVersionId"]; if (lastVersionId.length == 0) return nil; NSDictionary *parsed = [ModpackExportService parseVersionId:lastVersionId]; @@ -2600,7 +2625,8 @@ - (void)resetFilters { self.currentGameVersion = nil; self.currentModLoader = nil; self.currentSortField = @"follows"; - self.currentSearchQuery = nil; + self.modSearchQuery = nil; + self.shaderSearchQuery = nil; self.resourcepackSearchQuery = nil; self.datapackSearchQuery = nil; self.searchBar.text = nil; @@ -2614,12 +2640,8 @@ - (void)reloadCurrentList { UITableView *targetTable = nil; if (tabIndex == 1) { targetTable = self.modTableView; - self.currentModOffset = 0; - [self.modList removeAllObjects]; } else if (tabIndex == 2) { targetTable = self.shaderTableView; - self.currentShaderOffset = 0; - [self.shaderList removeAllObjects]; } else if (tabIndex == 3) { targetTable = self.resourcepackTableView; self.currentResourcepackOffset = 0; @@ -2642,8 +2664,8 @@ - (void)reloadCurrentList { targetTable.alpha = 0; } completion:^(BOOL finished) { switch (tabIndex) { - case 1: [self loadModList]; break; - case 2: [self loadShaderList]; break; + case 1: [self refreshModList]; break; + case 2: [self refreshShaderList]; break; case 3: [self loadResourcePackList]; break; case 4: [self loadDataPackList]; break; case 5: [self loadModpackList]; break; @@ -2689,7 +2711,8 @@ - (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { - (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar { searchBar.text = nil; - self.currentSearchQuery = nil; + self.modSearchQuery = nil; + self.shaderSearchQuery = nil; self.modpackSearchQuery = nil; self.resourcepackSearchQuery = nil; self.datapackSearchQuery = nil; @@ -4410,6 +4433,7 @@ - (void)installModpackAtIndexPath:(NSIndexPath *)indexPath { ModVersionViewController *versionVC = [[ModVersionViewController alloc] init]; versionVC.modItem = modItem; + versionVC.initialSource = modItem.apiSource; versionVC.delegate = self; versionVC.title = modItem.displayName; // FCL 风格:传入当前 profile 的偏好版本和加载器,自动选中匹配 chip 并置顶 @@ -4827,11 +4851,11 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N } - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { - if (tableView == self.modTableView && indexPath.row == self.modList.count - 5 && self.hasMoreMods && !self.isLoadingMore) { + if (tableView == self.modTableView && indexPath.row == self.modList.count - 5 && self.hasMoreMods && !self.isLoadingMods) { [self loadModList]; } - if (tableView == self.shaderTableView && indexPath.row == self.shaderList.count - 5 && self.hasMoreShaders && !self.isLoadingMore) { + if (tableView == self.shaderTableView && indexPath.row == self.shaderList.count - 5 && self.hasMoreShaders && !self.isLoadingShaders) { [self loadShaderList]; } @@ -4953,6 +4977,7 @@ - (void)downloadModAtIndexPath:(NSIndexPath *)indexPath { ModVersionViewController *versionVC = [[ModVersionViewController alloc] init]; versionVC.modItem = modItem; + versionVC.initialSource = modItem.apiSource; versionVC.delegate = self; versionVC.title = modItem.displayName; // FCL 风格:传入当前 profile 的偏好版本和加载器,自动选中匹配 chip 并置顶 @@ -4976,6 +5001,7 @@ - (void)downloadShaderAtIndexPath:(NSIndexPath *)indexPath { ShaderVersionViewController *versionVC = [[ShaderVersionViewController alloc] init]; versionVC.shaderItem = shaderItem; + versionVC.initialSource = shaderItem.apiSource; versionVC.delegate = self; versionVC.title = shaderItem.displayName; // FCL 风格:传入当前 profile 的偏好版本和加载器,自动选中匹配 chip 并置顶匹配版本 @@ -5002,6 +5028,7 @@ - (void)downloadResourcepackAtIndexPath:(NSIndexPath *)indexPath { AssetVersionViewController *versionVC = [[AssetVersionViewController alloc] init]; versionVC.assetType = AssetVersionTypeResourcePack; + versionVC.apiSource = item.apiSource; versionVC.projectID = item.onlineID; versionVC.projectDisplayName = item.displayName; versionVC.delegate = self; @@ -5036,6 +5063,7 @@ - (void)downloadDatapackAtIndexPath:(NSIndexPath *)indexPath { AssetVersionViewController *versionVC = [[AssetVersionViewController alloc] init]; versionVC.assetType = AssetVersionTypeDataPack; + versionVC.apiSource = item.apiSource; versionVC.projectID = item.onlineID; versionVC.projectDisplayName = item.displayName; versionVC.delegate = self; @@ -5070,6 +5098,7 @@ - (void)downloadWorldAtIndexPath:(NSIndexPath *)indexPath { AssetVersionViewController *versionVC = [[AssetVersionViewController alloc] init]; versionVC.assetType = AssetVersionTypeWorld; + versionVC.apiSource = item.apiSource; versionVC.projectID = item.onlineID; versionVC.projectDisplayName = item.displayName; versionVC.delegate = self; @@ -5177,7 +5206,7 @@ - (void)assetVersionViewController:(AssetVersionViewController *)viewController // 下载 Mod(redesign-download-ui Phase 4:进度由 Service 内部注册的下载任务 + // PLTaskStagesSingleFile 单阶段上报驱动统一进度页,调用方无需管理进度 UI。) - (void)startDownloadForModItem:(ModItem *)item { - NSString *profileName = PLProfiles.current.selectedProfileName ?: @"default"; + NSString *profileName = [self effectiveTargetProfileName]; __weak typeof(self) weakSelf = self; [[ModService sharedService] downloadMod:item toProfile:profileName @@ -5200,7 +5229,7 @@ - (void)startDownloadForModItem:(ModItem *)item { // redesign-download-ui Phase 3:进度由 Service 内部注册的下载任务 + // PLTaskStagesSingleFile 单阶段上报驱动统一进度页,调用方无需管理进度 UI。 - (void)startDownloadForResourcePackItem:(ResourcePackItem *)item { - NSString *profileName = PLProfiles.current.selectedProfileName ?: @"default"; + NSString *profileName = [self effectiveTargetProfileName]; __weak typeof(self) weakSelf = self; [[ResourcePackService sharedService] downloadResourcePack:item toProfile:profileName @@ -5223,7 +5252,7 @@ - (void)startDownloadForResourcePackItem:(ResourcePackItem *)item { // redesign-download-ui Phase 3:进度由 Service 内部注册的下载任务 + // PLTaskStagesSingleFile 单阶段上报驱动统一进度页,调用方无需管理进度 UI。 - (void)startDownloadForDataPackItem:(DataPackItem *)item { - NSString *profileName = PLProfiles.current.selectedProfileName ?: @"default"; + NSString *profileName = [self effectiveTargetProfileName]; __weak typeof(self) weakSelf = self; [[DataPackService sharedService] downloadDataPack:item toProfile:profileName @@ -5247,7 +5276,7 @@ - (void)startDownloadForDataPackItem:(DataPackItem *)item { // redesign-download-ui Phase 3:进度由 Service 内部注册的下载任务 + // PLTaskStagesSingleFile 单阶段上报驱动统一进度页,调用方无需管理进度 UI。 - (void)startDownloadForWorldItem:(WorldItem *)item { - NSString *profileName = PLProfiles.current.selectedProfileName ?: @"default"; + NSString *profileName = [self effectiveTargetProfileName]; __weak typeof(self) weakSelf = self; [[WorldService sharedService] downloadWorld:item toProfile:profileName @@ -5289,7 +5318,7 @@ - (void)shaderVersionViewController:(ShaderVersionViewController *)viewControlle // 下载光影包(redesign-download-ui Phase 4:进度由 Service 内部注册的下载任务 + // PLTaskStagesSingleFile 单阶段上报驱动统一进度页,调用方无需管理进度 UI。) - (void)startDownloadForShaderItem:(ShaderItem *)item { - NSString *profileName = PLProfiles.current.selectedProfileName ?: @"default"; + NSString *profileName = [self effectiveTargetProfileName]; __weak typeof(self) weakSelf = self; [[ShaderService sharedService] downloadShader:item toProfile:profileName @@ -5479,8 +5508,7 @@ - (NSString *)currentInstanceModsPath { // 参考 ModService.m 的 existingModsFolderForProfile: 逻辑: // 1. 优先读取 profile 的 gameDir,拼接 /mods // 2. 若 profile 无 gameDir 或 gameDir 为 ".",回退到 $POJAV_GAME_DIR/mods - NSString *instanceName = PLProfiles.current.selectedProfileName; - if (!instanceName) instanceName = @"default"; + NSString *instanceName = [self effectiveTargetProfileName]; NSString *modsDir = nil; diff --git a/Natives/JavaLauncher.m b/Natives/JavaLauncher.m index d3d336d7f5..7beff0e51b 100644 --- a/Natives/JavaLauncher.m +++ b/Natives/JavaLauncher.m @@ -156,16 +156,15 @@ void init_loadCustomEnv() { /// /// 渲染器与 MobileGlues 的关系(重要): /// - MobileGlues 渲染器(libmobileglues.dylib):直接加载 MobileGlues,config.json 生效。 -/// - Auto 渲染器:在 launchJVM 中被解析为 ANGLE(libtinygl4angle.dylib),MobileGlues 不会被加载, -/// config.json 虽然会写入但不会被读取。用户需显式选择 MobileGlues 渲染器才能让设置生效。 +/// - Auto 渲染器:由 PLResolveRendererKey 解析为 ANGLE(libtinygl4angle.dylib), +/// MobileGlues 不会被加载。用户需显式选择 MobileGlues 才能让这些设置生效。 /// - Vulkan 渲染器:Vulkan 模式下 OpenGL 回退库使用 MobileGlues(对齐 Ynnyny 仓库), /// config.json 会被 MobileGlues 读取并生效。 void init_loadMobileGluesConfig() { - NSString *renderer = [PLProfiles resolveKeyForCurrentProfile:@"renderer"]; + NSString *renderer = PLResolveRendererKey([PLProfiles resolveKeyForCurrentProfile:@"renderer"]); NSLog(@"[JavaLauncher] init_loadMobileGluesConfig: renderer=%@", renderer); BOOL usesMobileGlues = [renderer isEqualToString:@ RENDERER_NAME_MOBILEGLUES] || - [renderer isEqualToString:@"auto"] || [renderer isEqualToString:@ RENDERER_NAME_VULKAN]; if (!usesMobileGlues) { @@ -173,12 +172,7 @@ void init_loadMobileGluesConfig() { return; } - // 警告:auto 渲染器实际不会加载 MobileGlues,设置不会生效 - if ([renderer isEqualToString:@"auto"]) { - NSLog(@"[JavaLauncher] WARNING: renderer is 'auto', will be resolved to ANGLE. " - @"MobileGlues settings will NOT take effect. " - @"Please explicitly select 'MobileGlues' renderer to use these settings."); - } else if ([renderer isEqualToString:@ RENDERER_NAME_VULKAN]) { + if ([renderer isEqualToString:@ RENDERER_NAME_VULKAN]) { NSLog(@"[JavaLauncher] Vulkan renderer detected, MobileGlues used as GL fallback. Config will take effect."); } else { NSLog(@"[JavaLauncher] MobileGlues renderer detected, config will take effect."); @@ -469,8 +463,9 @@ int launchJVM(NSString *accountId, id launchTarget, int width, int height, int m } // Setup AMETHYST_RENDERER - NSString *renderer = [PLProfiles resolveKeyForCurrentProfile:@"renderer"]; - NSLog(@"[JavaLauncher] RENDERER is set to %@\n", renderer); + NSString *selectedRenderer = [PLProfiles resolveKeyForCurrentProfile:@"renderer"]; + NSString *renderer = PLResolveRendererKey(selectedRenderer); + NSLog(@"[JavaLauncher] RENDERER '%@' resolved to '%@'\n", selectedRenderer, renderer); setenv("AMETHYST_RENDERER", renderer.UTF8String, 1); // Apply Zink-specific environment variables if Zink renderer is selected @@ -738,17 +733,6 @@ int launchJVM(NSString *accountId, id launchTarget, int width, int height, int m // Preset OpenGL libname const char *glLibName = getenv("AMETHYST_RENDERER"); if (glLibName) { - if (!strcmp(glLibName, "auto")) { - // 关键修复(26.2 启动崩溃):Auto 渲染器始终选 ANGLE(对齐 Ynnyny 仓库) - // - // 之前 workspace 在 Java 21+ 优先选 MobileGlues,但 Ynnyny 仓库用 ANGLE 就能正常启动 26.2。 - // workspace 选 MobileGlues 后又缺少 init_loadMobileGluesConfig() 写 config.json, - // 导致 MobileGlues 用不安全默认值初始化 GL 上下文可能崩溃。现对齐 Ynnyny 始终选 ANGLE。 - // MobileGlues 仍保留为手动选项(用户可在设置中显式选择)。 - glLibName = RENDERER_NAME_MTL_ANGLE; - setenv("AMETHYST_RENDERER", glLibName, 1); - NSLog(@"[JavaLauncher] Auto renderer resolved to %s (always ANGLE)", glLibName); - } if (strcmp(glLibName, RENDERER_NAME_VULKAN) == 0) { // 对齐 Ynnyny 仓库:Vulkan 模式下 OpenGL 回退库使用 MobileGlues // diff --git a/Natives/LauncherPreferences.h b/Natives/LauncherPreferences.h index af18726e9d..38f1e4b87d 100644 --- a/Natives/LauncherPreferences.h +++ b/Natives/LauncherPreferences.h @@ -41,3 +41,24 @@ NSString* getSelectedJavaHome(NSString* defaultJRETag, int minVersion); NSArray* getRendererKeys(BOOL containsDefault); NSArray* getRendererNames(BOOL containsDefault); + +/// Profile 选择器内部使用的“继承全局设置”稳定值。 +/// 显示时必须使用 PLProfileInheritedDisplayName(),不要直接展示或比较本地化文本。 +FOUNDATION_EXPORT NSString * const PLProfileInheritedValue; +NSString *PLProfileInheritedDisplayName(void); + +/// 将偏好值规范化为受支持的渲染器 key;空值或旧版/损坏值回退为 "auto"。 +NSString *PLNormalizeRendererKey(id value); + +/// 将渲染器选择解析为本次启动实际使用的动态库。 +/// Auto 的唯一解析策略集中在这里,避免 JavaLauncher 与 EGL bridge 各自解释。 +NSString *PLResolveRendererKey(id value); + +/// MC 26.2+ Graphics API 的合法 key/本地化显示名。 +/// containsDefault=YES 时首项是“继承全局设置”,与显式 "default" 不同。 +NSArray* getGraphicsApiKeys(BOOL containsDefault); +NSArray* getGraphicsApiNames(BOOL containsDefault); + +/// 将 Graphics API 偏好规范化到 default/prefer_vulkan/prefer_opengl 白名单; +/// 空值或旧版/损坏值回退为 "default"。 +NSString *PLNormalizeGraphicsApiKey(id value); diff --git a/Natives/LauncherPreferences.m b/Natives/LauncherPreferences.m index d5ce034e6b..125d9b2724 100644 --- a/Natives/LauncherPreferences.m +++ b/Natives/LauncherPreferences.m @@ -208,6 +208,12 @@ UIEdgeInsets getDefaultSafeArea() { } #pragma mark Renderer +NSString * const PLProfileInheritedValue = @"(default)"; + +NSString *PLProfileInheritedDisplayName(void) { + return [NSString stringWithFormat:@"(%@)", localize(@"i18n_str_943", nil)]; +} + NSArray* getRendererKeys(BOOL containsDefault) { NSMutableArray *array = @[ @"auto", @@ -220,7 +226,7 @@ UIEdgeInsets getDefaultSafeArea() { ].mutableCopy; if (containsDefault) { - [array insertObject:@"(default)" atIndex:0]; + [array insertObject:PLProfileInheritedValue atIndex:0]; } return array; @@ -240,8 +246,72 @@ UIEdgeInsets getDefaultSafeArea() { ].mutableCopy; if (containsDefault) { - [array insertObject:@"(default)" atIndex:0]; + [array insertObject:PLProfileInheritedDisplayName() atIndex:0]; } return array; } + +NSString *PLNormalizeRendererKey(id value) { + if (![value isKindOfClass:[NSString class]]) { + return @"auto"; + } + + NSString *key = [(NSString *)value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (key.length == 0 || ![getRendererKeys(NO) containsObject:key]) { + if (key.length > 0) { + NSLog(@"[Renderer] Unsupported renderer key '%@'; falling back to Auto", key); + } + return @"auto"; + } + return key; +} + +NSString *PLResolveRendererKey(id value) { + NSString *key = PLNormalizeRendererKey(value); + // 26.2+ 在 MobileGlues 自动路径上存在上下文初始化崩溃;当前稳定策略与 + // JavaLauncher 既有行为保持一致,由 Auto 确定地选择 ANGLE。 + return [key isEqualToString:@"auto"] ? @ RENDERER_NAME_MTL_ANGLE : key; +} + +#pragma mark Graphics API +NSArray* getGraphicsApiKeys(BOOL containsDefault) { + NSMutableArray *array = @[ + @"default", + @"prefer_vulkan", + @"prefer_opengl" + ].mutableCopy; + + if (containsDefault) { + [array insertObject:PLProfileInheritedValue atIndex:0]; + } + return array; +} + +NSArray* getGraphicsApiNames(BOOL containsDefault) { + NSMutableArray *array = @[ + localize(@"i18n_str_943", nil), + localize(@"i18n_str_941", nil), + localize(@"i18n_str_942", nil) + ].mutableCopy; + + if (containsDefault) { + [array insertObject:PLProfileInheritedDisplayName() atIndex:0]; + } + return array; +} + +NSString *PLNormalizeGraphicsApiKey(id value) { + if (![value isKindOfClass:[NSString class]]) { + return @"default"; + } + + NSString *key = [(NSString *)value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (key.length == 0 || ![getGraphicsApiKeys(NO) containsObject:key]) { + if (key.length > 0) { + NSLog(@"[GraphicsAPI] Unsupported key '%@'; falling back to Default", key); + } + return @"default"; + } + return key; +} diff --git a/Natives/LauncherRightPanelViewController.m b/Natives/LauncherRightPanelViewController.m index 666f7b9797..8a7083f3fe 100644 --- a/Natives/LauncherRightPanelViewController.m +++ b/Natives/LauncherRightPanelViewController.m @@ -1131,19 +1131,8 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(N NSDictionary *profile = PLProfiles.current.profiles[profileName]; if (profile) { - // 应用渲染器设置 - NSString *renderer = profile[@"renderer"] ?: @"auto"; - if (![renderer isEqualToString:@"auto"]) { - setPrefString(@"video.renderer", renderer); - } - - // 应用图形 API 设置(MC 26.2+ 游戏内 OpenGL/Vulkan 切换) - // 由 JavaLauncher.m 读取并设置 AMETHYST_GRAPHICS_API 环境变量, - // PojavLauncher.java 写入 options.txt 的 graphicsApi 字段 - NSString *graphicsApi = profile[@"graphicsApi"]; - if (graphicsApi.length > 0) { - setPrefString(@"video.graphics_api", graphicsApi); - } + // renderer / graphicsApi 由 JavaLauncher 直接从当前 profile 解析。 + // 不再写回全局偏好,避免启动一个档案后污染其他继承全局设置的档案。 // 应用Java版本设置(兼容旧版直装器写入的 NSDictionary 格式) id javaVerRaw = profile[@"javaVersion"]; @@ -1310,4 +1299,4 @@ - (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; } -@end \ No newline at end of file +@end diff --git a/Natives/ModItem.h b/Natives/ModItem.h index f31bd80a39..f361286b7b 100644 --- a/Natives/ModItem.h +++ b/Natives/ModItem.h @@ -11,7 +11,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL disabled; // --- Properties for Online Mods --- -@property (nonatomic, copy, nullable) NSString *onlineID; +@property (nonatomic, copy, nullable) NSString *onlineID; +/// 在线 API 来源:1=Modrinth,2=CurseForge。 +@property (nonatomic, assign) NSInteger apiSource; @property (nonatomic, copy, nullable) NSString *author; @property (nonatomic, strong, nullable) NSNumber *downloads; @property (nonatomic, strong, nullable) NSNumber *likes; diff --git a/Natives/ModItem.m b/Natives/ModItem.m index b615128aa7..4c76ad03d4 100644 --- a/Natives/ModItem.m +++ b/Natives/ModItem.m @@ -22,8 +22,9 @@ - (instancetype)initWithFilePath:(NSString *)path { - (instancetype)initWithOnlineData:(NSDictionary *)data { if (self = [super init]) { - // Data from Modrinth or CurseForge search results - _onlineID = data[@"id"] ? [data[@"id"] description] : nil; // Ensure string + // Data from Modrinth or CurseForge search results + _onlineID = data[@"id"] ? [data[@"id"] description] : nil; // Ensure string + _apiSource = [data[@"apiSource"] integerValue] == 2 ? 2 : 1; _displayName = data[@"title"] ?: @""; _modDescription = data[@"description"] ?: @""; _iconURL = data[@"imageUrl"] ?: @""; diff --git a/Natives/ModService.h b/Natives/ModService.h index 4f7d1fdf3f..aca7b30380 100644 --- a/Natives/ModService.h +++ b/Natives/ModService.h @@ -21,17 +21,17 @@ typedef void(^ModDownloadHandler)(NSError * _Nullable error); // Added for downl + (instancetype)sharedService; // --- Local Mod Management --- -- (void)scanModsForProfile:(NSString *)profileName completion:(ModListHandler)completion; +- (void)scanModsForProfile:(NSString * _Nullable)profileName completion:(ModListHandler)completion; - (void)fetchMetadataForMod:(ModItem *)mod completion:(ModMetadataHandler)completion; - (BOOL)toggleEnableForMod:(ModItem *)mod error:(NSError **)error; - (BOOL)deleteMod:(ModItem *)mod error:(NSError **)error; // --- Online Mod Downloading --- -- (void)downloadMod:(ModItem *)mod toProfile:(NSString *)profileName completion:(ModDownloadHandler)completion; +- (void)downloadMod:(ModItem *)mod toProfile:(NSString * _Nullable)profileName completion:(ModDownloadHandler)completion; /// 下载 Mod 并上报进度 - (void)downloadMod:(ModItem *)mod - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName progress:(void (^)(NSProgress *downloadProgress))progress completion:(ModDownloadHandler)completion; @@ -40,7 +40,7 @@ typedef void(^ModDownloadHandler)(NSError * _Nullable error); // Added for downl /// CurseForge hashes algo=1),传入即启用校验,校验失败由统一下载器按镜像/退避节奏重试; /// 为 nil 时不做 SHA1 校验,靠 zip EOCD 兜底校验保证完整性。 - (void)downloadMod:(ModItem *)mod - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName expectedSHA1:(nullable NSString *)expectedSHA1 progress:(nullable void (^)(NSProgress *downloadProgress))progress completion:(ModDownloadHandler)completion; @@ -49,7 +49,7 @@ typedef void(^ModDownloadHandler)(NSError * _Nullable error); // Added for downl - (NSString *)iconCachePathForURL:(NSString *)urlString; /// 获取当前 profile 的 mods 目录,不存在时自动创建 -- (nullable NSString *)ensureModsFolderForProfile:(NSString *)profileName error:(NSError **)error; +- (nullable NSString *)ensureModsFolderForProfile:(NSString * _Nullable)profileName error:(NSError **)error; @end diff --git a/Natives/ModService.m b/Natives/ModService.m index a7fa369758..722ec20cd3 100644 --- a/Natives/ModService.m +++ b/Natives/ModService.m @@ -204,78 +204,30 @@ - (nullable NSData *)readFileFromJar:(NSString *)jarPath entryName:(NSString *)e /// 之前直接使用相对路径会导致 mods 文件夹找不到(fileExistsAtPath 对相对路径基于 cwd 解析, /// 而 cwd 不一定是 POJAV_GAME_DIR)。 - (nullable NSString *)resolveAbsoluteGameDirForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if (![prof isKindOfClass:[NSDictionary class]]) return nil; - NSString *gameDir = prof[@"gameDir"]; - if (![gameDir isKindOfClass:[NSString class]] || gameDir.length == 0) return nil; - if ([gameDir isEqualToString:@"."]) { - // "." 表示主目录 - const char *env = getenv("POJAV_GAME_DIR"); - return env ? [NSString stringWithUTF8String:env] : NSHomeDirectory(); - } - if ([gameDir isAbsolutePath]) { - return gameDir; - } - // 相对路径,相对于 POJAV_GAME_DIR 解析 - const char *env = getenv("POJAV_GAME_DIR"); - NSString *baseDir = env ? [NSString stringWithUTF8String:env] : NSHomeDirectory(); - // 去掉 "./" 前缀(如果有),stringByAppendingPathComponent 能正确处理 - NSString *cleanGameDir = [gameDir hasPrefix:@"./"] ? [gameDir substringFromIndex:2] : gameDir; - return [baseDir stringByAppendingPathComponent:cleanGameDir]; - } @catch (NSException *ex) { - return nil; - } + return [PLProfiles resolvedGameDirectoryForProfileName:profileName]; } - (nullable NSString *)existingModsFolderForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - // 优先用 profile gameDir(已解析为绝对路径) - NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profile]; - if (resolvedGameDir.length > 0) { - NSString *modsPath = [resolvedGameDir stringByAppendingPathComponent:@"mods"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:modsPath isDirectory:&isDir] && isDir) { - return modsPath; - } - } + NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profileName]; + if (resolvedGameDir.length == 0) return nil; - // 回退到 POJAV_GAME_DIR/mods - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - NSString *modsPath = [gameDir stringByAppendingPathComponent:@"mods"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:modsPath isDirectory:&isDir] && isDir) { - return modsPath; - } + NSString *modsPath = [resolvedGameDir stringByAppendingPathComponent:@"mods"]; + BOOL isDir = NO; + if ([fm fileExistsAtPath:modsPath isDirectory:&isDir] && isDir) { + return modsPath; } return nil; } /// 获取当前 profile 的 mods 目录,不存在时自动创建 - (nullable NSString *)ensureModsFolderForProfile:(NSString *)profileName error:(NSError **)error { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - NSString *modsPath = nil; - - // 优先用 profile gameDir(已解析为绝对路径) - NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profile]; - if (resolvedGameDir.length > 0) { - modsPath = [resolvedGameDir stringByAppendingPathComponent:@"mods"]; - } - - if (!modsPath) { - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - modsPath = [gameDir stringByAppendingPathComponent:@"mods"]; - } - } + NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profileName]; + NSString *modsPath = resolvedGameDir.length > 0 + ? [resolvedGameDir stringByAppendingPathComponent:@"mods"] + : nil; if (!modsPath) { if (error) { @@ -532,10 +484,11 @@ - (void)downloadMod:(ModItem *)mod expectedSHA1:(nullable NSString *)expectedSHA1 progress:(nullable void (^)(NSProgress *downloadProgress))progress completion:(ModDownloadHandler)completion { - NSString *modsFolder = [self existingModsFolderForProfile:profileName]; + NSError *folderError = nil; + NSString *modsFolder = [self ensureModsFolderForProfile:profileName error:&folderError]; if (!modsFolder) { if (completion) { - NSError *error = [NSError errorWithDomain:@"ModServiceError" code:1 userInfo:@{NSLocalizedDescriptionKey:localize(@"i18n_str_453", nil)}]; + NSError *error = folderError ?: [NSError errorWithDomain:@"ModServiceError" code:1 userInfo:@{NSLocalizedDescriptionKey:localize(@"i18n_str_453", nil)}]; dispatch_async(dispatch_get_main_queue(), ^{ completion(error); }); } return; @@ -565,6 +518,9 @@ - (void)downloadMod:(ModItem *)mod supportsResume:YES iconURL:mod.iconURL]; taskItem.downloadURL = mod.selectedVersionDownloadURL; + NSString *taskProfileName = [PLProfiles effectiveProfileNameForPreferredName:profileName]; + if (taskProfileName.length > 0) taskItem.userInfo[@"profileName"] = taskProfileName; + taskItem.userInfo[@"destinationPath"] = destinationPath; // redesign-download-ui Phase 4:单文件下载接入统一进度页—— // PLTaskStagesSingleFile 单阶段 + autoPresentDetail 自动弹出 PLTaskProgressViewController [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId stages:PLTaskStagesSingleFile()]; @@ -572,10 +528,11 @@ - (void)downloadMod:(ModItem *)mod // retryHandler:FCL 风格重新下载,复用同一 taskItem,重新发起 PLDownloadClient 请求 __weak typeof(self) weakSelf = self; + __block PLDownloadRequest *retryRequest = nil; taskItem.retryHandler = ^id(DownloadTaskItem *taskItemRef) { __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return nil; - return [strongSelf restartPLDownloadForTaskId:taskItemRef.taskId]; + if (!strongSelf || !retryRequest) return nil; + return [strongSelf startPLDownloadWithRequest:retryRequest taskItem:taskItemRef progress:progress completion:completion]; }; PLDownloadRequest *request = [[PLDownloadRequest alloc] init]; @@ -593,6 +550,7 @@ - (void)downloadMod:(ModItem *)mod request.taskIdentifier = taskItem.taskId; // 无 SHA1 时对 .jar(zip 格式)做 EOCD 兜底完整性校验 request.allowZipFallbackCheck = YES; + retryRequest = request; [self startPLDownloadWithRequest:request taskItem:taskItem progress:progress completion:completion]; } @@ -619,7 +577,6 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest self.downloadAccumulatedBytes[taskId] = @(0); self.downloadTotalBytes[taskId] = @(-1); self.downloadLastSpeeds[taskId] = @(0.0); - [self.downloadStateLock unlock]; PLDownloadOperation *operation = [[PLDownloadClient sharedClient] startRequest:request progress:^(int64_t deltaBytes, int64_t totalExpectedBytes) { @@ -639,12 +596,11 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest }]; if (!operation) { // 参数错误:PLDownloadClient 会异步回调 completion(error),由统一失败路径收尾 + [self.downloadStateLock unlock]; return nil; } - [self.downloadStateLock lock]; self.downloadOperations[taskId] = operation; - [self.downloadStateLock unlock]; // rawTask 为 weak 引用:operation 由 PLDownloadClient 与本 Service 共同持有, // DownloadTaskManager 据此对 PLDownloadOperation 做 pause/resume/cancel @@ -655,6 +611,8 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest [[DownloadTaskManager sharedManager] updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusRunning]; + // 与完成回调共用同一把锁,确保“注册为下载中”严格发生在任何终态之前。 + [self.downloadStateLock unlock]; return operation; } @@ -746,20 +704,20 @@ - (void)handlePLDownloadCompletion:(BOOL)success [self.downloadStateLock unlock]; DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + NSError *completionError = success ? nil : (error ?: [NSError errorWithDomain:@"ModServiceError" code:3 userInfo:@{NSLocalizedDescriptionKey: @"Mod download failed."}]); if (success) { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusCompleted]; - [manager setTaskWithId:taskId state:DownloadTaskStateCompleted]; + [manager setTaskWithId:taskId completedWithError:nil]; } else if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) { // 用户取消(DownloadTaskManager 已置 Cancelled,这里幂等对齐) [manager setTaskWithId:taskId state:DownloadTaskStateCancelled]; } else { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusFailed]; - [manager updateTaskWithId:taskId error:error]; - [manager setTaskWithId:taskId state:DownloadTaskStateFailed]; + [manager setTaskWithId:taskId completedWithError:completionError]; } if (completion) { - NSError *capturedError = success ? nil : error; + NSError *capturedError = completionError; dispatch_async(dispatch_get_main_queue(), ^{ completion(capturedError); }); @@ -779,4 +737,4 @@ - (void)cleanupPLDownloadStateForTaskId:(NSString *)taskId { [self.downloadCompletionHandlers removeObjectForKey:taskId]; } -@end \ No newline at end of file +@end diff --git a/Natives/ModVersionViewController.h b/Natives/ModVersionViewController.h index 76c2e88a79..df21b1e13a 100644 --- a/Natives/ModVersionViewController.h +++ b/Natives/ModVersionViewController.h @@ -16,6 +16,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong) UIActivityIndicatorView *activityIndicator; @property (nonatomic, strong) ModItem *modItem; @property (nonatomic, weak) id delegate; +/// 初始 API 来源:1=Modrinth,2=CurseForge;默认 1。 +@property (nonatomic, assign) NSInteger initialSource; // FCL 风格:传入当前 profile 的偏好版本和加载器 // ModVersionViewController 会优先选中匹配的 chip,并把匹配的版本置顶 diff --git a/Natives/ModVersionViewController.m b/Natives/ModVersionViewController.m index f2b1450929..5d2dec8fef 100644 --- a/Natives/ModVersionViewController.m +++ b/Natives/ModVersionViewController.m @@ -93,8 +93,8 @@ - (void)viewDidLoad { // 适配自定义启动器背景:透明化当前 VC,让全局背景图/毛玻璃透出 [[BackgroundManager sharedManager] makeViewControllerTransparent:self]; - // 初始化筛选状态(默认 Modrinth 源 + 相关性排序) - self.selectedSource = kSourceModrinth; + // 从搜索结果继承 API 来源,避免拿 CurseForge 数字 ID 请求 Modrinth。 + self.selectedSource = self.initialSource == kSourceCurseForge ? kSourceCurseForge : kSourceModrinth; self.selectedSort = kSortRelevance; [self setupSideFilterPanel]; diff --git a/Natives/ModsManagerViewController.m b/Natives/ModsManagerViewController.m index c80d3e3363..946b51f733 100644 --- a/Natives/ModsManagerViewController.m +++ b/Natives/ModsManagerViewController.m @@ -18,6 +18,7 @@ #import "PLProfiles.h" #import "LauncherPreferences.h" #import "DownloadViewController.h" +#import "DownloadTaskManager.h" #import "utils.h" #import @@ -146,6 +147,8 @@ - (instancetype)init { - (void)viewDidLoad { [super viewDidLoad]; // 基类构建背景/搜索栏/表格/空态/加载态/批量工具栏 + self.profileName = [PLProfiles effectiveProfileNameForPreferredName:self.profileName]; + self.localMods = [NSMutableArray array]; self.filteredLocalMods = [NSMutableArray array]; self.modDates = [NSMutableDictionary dictionary]; @@ -158,7 +161,14 @@ - (void)viewDidLoad { [self setupNavigationButtons]; [self setupChipsRow]; [self setupTableViewExtras]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleDownloadTaskCompleted:) + name:DownloadTaskManagerTaskCompletedNotification + object:nil]; +} +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; [self loadMods]; } @@ -489,6 +499,8 @@ - (void)applyFilter { /// 空状态"去下载":跳转统一下载页(无参数化资源类型入口,进入默认页) - (void)openDownloadPage { DownloadViewController *vc = [[DownloadViewController alloc] init]; + vc.initialTabIndex = 1; + vc.targetProfileName = self.profileName; if (self.navigationController) { [self.navigationController pushViewController:vc animated:YES]; } else { @@ -498,6 +510,14 @@ - (void)openDownloadPage { } } +- (void)handleDownloadTaskCompleted:(NSNotification *)notification { + DownloadTaskItem *task = notification.userInfo[DownloadTaskManagerTaskKey]; + if (task.state != DownloadTaskStateCompleted || + ![task.resourceType isEqualToString:DownloadTaskResourceTypeMod] || + ![task.userInfo[@"profileName"] isEqualToString:self.profileName]) return; + [self loadMods]; +} + #pragma mark - UISearchBarDelegate - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { diff --git a/Natives/PLProfiles.h b/Natives/PLProfiles.h index 8632b36557..207842c14b 100644 --- a/Natives/PLProfiles.h +++ b/Natives/PLProfiles.h @@ -11,6 +11,14 @@ + (id)profile:(NSMutableDictionary *)profile resolveKey:(id)key; + (NSString *)resolveKeyForCurrentProfile:(id)key; +/// preferredName 非空时仅接受真实存在的档案,否则返回 nil; +/// preferredName 为空时依次回退到当前选中档案和首个可用档案。 ++ (nullable NSString *)effectiveProfileNameForPreferredName:(nullable NSString *)preferredName; + +/// 将档案 gameDir 统一解析为绝对路径(支持 "."、相对隔离目录和绝对目录); +/// 显式指定不存在的档案时返回 nil。 ++ (nullable NSString *)resolvedGameDirectoryForProfileName:(nullable NSString *)profileName; + - (id)initWithCurrentInstance; - (NSMutableDictionary *> *)profiles; diff --git a/Natives/PLProfiles.m b/Natives/PLProfiles.m index 005a5119b0..ec4a81c400 100644 --- a/Natives/PLProfiles.m +++ b/Natives/PLProfiles.m @@ -41,6 +41,20 @@ + (void)updateCurrent { + (id)profile:(NSMutableDictionary *)profile resolveKey:(id)key { id rawValue = profile[key]; + // renderer 必须先做白名单规范化。显式 "auto" 是档案自己的选择,只有字段 + // 缺失/空字符串时才允许继承全局默认,避免不同档案之间相互污染。 + if ([key isEqual:@"renderer"] && + [rawValue isKindOfClass:[NSString class]] && + [(NSString *)rawValue length] > 0) { + return PLNormalizeRendererKey(rawValue); + } + // graphicsApi 与 renderer 使用相同的边界策略:档案显式值先做白名单 + // 规范化;只有字段缺失/空字符串时才继承全局设置。 + if ([key isEqual:@"graphicsApi"] && + [rawValue isKindOfClass:[NSString class]] && + [(NSString *)rawValue length] > 0) { + return PLNormalizeGraphicsApiKey(rawValue); + } // 兼容 javaVersion 字段:Mojang 规范是 NSDictionary({component, majorVersion}), // 但部分代码(如 ForgeDirectInstaller)也写入 NSDictionary。PLProfiles 期望返回 NSString。 if ([rawValue isKindOfClass:[NSDictionary class]]) { @@ -69,13 +83,65 @@ + (id)profile:(NSMutableDictionary *)profile resolveKey:(id)key { // 该字段仅在 MC 26.2+ 生效,旧版本会被 MC 忽略,无副作用。 @"graphicsApi": @"video.graphics_api" }; - return getPrefObject(prefDefaults[key]); + id prefValue = getPrefObject(prefDefaults[key]); + if ([key isEqual:@"renderer"]) { + return PLNormalizeRendererKey(prefValue); + } + if ([key isEqual:@"graphicsApi"]) { + return PLNormalizeGraphicsApiKey(prefValue); + } + return prefValue; } + (id)resolveKeyForCurrentProfile:(id)key { return [self profile:self.current.selectedProfile resolveKey:key]; } ++ (nullable NSString *)effectiveProfileNameForPreferredName:(nullable NSString *)preferredName { + NSDictionary *profiles = self.current.profiles; + if (preferredName.length > 0) { + // 显式指定的目标不能静默落到当前或任意首个档案,否则下载可能写错实例。 + return [profiles[preferredName] isKindOfClass:[NSDictionary class]] ? preferredName : nil; + } + + NSString *selectedName = self.current.selectedProfileName; + if (selectedName.length > 0 && [profiles[selectedName] isKindOfClass:[NSDictionary class]]) { + return selectedName; + } + + for (NSString *name in profiles) { + if ([name isKindOfClass:[NSString class]] && [profiles[name] isKindOfClass:[NSDictionary class]]) { + return name; + } + } + return nil; +} + ++ (nullable NSString *)resolvedGameDirectoryForProfileName:(nullable NSString *)profileName { + const char *gameDirC = getenv("POJAV_GAME_DIR"); + NSString *baseDirectory = (gameDirC ? [NSString stringWithUTF8String:gameDirC] : NSHomeDirectory()).stringByStandardizingPath; + if (!baseDirectory.isAbsolutePath) return nil; + NSString *effectiveName = [self effectiveProfileNameForPreferredName:profileName]; + if (effectiveName.length == 0) return nil; + NSDictionary *profile = self.current.profiles[effectiveName]; + NSString *gameDir = profile[@"gameDir"]; + + if (![gameDir isKindOfClass:[NSString class]] || gameDir.length == 0 || [gameDir isEqualToString:@"."]) { + return baseDirectory; + } + if (gameDir.isAbsolutePath) { + return gameDir.stringByStandardizingPath; + } + + NSString *relativePath = [gameDir hasPrefix:@"./"] ? [gameDir substringFromIndex:2] : gameDir; + NSString *resolvedPath = [[baseDirectory stringByAppendingPathComponent:relativePath] stringByStandardizingPath]; + NSString *basePrefix = [baseDirectory stringByAppendingString:@"/"]; + if (![resolvedPath isEqualToString:baseDirectory] && ![resolvedPath hasPrefix:basePrefix]) { + return nil; + } + return resolvedPath; +} + - (id)initWithCurrentInstance { self = [super init]; self.profilePath = [@(getenv("POJAV_GAME_DIR")) stringByAppendingPathComponent:@"launcher_profiles.json"]; diff --git a/Natives/PLTaskProgressViewController.m b/Natives/PLTaskProgressViewController.m index c889e9eed3..663535dd21 100644 --- a/Natives/PLTaskProgressViewController.m +++ b/Natives/PLTaskProgressViewController.m @@ -7,6 +7,7 @@ #import "BackgroundManager.h" #import "ModLoaderIconHelper.h" #import "IconLoader.h" +#include #pragma mark - 常量与辅助 @@ -350,7 +351,10 @@ - (void)configureWithStage:(PLTaskStage *)stage overallTask:(DownloadTaskItem *) self.flowView.hidden = YES; [self.flowView stopFlowing]; [self.progressView setProgress:(float)clamped animated:NO]; - NSString *percent = [NSString stringWithFormat:@"%.0f%%", clamped * 100.0]; + NSInteger displayPercent = (stage.status == PLTaskStageStatusCompleted) + ? 100 + : MIN(99, (NSInteger)floor(clamped * 100.0)); + NSString *percent = [NSString stringWithFormat:@"%ld%%", (long)displayPercent]; self.percentRateLabel.text = rateText ? [NSString stringWithFormat:@"%@ · %@", percent, rateText] : percent; } else { self.progressView.hidden = YES; @@ -969,7 +973,9 @@ - (NSString *)stateTextForTask:(DownloadTaskItem *)task { case DownloadTaskStatePending: return PLTaskProgressText(@"taskProgress.state.pending", localize(@"i18n_str_124", nil)); case DownloadTaskStateDownloading: - return PLTaskProgressText(@"taskProgress.state.downloading", localize(@"i18n_str_138", nil)); + return [task.userInfo[DownloadTaskUserInfoTransferCompleteKey] boolValue] + ? localize(@"i18n_str_78", nil) + : PLTaskProgressText(@"taskProgress.state.downloading", localize(@"i18n_str_138", nil)); case DownloadTaskStatePaused: return PLTaskProgressText(@"taskProgress.state.paused", localize(@"i18n_str_125", nil)); case DownloadTaskStateCompleted: @@ -1123,7 +1129,11 @@ - (void)configureTotalProgressForTask:(DownloadTaskItem *)task { self.totalFlowView.hidden = YES; [self.totalFlowView stopFlowing]; [self.totalProgressView setProgress:(float)MIN(1.0, MAX(0.0, overall)) animated:NO]; - NSString *percent = [NSString stringWithFormat:@"%.0f%%", overall * 100.0]; + double clampedOverall = MIN(1.0, MAX(0.0, overall)); + NSInteger displayPercent = (task.state == DownloadTaskStateCompleted) + ? 100 + : MIN(99, (NSInteger)floor(clampedOverall * 100.0)); + NSString *percent = [NSString stringWithFormat:@"%ld%%", (long)displayPercent]; self.totalValueLabel.text = rateText ? [NSString stringWithFormat:@"%@ · %@", percent, rateText] : percent; } else { // 不确定进度:流动动画,不显示百分比 diff --git a/Natives/PLTaskStages.h b/Natives/PLTaskStages.h index 9c4f3ccd33..56ab1889bc 100644 --- a/Natives/PLTaskStages.h +++ b/Natives/PLTaskStages.h @@ -137,6 +137,14 @@ NS_INLINE NSArray *PLTaskStagesSingleFile(void) { ]; } +/// 世界下载 2 步:下载压缩包→解压并验证 level.dat +NS_INLINE NSArray *PLTaskStagesWorld(void) { + return @[ + [PLTaskStage stageWithTitle:PLTaskStageTitleDownloadFile iconName:@"arrow.down.circle"], + [PLTaskStage stageWithTitle:PLTaskStageTitleExtractFiles iconName:@"archivebox"], + ]; +} + #pragma mark - 阶段标题渲染(UI 层共用,redesign-download-ui Phase 2) /// 渲染阶段标题为用户可见文案:PLTaskStage.title 存储本地化 key, diff --git a/Natives/ProfileSettingsViewController.m b/Natives/ProfileSettingsViewController.m index cfb898aed9..8b2cfff59f 100644 --- a/Natives/ProfileSettingsViewController.m +++ b/Natives/ProfileSettingsViewController.m @@ -470,10 +470,18 @@ - (void)calculateMaxMemory { - (void)loadSettings { // 渲染器 - self.selectedRenderer = self.profile[@"renderer"] ?: @"auto"; + id profileRenderer = self.profile[@"renderer"]; + self.selectedRenderer = ([profileRenderer isKindOfClass:[NSString class]] && + [(NSString *)profileRenderer length] > 0) + ? PLNormalizeRendererKey(profileRenderer) + : PLProfileInheritedValue; // 图形 API(MC 26.2+ 游戏内 OpenGL/Vulkan 切换) - self.selectedGraphicsApi = self.profile[@"graphicsApi"] ?: @"default"; + id profileGraphicsApi = self.profile[@"graphicsApi"]; + self.selectedGraphicsApi = ([profileGraphicsApi isKindOfClass:[NSString class]] && + [(NSString *)profileGraphicsApi length] > 0) + ? PLNormalizeGraphicsApiKey(profileGraphicsApi) + : PLProfileInheritedValue; // Java版本(兼容旧版直装器写入的 NSDictionary 格式) id javaVerRaw = self.profile[@"javaVersion"]; @@ -616,8 +624,16 @@ - (void)saveSettings { if (!existing) { existing = [NSMutableDictionary dictionary]; } - existing[@"renderer"] = self.selectedRenderer; - existing[@"graphicsApi"] = self.selectedGraphicsApi; + if ([self.selectedRenderer isEqualToString:PLProfileInheritedValue]) { + [existing removeObjectForKey:@"renderer"]; + } else { + existing[@"renderer"] = PLNormalizeRendererKey(self.selectedRenderer); + } + if ([self.selectedGraphicsApi isEqualToString:PLProfileInheritedValue]) { + [existing removeObjectForKey:@"graphicsApi"]; + } else { + existing[@"graphicsApi"] = PLNormalizeGraphicsApiKey(self.selectedGraphicsApi); + } existing[@"javaVersion"] = self.selectedJavaVersion; existing[@"allocatedMemory"] = @(self.allocatedMemory); existing[@"serverIp"] = self.serverIp ?: @""; @@ -1096,8 +1112,8 @@ - (BOOL)textFieldShouldReturn:(UITextField *)textField { #pragma mark - Helpers - (NSString *)rendererDisplayName:(NSString *)renderer { - NSArray *keys = getRendererKeys(NO); - NSArray *names = getRendererNames(NO); + NSArray *keys = getRendererKeys(YES); + NSArray *names = getRendererNames(YES); NSUInteger idx = [keys indexOfObject:renderer]; if (idx != NSNotFound && idx < names.count) { return names[idx]; @@ -1988,8 +2004,8 @@ - (void)showRendererSelector { message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - NSArray *renderers = getRendererKeys(NO); - NSArray *displayNames = getRendererNames(NO); + NSArray *renderers = getRendererKeys(YES); + NSArray *displayNames = getRendererNames(YES); for (NSInteger i = 0; i < renderers.count; i++) { NSString *renderer = renderers[i]; @@ -2039,8 +2055,12 @@ - (BOOL)isCurrentProfileModernVersion { /// 图形 API 显示名 - (NSString *)graphicsApiDisplayName:(NSString *)api { - if ([api isEqualToString:@"prefer_vulkan"]) return localize(@"i18n_str_941", nil); - if ([api isEqualToString:@"prefer_opengl"]) return localize(@"i18n_str_942", nil); + NSArray *keys = getGraphicsApiKeys(YES); + NSArray *names = getGraphicsApiNames(YES); + NSUInteger idx = [keys indexOfObject:api]; + if (idx != NSNotFound && idx < names.count) { + return names[idx]; + } return localize(@"i18n_str_943", nil); } @@ -2050,8 +2070,8 @@ - (void)showGraphicsApiSelector { message:localize(@"i18n_str_945", nil) preferredStyle:UIAlertControllerStyleActionSheet]; - NSArray *keys = @[@"default", @"prefer_vulkan", @"prefer_opengl"]; - NSArray *names = @[localize(@"i18n_str_943", nil), localize(@"i18n_str_941", nil), localize(@"i18n_str_942", nil)]; + NSArray *keys = getGraphicsApiKeys(YES); + NSArray *names = getGraphicsApiNames(YES); for (NSInteger i = 0; i < keys.count; i++) { NSString *key = keys[i]; diff --git a/Natives/ResourcePackItem.h b/Natives/ResourcePackItem.h index 8bf795e63d..3a4623138f 100644 --- a/Natives/ResourcePackItem.h +++ b/Natives/ResourcePackItem.h @@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN // --- 在线资源包属性 --- @property (nonatomic, copy, nullable) NSString *onlineID; +/// 在线 API 来源:1=Modrinth,2=CurseForge。 +@property (nonatomic, assign) NSInteger apiSource; @property (nonatomic, copy, nullable) NSString *author; @property (nonatomic, strong, nullable) NSNumber *downloads; @property (nonatomic, strong, nullable) NSNumber *likes; diff --git a/Natives/ResourcePackItem.m b/Natives/ResourcePackItem.m index f337cfed0d..21c89c7382 100644 --- a/Natives/ResourcePackItem.m +++ b/Natives/ResourcePackItem.m @@ -31,6 +31,7 @@ - (instancetype)initWithOnlineData:(NSDictionary *)data { if (self = [super init]) { // 来自 Modrinth 搜索结果 _onlineID = data[@"id"] ? [data[@"id"] description] : nil; + _apiSource = [data[@"apiSource"] integerValue] == 2 ? 2 : 1; _displayName = data[@"title"] ?: @""; _resourcePackDescription = data[@"description"] ?: @""; _iconURL = data[@"imageUrl"] ?: @""; diff --git a/Natives/ResourcePackService.h b/Natives/ResourcePackService.h index d2293c8782..7d28204ce8 100644 --- a/Natives/ResourcePackService.h +++ b/Natives/ResourcePackService.h @@ -30,7 +30,7 @@ typedef void(^ResourcePackDownloadProgressHandler)(NSProgress * _Nullable downlo // --- 本地资源包管理 --- // 扫描指定 profile 的 resourcepacks 目录,返回 .zip 和 .zip.disabled 文件列表 -- (void)scanResourcePacksForProfile:(NSString *)profileName completion:(ResourcePackListHandler)completion; +- (void)scanResourcePacksForProfile:(NSString * _Nullable)profileName completion:(ResourcePackListHandler)completion; // 获取资源包元数据(解析 zip 内的 pack.mcmeta,获取 pack_format 和 description) - (void)fetchMetadataForResourcePack:(ResourcePackItem *)item completion:(ResourcePackMetadataHandler)completion; // 启用/禁用资源包(加/去 .disabled 后缀) @@ -41,7 +41,7 @@ typedef void(^ResourcePackDownloadProgressHandler)(NSProgress * _Nullable downlo // --- 在线资源包下载 --- // 下载资源包到指定 profile 的 resourcepacks 目录,支持实时进度回调 - (void)downloadResourcePack:(ResourcePackItem *)item - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName progress:(ResourcePackDownloadProgressHandler _Nullable)progress completion:(ResourcePackDownloadCompletionHandler _Nullable)completion; @@ -50,7 +50,7 @@ typedef void(^ResourcePackDownloadProgressHandler)(NSProgress * _Nullable downlo /// CurseForge hashes algo=1),传入即启用校验,校验失败由统一下载器按镜像/退避节奏重试; /// 为 nil 时不做 SHA1 校验,靠 zip EOCD 兜底校验保证完整性。 - (void)downloadResourcePack:(ResourcePackItem *)item - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName expectedSHA1:(nullable NSString *)expectedSHA1 progress:(ResourcePackDownloadProgressHandler _Nullable)progress completion:(ResourcePackDownloadCompletionHandler _Nullable)completion; @@ -59,7 +59,7 @@ typedef void(^ResourcePackDownloadProgressHandler)(NSProgress * _Nullable downlo - (NSString *)iconCachePathForURL:(NSString *)urlString; /// 获取当前 profile 的 resourcepacks 目录,不存在时自动创建 -- (nullable NSString *)ensureResourcePacksFolderForProfile:(NSString *)profileName error:(NSError **)error; +- (nullable NSString *)ensureResourcePacksFolderForProfile:(NSString * _Nullable)profileName error:(NSError **)error; @end diff --git a/Natives/ResourcePackService.m b/Natives/ResourcePackService.m index 0796f006b3..011a6dda7b 100644 --- a/Natives/ResourcePackService.m +++ b/Natives/ResourcePackService.m @@ -132,61 +132,20 @@ - (void)parsePackMcmetaForItem:(ResourcePackItem *)item { // 查找指定 profile 的 resourcepacks 目录(已存在时返回路径,否则返回 nil) - (nullable NSString *)existingResourcePacksFolderForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - NSString *resourcePacksPath = [gameDir stringByAppendingPathComponent:@"resourcepacks"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:resourcePacksPath isDirectory:&isDir] && isDir) { - return resourcePacksPath; - } - } - } - } @catch (NSException *ex) { } - - // 回退:读取 POJAV_GAME_DIR 环境变量 - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - NSString *resourcePacksPath = [gameDir stringByAppendingPathComponent:@"resourcepacks"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:resourcePacksPath isDirectory:&isDir] && isDir) { - return resourcePacksPath; - } - } + NSString *gameDir = [PLProfiles resolvedGameDirectoryForProfileName:profileName]; + if (gameDir.length == 0) return nil; + NSString *resourcePacksPath = [gameDir stringByAppendingPathComponent:@"resourcepacks"]; + BOOL isDir = NO; + if ([fm fileExistsAtPath:resourcePacksPath isDirectory:&isDir] && isDir) return resourcePacksPath; return nil; } /// 获取当前 profile 的 resourcepacks 目录,不存在时自动创建 - (nullable NSString *)ensureResourcePacksFolderForProfile:(NSString *)profileName error:(NSError **)error { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - NSString *resourcePacksPath = nil; - - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - resourcePacksPath = [gameDir stringByAppendingPathComponent:@"resourcepacks"]; - } - } - } @catch (NSException *ex) { } - - if (!resourcePacksPath) { - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - resourcePacksPath = [gameDir stringByAppendingPathComponent:@"resourcepacks"]; - } - } + NSString *gameDir = [PLProfiles resolvedGameDirectoryForProfileName:profileName]; + NSString *resourcePacksPath = [gameDir stringByAppendingPathComponent:@"resourcepacks"]; if (!resourcePacksPath) { if (error) { @@ -320,54 +279,16 @@ - (void)downloadResourcePack:(ResourcePackItem *)item progress:(ResourcePackDownloadProgressHandler _Nullable)progress completion:(ResourcePackDownloadCompletionHandler _Nullable)completion { // 确保 resourcepacks 目录存在 - NSString *resourcePacksFolder = [self existingResourcePacksFolderForProfile:profileName]; - NSFileManager *fm = [NSFileManager defaultManager]; - + NSError *folderError = nil; + NSString *resourcePacksFolder = [self ensureResourcePacksFolderForProfile:profileName error:&folderError]; if (!resourcePacksFolder) { - // 目录不存在时尝试创建 - NSString *profile = profileName.length ? profileName : @"default"; - NSString *gameDir = nil; - - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - gameDir = prof[@"gameDir"]; - } - } @catch (NSException *ex) { } - - if (!gameDir) { - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - gameDir = [NSString stringWithUTF8String:gameDirC]; - } - } - - if (gameDir) { - resourcePacksFolder = [gameDir stringByAppendingPathComponent:@"resourcepacks"]; - NSError *dirError = nil; - BOOL created = [fm createDirectoryAtPath:resourcePacksFolder - withIntermediateDirectories:YES - attributes:nil - error:&dirError]; - if (!created || dirError) { - if (completion) { - NSError *error = [NSError errorWithDomain:@"ResourcePackServiceError" - code:1 - userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_951", nil)}]; - dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); - } - return; - } - } else { - if (completion) { - NSError *error = [NSError errorWithDomain:@"ResourcePackServiceError" - code:1 - userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_106", nil)}]; - dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); - } - return; + if (completion) { + NSError *error = folderError ?: [NSError errorWithDomain:@"ResourcePackServiceError" + code:1 + userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_106", nil)}]; + dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); } + return; } // 校验下载链接 @@ -409,6 +330,9 @@ - (void)downloadResourcePack:(ResourcePackItem *)item supportsResume:YES iconURL:item.iconURL]; taskItem.downloadURL = item.selectedVersionDownloadURL; + NSString *taskProfileName = [PLProfiles effectiveProfileNameForPreferredName:profileName]; + if (taskProfileName.length > 0) taskItem.userInfo[@"profileName"] = taskProfileName; + taskItem.userInfo[@"destinationPath"] = destinationPath; // redesign-download-ui Phase 3:单文件下载接入统一进度页—— // PLTaskStagesSingleFile 单阶段 + autoPresentDetail 自动弹出 PLTaskProgressViewController [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId stages:PLTaskStagesSingleFile()]; @@ -416,10 +340,11 @@ - (void)downloadResourcePack:(ResourcePackItem *)item // retryHandler:FCL 风格重新下载,复用同一 taskItem,重新发起 PLDownloadClient 请求 __weak typeof(self) weakSelf = self; + __block PLDownloadRequest *retryRequest = nil; taskItem.retryHandler = ^id(DownloadTaskItem *taskItemRef) { __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return nil; - return [strongSelf restartPLDownloadForTaskId:taskItemRef.taskId]; + if (!strongSelf || !retryRequest) return nil; + return [strongSelf startPLDownloadWithRequest:retryRequest taskItem:taskItemRef progress:progress completion:completion]; }; PLDownloadRequest *request = [[PLDownloadRequest alloc] init]; @@ -437,6 +362,7 @@ - (void)downloadResourcePack:(ResourcePackItem *)item request.taskIdentifier = taskItem.taskId; // 无 SHA1 时对 .zip 做 EOCD 兜底完整性校验 request.allowZipFallbackCheck = YES; + retryRequest = request; [self startPLDownloadWithRequest:request taskItem:taskItem progress:progress completion:completion]; @@ -465,7 +391,6 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest self.downloadAccumulatedBytes[taskId] = @(0); self.downloadTotalBytes[taskId] = @(-1); self.downloadLastSpeeds[taskId] = @(0.0); - [self.downloadStateLock unlock]; PLDownloadOperation *operation = [[PLDownloadClient sharedClient] startRequest:request progress:^(int64_t deltaBytes, int64_t totalExpectedBytes) { @@ -485,12 +410,11 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest }]; if (!operation) { // 参数错误:PLDownloadClient 会异步回调 completion(error),由统一失败路径收尾 + [self.downloadStateLock unlock]; return nil; } - [self.downloadStateLock lock]; self.downloadOperations[taskId] = operation; - [self.downloadStateLock unlock]; // rawTask 为 weak 引用:operation 由 PLDownloadClient 与本 Service 共同持有, // DownloadTaskManager 据此对 PLDownloadOperation 做 pause/resume/cancel @@ -501,6 +425,7 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest [[DownloadTaskManager sharedManager] updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusRunning]; + [self.downloadStateLock unlock]; return operation; } @@ -593,21 +518,21 @@ - (void)handlePLDownloadCompletion:(BOOL)success [self.downloadStateLock unlock]; DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + NSError *completionError = success ? nil : (error ?: [NSError errorWithDomain:@"ResourcePackServiceError" code:3 userInfo:@{NSLocalizedDescriptionKey: @"Resource pack download failed."}]); if (success) { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusCompleted]; - [manager setTaskWithId:taskId state:DownloadTaskStateCompleted]; + [manager setTaskWithId:taskId completedWithError:nil]; } else if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) { // 用户取消(DownloadTaskManager 已置 Cancelled,这里幂等对齐) [manager setTaskWithId:taskId state:DownloadTaskStateCancelled]; } else { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusFailed]; - [manager updateTaskWithId:taskId error:error]; - [manager setTaskWithId:taskId state:DownloadTaskStateFailed]; + [manager setTaskWithId:taskId completedWithError:completionError]; } if (completion) { BOOL successFlag = success ? YES : NO; - NSError *capturedError = success ? nil : error; + NSError *capturedError = completionError; dispatch_async(dispatch_get_main_queue(), ^{ completion(successFlag, capturedError); }); diff --git a/Natives/ResourcePacksManagerViewController.m b/Natives/ResourcePacksManagerViewController.m index 7534239a89..b1335ed9a2 100644 --- a/Natives/ResourcePacksManagerViewController.m +++ b/Natives/ResourcePacksManagerViewController.m @@ -12,6 +12,8 @@ #import "ResourcePackItem.h" #import "ResourceCardTableViewCell.h" #import "DownloadViewController.h" +#import "DownloadTaskManager.h" +#import "PLProfiles.h" #import "utils.h" #pragma mark - 资源包卡片 Cell(继承 Air-Design 卡片基类,本文件内轻量子类) @@ -86,6 +88,7 @@ - (instancetype)init { - (void)viewDidLoad { [super viewDidLoad]; + self.profileName = [PLProfiles effectiveProfileNameForPreferredName:self.profileName]; // 在线下载入口已移至下载界面:固定本地模式(currentMode 等属性保留仅为兼容 .h 既有声明) self.currentMode = ResourcePacksManagerModeLocal; self.localItems = [NSMutableArray array]; @@ -115,6 +118,14 @@ - (void)viewDidLoad { self.navigationItem.leftBarButtonItem = closeButton; self.navigationItem.rightBarButtonItems = @[self.importButton, self.refreshButton]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleDownloadTaskCompleted:) + name:DownloadTaskManagerTaskCompletedNotification + object:nil]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; [self refreshLocalList]; } @@ -279,6 +290,8 @@ - (void)updateEmptyState { - (void)openDownloadPage { // 在线下载入口已收敛到统一下载界面(未区分资源类型 Tab,进入默认页) DownloadViewController *downloadVC = [[DownloadViewController alloc] init]; + downloadVC.initialTabIndex = 3; + downloadVC.targetProfileName = self.profileName; if (self.navigationController) { [self.navigationController pushViewController:downloadVC animated:YES]; } else { @@ -289,6 +302,14 @@ - (void)openDownloadPage { } } +- (void)handleDownloadTaskCompleted:(NSNotification *)notification { + DownloadTaskItem *task = notification.userInfo[DownloadTaskManagerTaskKey]; + if (task.state != DownloadTaskStateCompleted || + ![task.resourceType isEqualToString:DownloadTaskResourceTypeResourcePack] || + ![task.userInfo[@"profileName"] isEqualToString:self.profileName]) return; + [self refreshLocalList]; +} + #pragma mark - UITableView DataSource & Delegate - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { diff --git a/Natives/ShaderItem.h b/Natives/ShaderItem.h index 87ed5124d5..b0a62377ed 100644 --- a/Natives/ShaderItem.h +++ b/Natives/ShaderItem.h @@ -18,7 +18,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) BOOL disabled; // --- Properties for Online Shaders --- -@property (nonatomic, copy, nullable) NSString *onlineID; +@property (nonatomic, copy, nullable) NSString *onlineID; +/// 在线 API 来源:1=Modrinth,2=CurseForge。 +@property (nonatomic, assign) NSInteger apiSource; @property (nonatomic, copy, nullable) NSString *author; @property (nonatomic, strong, nullable) NSNumber *downloads; @property (nonatomic, strong, nullable) NSNumber *likes; diff --git a/Natives/ShaderItem.m b/Natives/ShaderItem.m index 54e5fd35b3..f631751f09 100644 --- a/Natives/ShaderItem.m +++ b/Natives/ShaderItem.m @@ -29,8 +29,9 @@ - (instancetype)initWithFilePath:(NSString *)path { - (instancetype)initWithOnlineData:(NSDictionary *)data { if (self = [super init]) { - // Data from Modrinth search results - _onlineID = data[@"id"] ? [data[@"id"] description] : nil; + // Data from Modrinth search results + _onlineID = data[@"id"] ? [data[@"id"] description] : nil; + _apiSource = [data[@"apiSource"] integerValue] == 2 ? 2 : 1; _displayName = data[@"title"] ?: @""; _shaderDescription = data[@"description"] ?: @""; _iconURL = data[@"imageUrl"] ?: @""; diff --git a/Natives/ShaderService.h b/Natives/ShaderService.h index 6712d3f5d5..00a4e857c0 100644 --- a/Natives/ShaderService.h +++ b/Natives/ShaderService.h @@ -21,17 +21,17 @@ typedef void(^ShaderDownloadHandler)(NSError * _Nullable error); + (instancetype)sharedService; // --- Local Shader Management --- -- (void)scanShadersForProfile:(NSString *)profileName completion:(ShaderListHandler)completion; +- (void)scanShadersForProfile:(NSString * _Nullable)profileName completion:(ShaderListHandler)completion; - (void)fetchMetadataForShader:(ShaderItem *)shader completion:(ShaderMetadataHandler)completion; - (BOOL)toggleEnableForShader:(ShaderItem *)shader error:(NSError **)error; - (BOOL)deleteShader:(ShaderItem *)shader error:(NSError **)error; // --- Online Shader Downloading --- -- (void)downloadShader:(ShaderItem *)shader toProfile:(NSString *)profileName completion:(ShaderDownloadHandler)completion; +- (void)downloadShader:(ShaderItem *)shader toProfile:(NSString * _Nullable)profileName completion:(ShaderDownloadHandler)completion; /// 下载光影包并上报进度 - (void)downloadShader:(ShaderItem *)shader - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName progress:(void (^)(NSProgress *downloadProgress))progress completion:(ShaderDownloadHandler)completion; @@ -40,7 +40,7 @@ typedef void(^ShaderDownloadHandler)(NSError * _Nullable error); /// 传入即启用校验,校验失败由统一下载器按镜像/退避节奏重试; /// 为 nil 时不做 SHA1 校验,靠 zip EOCD 兜底校验保证完整性。 - (void)downloadShader:(ShaderItem *)shader - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName expectedSHA1:(nullable NSString *)expectedSHA1 progress:(nullable void (^)(NSProgress *downloadProgress))progress completion:(ShaderDownloadHandler)completion; @@ -49,7 +49,7 @@ typedef void(^ShaderDownloadHandler)(NSError * _Nullable error); - (NSString *)iconCachePathForURL:(NSString *)urlString; /// 获取当前 profile 的 shaderpacks 目录,不存在时自动创建 -- (nullable NSString *)ensureShadersFolderForProfile:(NSString *)profileName error:(NSError **)error; +- (nullable NSString *)ensureShadersFolderForProfile:(NSString * _Nullable)profileName error:(NSError **)error; @end diff --git a/Natives/ShaderService.m b/Natives/ShaderService.m index 22bf60f4e1..d0e53c2e8f 100644 --- a/Natives/ShaderService.m +++ b/Natives/ShaderService.m @@ -110,75 +110,30 @@ - (NSString *)iconCachePathForURL:(NSString *)urlString { /// 用户点击下载光影按钮后没反应(实际是 ensureShadersFolderForProfile 创建目录到错误位置, /// 下载完成后 moveItem 失败但 handler 已切主线程报错,用户感知"无反应")。 - (nullable NSString *)resolveAbsoluteGameDirForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if (![prof isKindOfClass:[NSDictionary class]]) return nil; - NSString *gameDir = prof[@"gameDir"]; - if (![gameDir isKindOfClass:[NSString class]] || gameDir.length == 0) return nil; - if ([gameDir isEqualToString:@"."]) { - const char *env = getenv("POJAV_GAME_DIR"); - return env ? [NSString stringWithUTF8String:env] : NSHomeDirectory(); - } - if ([gameDir isAbsolutePath]) { - return gameDir; - } - const char *env = getenv("POJAV_GAME_DIR"); - NSString *baseDir = env ? [NSString stringWithUTF8String:env] : NSHomeDirectory(); - NSString *cleanGameDir = [gameDir hasPrefix:@"./"] ? [gameDir substringFromIndex:2] : gameDir; - return [baseDir stringByAppendingPathComponent:cleanGameDir]; - } @catch (NSException *ex) { - return nil; - } + return [PLProfiles resolvedGameDirectoryForProfileName:profileName]; } - (nullable NSString *)existingShadersFolderForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - // 优先用 profile gameDir(已解析为绝对路径) - NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profile]; - if (resolvedGameDir.length > 0) { - NSString *shadersPath = [resolvedGameDir stringByAppendingPathComponent:@"shaderpacks"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:shadersPath isDirectory:&isDir] && isDir) { - return shadersPath; - } - } + NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profileName]; + if (resolvedGameDir.length == 0) return nil; - // 回退到 POJAV_GAME_DIR/shaderpacks - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - NSString *shadersPath = [gameDir stringByAppendingPathComponent:@"shaderpacks"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:shadersPath isDirectory:&isDir] && isDir) { - return shadersPath; - } + NSString *shadersPath = [resolvedGameDir stringByAppendingPathComponent:@"shaderpacks"]; + BOOL isDir = NO; + if ([fm fileExistsAtPath:shadersPath isDirectory:&isDir] && isDir) { + return shadersPath; } return nil; } /// 获取当前 profile 的 shaderpacks 目录,不存在时自动创建 - (nullable NSString *)ensureShadersFolderForProfile:(NSString *)profileName error:(NSError **)error { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - NSString *shadersPath = nil; - - // 优先用 profile gameDir(已解析为绝对路径) - NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profile]; - if (resolvedGameDir.length > 0) { - shadersPath = [resolvedGameDir stringByAppendingPathComponent:@"shaderpacks"]; - } - - if (!shadersPath) { - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - shadersPath = [gameDir stringByAppendingPathComponent:@"shaderpacks"]; - } - } + NSString *resolvedGameDir = [self resolveAbsoluteGameDirForProfile:profileName]; + NSString *shadersPath = resolvedGameDir.length > 0 + ? [resolvedGameDir stringByAppendingPathComponent:@"shaderpacks"] + : nil; if (!shadersPath) { if (error) { @@ -316,9 +271,8 @@ - (void)downloadShader:(ShaderItem *)shader if (!shadersFolder) { // 回退到 ensureShadersFolderForProfile:error:,复用绝对路径解析逻辑 // (之前直接读 prof[@"gameDir"] 不做相对路径解析,会导致目录创建到错误位置) - NSString *profile = profileName.length ? profileName : @"default"; NSError *dirError = nil; - NSString *created = [self ensureShadersFolderForProfile:profile error:&dirError]; + NSString *created = [self ensureShadersFolderForProfile:profileName error:&dirError]; if (!created) { if (completion) { NSError *error = [NSError errorWithDomain:@"ShaderServiceError" @@ -370,6 +324,9 @@ - (void)downloadShader:(ShaderItem *)shader supportsResume:YES iconURL:shader.iconURL]; taskItem.downloadURL = shader.selectedVersionDownloadURL; + NSString *taskProfileName = [PLProfiles effectiveProfileNameForPreferredName:profileName]; + if (taskProfileName.length > 0) taskItem.userInfo[@"profileName"] = taskProfileName; + taskItem.userInfo[@"destinationPath"] = destinationPath; // redesign-download-ui Phase 4:单文件下载接入统一进度页—— // PLTaskStagesSingleFile 单阶段 + autoPresentDetail 自动弹出 PLTaskProgressViewController [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId stages:PLTaskStagesSingleFile()]; @@ -377,10 +334,11 @@ - (void)downloadShader:(ShaderItem *)shader // retryHandler:FCL 风格重新下载,复用同一 taskItem,重新发起 PLDownloadClient 请求 __weak typeof(self) weakSelf = self; + __block PLDownloadRequest *retryRequest = nil; taskItem.retryHandler = ^id(DownloadTaskItem *taskItemRef) { __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) return nil; - return [strongSelf restartPLDownloadForTaskId:taskItemRef.taskId]; + if (!strongSelf || !retryRequest) return nil; + return [strongSelf startPLDownloadWithRequest:retryRequest taskItem:taskItemRef progress:progress completion:completion]; }; PLDownloadRequest *request = [[PLDownloadRequest alloc] init]; @@ -397,6 +355,7 @@ - (void)downloadShader:(ShaderItem *)shader request.taskIdentifier = taskItem.taskId; // 无 SHA1 时对 .zip 做 EOCD 兜底完整性校验 request.allowZipFallbackCheck = YES; + retryRequest = request; [self startPLDownloadWithRequest:request taskItem:taskItem progress:progress completion:completion]; @@ -425,7 +384,6 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest self.downloadAccumulatedBytes[taskId] = @(0); self.downloadTotalBytes[taskId] = @(-1); self.downloadLastSpeeds[taskId] = @(0.0); - [self.downloadStateLock unlock]; PLDownloadOperation *operation = [[PLDownloadClient sharedClient] startRequest:request progress:^(int64_t deltaBytes, int64_t totalExpectedBytes) { @@ -445,12 +403,11 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest }]; if (!operation) { // 参数错误:PLDownloadClient 会异步回调 completion(error),由统一失败路径收尾 + [self.downloadStateLock unlock]; return nil; } - [self.downloadStateLock lock]; self.downloadOperations[taskId] = operation; - [self.downloadStateLock unlock]; // rawTask 为 weak 引用:operation 由 PLDownloadClient 与本 Service 共同持有, // DownloadTaskManager 据此对 PLDownloadOperation 做 pause/resume/cancel @@ -461,6 +418,7 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest [[DownloadTaskManager sharedManager] updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusRunning]; + [self.downloadStateLock unlock]; return operation; } @@ -552,20 +510,20 @@ - (void)handlePLDownloadCompletion:(BOOL)success [self.downloadStateLock unlock]; DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + NSError *completionError = success ? nil : (error ?: [NSError errorWithDomain:@"ShaderServiceError" code:3 userInfo:@{NSLocalizedDescriptionKey: @"Shader download failed."}]); if (success) { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusCompleted]; - [manager setTaskWithId:taskId state:DownloadTaskStateCompleted]; + [manager setTaskWithId:taskId completedWithError:nil]; } else if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) { // 用户取消(DownloadTaskManager 已置 Cancelled,这里幂等对齐) [manager setTaskWithId:taskId state:DownloadTaskStateCancelled]; } else { [manager updateTaskWithId:taskId stageAtIndex:0 status:PLTaskStageStatusFailed]; - [manager updateTaskWithId:taskId error:error]; - [manager setTaskWithId:taskId state:DownloadTaskStateFailed]; + [manager setTaskWithId:taskId completedWithError:completionError]; } if (completion) { - NSError *capturedError = success ? nil : error; + NSError *capturedError = completionError; dispatch_async(dispatch_get_main_queue(), ^{ completion(capturedError); }); @@ -585,4 +543,4 @@ - (void)cleanupPLDownloadStateForTaskId:(NSString *)taskId { [self.downloadCompletionHandlers removeObjectForKey:taskId]; } -@end \ No newline at end of file +@end diff --git a/Natives/ShaderVersionViewController.h b/Natives/ShaderVersionViewController.h index 5075fb71a2..0ada86e669 100644 --- a/Natives/ShaderVersionViewController.h +++ b/Natives/ShaderVersionViewController.h @@ -22,6 +22,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong) UIActivityIndicatorView *activityIndicator; @property (nonatomic, strong) ShaderItem *shaderItem; @property (nonatomic, weak) id delegate; +/// 初始 API 来源:1=Modrinth,2=CurseForge;默认 1。 +@property (nonatomic, assign) NSInteger initialSource; // FCL 风格:传入当前 profile 的偏好版本和加载器 // ShaderVersionViewController 会优先选中匹配的 chip,并把匹配的版本置顶 diff --git a/Natives/ShaderVersionViewController.m b/Natives/ShaderVersionViewController.m index 2eb489d631..85aa5c060c 100644 --- a/Natives/ShaderVersionViewController.m +++ b/Natives/ShaderVersionViewController.m @@ -102,8 +102,8 @@ - (void)viewDidLoad { // 适配自定义启动器背景:透明化当前 VC,让全局背景图/毛玻璃透出 [[BackgroundManager sharedManager] makeViewControllerTransparent:self]; - // 初始化筛选状态(默认 Modrinth 源 + 相关性排序) - self.selectedSource = kSourceModrinth; + // 从搜索结果继承 API 来源,避免拿 CurseForge 数字 ID 请求 Modrinth。 + self.selectedSource = self.initialSource == kSourceCurseForge ? kSourceCurseForge : kSourceModrinth; self.selectedSort = kSortRelevance; [self setupSideFilterPanel]; diff --git a/Natives/ShadersManagerViewController.m b/Natives/ShadersManagerViewController.m index 0bc98a424a..0d8e7c1817 100644 --- a/Natives/ShadersManagerViewController.m +++ b/Natives/ShadersManagerViewController.m @@ -10,6 +10,8 @@ #import "ShaderService.h" #import "ShaderItem.h" #import "DownloadViewController.h" +#import "DownloadTaskManager.h" +#import "PLProfiles.h" #import "utils.h" #pragma mark - ShaderCardCell(光影卡片,Air-Design L2 标准卡片) @@ -88,6 +90,8 @@ - (instancetype)init { - (void)viewDidLoad { [super viewDidLoad]; // 基类完成标题/毛玻璃背景/搜索栏/表格/三态视图/批量工具栏构建 + self.profileName = [PLProfiles effectiveProfileNameForPreferredName:self.profileName]; + // 始终使用本地模式(在线下载入口已移至下载界面) self.localShaders = [NSMutableArray array]; self.filteredLocalShaders = [NSMutableArray array]; @@ -108,7 +112,14 @@ - (void)viewDidLoad { self.tableView.refreshControl = refreshControl; [self setupNavigationButtons]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleDownloadTaskCompleted:) + name:DownloadTaskManagerTaskCompletedNotification + object:nil]; +} +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; [self refreshLocalShadersList]; } @@ -405,6 +416,7 @@ - (void)openDownloadPage { // 跳转统一下载界面并定位到光影 tab(在线下载入口已统一收口到下载页) DownloadViewController *downloadVC = [[DownloadViewController alloc] init]; downloadVC.initialTabIndex = 2; // 0版本 1模组 2光影 + downloadVC.targetProfileName = self.profileName; if (self.navigationController) { [self.navigationController pushViewController:downloadVC animated:YES]; } else { @@ -415,6 +427,14 @@ - (void)openDownloadPage { } } +- (void)handleDownloadTaskCompleted:(NSNotification *)notification { + DownloadTaskItem *task = notification.userInfo[DownloadTaskManagerTaskKey]; + if (task.state != DownloadTaskStateCompleted || + ![task.resourceType isEqualToString:DownloadTaskResourceTypeShader] || + ![task.userInfo[@"profileName"] isEqualToString:self.profileName]) return; + [self refreshLocalShadersList]; +} + #pragma mark - 搜索(UISearchBarDelegate) - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { diff --git a/Natives/VersionManagerViewController.m b/Natives/VersionManagerViewController.m index a452398f9c..d6c53e629d 100644 --- a/Natives/VersionManagerViewController.m +++ b/Natives/VersionManagerViewController.m @@ -1136,12 +1136,7 @@ - (BOOL)isCurrentProfileModernVersion { /// 获取当前选中 profile 的渲染器(如未设置则回退到全局偏好) - (NSString *)currentRendererForSelectedProfile { if (!self.selectedProfile) return @"auto"; - NSDictionary *profile = PLProfiles.current.profiles[self.selectedProfile]; - NSString *r = profile[@"renderer"]; - if (r.length == 0) { - r = getPrefObject(@"video.renderer"); - } - return r.length > 0 ? r : @"auto"; + return [PLProfiles profile:PLProfiles.current.profiles[self.selectedProfile] resolveKey:@"renderer"] ?: @"auto"; } /// 获取当前选中 profile 的图形 API(MC 26.2+,如未设置则回退到全局偏好,再回退到 default) @@ -1592,9 +1587,6 @@ - (void)selectRendererAtIndex:(NSInteger)index { profiles[self.selectedProfile] = profile; [PLProfiles.current save]; - // 同步到全局偏好(保证启动游戏时 LauncherRightPanelViewController 能读到) - setPrefString(@"video.renderer", key); - [self.collectionView reloadData]; NSLog(@"[VersionMgr] Renderer for profile '%@' set to '%@' (%@)", self.selectedProfile, key, displayName); @@ -1628,9 +1620,6 @@ - (void)selectGraphicsApiAtIndex:(NSInteger)index { profiles[self.selectedProfile] = profile; [PLProfiles.current save]; - // 同步到全局偏好 - setPrefString(@"video.graphics_api", key); - [self.collectionView reloadData]; NSLog(@"[VersionMgr] Graphics API for profile '%@' set to '%@' (%@)", self.selectedProfile, key, displayName); diff --git a/Natives/WorldItem.h b/Natives/WorldItem.h index 712f35a4e5..929e1fda34 100644 --- a/Natives/WorldItem.h +++ b/Natives/WorldItem.h @@ -27,6 +27,8 @@ NS_ASSUME_NONNULL_BEGIN // --- 在线世界属性(用于在线下载) --- @property (nonatomic, copy, nullable) NSString *onlineID; +/// 在线 API 来源:1=Modrinth,2=CurseForge。 +@property (nonatomic, assign) NSInteger apiSource; @property (nonatomic, copy, nullable) NSString *author; @property (nonatomic, strong, nullable) NSNumber *downloads; @property (nonatomic, strong, nullable) NSNumber *likes; diff --git a/Natives/WorldItem.m b/Natives/WorldItem.m index b01a344256..a9b4590af1 100644 --- a/Natives/WorldItem.m +++ b/Natives/WorldItem.m @@ -42,6 +42,7 @@ - (instancetype)initWithOnlineData:(NSDictionary *)data { if (self = [super init]) { // 来自 Modrinth/CurseForge 搜索结果 _onlineID = data[@"id"] ? [data[@"id"] description] : nil; + _apiSource = [data[@"apiSource"] integerValue] == 2 ? 2 : 1; _displayName = data[@"title"] ?: @""; _worldDescription = data[@"description"] ?: @""; _iconURL = data[@"imageUrl"] ?: @""; diff --git a/Natives/WorldService.h b/Natives/WorldService.h index 94ea587d1e..fa112e190a 100644 --- a/Natives/WorldService.h +++ b/Natives/WorldService.h @@ -26,7 +26,7 @@ typedef void(^WorldDownloadProgressHandler)(NSProgress * _Nullable downloadProgr // --- 本地世界管理 --- // 扫描指定 profile 的 saves 目录,返回每个含 level.dat 的子目录作为一个 WorldItem -- (void)scanWorldsForProfile:(NSString *)profileName completion:(WorldListHandler)completion; +- (void)scanWorldsForProfile:(NSString * _Nullable)profileName completion:(WorldListHandler)completion; // 删除世界目录(递归删除) - (BOOL)deleteWorld:(WorldItem *)item error:(NSError **)error; @@ -35,24 +35,24 @@ typedef void(^WorldDownloadProgressHandler)(NSProgress * _Nullable downloadProgr // progress 回调实时上报下载进度(不含解压阶段) // completion 在主线程回调,success 表示下载并解压是否成功 - (void)downloadWorld:(WorldItem *)item - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName progress:(WorldDownloadProgressHandler _Nullable)progress completion:(WorldDownloadCompletionHandler _Nullable)completion; // 从本地文件 URL 导入世界 zip(如 UIDocumentPicker 选择的文件) // 同样做健壮解压,导入完成后可删除临时 zip - (void)importWorldFromURL:(NSURL *)sourceURL - toProfile:(NSString *)profileName + toProfile:(NSString * _Nullable)profileName progress:(WorldDownloadProgressHandler _Nullable)progress completion:(WorldDownloadCompletionHandler _Nullable)completion; // --- 工具方法 --- /// 获取当前 profile 的 saves 目录,不存在时自动创建 -- (nullable NSString *)ensureWorldsFolderForProfile:(NSString *)profileName error:(NSError **)error; +- (nullable NSString *)ensureWorldsFolderForProfile:(NSString * _Nullable)profileName error:(NSError **)error; /// 查找当前 profile 的 saves 目录(已存在时返回路径,否则返回 nil) -- (nullable NSString *)existingWorldsFolderForProfile:(NSString *)profileName; +- (nullable NSString *)existingWorldsFolderForProfile:(NSString * _Nullable)profileName; @end diff --git a/Natives/WorldService.m b/Natives/WorldService.m index c13360c2d0..0b36a3e316 100644 --- a/Natives/WorldService.m +++ b/Natives/WorldService.m @@ -20,16 +20,33 @@ #import "PLTaskStages.h" #import "LauncherPreferences.h" +static NSString * const PLWorldDownloadGenerationKey = @"worldDownloadGeneration"; +static NSString * const PLWorldStagingRootName = @".amethyst-world-staging"; +static NSString * const PLWorldArchiveFileName = @"world.zip"; + +@interface PLStagedWorld : NSObject +@property (nonatomic, copy) NSString *stagingDirectory; +@property (nonatomic, copy) NSString *worldDirectory; +@property (nonatomic, copy) NSString *suggestedName; +@end + +@implementation PLStagedWorld +@end + @interface WorldService () @property (nonatomic, strong) NSURLSession *downloadSession; // 内部统一存储带 success/error 的 completion handler @property (nonatomic, strong) NSMutableDictionary *downloadCompletionHandlers; @property (nonatomic, strong) NSMutableDictionary *downloadDestinationPaths; +@property (nonatomic, strong) NSMutableDictionary *downloadStagingDirectories; +@property (nonatomic, strong) NSMutableDictionary *downloadWorldNames; +@property (nonatomic, strong) NSMutableDictionary *downloadSavesFolders; // 进度回调相关:分别保存进度 handler 和 NSProgress 对象 @property (nonatomic, strong) NSMutableDictionary *downloadProgressHandlers; @property (nonatomic, strong) NSMutableDictionary *downloadProgresses; @property (nonatomic, strong) NSMutableDictionary *downloadTaskItems; @property (nonatomic, strong) NSMutableDictionary *downloadProgressSnapshots; +@property (nonatomic, strong) NSLock *downloadStateLock; // 导入任务专用字典(不通过 NSURLSession 下载,但同样需要进度上报) @property (nonatomic, strong) NSMutableDictionary *importCompletionHandlers; @end @@ -57,10 +74,14 @@ - (instancetype)init { _downloadSession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil]; _downloadCompletionHandlers = [NSMutableDictionary dictionary]; _downloadDestinationPaths = [NSMutableDictionary dictionary]; + _downloadStagingDirectories = [NSMutableDictionary dictionary]; + _downloadWorldNames = [NSMutableDictionary dictionary]; + _downloadSavesFolders = [NSMutableDictionary dictionary]; _downloadProgressHandlers = [NSMutableDictionary dictionary]; _downloadProgresses = [NSMutableDictionary dictionary]; _downloadTaskItems = [NSMutableDictionary dictionary]; _downloadProgressSnapshots = [NSMutableDictionary dictionary]; + _downloadStateLock = [[NSLock alloc] init]; _importCompletionHandlers = [NSMutableDictionary dictionary]; } return self; @@ -70,84 +91,26 @@ - (instancetype)init { // 解析 profile 的 gameDir,返回 gameDir 或 nil - (nullable NSString *)gameDirForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - return gameDir; - } - } - } @catch (NSException *ex) { } - - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - return [NSString stringWithUTF8String:gameDirC]; - } - return nil; + return [PLProfiles resolvedGameDirectoryForProfileName:profileName]; } #pragma mark - Saves folder detection & scan // 查找指定 profile 的 saves 目录(已存在时返回路径,否则返回 nil) - (nullable NSString *)existingWorldsFolderForProfile:(NSString *)profileName { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - NSString *savesPath = [gameDir stringByAppendingPathComponent:@"saves"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:savesPath isDirectory:&isDir] && isDir) { - return savesPath; - } - } - } - } @catch (NSException *ex) { } - - // 回退:读取 POJAV_GAME_DIR 环境变量 - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - NSString *savesPath = [gameDir stringByAppendingPathComponent:@"saves"]; - BOOL isDir = NO; - if ([fm fileExistsAtPath:savesPath isDirectory:&isDir] && isDir) { - return savesPath; - } - } + NSString *gameDir = [self gameDirForProfile:profileName]; + if (gameDir.length == 0) return nil; + NSString *savesPath = [gameDir stringByAppendingPathComponent:@"saves"]; + BOOL isDir = NO; + if ([fm fileExistsAtPath:savesPath isDirectory:&isDir] && isDir) return savesPath; return nil; } /// 获取当前 profile 的 saves 目录,不存在时自动创建 - (nullable NSString *)ensureWorldsFolderForProfile:(NSString *)profileName error:(NSError **)error { - NSString *profile = profileName.length ? profileName : @"default"; NSFileManager *fm = [NSFileManager defaultManager]; - NSString *savesPath = nil; - - @try { - NSDictionary *profiles = PLProfiles.current.profiles; - NSDictionary *prof = profiles[profile]; - if ([prof isKindOfClass:[NSDictionary class]]) { - NSString *gameDir = prof[@"gameDir"]; - if ([gameDir isKindOfClass:[NSString class]] && gameDir.length > 0) { - savesPath = [gameDir stringByAppendingPathComponent:@"saves"]; - } - } - } @catch (NSException *ex) { } - - if (!savesPath) { - const char *gameDirC = getenv("POJAV_GAME_DIR"); - if (gameDirC) { - NSString *gameDir = [NSString stringWithUTF8String:gameDirC]; - savesPath = [gameDir stringByAppendingPathComponent:@"saves"]; - } - } + NSString *savesPath = [[self gameDirForProfile:profileName] stringByAppendingPathComponent:@"saves"]; if (!savesPath) { if (error) { @@ -236,108 +199,294 @@ - (BOOL)deleteWorld:(WorldItem *)item error:(NSError **)error { #pragma mark - 健壮解压逻辑 -// 检测 zip 内是否存在顶层目录(即所有条目都以同一个目录名开头) -// 若存在,返回该顶层目录名;否则返回 nil(说明 zip 直接散装了 level.dat 等文件) -- (nullable NSString *)detectTopLevelDirectoryInZip:(NSString *)zipPath { - NSError *err = nil; - UZKArchive *archive = [[UZKArchive alloc] initWithPath:zipPath error:&err]; - if (!archive || err) return nil; - - NSArray *fileNames = [archive listFilenames:&err]; - if (!fileNames || fileNames.count == 0) return nil; - - NSMutableSet *topLevels = [NSMutableSet set]; - for (NSString *name in fileNames) { - if (name.length == 0) continue; - // 跳过 macOS 元数据文件(如 __MACOSX/...) - if ([name hasPrefix:@"__MACOSX/"]) continue; - // 取第一段作为顶层目录候选 - NSString *firstComponent = [name componentsSeparatedByString:@"/"].firstObject; - if (firstComponent.length == 0) continue; - [topLevels addObject:firstComponent]; +// 每次下载/导入使用 saves 同级的唯一 staging。它与 saves 位于同一卷,最终 +// moveItemAtPath: 可安全 rename,同时任何解压失败都不会触碰已有世界。 +- (nullable NSString *)createWorldStagingDirectoryForSavesDir:(NSString *)savesDir + error:(NSError **)error { + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *gameDirectory = savesDir.stringByDeletingLastPathComponent; + NSString *stagingRoot = [gameDirectory stringByAppendingPathComponent:PLWorldStagingRootName]; + BOOL isDirectory = NO; + if ([fm fileExistsAtPath:stagingRoot isDirectory:&isDirectory]) { + if (!isDirectory) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:8 + userInfo:@{NSLocalizedDescriptionKey: @"The world staging path is not a directory."}]; + } + return nil; + } + } else if (![fm createDirectoryAtPath:stagingRoot + withIntermediateDirectories:YES + attributes:nil + error:error]) { + return nil; } - // 仅当所有条目共享同一个顶层目录时,才认为 zip 内有顶层目录 - if (topLevels.count == 1) { - return [topLevels anyObject]; + NSString *stagingDirectory = [stagingRoot stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + if (![fm createDirectoryAtPath:stagingDirectory + withIntermediateDirectories:NO + attributes:nil + error:error]) { + return nil; } - return nil; + return stagingDirectory.stringByStandardizingPath; +} + +- (void)cleanupWorldStagingDirectory:(nullable NSString *)stagingDirectory { + if (stagingDirectory.length == 0) return; + NSString *standardPath = stagingDirectory.stringByStandardizingPath; + NSString *parentName = standardPath.stringByDeletingLastPathComponent.lastPathComponent; + if (![parentName isEqualToString:PLWorldStagingRootName] || standardPath.lastPathComponent.length == 0) { + NSLog(@"[WorldService] refusing to remove unexpected staging path: %@", stagingDirectory); + return; + } + [[NSFileManager defaultManager] removeItemAtPath:standardPath error:nil]; +} + +- (BOOL)archiveEntries:(NSArray *)entries + areSafeUnderDirectory:(NSString *)directory + error:(NSError **)error { + NSString *root = directory.stringByStandardizingPath; + NSString *rootPrefix = [root stringByAppendingString:@"/"]; + for (id rawEntry in entries) { + if (![rawEntry isKindOfClass:[NSString class]]) continue; + NSString *entry = [(NSString *)rawEntry stringByReplacingOccurrencesOfString:@"\\" withString:@"/"]; + if (entry.length == 0) continue; + NSArray *components = [entry componentsSeparatedByString:@"/"]; + if ([entry hasPrefix:@"/"] || [components containsObject:@".."]) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:9 + userInfo:@{NSLocalizedDescriptionKey: @"The world archive contains an unsafe path."}]; + } + return NO; + } + NSString *candidate = [[root stringByAppendingPathComponent:entry] stringByStandardizingPath]; + if (![candidate isEqualToString:root] && ![candidate hasPrefix:rootPrefix]) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:9 + userInfo:@{NSLocalizedDescriptionKey: @"The world archive contains an unsafe path."}]; + } + return NO; + } + } + return YES; } -// 健壮解压: -// - 若 zip 内有顶层目录,直接解压到 saves/ -// - 若 zip 内无顶层目录(散装),先用世界名创建子目录,再解压到该子目录 -// - worldName 用于无顶层目录时命名新世界目录 -- (BOOL)extractWorldZipAt:(NSString *)zipPath - toSavesDir:(NSString *)savesDir - worldName:(NSString *)worldName - error:(NSError **)error { +// 找到唯一的最浅层世界根目录。压缩包可有任意层 wrapper,但多个同层世界 +// 属于歧义输入,不能依赖目录枚举顺序任意安装其中一个。 +- (nullable NSString *)worldDirectoryContainingLevelDatUnderPath:(NSString *)rootPath + error:(NSError **)error { NSFileManager *fm = [NSFileManager defaultManager]; + NSString *root = rootPath.stringByStandardizingPath; + NSString *rootPrefix = [root stringByAppendingString:@"/"]; + NSMutableDictionary *candidates = [NSMutableDictionary dictionary]; + NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:root]; + + for (NSString *relativePath in enumerator) { + NSString *fullPath = [[root stringByAppendingPathComponent:relativePath] stringByStandardizingPath]; + if (![fullPath hasPrefix:rootPrefix]) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:9 + userInfo:@{NSLocalizedDescriptionKey: @"The extracted world escaped its staging directory."}]; + } + return nil; + } - NSString *topLevelDir = [self detectTopLevelDirectoryInZip:zipPath]; - NSString *extractTargetDir = nil; - NSString *finalWorldDir = nil; + NSError *attributeError = nil; + NSDictionary *attributes = [fm attributesOfItemAtPath:fullPath error:&attributeError]; + if (attributeError) { + if (error) *error = attributeError; + return nil; + } + if ([attributes[NSFileType] isEqualToString:NSFileTypeSymbolicLink]) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:9 + userInfo:@{NSLocalizedDescriptionKey: @"The world archive contains a symbolic link."}]; + } + return nil; + } + if (![relativePath.lastPathComponent isEqualToString:@"level.dat"] || + [[relativePath pathComponents] containsObject:@"__MACOSX"]) { + continue; + } + if (![attributes[NSFileType] isEqualToString:NSFileTypeRegular]) continue; + + NSString *worldDirectory = fullPath.stringByDeletingLastPathComponent; + NSString *relativeWorld = [worldDirectory isEqualToString:root] + ? @"" + : [worldDirectory substringFromIndex:rootPrefix.length]; + NSUInteger depth = relativeWorld.length == 0 ? 0 : relativeWorld.pathComponents.count; + candidates[worldDirectory] = @(depth); + } - if (topLevelDir) { - // zip 内有顶层目录,直接解压到 saves/ - extractTargetDir = savesDir; - finalWorldDir = [savesDir stringByAppendingPathComponent:topLevelDir]; - } else { - // zip 内无顶层目录,需要创建子目录后再解压 - NSString *baseName = worldName.length > 0 ? worldName : [zipPath lastPathComponent]; - // 去除可能的 .zip 后缀 - if ([baseName.lowercaseString hasSuffix:@".zip"]) { - baseName = [baseName substringToIndex:baseName.length - @".zip".length]; + if (candidates.count == 0) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:7 + userInfo:@{NSLocalizedDescriptionKey: @"The downloaded archive does not contain a valid Minecraft world (level.dat is missing)."}]; } - // 若 saves/ 已存在,则追加数字后缀避免覆盖 - NSString *candidate = [savesDir stringByAppendingPathComponent:baseName]; - NSInteger suffix = 1; - while ([fm fileExistsAtPath:candidate]) { - candidate = [savesDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@_%ld", baseName, (long)suffix]]; - suffix++; + return nil; + } + + NSUInteger minimumDepth = NSUIntegerMax; + for (NSNumber *depth in candidates.allValues) minimumDepth = MIN(minimumDepth, depth.unsignedIntegerValue); + NSArray *shallowest = [candidates keysOfEntriesPassingTest:^BOOL(NSString *key, NSNumber *depth, BOOL *stop) { + return depth.unsignedIntegerValue == minimumDepth; + }].allObjects; + if (shallowest.count != 1) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:10 + userInfo:@{NSLocalizedDescriptionKey: @"The world archive contains multiple ambiguous worlds."}]; } - extractTargetDir = candidate; - finalWorldDir = candidate; + return nil; + } + return shallowest.firstObject; +} - // 创建目标子目录 - NSError *createError = nil; - if (![fm createDirectoryAtPath:extractTargetDir withIntermediateDirectories:YES attributes:nil error:&createError]) { - if (error) *error = createError; - return NO; +- (NSString *)sanitizedWorldDirectoryName:(nullable NSString *)preferredName { + NSString *normalized = [(preferredName ?: @"") stringByReplacingOccurrencesOfString:@"\\" withString:@"/"]; + NSString *name = [normalized.lastPathComponent stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if ([name.lowercaseString hasSuffix:@".zip"]) { + name = [name substringToIndex:name.length - @".zip".length]; + } + name = [[name componentsSeparatedByCharactersInSet:NSCharacterSet.controlCharacterSet] componentsJoinedByString:@"_"]; + if (name.length == 0 || [name isEqualToString:@"."] || [name isEqualToString:@".."]) { + return @"imported_world"; + } + return name; +} + +- (nullable PLStagedWorld *)stageWorldZipAt:(NSString *)zipPath + stagingDirectory:(NSString *)stagingDirectory + worldName:(NSString *)worldName + error:(NSError **)error { + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *staging = stagingDirectory.stringByStandardizingPath; + NSString *stagingPrefix = [staging stringByAppendingString:@"/"]; + NSString *archivePath = zipPath.stringByStandardizingPath; + if (![archivePath hasPrefix:stagingPrefix]) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:9 + userInfo:@{NSLocalizedDescriptionKey: @"The world archive is outside its staging directory."}]; } + return nil; } - // 使用 UnzipKit 解压到目标目录 NSError *archiveError = nil; - UZKArchive *archive = [[UZKArchive alloc] initWithPath:zipPath error:&archiveError]; - if (!archive || archiveError) { - if (error) *error = archiveError; - return NO; + UZKArchive *archive = [[UZKArchive alloc] initWithPath:archivePath error:&archiveError]; + NSArray *entries = archive ? [archive listFilenames:&archiveError] : nil; + if (!archive || archiveError || entries.count == 0) { + if (error) *error = archiveError ?: [NSError errorWithDomain:@"WorldServiceError" + code:5 + userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1097", nil)}]; + return nil; + } + + NSString *extractDirectory = [staging stringByAppendingPathComponent:@"extracted"]; + if (![self archiveEntries:entries areSafeUnderDirectory:extractDirectory error:error]) return nil; + if (![fm createDirectoryAtPath:extractDirectory + withIntermediateDirectories:NO + attributes:nil + error:error]) { + return nil; } NSError *extractError = nil; - BOOL success = [archive extractFilesTo:extractTargetDir overwrite:YES error:&extractError]; - if (!success || extractError) { + if (![archive extractFilesTo:extractDirectory overwrite:NO error:&extractError] || extractError) { if (error) *error = extractError; - // 解压失败时若我们创建了子目录,清理掉空目录 - if (!topLevelDir && [fm fileExistsAtPath:extractTargetDir]) { - NSArray *leftover = [fm contentsOfDirectoryAtPath:extractTargetDir error:nil]; - if (leftover.count == 0) { - [fm removeItemAtPath:extractTargetDir error:nil]; + return nil; + } + + NSString *worldDirectory = [self worldDirectoryContainingLevelDatUnderPath:extractDirectory error:error]; + if (!worldDirectory) return nil; + + PLStagedWorld *stagedWorld = [[PLStagedWorld alloc] init]; + stagedWorld.stagingDirectory = staging; + stagedWorld.worldDirectory = worldDirectory; + stagedWorld.suggestedName = [self sanitizedWorldDirectoryName: + [worldDirectory isEqualToString:extractDirectory] ? worldName : worldDirectory.lastPathComponent]; + return stagedWorld; +} + +- (nullable NSString *)commitStagedWorld:(PLStagedWorld *)stagedWorld + toSavesDir:(NSString *)savesDir + error:(NSError **)error { + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *staging = stagedWorld.stagingDirectory.stringByStandardizingPath; + NSString *source = stagedWorld.worldDirectory.stringByStandardizingPath; + NSString *stagingPrefix = [staging stringByAppendingString:@"/"]; + if (![source hasPrefix:stagingPrefix]) { + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:9 + userInfo:@{NSLocalizedDescriptionKey: @"The staged world is outside its staging directory."}]; + } + return nil; + } + + NSString *levelDat = [source stringByAppendingPathComponent:@"level.dat"]; + NSDictionary *attributes = [fm attributesOfItemAtPath:levelDat error:error]; + if (![attributes[NSFileType] isEqualToString:NSFileTypeRegular]) { + if (error && !*error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:7 + userInfo:@{NSLocalizedDescriptionKey: @"The staged world does not contain a regular level.dat file."}]; + } + return nil; + } + + NSString *baseName = [self sanitizedWorldDirectoryName:stagedWorld.suggestedName]; + for (NSInteger suffix = 0; suffix < 10000; suffix++) { + NSString *candidateName = suffix == 0 + ? baseName + : [NSString stringWithFormat:@"%@_%ld", baseName, (long)suffix]; + NSString *destination = [savesDir stringByAppendingPathComponent:candidateName]; + if ([fm fileExistsAtPath:destination]) continue; + + NSError *moveError = nil; + if ([fm moveItemAtPath:source toPath:destination error:&moveError]) { + NSString *installedLevelDat = [destination stringByAppendingPathComponent:@"level.dat"]; + NSDictionary *installedAttributes = [fm attributesOfItemAtPath:installedLevelDat error:&moveError]; + if ([installedAttributes[NSFileType] isEqualToString:NSFileTypeRegular]) { + NSLog(@"[WorldService] staged world committed to: %@", destination); + return destination; } + [fm removeItemAtPath:destination error:nil]; + if (error) *error = moveError ?: [NSError errorWithDomain:@"WorldServiceError" + code:7 + userInfo:@{NSLocalizedDescriptionKey: @"The installed world failed level.dat validation."}]; + return nil; } - return NO; + + // 两个并发导入可能同时选择同一名字。目标刚被占用时换下一个后缀; + // 其他移动错误直接返回,绝不删除或覆盖目标目录。 + if ([fm fileExistsAtPath:destination]) continue; + if (error) *error = moveError; + return nil; } - // 校验解压结果:必须存在 level.dat(可能在 finalWorldDir 直接下,也可能在更深一层) - NSString *levelDatCheck = [finalWorldDir stringByAppendingPathComponent:@"level.dat"]; - if (![fm fileExistsAtPath:levelDatCheck]) { - // 有些 zip 即使有顶层目录,level.dat 可能还在更深层。这里只做日志,不阻断 - NSLog(@"[WorldService] warning: level.dat not found at %@ after extraction", finalWorldDir); + if (error) { + *error = [NSError errorWithDomain:@"WorldServiceError" + code:11 + userInfo:@{NSLocalizedDescriptionKey: @"Unable to allocate a unique world directory name."}]; } + return nil; +} - NSLog(@"[WorldService] world extracted to: %@", finalWorldDir); - return YES; +- (BOOL)isWorldTaskItemCurrent:(nullable DownloadTaskItem *)taskItem + generation:(NSNumber *)generation { + if (!taskItem || !generation) return NO; + DownloadTaskItem *latestTask = [[DownloadTaskManager sharedManager] taskWithId:taskItem.taskId]; + return latestTask != nil && + latestTask.state == DownloadTaskStateDownloading && + [latestTask.userInfo[PLWorldDownloadGenerationKey] isEqual:generation]; } #pragma mark - 在线世界下载(含健壮解压) @@ -371,24 +520,29 @@ - (void)downloadWorld:(WorldItem *)item return; } - // 下载到临时 zip 路径,解压后再删除 - NSString *tempZipName = [NSString stringWithFormat:@"world_%@.zip", [[NSUUID UUID] UUIDString]]; - NSString *destinationPath = [NSTemporaryDirectory() stringByAppendingPathComponent:tempZipName]; + // 下载到 saves 同卷的唯一 staging。任何下载、解压或验证失败都只清理该目录, + // 不会把压缩包内容直接写入已有世界。 + NSError *stagingError = nil; + NSString *stagingDirectory = [self createWorldStagingDirectoryForSavesDir:savesFolder error:&stagingError]; + if (!stagingDirectory) { + if (completion) { + NSError *error = stagingError ?: [NSError errorWithDomain:@"WorldServiceError" + code:8 + userInfo:@{NSLocalizedDescriptionKey: @"Unable to create world staging directory."}]; + dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); + } + return; + } + NSString *destinationPath = [stagingDirectory stringByAppendingPathComponent:PLWorldArchiveFileName]; // 同时记录预期世界名(用于无顶层目录时的子目录命名) NSString *worldNameForExtract = item.displayName ?: item.worldName ?: [url lastPathComponent]; // 创建下载任务(默认会话配置,无后台限速) NSURLSessionDownloadTask *task = [self.downloadSession downloadTaskWithURL:url]; - self.downloadCompletionHandlers[task] = completion; - self.downloadDestinationPaths[task] = destinationPath; - // 用 taskDescription 暂存世界名和 saves 目录(用于解压阶段) - // 格式:"worldName\nsavesFolder" - task.taskDescription = [NSString stringWithFormat:@"%@\n%@", worldNameForExtract, savesFolder]; + NSProgress *progressObj = nil; if (progress) { - NSProgress *progressObj = [NSProgress progressWithTotalUnitCount:-1]; + progressObj = [NSProgress progressWithTotalUnitCount:-1]; progressObj.kind = NSProgressKindFile; - self.downloadProgresses[task] = progressObj; - self.downloadProgressHandlers[task] = progress; } // 注册到统一下载任务管理器(悬浮球已移除,始终注册以便下载任务列表跟踪) @@ -404,12 +558,32 @@ - (void)downloadWorld:(WorldItem *)item supportsResume:YES iconURL:item.iconURL]; taskItem.downloadURL = item.selectedVersionDownloadURL; + NSString *taskProfileName = [PLProfiles effectiveProfileNameForPreferredName:profileName]; + if (taskProfileName.length > 0) taskItem.userInfo[@"profileName"] = taskProfileName; + taskItem.userInfo[@"destinationPath"] = destinationPath; + taskItem.userInfo[@"stagingDirectory"] = stagingDirectory; + taskItem.userInfo[PLWorldDownloadGenerationKey] = @(task.taskIdentifier); + // 世界暂停恢复需要用新的 NSURLSessionTask 从头重建,不应消耗网络失败重试预算。 + taskItem.maxRetryCount = 0; + taskItem.autoPresentDetail = YES; + [self.downloadStateLock lock]; + if (completion) self.downloadCompletionHandlers[task] = completion; + self.downloadDestinationPaths[task] = destinationPath; + self.downloadStagingDirectories[task] = stagingDirectory; + self.downloadWorldNames[task] = worldNameForExtract; + self.downloadSavesFolders[task] = savesFolder; + if (progressObj) self.downloadProgresses[task] = progressObj; + if (progress) self.downloadProgressHandlers[task] = progress; self.downloadTaskItems[task] = taskItem; + [self.downloadStateLock unlock]; + [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId stages:PLTaskStagesWorld()]; [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId state:DownloadTaskStateDownloading]; + [[DownloadTaskManager sharedManager] updateTaskWithId:taskItem.taskId stageAtIndex:0 status:PLTaskStageStatusRunning]; // 设置 retryHandler:FCL 风格重新下载 __weak typeof(self) weakSelf = self; - NSString *capturedDestPath = destinationPath; + NSString *capturedWorldName = worldNameForExtract; + NSString *capturedSavesFolder = savesFolder; WorldDownloadCompletionHandler capturedCompletion = completion; void (^capturedProgress)(NSProgress *) = progress; taskItem.retryHandler = ^id(DownloadTaskItem *taskItemRef) { @@ -417,14 +591,41 @@ - (void)downloadWorld:(WorldItem *)item if (!strongSelf) return nil; NSURL *retryURL = [NSURL URLWithString:taskItemRef.downloadURL] ?: url; if (!retryURL) return nil; + NSError *retryStagingError = nil; + NSString *retryStagingDirectory = [strongSelf createWorldStagingDirectoryForSavesDir:capturedSavesFolder + error:&retryStagingError]; + if (!retryStagingDirectory) { + NSError *finalError = retryStagingError ?: [NSError errorWithDomain:@"WorldServiceError" + code:8 + userInfo:@{NSLocalizedDescriptionKey: @"Unable to create world staging directory."}]; + [[DownloadTaskManager sharedManager] setTaskWithId:taskItemRef.taskId completedWithError:finalError]; + return nil; + } + NSString *retryDestinationPath = [retryStagingDirectory stringByAppendingPathComponent:PLWorldArchiveFileName]; NSURLSessionDownloadTask *newTask = [strongSelf.downloadSession downloadTaskWithURL:retryURL]; - strongSelf.downloadCompletionHandlers[newTask] = capturedCompletion; - strongSelf.downloadDestinationPaths[newTask] = capturedDestPath; + taskItemRef.userInfo[PLWorldDownloadGenerationKey] = @(newTask.taskIdentifier); + taskItemRef.userInfo[@"destinationPath"] = retryDestinationPath; + taskItemRef.userInfo[@"stagingDirectory"] = retryStagingDirectory; + taskItemRef.supportsResume = YES; + // manager 随后依据 rawTask 获取并发槽;必须在切换 Downloading 前写入。 + taskItemRef.rawTask = newTask; + [strongSelf.downloadStateLock lock]; + if (capturedCompletion) strongSelf.downloadCompletionHandlers[newTask] = capturedCompletion; + strongSelf.downloadDestinationPaths[newTask] = retryDestinationPath; + strongSelf.downloadStagingDirectories[newTask] = retryStagingDirectory; + strongSelf.downloadWorldNames[newTask] = capturedWorldName; + strongSelf.downloadSavesFolders[newTask] = capturedSavesFolder; if (capturedProgress) { + NSProgress *progressObj = [NSProgress progressWithTotalUnitCount:-1]; + progressObj.kind = NSProgressKindFile; + strongSelf.downloadProgresses[newTask] = progressObj; strongSelf.downloadProgressHandlers[newTask] = capturedProgress; } strongSelf.downloadTaskItems[newTask] = taskItemRef; + [strongSelf.downloadStateLock unlock]; + [[DownloadTaskManager sharedManager] setTaskWithId:taskItemRef.taskId stages:PLTaskStagesWorld()]; [[DownloadTaskManager sharedManager] setTaskWithId:taskItemRef.taskId state:DownloadTaskStateDownloading]; + [[DownloadTaskManager sharedManager] updateTaskWithId:taskItemRef.taskId stageAtIndex:0 status:PLTaskStageStatusRunning]; [newTask resume]; return newTask; }; @@ -460,6 +661,7 @@ - (void)importWorldFromURL:(NSURL *)sourceURL needsStopAccessing = [sourceURL startAccessingSecurityScopedResource]; } + __block NSString *stagingDirectory = nil; @try { NSString *sourcePath = sourceURL.path; if (!sourcePath || ![[NSFileManager defaultManager] fileExistsAtPath:sourcePath]) { @@ -472,11 +674,23 @@ - (void)importWorldFromURL:(NSURL *)sourceURL return; } - // 复制到临时文件以便 UnzipKit 安全读取(避免安全作用域限制) - NSString *tempZipPath = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"world_import_%@.zip", [[NSUUID UUID] UUIDString]]]; + NSError *stagingError = nil; + stagingDirectory = [self createWorldStagingDirectoryForSavesDir:savesFolder error:&stagingError]; + if (!stagingDirectory) { + if (completion) { + NSError *error = stagingError ?: [NSError errorWithDomain:@"WorldServiceError" + code:8 + userInfo:@{NSLocalizedDescriptionKey: @"Unable to create world staging directory."}]; + dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); + } + return; + } + + // 复制进唯一 staging 后再读取,既保持 security-scoped URL 生命周期安全, + // 也确保后续解压和最终 rename 都不接触现有世界。 + NSString *stagedZipPath = [stagingDirectory stringByAppendingPathComponent:PLWorldArchiveFileName]; NSError *copyError = nil; - if (![[NSFileManager defaultManager] copyItemAtPath:sourcePath toPath:tempZipPath error:©Error]) { + if (![[NSFileManager defaultManager] copyItemAtPath:sourcePath toPath:stagedZipPath error:©Error]) { if (completion) { NSError *error = copyError ?: [NSError errorWithDomain:@"WorldServiceError" code:4 @@ -486,41 +700,40 @@ - (void)importWorldFromURL:(NSURL *)sourceURL return; } - // 本地导入可直接上报 100% 进度(无网络下载阶段) - if (progress) { - NSProgress *prog = [NSProgress progressWithTotalUnitCount:1]; - prog.completedUnitCount = 1; - dispatch_async(dispatch_get_main_queue(), ^{ progress(prog); }); - } - // 推导世界名(去除 .zip 后缀) NSString *worldName = [sourceURL lastPathComponent]; if ([worldName.lowercaseString hasSuffix:@".zip"]) { worldName = [worldName substringToIndex:worldName.length - @".zip".length]; } - // 解压 - NSError *extractError = nil; - BOOL success = [self extractWorldZipAt:tempZipPath - toSavesDir:savesFolder - worldName:worldName - error:&extractError]; - - // 删除临时文件 - [[NSFileManager defaultManager] removeItemAtPath:tempZipPath error:nil]; - - if (completion) { + NSError *installError = nil; + PLStagedWorld *stagedWorld = [self stageWorldZipAt:stagedZipPath + stagingDirectory:stagingDirectory + worldName:worldName + error:&installError]; + NSString *installedWorldPath = stagedWorld + ? [self commitStagedWorld:stagedWorld toSavesDir:savesFolder error:&installError] + : nil; + BOOL success = installedWorldPath.length > 0; + + if (completion || (success && progress)) { dispatch_async(dispatch_get_main_queue(), ^{ if (success) { - completion(YES, nil); - } else { - completion(NO, extractError ?: [NSError errorWithDomain:@"WorldServiceError" + if (progress) { + NSProgress *prog = [NSProgress progressWithTotalUnitCount:1]; + prog.completedUnitCount = 1; + progress(prog); + } + if (completion) completion(YES, nil); + } else if (completion) { + completion(NO, installError ?: [NSError errorWithDomain:@"WorldServiceError" code:5 userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1097", nil)}]); } }); } } @finally { + [self cleanupWorldStagingDirectory:stagingDirectory]; if (needsStopAccessing) { [sourceURL stopAccessingSecurityScopedResource]; } @@ -535,6 +748,7 @@ - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTas didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite { + [self.downloadStateLock lock]; NSProgress *progressObj = self.downloadProgresses[downloadTask]; WorldDownloadProgressHandler progressHandler = self.downloadProgressHandlers[downloadTask]; DownloadTaskItem *taskItem = self.downloadTaskItems[downloadTask]; @@ -560,7 +774,7 @@ - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTas } snapshot[@"lastTime"] = @(now); snapshot[@"lastBytes"] = @(totalBytesWritten); - + [self.downloadStateLock unlock]; [[DownloadTaskManager sharedManager] updateTaskWithId:taskItem.taskId progress:fraction totalBytes:totalBytesExpectedToWrite @@ -568,6 +782,12 @@ - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTas [[DownloadTaskManager sharedManager] updateTaskWithId:taskItem.taskId speed:speed estimatedTimeRemaining:eta]; + [[DownloadTaskManager sharedManager] updateTaskWithId:taskItem.taskId + stageAtIndex:0 + progress:fraction + message:nil]; + } else { + [self.downloadStateLock unlock]; } if (!progressObj || !progressHandler) return; @@ -585,103 +805,182 @@ - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTas } - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location { + NSNumber *generation = @(downloadTask.taskIdentifier); + [self.downloadStateLock lock]; WorldDownloadCompletionHandler handler = self.downloadCompletionHandlers[downloadTask]; NSString *destinationPath = self.downloadDestinationPaths[downloadTask]; - NSString *taskDescription = downloadTask.taskDescription; + NSString *stagingDirectory = self.downloadStagingDirectories[downloadTask]; + NSString *worldName = self.downloadWorldNames[downloadTask]; + NSString *savesFolder = self.downloadSavesFolders[downloadTask]; DownloadTaskItem *taskItem = self.downloadTaskItems[downloadTask]; [self.downloadCompletionHandlers removeObjectForKey:downloadTask]; [self.downloadDestinationPaths removeObjectForKey:downloadTask]; + [self.downloadStagingDirectories removeObjectForKey:downloadTask]; + [self.downloadWorldNames removeObjectForKey:downloadTask]; + [self.downloadSavesFolders removeObjectForKey:downloadTask]; [self.downloadProgresses removeObjectForKey:downloadTask]; [self.downloadProgressHandlers removeObjectForKey:downloadTask]; [self.downloadTaskItems removeObjectForKey:downloadTask]; [self.downloadProgressSnapshots removeObjectForKey:downloadTask]; + [self.downloadStateLock unlock]; - if (!handler || !destinationPath) { + if (!destinationPath || !stagingDirectory || !savesFolder) { + NSError *metadataError = [NSError errorWithDomain:@"WorldServiceError" + code:6 + userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1098", nil)}]; + [self cleanupWorldStagingDirectory:stagingDirectory]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self isWorldTaskItemCurrent:taskItem generation:generation]) return; + DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + [manager updateTaskWithId:taskItem.taskId stageAtIndex:0 status:PLTaskStageStatusFailed]; + [manager setTaskWithId:taskItem.taskId completedWithError:metadataError]; + if (handler) handler(NO, metadataError); + }); return; } NSFileManager *fm = [NSFileManager defaultManager]; NSError *moveError = nil; - NSString *dir = [destinationPath stringByDeletingLastPathComponent]; - if (![fm fileExistsAtPath:dir]) { - [fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; - } - if ([fm fileExistsAtPath:destinationPath]) { - [fm removeItemAtPath:destinationPath error:nil]; - } if (![fm moveItemAtURL:location toURL:[NSURL fileURLWithPath:destinationPath] error:&moveError]) { - if (taskItem) { - [[DownloadTaskManager sharedManager] updateTaskWithId:taskItem.taskId error:moveError]; - [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId state:DownloadTaskStateFailed]; - } - dispatch_async(dispatch_get_main_queue(), ^{ handler(NO, moveError); }); + NSError *finalMoveError = moveError ?: [NSError errorWithDomain:@"WorldServiceError" + code:4 + userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1096", nil)}]; + [self cleanupWorldStagingDirectory:stagingDirectory]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self isWorldTaskItemCurrent:taskItem generation:generation]) return; + DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + [manager updateTaskWithId:taskItem.taskId stageAtIndex:0 status:PLTaskStageStatusFailed]; + [manager setTaskWithId:taskItem.taskId completedWithError:finalMoveError]; + if (handler) handler(NO, finalMoveError); + }); return; } - if (taskItem) { - [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId state:DownloadTaskStateCompleted]; + // 下载完成后只允许在 staging 中继续解压。暂停、取消或旧 generation 即使 + // 解压仍在运行,也只能清理 staging,不能把任何目录提交到 saves。 + if (![self isWorldTaskItemCurrent:taskItem generation:generation]) { + [self cleanupWorldStagingDirectory:stagingDirectory]; + return; } + taskItem.supportsResume = NO; + DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + [manager updateTaskWithId:taskItem.taskId stageAtIndex:0 status:PLTaskStageStatusCompleted]; + [manager updateTaskWithId:taskItem.taskId currentStageIndex:1]; + [manager updateTaskWithId:taskItem.taskId stageAtIndex:1 status:PLTaskStageStatusRunning]; + + // 后台阶段只写唯一 staging;最终进入 saves 的 rename 在主线程 gate 内完成, + // 与 UI 的暂停/取消操作形成确定顺序。 + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSError *stageError = nil; + PLStagedWorld *stagedWorld = [self stageWorldZipAt:destinationPath + stagingDirectory:stagingDirectory + worldName:worldName ?: @"imported_world" + error:&stageError]; - // 解析 taskDescription:worldName 与 savesFolder - NSString *worldName = nil; - NSString *savesFolder = nil; - if (taskDescription.length > 0) { - NSArray *parts = [taskDescription componentsSeparatedByString:@"\n"]; - if (parts.count >= 1) worldName = parts[0]; - if (parts.count >= 2) savesFolder = parts[1]; - } - if (!savesFolder) { - // 无 saves 目录信息,回退:删除临时 zip 并报错 - [fm removeItemAtPath:destinationPath error:nil]; dispatch_async(dispatch_get_main_queue(), ^{ - handler(NO, [NSError errorWithDomain:@"WorldServiceError" - code:6 - userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1098", nil)}]); - }); - return; - } + if (![self isWorldTaskItemCurrent:taskItem generation:generation]) { + [self cleanupWorldStagingDirectory:stagingDirectory]; + return; + } - // 在后台线程做解压 - dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ - NSError *extractError = nil; - BOOL success = [self extractWorldZipAt:destinationPath - toSavesDir:savesFolder - worldName:worldName ?: @"imported_world" - error:&extractError]; + DownloadTaskManager *currentManager = [DownloadTaskManager sharedManager]; + if (!stagedWorld) { + NSError *finalError = stageError ?: [NSError errorWithDomain:@"WorldServiceError" + code:5 + userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1097", nil)}]; + [self cleanupWorldStagingDirectory:stagingDirectory]; + [currentManager updateTaskWithId:taskItem.taskId stageAtIndex:1 status:PLTaskStageStatusFailed]; + [currentManager setTaskWithId:taskItem.taskId completedWithError:finalError]; + if (handler) handler(NO, finalError); + return; + } - // 解压完成后删除临时 zip - [fm removeItemAtPath:destinationPath error:nil]; + NSError *commitError = nil; + NSString *installedWorldPath = [self commitStagedWorld:stagedWorld + toSavesDir:savesFolder + error:&commitError]; + [self cleanupWorldStagingDirectory:stagingDirectory]; + if (!installedWorldPath) { + NSError *finalError = commitError ?: [NSError errorWithDomain:@"WorldServiceError" + code:5 + userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1097", nil)}]; + if (![self isWorldTaskItemCurrent:taskItem generation:generation]) return; + [currentManager updateTaskWithId:taskItem.taskId stageAtIndex:1 status:PLTaskStageStatusFailed]; + [currentManager setTaskWithId:taskItem.taskId completedWithError:finalError]; + if (handler) handler(NO, finalError); + return; + } - dispatch_async(dispatch_get_main_queue(), ^{ - if (success) { - handler(YES, nil); - } else { - handler(NO, extractError ?: [NSError errorWithDomain:@"WorldServiceError" - code:5 - userInfo:@{NSLocalizedDescriptionKey: localize(@"i18n_str_1097", nil)}]); + // 防御后台状态变更:若 commit 后任务已不再属于本 generation,回滚 + // 本次唯一目标。它从未覆盖旧世界,因此可安全删除。 + if (![self isWorldTaskItemCurrent:taskItem generation:generation]) { + [fm removeItemAtPath:installedWorldPath error:nil]; + return; } + [currentManager updateTaskWithId:taskItem.taskId stageAtIndex:1 status:PLTaskStageStatusCompleted]; + [currentManager setTaskWithId:taskItem.taskId completedWithError:nil]; + DownloadTaskItem *completedTask = [currentManager taskWithId:taskItem.taskId]; + if (completedTask.state != DownloadTaskStateCompleted || + ![completedTask.userInfo[PLWorldDownloadGenerationKey] isEqual:generation]) { + [fm removeItemAtPath:installedWorldPath error:nil]; + return; + } + if (handler) handler(YES, nil); }); }); } - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { if (error) { + NSNumber *generation = @(task.taskIdentifier); + [self.downloadStateLock lock]; WorldDownloadCompletionHandler handler = self.downloadCompletionHandlers[task]; DownloadTaskItem *taskItem = self.downloadTaskItems[task]; - if (taskItem) { - [[DownloadTaskManager sharedManager] updateTaskWithId:taskItem.taskId error:error]; - [[DownloadTaskManager sharedManager] setTaskWithId:taskItem.taskId state:DownloadTaskStateFailed]; - [self.downloadTaskItems removeObjectForKey:task]; - [self.downloadProgressSnapshots removeObjectForKey:task]; - } - if (handler) { - handler(NO, error); - [self.downloadCompletionHandlers removeObjectForKey:task]; - [self.downloadDestinationPaths removeObjectForKey:task]; - [self.downloadProgresses removeObjectForKey:task]; - [self.downloadProgressHandlers removeObjectForKey:task]; - } + NSString *stagingDirectory = self.downloadStagingDirectories[task]; + [self.downloadTaskItems removeObjectForKey:task]; + [self.downloadProgressSnapshots removeObjectForKey:task]; + [self.downloadCompletionHandlers removeObjectForKey:task]; + [self.downloadDestinationPaths removeObjectForKey:task]; + [self.downloadStagingDirectories removeObjectForKey:task]; + [self.downloadWorldNames removeObjectForKey:task]; + [self.downloadSavesFolders removeObjectForKey:task]; + [self.downloadProgresses removeObjectForKey:task]; + [self.downloadProgressHandlers removeObjectForKey:task]; + [self.downloadStateLock unlock]; + [self cleanupWorldStagingDirectory:stagingDirectory]; + + dispatch_async(dispatch_get_main_queue(), ^{ + DownloadTaskManager *manager = [DownloadTaskManager sharedManager]; + DownloadTaskItem *latestTask = taskItem ? [manager taskWithId:taskItem.taskId] : nil; + BOOL isCancellation = [error.domain isEqualToString:NSURLErrorDomain] && + error.code == NSURLErrorCancelled; + // 该回调若属于已被 retry 替换的旧 NSURLSessionTask,只清理旧映射, + // 不得影响新一代任务。 + if (taskItem && + (!latestTask || + ![latestTask.userInfo[PLWorldDownloadGenerationKey] isEqual:generation])) { + return; + } + // cancelByProducingResumeData: 也以 NSURLErrorCancelled 收尾。暂停时等待 + // retryHandler 的最终结果;显式取消由任务列表状态表达,均不误报失败。 + if (isCancellation) { + // 用户在 cancelByProducingResumeData: 尚未收尾时快速点了继续, + // manager 会暂时回到 Downloading。旧任务已经无法恢复,转为一次 + // 原子重试,避免卡在没有活动 rawTask 的“下载中”。 + if (latestTask.state == DownloadTaskStateDownloading) { + [manager setTaskWithId:taskItem.taskId state:DownloadTaskStatePaused]; + [manager retryTaskWithId:taskItem.taskId]; + } + return; + } + if (taskItem && latestTask.state != DownloadTaskStateDownloading) return; + if (taskItem) { + [manager updateTaskWithId:taskItem.taskId stageAtIndex:0 status:PLTaskStageStatusFailed]; + [manager setTaskWithId:taskItem.taskId completedWithError:error]; + } + if (handler) handler(NO, error); + }); } } diff --git a/Natives/WorldsManagerViewController.m b/Natives/WorldsManagerViewController.m index 1e6eb1e013..5cf573fcfb 100644 --- a/Natives/WorldsManagerViewController.m +++ b/Natives/WorldsManagerViewController.m @@ -12,6 +12,8 @@ #import "WorldItem.h" #import "ResourceCardTableViewCell.h" #import "DownloadViewController.h" +#import "DownloadTaskManager.h" +#import "PLProfiles.h" #import "utils.h" #pragma mark - 世界卡片 Cell(继承 Air-Design 卡片基类,本文件内轻量子类) @@ -77,6 +79,7 @@ - (instancetype)init { - (void)viewDidLoad { [super viewDidLoad]; + self.profileName = [PLProfiles effectiveProfileNameForPreferredName:self.profileName]; // 在线下载入口已移至下载界面:固定本地模式(currentMode 等属性保留仅为兼容 .h 既有声明) self.currentMode = WorldsManagerModeLocal; self.localItems = [NSMutableArray array]; @@ -106,6 +109,14 @@ - (void)viewDidLoad { self.navigationItem.leftBarButtonItem = closeButton; self.navigationItem.rightBarButtonItems = @[self.importButton, self.refreshButton]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleDownloadTaskCompleted:) + name:DownloadTaskManagerTaskCompletedNotification + object:nil]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; [self refreshLocalList]; } @@ -275,6 +286,8 @@ - (void)updateEmptyState { - (void)openDownloadPage { // 在线下载入口已收敛到统一下载界面(未区分资源类型 Tab,进入默认页) DownloadViewController *downloadVC = [[DownloadViewController alloc] init]; + downloadVC.initialTabIndex = 6; + downloadVC.targetProfileName = self.profileName; if (self.navigationController) { [self.navigationController pushViewController:downloadVC animated:YES]; } else { @@ -285,6 +298,14 @@ - (void)openDownloadPage { } } +- (void)handleDownloadTaskCompleted:(NSNotification *)notification { + DownloadTaskItem *task = notification.userInfo[DownloadTaskManagerTaskKey]; + if (task.state != DownloadTaskStateCompleted || + ![task.resourceType isEqualToString:DownloadTaskResourceTypeWorld] || + ![task.userInfo[@"profileName"] isEqualToString:self.profileName]) return; + [self refreshLocalList]; +} + #pragma mark - UITableView DataSource & Delegate - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { diff --git a/Natives/egl_bridge.m b/Natives/egl_bridge.m index dcbb92366b..4ce471e8dc 100644 --- a/Natives/egl_bridge.m +++ b/Natives/egl_bridge.m @@ -1,4 +1,5 @@ #import "SurfaceViewController.h" +#import "LauncherPreferences.h" #include "jni.h" #include @@ -107,12 +108,10 @@ int pojavInit(BOOL useStackQueue) { } int pojavInitOpenGL() { - NSString *renderer = NSProcessInfo.processInfo.environment[@"AMETHYST_RENDERER"]; - BOOL isAuto = [renderer isEqualToString:@"auto"]; - if (isAuto || [renderer isEqualToString:@ RENDERER_NAME_GL4ES]) { - // At this point, if renderer is still auto (unspecified major version), pick gl4es - renderer = @ RENDERER_NAME_GL4ES; - setenv("AMETHYST_RENDERER", renderer.UTF8String, 1); + // 复用唯一 resolver;即使旁路调用没有经过 JavaLauncher,也与正常启动保持同一语义。 + NSString *renderer = PLResolveRendererKey(NSProcessInfo.processInfo.environment[@"AMETHYST_RENDERER"]); + setenv("AMETHYST_RENDERER", renderer.UTF8String, 1); + if ([renderer isEqualToString:@ RENDERER_NAME_GL4ES]) { set_gl_bridge_tbl(); } else if ([renderer isEqualToString:@ RENDERER_NAME_MOBILEGLUES]) { renderer = @ RENDERER_NAME_MOBILEGLUES; @@ -187,19 +186,6 @@ int pojavInitOpenGL() { void pojavSetWindowHint(int hint, int value) { if (hint == GLFW_CLIENT_API) { clientAPI = value; - } else if (strcmp(getenv("AMETHYST_RENDERER"), "auto")==0 && hint == GLFW_CONTEXT_VERSION_MAJOR) { - switch (value) { - case 1: - case 2: - setenv("AMETHYST_RENDERER", RENDERER_NAME_GL4ES, 1); - JNI_LWJGL_changeRenderer(RENDERER_NAME_GL4ES); - break; - // case 4: use Zink? - default: - setenv("AMETHYST_RENDERER", RENDERER_NAME_MOBILEGLUES, 1); - JNI_LWJGL_changeRenderer(RENDERER_NAME_MOBILEGLUES); - break; - } } } @@ -313,4 +299,3 @@ void pojavSwapInterval(int interval) { br_swap_interval(interval); } - diff --git a/Natives/resources/ar.lproj/Localizable.strings b/Natives/resources/ar.lproj/Localizable.strings index 3622d43c6d..6bf6e088bd 100644 --- a/Natives/resources/ar.lproj/Localizable.strings +++ b/Natives/resources/ar.lproj/Localizable.strings @@ -167,7 +167,7 @@ "preference.title.renderer.release.angle" = "ANGLE"; "preference.title.renderer.release.zink" = "Zink"; -"preference.title.renderer.debug.auto" = "تلقائياً: gl4es أو ANGLE"; +"preference.title.renderer.debug.auto" = "تلقائياً: ANGLE"; "preference.title.renderer.debug.gl4es" = "holy gl4es - exports OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - الصادرات OpenGL 3.2 (موجز أساسي محدود)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - exports OpenGL 4.1"; diff --git a/Natives/resources/cs.lproj/Localizable.strings b/Natives/resources/cs.lproj/Localizable.strings index 8446882e8d..adeb9ebc5d 100644 --- a/Natives/resources/cs.lproj/Localizable.strings +++ b/Natives/resources/cs.lproj/Localizable.strings @@ -167,7 +167,7 @@ "preference.title.renderer.release.angle" = "ANGLE"; "preference.title.renderer.release.zink" = "Zink"; -"preference.title.renderer.debug.auto" = "Automaticky: gl4es či ANGLE"; +"preference.title.renderer.debug.auto" = "Automaticky: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - exportuje OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - exportuje OpenGL 3.2 (Jádrový Profil, omezen)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - exportuje OpenGL 4.1"; diff --git a/Natives/resources/de.lproj/Localizable.strings b/Natives/resources/de.lproj/Localizable.strings index b873abbdaa..b2eefc2bb7 100644 --- a/Natives/resources/de.lproj/Localizable.strings +++ b/Natives/resources/de.lproj/Localizable.strings @@ -168,7 +168,7 @@ der Minecraft-Version zu wählen. Verringerte Auflösung reduziert die Arbeitsla "preference.title.renderer.release.angle" = "Angle"; "preference.title.renderer.release.zink" = "Zink"; -"preference.title.renderer.debug.auto" = "Auto: gl4es or ANGLE"; +"preference.title.renderer.debug.auto" = "Auto: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - gibt OpenGL 2.1 aus"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - gibt OpenGL 3.2 (Kernprofil, begrenzt) aus"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - gibt OpenGL 4.1 aus"; diff --git a/Natives/resources/en.lproj/Localizable.strings b/Natives/resources/en.lproj/Localizable.strings index 7de328fba7..135ed71be7 100644 --- a/Natives/resources/en.lproj/Localizable.strings +++ b/Natives/resources/en.lproj/Localizable.strings @@ -233,7 +233,7 @@ "preference.title.renderer.release.mg" = "MobileGlues"; "preference.title.renderer.release.zink" = "Zink"; -"preference.title.renderer.debug.auto" = "Auto: gl4es or ANGLE"; +"preference.title.renderer.debug.auto" = "Auto: ANGLE"; "preference.title.renderer.debug.gl4es" = "holy gl4es - exports OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - exports OpenGL 3.2 (Core Profile, limited)"; "preference.title.renderer.debug.mg" = "MobileGlues (1.17+) - exports OpenGL 4.0, EXPERIMENTAL"; diff --git a/Natives/resources/es.lproj/Localizable.strings b/Natives/resources/es.lproj/Localizable.strings index 2805c45dcc..bf8cf20b09 100644 --- a/Natives/resources/es.lproj/Localizable.strings +++ b/Natives/resources/es.lproj/Localizable.strings @@ -129,7 +129,7 @@ "preference.title.renderer" = "Renderizador"; -"preference.title.renderer.debug.auto" = "Automático: gl4es o ANGLE"; +"preference.title.renderer.debug.auto" = "Automático: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - exporta a OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - exporta OpenGL 3.1 (perfil Core, limitado)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - exporta a OpenGL 4.1"; diff --git a/Natives/resources/et.lproj/Localizable.strings b/Natives/resources/et.lproj/Localizable.strings index 0e3d508ddb..4fc1f88ae4 100644 --- a/Natives/resources/et.lproj/Localizable.strings +++ b/Natives/resources/et.lproj/Localizable.strings @@ -52,7 +52,7 @@ "preference.title.renderer" = "Renderdaja"; -"preference.title.renderer.debug.auto" = "Autom.: gl4es või ANGLE"; +"preference.title.renderer.debug.auto" = "Autom.: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - väljastab OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - väljastab OpenGL 3.2 (Core Profile, piiratud)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - väljastab OpenGL 4.1"; diff --git a/Natives/resources/fa.lproj/Localizable.strings b/Natives/resources/fa.lproj/Localizable.strings index e3ce18e57b..d2d162b8d7 100644 --- a/Natives/resources/fa.lproj/Localizable.strings +++ b/Natives/resources/fa.lproj/Localizable.strings @@ -137,7 +137,7 @@ "preference.title.renderer" = "رندر کننده"; -"preference.title.renderer.debug.auto" = "خودکار: gl4es یا ANGLE"; +"preference.title.renderer.debug.auto" = "خودکار: ANGLE"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - OpenGL 3.2 را صادر می کند (نمایه هسته، محدود)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - OpenGL 4.1 را صادر می کند"; diff --git a/Natives/resources/fil.lproj/Localizable.strings b/Natives/resources/fil.lproj/Localizable.strings index 7c484c1bf9..fcd01b84bf 100644 --- a/Natives/resources/fil.lproj/Localizable.strings +++ b/Natives/resources/fil.lproj/Localizable.strings @@ -137,7 +137,7 @@ "preference.title.renderer" = "Renderer"; -"preference.title.renderer.debug.auto" = "Auto: gl4es or ANGLE"; +"preference.title.renderer.debug.auto" = "Auto: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - ini-export ang OpenGL 2.1"; "preference.title.renderer.debug.angle" = "tinygl4angle (1.17+) - ini-export ang OpenGL 3.2 (Core Profile, limitado)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - ini-export ang OpenGL 4.1"; diff --git a/Natives/resources/fr.lproj/Localizable.strings b/Natives/resources/fr.lproj/Localizable.strings index e1d34040a9..049949e5af 100644 --- a/Natives/resources/fr.lproj/Localizable.strings +++ b/Natives/resources/fr.lproj/Localizable.strings @@ -139,7 +139,7 @@ "preference.title.renderer" = "Moteur de rendu"; -"preference.title.renderer.debug.auto" = "Auto: gl4es ou ANGLE"; +"preference.title.renderer.debug.auto" = "Auto: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - exporte OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - exporte OpenGL 3.2 (Profil de base, Limité)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - exporte OpenGL 4.1"; diff --git a/Natives/resources/id.lproj/Localizable.strings b/Natives/resources/id.lproj/Localizable.strings index 5b61fa010d..c8251d6bb0 100644 --- a/Natives/resources/id.lproj/Localizable.strings +++ b/Natives/resources/id.lproj/Localizable.strings @@ -151,7 +151,7 @@ "preference.title.renderer" = "Renderer"; -"preference.title.renderer.debug.auto" = "Otomatis: gl4es atau ANGLE"; +"preference.title.renderer.debug.auto" = "Otomatis: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - mengekspor OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - mengekspor OpenGL 3.2 (Profil Core, terbatas)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - mengekspor OpenGL 4.1"; diff --git a/Natives/resources/ja.lproj/Localizable.strings b/Natives/resources/ja.lproj/Localizable.strings index 116c6b6870..6fbc1d7964 100644 --- a/Natives/resources/ja.lproj/Localizable.strings +++ b/Natives/resources/ja.lproj/Localizable.strings @@ -137,7 +137,7 @@ "preference.title.renderer" = "レンダラー"; -"preference.title.renderer.debug.auto" = "自動: gl4es または angle"; +"preference.title.renderer.debug.auto" = "自動: ANGLE"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - エクスポートOpenGL 3.2 (Core Profile, limited)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - exports OpenGL 4.1"; diff --git a/Natives/resources/pt-BR.lproj/Localizable.strings b/Natives/resources/pt-BR.lproj/Localizable.strings index c84a912994..404f2edf92 100644 --- a/Natives/resources/pt-BR.lproj/Localizable.strings +++ b/Natives/resources/pt-BR.lproj/Localizable.strings @@ -127,7 +127,7 @@ "preference.title.renderer" = "Renderizador"; -"preference.title.renderer.debug.auto" = "Auto: gl4es ou ANGLE"; +"preference.title.renderer.debug.auto" = "Auto: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - exports OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - exporta OpenGL 3.2 (Perfil de navegação, limitado)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - exports OpenGL 4.1"; diff --git a/Natives/resources/ru.lproj/Localizable.strings b/Natives/resources/ru.lproj/Localizable.strings index 45212186ca..57d68b94cc 100644 --- a/Natives/resources/ru.lproj/Localizable.strings +++ b/Natives/resources/ru.lproj/Localizable.strings @@ -166,7 +166,7 @@ "preference.title.renderer.release.angle" = "ANGLE"; "preference.title.renderer.release.zink" = "Zink"; -"preference.title.renderer.debug.auto" = "Авто: gl4es или ANGLE"; +"preference.title.renderer.debug.auto" = "Авто: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 — обеспечивает OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) — обеспечивает OpenGL 3.2 (ограниченно, Core Profile)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) — обеспечивает OpenGL 4.1"; diff --git a/Natives/resources/tr.lproj/Localizable.strings b/Natives/resources/tr.lproj/Localizable.strings index ba96d1fa98..b2a0b1c487 100644 --- a/Natives/resources/tr.lproj/Localizable.strings +++ b/Natives/resources/tr.lproj/Localizable.strings @@ -139,7 +139,7 @@ Bu seçenek varsayılan JitStreamer sunucusunu kullanıyorsanız olduğu gibi b "preference.title.renderer" = "İşleyici"; -"preference.title.renderer.debug.auto" = "Oto: gl4es ya da ANGLE"; +"preference.title.renderer.debug.auto" = "Oto: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - OpenGL 2.1 çıkarır"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - OpenGL 3.2 dışa aktarır (Çekirdek profili, kısıtlı)"; "preference.title.renderer.debug.zink" = "Zonk (Mesa 25.0.7) - OpenGL 4.1 çıkarır"; diff --git a/Natives/resources/uk.lproj/Localizable.strings b/Natives/resources/uk.lproj/Localizable.strings index 144e69149e..6a8ed22748 100644 --- a/Natives/resources/uk.lproj/Localizable.strings +++ b/Natives/resources/uk.lproj/Localizable.strings @@ -165,7 +165,7 @@ "preference.title.renderer.release.angle" = "ANGLE"; "preference.title.renderer.release.zink" = "Zink"; -"preference.title.renderer.debug.auto" = "Авто: gl4 або ANGLE"; +"preference.title.renderer.debug.auto" = "Авто: ANGLE"; "preference.title.renderer.debug.gl4es" = "Gl4es 1.1.4 - експортує OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE (1.17+) - експортує OpenGL 3.2 (Базовий профіль, обмежений)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - експортує OpenGL 4.1"; diff --git a/Natives/resources/vi.lproj/Localizable.strings b/Natives/resources/vi.lproj/Localizable.strings index 6a5ace5709..ba0a37a50d 100644 --- a/Natives/resources/vi.lproj/Localizable.strings +++ b/Natives/resources/vi.lproj/Localizable.strings @@ -167,7 +167,7 @@ "preference.title.renderer.release.angle" = "GÓC"; "preference.title.renderer.release.zink" = "Kẽm"; -"preference.title.renderer.debug.auto" = "Tự động: gl4es hoặc ANGLE"; +"preference.title.renderer.debug.auto" = "Tự động: ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - xuất ra OpenGL 2.1"; "preference.title.renderer.debug.angle" = "tinygl4angle (1.17+) - xuất ra OpenGL 3.2 (Core Profile, bị giới hạn)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - xuất ra OpenGL 4.1"; diff --git a/Natives/resources/zh-Hans.lproj/Localizable.strings b/Natives/resources/zh-Hans.lproj/Localizable.strings index 7efefee651..a2e7a5907c 100644 --- a/Natives/resources/zh-Hans.lproj/Localizable.strings +++ b/Natives/resources/zh-Hans.lproj/Localizable.strings @@ -236,7 +236,7 @@ "preference.title.renderer.release.angle" = "ANGLE"; "preference.title.renderer.release.zink" = "Zink"; -"preference.title.renderer.debug.auto" = "自动:gl4es或MobileGlues"; +"preference.title.renderer.debug.auto" = "自动:ANGLE"; "preference.title.renderer.debug.gl4es" = "holy gl4es 1.1.4 - OpenGL版本2.1"; "preference.title.renderer.debug.angle" = "ANGLE(仅支持1.17+)- OpenGL 版本 3.2"; "preference.title.renderer.debug.mg" = "MobileGlues(1.17+)- OpenGL 版本 4.0,实验性"; diff --git a/Natives/resources/zh-Hant.lproj/Localizable.strings b/Natives/resources/zh-Hant.lproj/Localizable.strings index 59586a6f7b..d2fbd40815 100644 --- a/Natives/resources/zh-Hant.lproj/Localizable.strings +++ b/Natives/resources/zh-Hant.lproj/Localizable.strings @@ -156,7 +156,7 @@ "preference.title.renderer" = "渲染器"; -"preference.title.renderer.debug.auto" = "自動:gl4es或ANGLE"; +"preference.title.renderer.debug.auto" = "自動:ANGLE"; "preference.title.renderer.debug.gl4es" = "gl4es 1.1.4 - 輸出OpenGL 2.1"; "preference.title.renderer.debug.angle" = "ANGLE(1.17+)— 輸出OpenGL 3.2(核心,有限)"; "preference.title.renderer.debug.zink" = "Zink (Mesa 25.0.7) - 輸出OpenGL 4.1"; diff --git a/README.md b/README.md index e837f126b3..23ef20851a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ A premium Minecraft: Java Edition launcher for iOS and iPadOS, rebuilt from the - **Complete Chinese Localization** -- Fully translated interface with native-quality Chinese language support. - **Unrestricted Accounts** -- Local accounts, demo mode, and third-party authentication all supported; no Microsoft account required to download and play. - **Multi-Account** -- Seamlessly switch between Microsoft, local, and third-party authentication accounts. -- **Auto Renderer Selection** -- Automatically chooses the optimal rendering backend (including MobileGlues, MoltenVK, and more) when set to Auto. +- **Safe Auto Renderer** -- Auto currently resolves to ANGLE for consistent Minecraft 26.2+ compatibility. Choose MobileGlues, GL4ES, Zink, or MoltenVK explicitly when needed. - **Auto JVM Selection** -- Automatically selects the correct JVM version (Java 8, 17, 21, or 25) based on the game version. - **Minecraft 26.X Support** -- Experimental support for Minecraft 26.x. - **Custom Mouse Pointer** -- Customize the virtual mouse pointer skin in settings. diff --git a/README_CN.md b/README_CN.md index aeaf8f75b8..fedab257a3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -44,7 +44,7 @@ - **完整中文本地化** -- 界面完整汉化,提供原生级中文语言体验。 - **账户限制解除** -- 支持本地账户、演示模式和第三方认证,无需 Microsoft 账户即可下载和游玩。 - **多账户支持** -- 在 Microsoft 账户、本地账户和第三方认证账户之间无缝切换。 -- **自动渲染器选择** -- 设为 Auto 时自动选择最优渲染后端(含 MobileGlues、MoltenVK 等渲染器)。 +- **安全的自动渲染器** -- Auto 当前固定解析为 ANGLE,以兼容 Minecraft 26.2+;如需 MobileGlues、GL4ES、Zink 或 MoltenVK,请手动明确选择。 - **适配 Minecraft 26.X** -- 添加 Minecraft 26.X 支持(实验性) - **自定义鼠标指针** -- 在设置中自定义虚拟鼠标指针皮肤。 - **TouchController 支持** -- 通过 UDP 和 XCFramework 两种通信方式与 TouchController Mod 通信,为 iOS 提供完整的触屏控制。 diff --git a/tests/test_source_contracts.py b/tests/test_source_contracts.py new file mode 100644 index 0000000000..ecca56181c --- /dev/null +++ b/tests/test_source_contracts.py @@ -0,0 +1,328 @@ +"""Fast regression checks for renderer and resource-download wiring. + +These tests intentionally inspect source contracts so they can run on Windows without Xcode. +The macOS CI build remains responsible for Objective-C compilation. +""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def source(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +class RendererContracts(unittest.TestCase): + def test_auto_has_one_canonical_resolution(self) -> None: + preferences = source("Natives/LauncherPreferences.m") + self.assertIn("NSString *PLResolveRendererKey", preferences) + self.assertRegex( + preferences, + r'isEqualToString:@"auto"\]\s*\?\s*@\s*RENDERER_NAME_MTL_ANGLE', + ) + self.assertIn("PLResolveRendererKey(selectedRenderer)", source("Natives/JavaLauncher.m")) + self.assertIn( + 'PLResolveRendererKey(NSProcessInfo.processInfo.environment[@"AMETHYST_RENDERER"])', + source("Natives/egl_bridge.m"), + ) + + def test_profile_default_is_distinct_from_explicit_auto(self) -> None: + preferences = source("Natives/LauncherPreferences.m") + settings = source("Natives/ProfileSettingsViewController.m") + self.assertIn("NSString * const PLProfileInheritedValue", preferences) + self.assertIn("PLProfileInheritedDisplayName()", preferences) + self.assertIn("[(NSString *)profileRenderer length] > 0", settings) + self.assertIn("[(NSString *)profileGraphicsApi length] > 0", settings) + self.assertIn('[existing removeObjectForKey:@"renderer"]', settings) + self.assertIn('[existing removeObjectForKey:@"graphicsApi"]', settings) + self.assertIn("PLNormalizeRendererKey(self.selectedRenderer)", settings) + self.assertNotIn( + '[self.selectedRenderer isEqualToString:@"(default)"]', settings + ) + self.assertNotIn( + '[self.selectedGraphicsApi isEqualToString:@"(default)"]', settings + ) + + def test_graphics_api_has_whitelist_normalization(self) -> None: + preferences = source("Natives/LauncherPreferences.m") + profiles = source("Natives/PLProfiles.m") + settings = source("Natives/ProfileSettingsViewController.m") + self.assertIn("NSString *PLNormalizeGraphicsApiKey", preferences) + self.assertIn("![getGraphicsApiKeys(NO) containsObject:key]", preferences) + self.assertGreaterEqual(profiles.count("PLNormalizeGraphicsApiKey"), 2) + self.assertGreaterEqual(settings.count("PLNormalizeGraphicsApiKey"), 2) + + def test_explicit_unknown_target_profile_does_not_fall_back(self) -> None: + profiles = source("Natives/PLProfiles.m") + resolver = profiles[ + profiles.index("+ (nullable NSString *)effectiveProfileNameForPreferredName:") : + profiles.index("- (id)initWithCurrentInstance") + ] + self.assertIn("if (preferredName.length > 0)", resolver) + self.assertIn("? preferredName : nil", resolver) + self.assertIn("if (effectiveName.length == 0) return nil", resolver) + self.assertIn("if (!baseDirectory.isAbsolutePath) return nil", resolver) + self.assertIn("![resolvedPath hasPrefix:basePrefix]", resolver) + + def test_launch_panels_do_not_overwrite_global_renderer(self) -> None: + for relative in ( + "Natives/LauncherRightPanelViewController.m", + "Natives/VersionManagerViewController.m", + ): + text = source(relative) + self.assertNotRegex(text, r'setPrefString\(@"video\.(renderer|graphics_api)"') + + +class ResourceContracts(unittest.TestCase): + SERVICES = ( + "ModService", + "ShaderService", + "ResourcePackService", + "DataPackService", + "WorldService", + ) + + def test_download_source_reaches_version_request(self) -> None: + download = source("Natives/DownloadViewController.m") + self.assertIn("initialSource = modItem.apiSource", download) + self.assertIn("initialSource = shaderItem.apiSource", download) + self.assertIn("apiSource = item.apiSource", download) + asset_versions = source("Natives/AssetVersionViewController.m") + self.assertIn("if (self.apiSource == 2)", asset_versions) + self.assertIn("[CurseForgeAPI sharedInstance]", asset_versions) + + def test_download_target_profile_is_preserved(self) -> None: + header = source("Natives/DownloadViewController.h") + implementation = source("Natives/DownloadViewController.m") + self.assertIn("targetProfileName", header) + self.assertGreaterEqual(implementation.count("[self effectiveTargetProfileName]"), 7) + + expected_tabs = { + "Mods": 1, + "Shaders": 2, + "ResourcePacks": 3, + "DataPacks": 4, + "Worlds": 6, + } + for manager, tab in expected_tabs.items(): + text = source(f"Natives/{manager}ManagerViewController.m") + self.assertRegex(text, rf"(?:downloadVC|vc)\.initialTabIndex = {tab};") + self.assertRegex( + text, r"(?:downloadVC|vc)\.targetProfileName = self\.profileName;" + ) + + def test_resource_services_publish_terminal_completion(self) -> None: + for service in self.SERVICES: + text = source(f"Natives/{service}.m") + self.assertIn("completedWithError:", text) + self.assertNotRegex( + text, + r'setTaskWithId:[^;]+state:DownloadTaskState(?:Completed|Failed)', + ) + + def test_paths_use_the_shared_profile_resolver(self) -> None: + for service in self.SERVICES: + text = source(f"Natives/{service}.m") + self.assertIn("resolvedGameDirectoryForProfileName", text) + + self.assertIn( + "if (gameDir.length == 0) return nil", + source("Natives/ResourcePackService.m"), + ) + self.assertIn( + "if (gameDir.length == 0) return nil", + source("Natives/WorldService.m"), + ) + + def test_profile_aware_service_headers_accept_an_unspecified_target(self) -> None: + for service in self.SERVICES: + header = source(f"Natives/{service}.h") + self.assertIn("NSString * _Nullable)profileName", header) + profiles = source("Natives/PLProfiles.h") + self.assertIn("effectiveProfileNameForPreferredName:(nullable NSString *)", profiles) + self.assertIn("resolvedGameDirectoryForProfileName:(nullable NSString *)", profiles) + + def test_explicit_invalid_profile_never_falls_back_to_shared_game_dir(self) -> None: + for service_name, existing_method, ensure_method in ( + ("ModService", "existingModsFolderForProfile", "ensureModsFolderForProfile"), + ( + "ShaderService", + "existingShadersFolderForProfile", + "ensureShadersFolderForProfile", + ), + ): + text = source(f"Natives/{service_name}.m") + start = text.index(f"- (nullable NSString *){existing_method}") + end = text.index("#pragma mark", start) + folder_methods = text[start:end] + self.assertIn("resolvedGameDir.length == 0", folder_methods) + self.assertIn(ensure_method, folder_methods) + self.assertNotIn('getenv("POJAV_GAME_DIR")', folder_methods) + + resource_pack = source("Natives/ResourcePackService.m") + download = resource_pack[ + resource_pack.index("- (void)downloadResourcePack:") : + resource_pack.index("#pragma mark - PLDownloadClient", resource_pack.index("- (void)downloadResourcePack:")) + ] + self.assertIn("ensureResourcePacksFolderForProfile:profileName", download) + self.assertNotIn("PLProfiles.current.profiles", download) + self.assertNotIn('getenv("POJAV_GAME_DIR")', download) + + shader = source("Natives/ShaderService.m") + shader_download = shader[ + shader.index("- (void)downloadShader:") : + shader.index("#pragma mark - PLDownloadClient", shader.index("- (void)downloadShader:")) + ] + self.assertIn("ensureShadersFolderForProfile:profileName", shader_download) + self.assertNotIn('profileName.length ? profileName : @"default"', shader_download) + + def test_completed_transfer_is_not_shown_as_downloading_100_percent(self) -> None: + manager = source("Natives/DownloadTaskManager.m") + task_ui = source("Natives/DownloadTasksViewController.m") + detail_ui = source("Natives/PLTaskProgressViewController.m") + self.assertIn("item.progress = transferComplete ? 0.99 : progress", manager) + self.assertIn("stage.progress = transferComplete ? 0.99 : progress", manager) + self.assertIn("DownloadTaskUserInfoTransferCompleteKey", task_ui) + self.assertIn("DownloadTaskUserInfoTransferCompleteKey", detail_ui) + self.assertIn("PLDownloadTaskStateIsTerminal(oldState)", manager) + self.assertIn("floor(clamped * 1000.0)", task_ui) + self.assertIn("MIN(99", detail_ui) + + def test_immediate_cache_hit_cannot_be_written_back_to_downloading(self) -> None: + manager = source("Natives/DownloadTaskManager.m") + self.assertIn("PLDownloadTaskStateIsTerminal(item.state)", manager) + for service in self.SERVICES[:-1]: + text = source(f"Natives/{service}.m") + start = text.index("startRequest:request") + mark_downloading = text.index("state:DownloadTaskStateDownloading", start) + unlock_after_registration = text.index( + "[self.downloadStateLock unlock]", mark_downloading + ) + self.assertGreater(unlock_after_registration, mark_downloading) + + def test_retry_cannot_rebuild_an_active_task_twice(self) -> None: + manager = source("Natives/DownloadTaskManager.m") + retry_method = manager[manager.index("- (void)retryTaskWithId:") :] + state_guard = retry_method.index("item.state != DownloadTaskStateFailed") + reset_pending = retry_method.index("item.state = DownloadTaskStatePending") + self.assertLess(state_guard, reset_pending) + self.assertIn("item.state != DownloadTaskStateCancelled", retry_method) + self.assertIn("item.state != DownloadTaskStatePaused", retry_method) + + def test_paused_weak_raw_task_can_be_recreated(self) -> None: + manager = source("Natives/DownloadTaskManager.m") + resume_method = manager[ + manager.index("- (void)resumeTaskWithId:") : manager.index( + "- (void)cancelTaskWithId:" + ) + ] + self.assertIn( + "!rawTask && state == DownloadTaskStatePaused && item.retryHandler", + resume_method, + ) + self.assertIn("[self retryTaskWithId:taskId]", resume_method) + + def test_mod_and_shader_pagination_state_is_independent(self) -> None: + download = source("Natives/DownloadViewController.m") + self.assertNotIn("isLoadingMore", download) + self.assertNotIn("currentSearchQuery", download) + for token in ( + "isLoadingMods", + "isLoadingShaders", + "modRequestGeneration", + "shaderRequestGeneration", + "modSearchQuery", + "shaderSearchQuery", + ): + self.assertIn(token, download) + self.assertIn("case 1: [self refreshModList]", download) + self.assertIn("case 2: [self refreshShaderList]", download) + + def test_world_archive_is_staged_and_never_extracted_into_saves(self) -> None: + world = source("Natives/WorldService.m") + self.assertIn('PLWorldStagingRootName = @".amethyst-world-staging"', world) + self.assertGreaterEqual( + world.count("createWorldStagingDirectoryForSavesDir"), 4 + ) + self.assertIn("downloadStagingDirectories", world) + stage_method = world[ + world.index("- (nullable PLStagedWorld *)stageWorldZipAt:") : + world.index("- (nullable NSString *)commitStagedWorld:") + ] + self.assertIn("extractFilesTo:extractDirectory overwrite:NO", stage_method) + self.assertNotIn("overwrite:YES", world) + self.assertNotIn("extractFilesTo:savesDir", world) + self.assertIn("archiveEntries:entries areSafeUnderDirectory", stage_method) + + commit_method = world[ + world.index("- (nullable NSString *)commitStagedWorld:") : + world.index("- (BOOL)isWorldTaskItemCurrent:") + ] + self.assertIn("if ([fm fileExistsAtPath:destination]) continue", commit_method) + self.assertIn("moveItemAtPath:source toPath:destination", commit_method) + self.assertIn('stringWithFormat:@"%@_%ld"', commit_method) + move = commit_method.index("moveItemAtPath:source toPath:destination") + rollback = commit_method.index("removeItemAtPath:destination") + self.assertLess(move, rollback) + + def test_world_archive_requires_one_safe_visible_world(self) -> None: + world = source("Natives/WorldService.m") + self.assertIn("worldDirectoryContainingLevelDatUnderPath", world) + self.assertIn("level.dat is missing", world) + self.assertIn("multiple ambiguous worlds", world) + self.assertIn("NSFileTypeSymbolicLink", world) + self.assertIn("NSFileTypeRegular", world) + self.assertIn("sanitizedWorldDirectoryName", world) + self.assertIn("PLWorldDownloadGenerationKey", world) + self.assertIn("taskItem.maxRetryCount = 0", world) + self.assertIn("downloadWorldNames[newTask] = capturedWorldName", world) + self.assertIn("downloadSavesFolders[newTask] = capturedSavesFolder", world) + self.assertNotIn("task.taskDescription", world) + self.assertNotIn("componentsSeparatedByString:@\"\\n\"", world) + self.assertIn("taskItemRef.rawTask = newTask", world) + self.assertIn("PLTaskStagesWorld()", world) + + def test_world_pause_and_cancel_do_not_report_failure_or_success(self) -> None: + world = source("Natives/WorldService.m") + self.assertIn("error.code == NSURLErrorCancelled", world) + self.assertIn("if (isCancellation)", world) + current_guard = world[ + world.index("- (BOOL)isWorldTaskItemCurrent:") : + world.index("#pragma mark - 在线世界下载") + ] + self.assertIn("latestTask.state == DownloadTaskStateDownloading", current_guard) + self.assertIn( + "[latestTask.userInfo[PLWorldDownloadGenerationKey] isEqual:generation]", + current_guard, + ) + self.assertGreaterEqual(world.count("isWorldTaskItemCurrent"), 7) + online_finish = world[world.index("didFinishDownloadingToURL:") :] + commit = online_finish.index("commitStagedWorld:stagedWorld") + gate = online_finish.rfind("isWorldTaskItemCurrent", 0, commit) + main_queue = online_finish.rfind("dispatch_get_main_queue()", 0, commit) + self.assertGreaterEqual(gate, 0) + self.assertGreaterEqual(main_queue, 0) + self.assertLess(main_queue, gate) + self.assertIn("taskItem.supportsResume = NO", world) + self.assertIn("taskItemRef.supportsResume = YES", world) + self.assertIn("[fm removeItemAtPath:installedWorldPath error:nil]", world) + self.assertIn("[manager retryTaskWithId:taskItem.taskId]", world) + + def test_local_world_import_reports_100_only_after_safe_commit(self) -> None: + world = source("Natives/WorldService.m") + import_method = world[ + world.index("- (void)importWorldFromURL:") : + world.index("#pragma mark - NSURLSessionDownloadDelegate") + ] + commit = import_method.index("commitStagedWorld:stagedWorld") + completed = import_method.index("prog.completedUnitCount = 1") + self.assertLess(commit, completed) + self.assertIn("cleanupWorldStagingDirectory:stagingDirectory", import_method) + + +if __name__ == "__main__": + unittest.main()