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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ $null
/docs
.trae-html-share-packages/
.trae/specs/
__pycache__/
*.pyc
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
6. 渲染器设置为 Auto 时固定解析为 ANGLE;其他渲染后端需手动明确选择
7. JVM 版本将根据游戏版本自动选择 (Java 8/17/21)。
2 changes: 2 additions & 0 deletions Natives/AssetVersionViewController.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
// 代理
Expand Down
15 changes: 10 additions & 5 deletions Natives/AssetVersionViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -509,8 +509,7 @@ - (void)fetchVersions {
}

[self.activityIndicator startAnimating];
// 复用 getVersionsForModWithID:(Modrinth /project/<id>/version 端点对所有 project_type 通用)
[[ModrinthAPI sharedInstance] getVersionsForModWithID:self.projectID completion:^(NSArray<ModVersion *> * _Nullable versions, NSError * _Nullable error) {
void (^completion)(NSArray<ModVersion *> * _Nullable, NSError * _Nullable) = ^(NSArray<ModVersion *> * _Nullable versions, NSError * _Nullable error) {
dispatch_async(dispatch_get_main_queue(), ^{
[self.activityIndicator stopAnimating];
if (error) {
Expand All @@ -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 - 筛选 + 排序
Expand Down
2 changes: 2 additions & 0 deletions Natives/DataPackItem.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions Natives/DataPackItem.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"] ?: @"";
Expand Down
10 changes: 5 additions & 5 deletions Natives/DataPackService.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 后缀)
Expand All @@ -44,14 +44,14 @@ typedef void(^DataPackDownloadProgressHandler)(NSProgress * _Nullable downloadPr
// 注意:Minecraft 要求数据包放在 <gameDir>/saves/<世界名>/datapacks/,但 iOS 上无法选择世界,
// 因此默认下载到 <gameDir>/datapacks/,需用户手动移动到对应世界目录。
- (void)downloadDataPack:(DataPackItem *)item
toProfile:(NSString *)profileName
toProfile:(NSString * _Nullable)profileName
progress:(DataPackDownloadProgressHandler _Nullable)progress
completion:(DataPackDownloadCompletionHandler _Nullable)completion;

// 下载数据包到指定世界的 datapacks 目录(<gameDir>/saves/<worldName>/datapacks/)
// worldName 为 nil 时回退到 <gameDir>/datapacks/
- (void)downloadDataPack:(DataPackItem *)item
toProfile:(NSString *)profileName
toProfile:(NSString * _Nullable)profileName
worldName:(nullable NSString *)worldName
progress:(DataPackDownloadProgressHandler _Nullable)progress
completion:(DataPackDownloadCompletionHandler _Nullable)completion;
Expand All @@ -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
Expand All @@ -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

Expand Down
92 changes: 18 additions & 74 deletions Natives/DataPackService.m
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -457,17 +397,21 @@ - (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()];
taskItem.autoPresentDetail = YES;

// 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];
Expand All @@ -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];

Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -550,6 +493,7 @@ - (nullable PLDownloadOperation *)startPLDownloadWithRequest:(PLDownloadRequest
[[DownloadTaskManager sharedManager] updateTaskWithId:taskId
stageAtIndex:0
status:PLTaskStageStatusRunning];
[self.downloadStateLock unlock];
return operation;
}

Expand Down Expand Up @@ -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);
});
Expand Down
21 changes: 21 additions & 0 deletions Natives/DataPacksManagerViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 卡片基类,本文件内轻量子类)
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions Natives/DownloadTaskItem.h
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
1 change: 1 addition & 0 deletions Natives/DownloadTaskItem.m
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading