diff --git a/Asspp.xcodeproj/project.pbxproj b/Asspp.xcodeproj/project.pbxproj index 8c01b1f..ca1d339 100644 --- a/Asspp.xcodeproj/project.pbxproj +++ b/Asspp.xcodeproj/project.pbxproj @@ -106,20 +106,52 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 5095BD242C422B00000EA1F6 /* Backend */ = { + 5047BC102C4283B8006EB288 /* Downloader */ = { isa = PBXGroup; children = ( - 50D44D312C3F9F3500CF6A69 /* AppStore.swift */, 50D44D442C3FAC4900CF6A69 /* Downloads.swift */, 506367AA2C423D4C00634EEA /* Downloads+Request.swift */, 506367A82C423D2700634EEA /* Downloads+Report.swift */, + ); + path = Downloader; + sourceTree = ""; + }; + 5047BC112C4283C1006EB288 /* AppStore */ = { + isa = PBXGroup; + children = ( + 50D44D312C3F9F3500CF6A69 /* AppStore.swift */, + ); + path = AppStore; + sourceTree = ""; + }; + 5047BC122C4283CF006EB288 /* MD5 */ = { + isa = PBXGroup; + children = ( 506367A62C423CE900634EEA /* MD5.swift */, + ); + path = MD5; + sourceTree = ""; + }; + 5047BC132C4283D6006EB288 /* Installer */ = { + isa = PBXGroup; + children = ( 50D0C8C12C40253800538F49 /* Installer.swift */, 50D0C8BF2C401DE000538F49 /* Installer+Compute.swift */, 50D0C8BD2C401B7B00538F49 /* Installer+Pic.swift */, 50D0C8BB2C401B3000538F49 /* Installer+App.swift */, 50D0C8B92C401AD600538F49 /* Installer+TLS.swift */, ); + path = Installer; + sourceTree = ""; + }; + 5095BD242C422B00000EA1F6 /* Backend */ = { + isa = PBXGroup; + children = ( + 5047BC112C4283C1006EB288 /* AppStore */, + 5047BC102C4283B8006EB288 /* Downloader */, + 5047BC122C4283CF006EB288 /* MD5 */, + 5047BC132C4283D6006EB288 /* Installer */, + ); path = Backend; sourceTree = ""; }; @@ -492,7 +524,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -522,7 +554,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; diff --git a/Asspp/App/Assets.xcassets/AccentColor.colorset/Contents.json b/Asspp/App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..6ab99c0 --- /dev/null +++ b/Asspp/App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.575", + "green" : "0.329", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.838", + "red" : "0.462" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Asspp/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Asspp/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3fe7e52 --- /dev/null +++ b/Asspp/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "asspp.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Asspp/App/Assets.xcassets/AppIcon.appiconset/asspp.png b/Asspp/App/Assets.xcassets/AppIcon.appiconset/asspp.png new file mode 100644 index 0000000..bd8b518 Binary files /dev/null and b/Asspp/App/Assets.xcassets/AppIcon.appiconset/asspp.png differ diff --git a/Asspp/App/Assets.xcassets/Avatar.imageset/Avatar.png b/Asspp/App/Assets.xcassets/Avatar.imageset/Avatar.png new file mode 100644 index 0000000..2f6e59c Binary files /dev/null and b/Asspp/App/Assets.xcassets/Avatar.imageset/Avatar.png differ diff --git a/Asspp/App/Assets.xcassets/Avatar.imageset/Contents.json b/Asspp/App/Assets.xcassets/Avatar.imageset/Contents.json new file mode 100644 index 0000000..04c9d3c --- /dev/null +++ b/Asspp/App/Assets.xcassets/Avatar.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Avatar.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Asspp/App/Assets.xcassets/Contents.json b/Asspp/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Asspp/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Asspp/App/InfoPlist.xcstrings b/Asspp/App/InfoPlist.xcstrings new file mode 100644 index 0000000..d64cf0f --- /dev/null +++ b/Asspp/App/InfoPlist.xcstrings @@ -0,0 +1,24 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Asspp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "爱啪思道" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Asspp/App/Localizable.xcstrings b/Asspp/App/Localizable.xcstrings new file mode 100644 index 0000000..77023f9 --- /dev/null +++ b/Asspp/App/Localizable.xcstrings @@ -0,0 +1,1224 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "@Lakr233" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "@Lakr233" + } + } + } + }, + "%@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } + }, + "%@ - %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ - %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + } + } + }, + "%@ - %@ - %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ - %2$@ - %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ - %3$@" + } + } + } + }, + "%@ %@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@ %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ %3$@" + } + } + } + }, + "%lld%%..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%..." + } + } + } + }, + "2FA Code" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "验证码" + } + } + } + }, + "2FA Code (Optional)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "验证码(可选)" + } + } + } + }, + "88888888888" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "88888888888" + } + } + } + }, + "About" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关于" + } + } + } + }, + "Account" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "账户" + } + } + } + }, + "Acquire License" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "获取许可" + } + } + } + }, + "Acquire license is not available for paid apps. If so, make purchase from the real App Store before download from here. If you already purchased this app, this operation will fail." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "获取许可的选项对于付费的软件不可用。请使用正版 App Store 获取付费软件的许可证。如果已拥有软件许可证,此操作会失败。" + } + } + } + }, + "Add Account" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加账号" + } + } + } + }, + "All Region" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有地区" + } + } + } + }, + "Although 2FA code is marked as optional, that is because we dont know if you have it or just incorrect password, you should provide it if you have it enabled.\n\nhttps://support.apple.com/102606" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "我们无法知晓你的账户是否启用了双重认证,因此无法确定是否需要填写验证码。尽管此处标记为可选,若你的账户已经开启这项功能,请务必提供验证码。\n\n如果没有收到验证码,请从系统设置中获取一个验证码。\n\nhttps://support.apple.com/zh-cn/102606" + } + } + } + }, + "App Store itself is unstable, retry if needed." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store 的接口并不稳定。如遇错误,可多尝试。" + } + } + } + }, + "App Store requires this country code to identify your package region." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store 需要你的国家代码来确认软件包的所属区域。" + } + } + } + }, + "Authenticate" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "验证" + } + } + } + }, + "Bundle ID" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "捆绑包 ID" + } + } + } + }, + "Buy me a coffee! ☕️" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请我喝杯奶茶!" + } + } + } + }, + "By enabling this, all your account will be redacted." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用此选项会将你的敏感信息遮蔽或隐藏。部分选项将不可用或使用默认。" + } + } + } + }, + "Cancel" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + } + } + }, + "Communicating with Apple..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与 Apple 沟通中..." + } + } + } + }, + "Completed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已完成" + } + } + } + }, + "Continue Download" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续下载" + } + } + } + }, + "Control" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制" + } + } + } + }, + "Country Code" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "国家代码" + } + } + } + }, + "Danger Zone" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "危险区域" + } + } + } + }, + "Debug" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "调试" + } + } + } + }, + "Delete" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + } + } + }, + "Delete All Download" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除全部下载" + } + } + } + }, + "Demo Mode" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "演示模式" + } + } + } + }, + "Demo Mode Redacted" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "演示模式遮蔽" + } + } + } + }, + "Description" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "描述" + } + } + } + }, + "Detail" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "详情" + } + } + } + }, + "Device Seed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设备随机数" + } + } + } + }, + "Direct Download" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接下载" + } + } + } + }, + "Direct install may have limitations that is not able to bypass. Use AirDrop method if possible on another device." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接安装模式包含限制,无法覆盖安装,也无法升级软件。这可能无法满足你的需求。请考虑使用 AirDrop 来安装软件包。" + } + } + } + }, + "Download" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载" + } + } + } + }, + "Download and save the ipa file." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载并保存软件安装包。" + } + } + } + }, + "Download In Progress..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在下载..." + } + } + } + }, + "Download Requested" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已请求下载" + } + } + } + }, + "Downloads" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载" + } + } + } + }, + "Either connection is lost or the download is interrupted. Tap to continue." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载被中断。点击以继续。" + } + } + } + }, + "Email (Apple ID)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "邮箱(Apple ID)" + } + } + } + }, + "EntityType" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实体类型" + } + } + } + }, + "Error" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "错误" + } + } + } + }, + "Failed to rotate password token, please re-authenticate within account page." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法更新你的凭证,请删除账户并重新认证。" + } + } + } + }, + "Feedback & Contact" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "问题反馈" + } + } + } + }, + "FLEX is a set of in-app debugging and exploration tools for iOS development." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用工具调试应用程序及其运行环境。" + } + } + } + }, + "Home" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主页" + } + } + } + }, + "Hope my app helps you out." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "希望我的软件能给你带来快乐 🎉" + } + } + } + }, + "Host Name" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主机名" + } + } + } + }, + "ID" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID" + } + } + } + }, + "ID combined with a random seed generated on this device can download package from App Store." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的 ID 与设备随机数将会被用于下载软件包。" + } + } + } + }, + "IDs" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID" + } + } + } + }, + "If you failed to acquire license for product, rotate the password token may help. This will use the initial password to authenticate with App Store again." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "如果你无法获取软件许可证,可以在此处刷新你的账户信息。" + } + } + } + }, + "Incomplete Package" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未完成的软件包" + } + } + } + }, + "Install" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装" + } + } + } + }, + "Install Completed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装已完成" + } + } + } + }, + "Install or AirDrop to install." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装软件包或者 AirDrop 到另一台设备以安装。" + } + } + } + }, + "Install via AirDrop" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 @ AirDrop" + } + } + } + }, + "Keyword" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关键词" + } + } + } + }, + "License Not Found, please acquire license first." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法找到软件许可证,请先获取。" + } + } + } + }, + "MD5 mismatch" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "MD5 比对失败" + } + } + } + }, + "Metadata" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "元数据" + } + } + } + }, + "No account available for this region." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此地区选项没有可用的账户。" + } + } + } + }, + "Open Setting" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开设置" + } + } + } + }, + "Operating download manager." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作下载管理器" + } + } + } + }, + "Package" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "软件包" + } + } + } + }, + "Package can be installed later in download page." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你可以稍后在软件包下载页面发起安装。" + } + } + } + }, + "Packages" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "软件包" + } + } + } + }, + "Password" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密码" + } + } + } + }, + "Password Token" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密码凭证" + } + } + } + }, + "Password Token Expired, please re-authenticate within account page." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密码凭证已过期,请重新验证账户。" + } + } + } + }, + "Pending..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在等待下载..." + } + } + } + }, + "Please add account in account page." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请在设置页面添加账户。" + } + } + } + }, + "Pricing - %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "价格 - %@" + } + } + } + }, + "Puase" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂停" + } + } + } + }, + "Ready To Install" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "准备就绪" + } + } + } + }, + "Region" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "地区" + } + } + } + }, + "Request Download" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求下载" + } + } + } + }, + "Request Successes" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求成功" + } + } + } + }, + "Reset" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置" + } + } + } + }, + "Resume" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + } + } + }, + "Rotate Token" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新凭证" + } + } + } + }, + "Rotating..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在更新..." + } + } + } + }, + "Search" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索" + } + } + } + }, + "Search for apps you want to install." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索你希望安装的软件。" + } + } + } + }, + "Searching..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索中..." + } + } + } + }, + "Select Account" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择账号" + } + } + } + }, + "Select an account to download this app" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个账号来下载这个软件" + } + } + } + }, + "Sending Manifest..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在发送清单" + } + } + } + }, + "Sending Payload..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在发送软件包" + } + } + } + }, + "Services ID" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务 ID" + } + } + } + }, + "Setting" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + } + } + }, + "Show Download" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示下载项" + } + } + } + }, + "Show FLEX" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示调试工具" + } + } + } + }, + "Sign in to your account." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录你的账号。" + } + } + } + }, + "Sorry, nothing here." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "『僕らの手には何もないけど、』 🎵" + } + } + } + }, + "Success" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "成功" + } + } + } + }, + "Suspended" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已暂停" + } + } + } + }, + "Tell us the bundle ID of the app to initial a direct download. Useful to download apps that are no longer available in App Store." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请输入软件的捆绑包 ID。此功能适用于下载已经下架的软件。" + } + } + } + }, + "Temporarily Unavailable, please try again later." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务暂不可用,请稍后再试。" + } + } + } + }, + "This account is used to download this package. If you choose to AirDrop, your target device must sign in or previously signed in to this account and have at least one app installed." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此账号被用于下载这个软件。如果你选择使用 AirDrop 安装该软件包,目标设备需要登录过此账号并且使用此账号安装最少一个软件。" + } + } + } + }, + "This address is used to be a MAC address from your hardware to identify your device with Apple. Here we use a random one." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这个地址通常是你设备的网络硬件地址。由于无法获取到这个数据,我们使用随机生成的值来代替。" + } + } + } + }, + "This email is used to sign in to Apple services." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这个邮箱地址通常是用于登录各个 Apple 服务的地址。" + } + } + } + }, + "This will reset all your settings." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这会删除所有设置和数据。请谨慎操作。" + } + } + } + }, + "To install app, you need to grant local area network permission in order to communicate with system services." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "若要安装软件,爱啪思道需要本地网络连接权限。请前往设置授予这个权限。" + } + } + } + }, + "To install app, you need to grant local area network permission in order to communicate with system services. If your host name is empty, go to Settings.app to grant permission." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "若要安装软件,爱啪思道需要本地网络连接权限。若无法获取到主机名称,通常说明此权限缺失。请前往设置授予这个权限。" + } + } + } + }, + "Type" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类型" + } + } + } + }, + "Unable to retrieve download url, please try again later." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法获取下载链接,请稍后再试。" + } + } + } + }, + "Unknown" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + } + } + }, + "Verification In Progress..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在验证..." + } + } + } + }, + "Verifying..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在验证..." + } + } + } + }, + "We will store your account and password on disk without encryption. Please do not connect your device to untrusted hardware or use this app on a open system like macOS." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的账号和密码在储存中不会加密。请不要将你的设备连接到不信任的硬件上,亦不要在例如 macOS 的开放系统中使用此软件。" + } + } + } + }, + "Welcome to Asspp" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "欢迎使用 爱啪思道" + } + } + } + }, + "You have searched this package with region %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你使用了 %@ 作为此次搜索的地区" + } + } + } + }, + "Your account is not encrypted on disk." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的账户在储存时不会加密,请不要让他人连接到这个设备。" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Asspp/App/main.swift b/Asspp/App/main.swift new file mode 100644 index 0000000..aeb5d82 --- /dev/null +++ b/Asspp/App/main.swift @@ -0,0 +1,62 @@ +// +// main.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import SwiftUI + +let bundleIdentifier = Bundle.main.bundleIdentifier! +let appVersion = "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""))" + +private let availableDirectories = FileManager + .default + .urls(for: .documentDirectory, in: .userDomainMask) +let documentsDirectory = availableDirectories[0] + .appendingPathComponent("Asspp") +do { + let enumerator = FileManager.default.enumerator(atPath: documentsDirectory.path) + while let file = enumerator?.nextObject() as? String { + let path = documentsDirectory.appendingPathComponent(file) + if let content = try? FileManager.default.contentsOfDirectory(atPath: path.path), + content.isEmpty + { try? FileManager.default.removeItem(at: path) } + } +} + +try? FileManager.default.createDirectory( + at: documentsDirectory, + withIntermediateDirectories: true, + attributes: nil +) +let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(bundleIdentifier) +do { + let enumerator = FileManager.default.enumerator(atPath: temporaryDirectory.path) + while let file = enumerator?.nextObject() as? String { + let path = temporaryDirectory.appendingPathComponent(file) + if let content = try? FileManager.default.contentsOfDirectory(atPath: path.path), + content.isEmpty + { try? FileManager.default.removeItem(at: path) } + } +} + +try? FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true, + attributes: nil +) + +_ = ProcessInfo.processInfo.hostName + +AppStore.this.setupGUID() +_ = Downloads.this + +App.main() + +private struct App: SwiftUI.App { + var body: some Scene { + WindowGroup { MainView() } + } +} diff --git a/Asspp/Backend/AppStore/AppStore.swift b/Asspp/Backend/AppStore/AppStore.swift new file mode 100644 index 0000000..b5db813 --- /dev/null +++ b/Asspp/Backend/AppStore/AppStore.swift @@ -0,0 +1,96 @@ +// +// AppStore.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import Combine +import Foundation + +class AppStore: ObservableObject { + struct Account: Codable, Identifiable, Hashable { + var id: String { email } + + var email: String + var password: String + var countryCode: String + var storeResponse: StoreResponse.Account + } + + var cancellables: Set = .init() + + @PublishedPersist(key: "DeviceSeedAddress", defaultValue: "") + var deviceSeedAddress: String + + static func createSeed() -> String { + "00:00:00:00:00:00" + .components(separatedBy: ":") + .map { _ in + let randomHex = String(Int.random(in: 0 ... 255), radix: 16) + return randomHex.count == 1 ? "0\(randomHex)" : randomHex + } + .joined(separator: ":") + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ":", with: "") + .uppercased() + } + + @PublishedPersist(key: "Accounts", defaultValue: []) + var accounts: [Account] + + @PublishedPersist(key: "DemoMode", defaultValue: false) + var demoMode: Bool + + static let this = AppStore() + private init() { + $deviceSeedAddress + .removeDuplicates() + .sink { input in + print("[*] updating guid \(input) as the seed") + ApplePackage.overrideGUID = input + } + .store(in: &cancellables) + } + + func setupGUID() { + if deviceSeedAddress.isEmpty { deviceSeedAddress = Self.createSeed() } + assert(!deviceSeedAddress.isEmpty) + deviceSeedAddress = deviceSeedAddress + } + + @discardableResult + func save(email: String, password: String, account: StoreResponse.Account) -> Account { + let account = Account( + email: email, + password: password, + countryCode: account.countryCode, + storeResponse: account + ) + accounts = accounts + .filter { $0.email.lowercased() != email.lowercased() } + + [account] + return account + } + + func delete(id: Account.ID) { + accounts = accounts.filter { $0.id != id } + } + + @discardableResult + func rotate(id: Account.ID) throws -> Account? { + guard let account = accounts.first(where: { $0.id == id }) else { return nil } + let auth = ApplePackage.Authenticator(email: account.email) + let newAccount = try auth.authenticate(password: account.password, code: nil) + if Thread.isMainThread { + return save(email: account.email, password: account.password, account: newAccount) + } else { + var result: Account? + DispatchQueue.main.asyncAndWait { + result = self.save(email: account.email, password: account.password, account: newAccount) + } + return result + } + } +} diff --git a/Asspp/Backend/Downloader/Downloads+Report.swift b/Asspp/Backend/Downloader/Downloads+Report.swift new file mode 100644 index 0000000..3b6811f --- /dev/null +++ b/Asspp/Backend/Downloader/Downloads+Report.swift @@ -0,0 +1,62 @@ +// +// Downloads+Report.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/13. +// + +import Foundation + +extension Downloads { + func alter(reqID: Request.ID, _ callback: @escaping (inout Request) -> Void) { + DispatchQueue.main.async { [self] in + guard let index = requests.firstIndex(where: { $0.id == reqID }) else { return } + var req = requests[index] + let deduplicate = req + callback(&req) + guard deduplicate != req else { return } + requests[index] = req + } + } + + func reportValidating(reqId: Request.ID) { + alter(reqID: reqId) { req in + req.runtime.status = .verifying + } + } + + func reportSuccess(reqId: Request.ID) { + alter(reqID: reqId) { req in + req.runtime.status = .completed + req.runtime.percent = 1 + req.runtime.error = nil + } + } + + func report(error: Error?, reqId: Request.ID) { + print(Thread.callStackSymbols.joined(separator: "\n")) + let error = error ?? NSError(domain: "DownloadManager", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Unknown error", + ]) + alter(reqID: reqId) { req in + req.runtime.error = error.localizedDescription + req.runtime.status = .stopped + } + } + + func report(progress: Progress, reqId: Request.ID) { + alter(reqID: reqId) { req in + req.runtime.percent = progress.fractionCompleted + req.runtime.status = .downloading + req.runtime.error = nil + } + } + + func report(speed: String, reqId: Request.ID) { + alter(reqID: reqId) { req in + req.runtime.speed = speed + req.runtime.status = .downloading + req.runtime.error = nil + } + } +} diff --git a/Asspp/Backend/Downloader/Downloads+Request.swift b/Asspp/Backend/Downloader/Downloads+Request.swift new file mode 100644 index 0000000..d9a6eb8 --- /dev/null +++ b/Asspp/Backend/Downloader/Downloads+Request.swift @@ -0,0 +1,83 @@ +// +// Downloads+Request.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/13. +// + +import AnyCodable +import ApplePackage +import Foundation + +private let storeDir = { + let ret = documentsDirectory.appendingPathComponent("Packages") + try? FileManager.default.createDirectory(at: ret, withIntermediateDirectories: true) + return ret +}() + +extension Downloads { + struct Request: Identifiable, Codable, Hashable { + var id: UUID = .init() + + var account: AppStore.Account + var package: iTunesResponse.iTunesArchive + + var url: URL + var md5: String + var signatures: [StoreResponse.Item.Signature] + var metadata: [String: AnyCodable] + + var creation: Date + var targetLocation: URL { + storeDir + .appendingPathComponent(package.bundleIdentifier) + .appendingPathComponent(package.version) + .appendingPathComponent("\(md5)_\(id.uuidString)") + .appendingPathExtension("ipa") + } + + var runtime: Runtime = .init() + + init(account: AppStore.Account, package: iTunesResponse.iTunesArchive, item: StoreResponse.Item) { + self.account = account + self.package = package + url = item.url + md5 = item.md5 + signatures = item.signatures + creation = .init() + if let jsonData = try? JSONSerialization.data(withJSONObject: item.metadata), + let json = try? JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + { + metadata = json + } else { + metadata = [:] + } + } + } +} + +extension Downloads.Request { + struct Runtime: Codable, Hashable { + enum Status: String, Codable { + case stopped + case pending + case downloading + case verifying + case completed + } + + var status: Status = .stopped { + didSet { if status != .downloading { speed = "" } } + } + + var speed: String = "" + var percent: Double = 0 + var error: String? = nil + + var progress: Progress { + let p = Progress(totalUnitCount: 100) + p.completedUnitCount = Int64(percent * 100) + return p + } + } +} diff --git a/Asspp/Backend/Downloader/Downloads.swift b/Asspp/Backend/Downloader/Downloads.swift new file mode 100644 index 0000000..39a3e75 --- /dev/null +++ b/Asspp/Backend/Downloader/Downloads.swift @@ -0,0 +1,195 @@ +// +// Downloads.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import AnyCodable +import ApplePackage +import Combine +import Digger +import Foundation + +private let byteFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .file + return formatter +}() + +class Downloads: ObservableObject { + static let this = Downloads() + + @PublishedPersist(key: "DownloadRequests", defaultValue: []) + var requests: [Request] + + var runningTaskCount: Int { + requests.filter { $0.runtime.status == .downloading }.count + } + + init() { + let copy = requests + for req in copy where !isCompleted(for: req) { + alter(reqID: req.id) { req in + req.runtime.status = .stopped + } + } + + DiggerManager.shared.maxConcurrentTasksCount = 4 + DiggerManager.shared.timeout = 15 + } + + func isCompleted(for request: Request) -> Bool { + if FileManager.default.fileExists(atPath: request.targetLocation.path) { + reportSuccess(reqId: request.id) + return true + } + return false + } + + @discardableResult + func add(request: Request) -> Request.ID { + if Thread.isMainThread { + requests.insert(request, at: 0) + return request.id + } else { + DispatchQueue.main.asyncAndWait { + self.requests.insert(request, at: 0) + } + return request.id + } + } + + func byteFormat(bytes: Int64) -> String { + if bytes > 0 { + return byteFormatter.string(fromByteCount: bytes) + } + return "" + } + + func suspend(requestID: Request.ID) { + let request = requests.first(where: { $0.id == requestID }) + guard let request else { return } + if isCompleted(for: request) { return } + DiggerManager.shared.stopTask(for: request.url) + // wait for callback to trigger + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + self.alter(reqID: requestID) { req in + req.runtime.status = .stopped + req.runtime.error = nil + req.runtime.speed = "" + req.runtime.percent = 0 + } + } + } + + func resume(requestID: Request.ID) { + let request = requests.first(where: { $0.id == requestID }) + guard let request else { return } + if isCompleted(for: request) { return } + + alter(reqID: requestID) { req in + req.runtime.status = .pending + req.runtime.error = nil + req.runtime.speed = "" + req.runtime.percent = 0 + } + DispatchQueue.global().async { + DiggerManager.shared.download(with: request.url) + .speed { speedInput in + let speed = self.byteFormat(bytes: speedInput) + self.report(speed: speed, reqId: requestID) + } + .progress { progress in + self.report(progress: progress, reqId: requestID) + } + .completion { output in + DispatchQueue.global().async { + switch output { + case let .success(url): + self.reportValidating(reqId: requestID) + self.finalize(request: request, url: url) + case let .failure(error): + self.report(error: error, reqId: requestID) + } + } + } + } + } + + func finalize(request: Request, url: URL) { + let targetLocation = request.targetLocation + + do { + let md5 = request.md5 + let fileMD5 = md5File(url: url) + guard md5.lowercased() == fileMD5?.lowercased() else { + report(error: NSError(domain: "MD5", code: 1, userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("MD5 mismatch", comment: ""), + ]), reqId: request.id) + return + } + + try? FileManager.default.removeItem(at: targetLocation) + try? FileManager.default.createDirectory( + at: targetLocation.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.moveItem(at: url, to: targetLocation) + let data = try JSONEncoder().encode(request.metadata) + let object = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] ?? [:] + + print("[*] sending metadata into \(targetLocation.path)") + let item = StoreResponse.Item( + url: request.url, + md5: request.md5, + signatures: request.signatures, + metadata: object + ) + let signatureClient = SignatureClient(fileManager: .default, filePath: targetLocation.path) + try signatureClient.appendMetadata(item: item, email: request.account.email) + try signatureClient.appendSignature(item: item) + + reportSuccess(reqId: request.id) + } catch { + try? FileManager.default.removeItem(at: targetLocation) + report(error: error, reqId: request.id) + } + } + + func delete(request: Request) { + DispatchQueue.main.async { [self] in + DiggerManager.shared.cancelTask(for: request.url) + DiggerManager.shared.removeDigeerSeed(for: request.url) + requests.removeAll { $0.id == request.id } + try? FileManager.default.removeItem(at: request.targetLocation) + } + } + + func resumeAll() { + for req in requests { + resume(requestID: req.id) + } + } + + func suspendAll() { + DiggerManager.shared.stopAllTasks() + } + + func removeAll() { + let copy = requests + for req in copy { + delete(request: req) + } + } + + func downloadRequest(forArchive archive: iTunesResponse.iTunesArchive) -> Request? { + for req in requests { + if req.package == archive { + return req + } + } + return nil + } +} diff --git a/Asspp/Backend/Installer/Installer+App.swift b/Asspp/Backend/Installer/Installer+App.swift new file mode 100644 index 0000000..bddcc98 --- /dev/null +++ b/Asspp/Backend/Installer/Installer+App.swift @@ -0,0 +1,35 @@ +// +// Installer+App.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import Foundation +import Vapor + +extension Installer { + private static let env: Environment = { + var env = try! Environment.detect() + try! LoggingSystem.bootstrap(from: &env) + return env + }() + + static func setupApp(port: Int) throws -> Application { + let app = Application(env) + + app.threadPool = .init(numberOfThreads: 1) + + app.http.server.configuration.tlsConfiguration = try Self.setupTLS() + app.http.server.configuration.hostname = Self.sni + app.http.server.configuration.tcpNoDelay = true + + app.http.server.configuration.address = .hostname("0.0.0.0", port: port) + app.http.server.configuration.port = port + + app.routes.defaultMaxBodySize = "128mb" + app.routes.caseInsensitive = false + + return app + } +} diff --git a/Asspp/Backend/Installer/Installer+Compute.swift b/Asspp/Backend/Installer/Installer+Compute.swift new file mode 100644 index 0000000..9bc69ec --- /dev/null +++ b/Asspp/Backend/Installer/Installer+Compute.swift @@ -0,0 +1,110 @@ +// +// Installer+Compute.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import Foundation + +extension Installer { + var plistEndpoint: URL { + var comps = URLComponents() + comps.scheme = "https" + comps.host = Self.sni + comps.path = "/\(id).plist" + comps.port = port + return comps.url! + } + + var payloadEndpoint: URL { + var comps = URLComponents() + comps.scheme = "https" + comps.host = Self.sni + comps.path = "/\(id).ipa" + comps.port = port + return comps.url! + } + + var iTunesLink: URL { + var comps = URLComponents() + comps.scheme = "itms-services" + comps.path = "/" + comps.queryItems = [ + URLQueryItem(name: "action", value: "download-manifest"), + URLQueryItem(name: "url", value: plistEndpoint.absoluteString), + ] + comps.port = port + return comps.url! + } + + var displayImageSmallEndpoint: URL { + var comps = URLComponents() + comps.scheme = "https" + comps.host = Self.sni + comps.path = "/app57x57.png" + comps.port = port + return comps.url! + } + + var displayImageSmallData: Data { + createWhite(57) + } + + var displayImageLargeEndpoint: URL { + var comps = URLComponents() + comps.scheme = "https" + comps.host = Self.sni + comps.path = "/app512x512.png" + comps.port = port + return comps.url! + } + + var displayImageLargeData: Data { + createWhite(512) + } + + var indexHtml: String { + """ + + """ + } + + var installManifest: [String: Any] { + [ + "items": [ + [ + "assets": [ + [ + "kind": "software-package", + "url": payloadEndpoint.absoluteString, + ], + [ + "kind": "display-image", + "url": displayImageSmallEndpoint.absoluteString, + ], + [ + "kind": "full-size-image", + "url": displayImageLargeEndpoint.absoluteString, + ], + ], + "metadata": [ + "bundle-identifier": archive.bundleIdentifier, + "bundle-version": archive.version, + "kind": "software", + "title": archive.name, + ], + ], + ], + ] + } + + var installManifestData: Data { + (try? PropertyListSerialization.data( + fromPropertyList: installManifest, + format: .xml, + options: .zero + )) ?? .init() + } +} diff --git a/Asspp/Backend/Installer/Installer+Pic.swift b/Asspp/Backend/Installer/Installer+Pic.swift new file mode 100644 index 0000000..0d827cd --- /dev/null +++ b/Asspp/Backend/Installer/Installer+Pic.swift @@ -0,0 +1,19 @@ +// +// Installer+Pic.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import UIKit + +extension Installer { + func createWhite(_ r: CGFloat) -> Data { + let renderer = UIGraphicsImageRenderer(size: .init(width: r, height: r)) + let image = renderer.image { ctx in + UIColor.white.setFill() + ctx.fill(.init(x: 0, y: 0, width: r, height: r)) + } + return image.pngData()! + } +} diff --git a/Asspp/Backend/Installer/Installer+TLS.swift b/Asspp/Backend/Installer/Installer+TLS.swift new file mode 100644 index 0000000..9fe7ab2 --- /dev/null +++ b/Asspp/Backend/Installer/Installer+TLS.swift @@ -0,0 +1,39 @@ +// +// Installer+TLS.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import Foundation +import NIOSSL +import NIOTLS +import Vapor + +extension Installer { + static let sni = "app.localhost.direct" + static let pem = Bundle.main.url( + forResource: "localhost.direct", + withExtension: "pem", + subdirectory: "Certificates/localhost.direct" + ) + static let crt = Bundle.main.url( + forResource: "localhost.direct", + withExtension: "crt", + subdirectory: "Certificates/localhost.direct" + ) + + static func setupTLS() throws -> TLSConfiguration { + guard let crt, let pem else { + throw NSError(domain: "Installer", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Failed to load ssl certificates", + ]) + } + return try TLSConfiguration.makeServerConfiguration( + certificateChain: NIOSSLCertificate + .fromPEMFile(crt.path) + .map { NIOSSLCertificateSource.certificate($0) }, + privateKey: .file(pem.path) + ) + } +} diff --git a/Asspp/Backend/Installer/Installer.swift b/Asspp/Backend/Installer/Installer.swift new file mode 100644 index 0000000..264f9c4 --- /dev/null +++ b/Asspp/Backend/Installer/Installer.swift @@ -0,0 +1,92 @@ +// +// Installer.swift +// AppInstaller +// +// Created by 秋星桥 on 2024/7/10. +// + +import ApplePackage +import Logging +import UIKit +import Vapor + +class Installer: Identifiable, ObservableObject { + let id: UUID + let app: Application + let archive: iTunesResponse.iTunesArchive + let port = Int.random(in: 4000 ... 8000) + + enum Status { + case ready + case sendingManifest + case sendingPayload + case completed(Result) + case broken(Error) + } + + @Published var status: Status = .ready + + var needsShutdown = false + + init(archive: iTunesResponse.iTunesArchive, path packagePath: URL) throws { + let id: UUID = .init() + self.id = id + self.archive = archive + app = try Self.setupApp(port: port) + + app.get("*") { [weak self] req in + guard let self else { return Response(status: .badGateway) } + + switch req.url.path { + case "/ping": + return Response(status: .ok, body: .init(string: "pong")) + case "/", "/index.html": + return Response(status: .ok, version: req.version, headers: [ + "Content-Type": "text/html", + ], body: .init(string: indexHtml)) + case plistEndpoint.path: + DispatchQueue.main.async { self.status = .sendingManifest } + return Response(status: .ok, version: req.version, headers: [ + "Content-Type": "text/xml", + ], body: .init(data: installManifestData)) + case displayImageSmallEndpoint.path: + DispatchQueue.main.async { self.status = .sendingManifest } + return Response(status: .ok, version: req.version, headers: [ + "Content-Type": "image/png", + ], body: .init(data: displayImageSmallData)) + case displayImageLargeEndpoint.path: + DispatchQueue.main.async { self.status = .sendingManifest } + return Response(status: .ok, version: req.version, headers: [ + "Content-Type": "image/png", + ], body: .init(data: displayImageLargeData)) + case payloadEndpoint.path: + DispatchQueue.main.async { self.status = .sendingPayload } + return req.fileio.streamFile( + at: packagePath.path + ) { result in + DispatchQueue.main.async { self.status = .completed(result) } + } + default: + // 404 + return Response(status: .notFound) + } + } + + try app.server.start() + needsShutdown = true + print("[*] installer init at port \(port) for sni \(Self.sni)") + } + + deinit { + destroy() + } + + func destroy() { + print("[*] installer destroy") + if needsShutdown { + needsShutdown = false + app.server.shutdown() + app.shutdown() + } + } +} diff --git a/Asspp/Backend/MD5/MD5.swift b/Asspp/Backend/MD5/MD5.swift new file mode 100644 index 0000000..5276ba8 --- /dev/null +++ b/Asspp/Backend/MD5/MD5.swift @@ -0,0 +1,35 @@ +// +// MD5.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/13. +// + +import CommonCrypto +import CryptoKit +import Foundation + +func md5File(url: URL) -> String? { + do { + var hasher = Insecure.MD5() + let bufferSize = 1024 * 1024 * 32 // 32MB + + let fileHandler = try FileHandle(forReadingFrom: url) + fileHandler.seekToEndOfFile() + let size = fileHandler.offsetInFile + try fileHandler.seek(toOffset: 0) + + while fileHandler.offsetInFile < size { + autoreleasepool { + let data = fileHandler.readData(ofLength: bufferSize) + hasher.update(data: data) + } + } + + let digest = hasher.finalize() + return digest.map { String(format: "%02hhx", $0) }.joined() + } catch { + print("[-] error reading file: \(error)") + return nil + } +} diff --git a/Asspp/Extension/PublishedPersist.swift b/Asspp/Extension/PublishedPersist.swift new file mode 100644 index 0000000..84fd82c --- /dev/null +++ b/Asspp/Extension/PublishedPersist.swift @@ -0,0 +1,117 @@ +// +// PublishedPersist.swift +// NotchDrop +// +// Created by 秋星桥 on 2024/7/8. +// + +import Combine +import Foundation + +protocol PersistProvider { + func data(forKey: String) -> Data? + func set(_ data: Data?, forKey: String) +} + +private let valueEncoder = JSONEncoder() +private let valueDecoder = JSONDecoder() +private let configDir = documentsDirectory + .appendingPathComponent("Config") + +/* + We do not encrypt so we exclude these from backup. + That means, no one else is able to access it if app is properly signed. + */ +class FileStorage: PersistProvider { + func pathForKey(_ key: String) -> URL { + try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + var url = configDir.appendingPathComponent(key) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try? url.setResourceValues(resourceValues) + return url + } + + func data(forKey key: String) -> Data? { + try? Data(contentsOf: pathForKey(key)) + } + + func set(_ data: Data?, forKey key: String) { + try? data?.write(to: pathForKey(key)) + } +} + +@propertyWrapper +struct Persist { + private let subject: CurrentValueSubject + private let cancellables: Set + + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() + } + + public init(key: String, defaultValue: Value, engine: PersistProvider) { + if let data = engine.data(forKey: key), + let object = try? valueDecoder.decode(Value.self, from: data) + { + subject = CurrentValueSubject(object) + } else { + subject = CurrentValueSubject(defaultValue) + } + + var cancellables: Set = .init() + subject + .receive(on: DispatchQueue.global()) + .map { try? valueEncoder.encode($0) } + .removeDuplicates() + .sink { engine.set($0, forKey: key) } + .store(in: &cancellables) + self.cancellables = cancellables + } + + public var wrappedValue: Value { + get { subject.value } + set { subject.send(newValue) } + } +} + +@propertyWrapper +struct PublishedPersist { + @Persist private var value: Value + + public var projectedValue: AnyPublisher { $value } + + @available(*, unavailable, message: "accessing wrappedValue will result undefined behavior") + public var wrappedValue: Value { + get { value } + set { value = newValue } + } + + public static subscript( + _enclosingInstance object: EnclosingSelf, + wrapped _: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath> + ) -> Value { + get { object[keyPath: storageKeyPath].value } + set { + (object.objectWillChange as? ObservableObjectPublisher)?.send() + object[keyPath: storageKeyPath].value = newValue + } + } + + public init(key: String, defaultValue: Value, engine: PersistProvider) { + _value = .init(key: key, defaultValue: defaultValue, engine: engine) + } +} + +extension Persist { + init(key: String, defaultValue: Value) { + self.init(key: key, defaultValue: defaultValue, engine: FileStorage()) + } +} + +extension PublishedPersist { + init(key: String, defaultValue: Value) { + self.init(key: key, defaultValue: defaultValue, engine: FileStorage()) + } +} diff --git a/Asspp/Interface/Account/AccountDetailView.swift b/Asspp/Interface/Account/AccountDetailView.swift new file mode 100644 index 0000000..7bf9e00 --- /dev/null +++ b/Asspp/Interface/Account/AccountDetailView.swift @@ -0,0 +1,108 @@ +// +// AccountDetailView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import SwiftUI + +struct AccountDetailView: View { + let account: AppStore.Account + + @StateObject var vm = AppStore.this + @Environment(\.dismiss) var dismiss + + @State var rotating = false + @State var rotatingHint = "" + + var body: some View { + List { + Section { + if vm.demoMode { + Text("88888888888") + .redacted(reason: .placeholder) + } else { + Text(account.email) + .onTapGesture { UIPasteboard.general.string = account.email } + } + } header: { + Text("ID") + } footer: { + Text("This email is used to sign in to Apple services.") + } + Section { + Text("\(account.countryCode) - \(ApplePackage.countryCodeMap[account.countryCode] ?? NSLocalizedString("Unknown", comment: ""))") + .onTapGesture { UIPasteboard.general.string = account.email } + } header: { + Text("Country Code") + } footer: { + Text("App Store requires this country code to identify your package region.") + } + Section { + if vm.demoMode { + Text("88888888888") + .redacted(reason: .placeholder) + } else { + Text(account.storeResponse.directoryServicesIdentifier) + .font(.system(.body, design: .monospaced)) + .onTapGesture { UIPasteboard.general.string = account.email } + } + Text(ApplePackage.overrideGUID ?? "Seed Not Available") + .font(.system(.body, design: .monospaced)) + .onTapGesture { UIPasteboard.general.string = account.email } + } header: { + Text("Services ID") + } footer: { + Text("ID combined with a random seed generated on this device can download package from App Store.") + } + Section { + SecureField(text: .constant(account.storeResponse.passwordToken)) { + Text("Password Token") + } + if rotating { + Button("Rotating...") {} + .disabled(true) + } else { + Button("Rotate Token") { rotate() } + } + } header: { + Text("Password Token") + } footer: { + if rotatingHint.isEmpty { + Text("If you failed to acquire license for product, rotate the password token may help. This will use the initial password to authenticate with App Store again.") + } else { + Text(rotatingHint) + .foregroundStyle(.red) + } + } + Section { + Button("Delete") { + vm.delete(id: account.id) + dismiss() + } + .foregroundStyle(.red) + } + } + .navigationTitle("Detail") + } + + func rotate() { + rotating = true + DispatchQueue.global().async { + do { + try vm.rotate(id: account.id) + DispatchQueue.main.async { + rotating = false + rotatingHint = NSLocalizedString("Success", comment: "") + } + } catch { + DispatchQueue.main.async { + rotating = false + rotatingHint = error.localizedDescription + } + } + } + } +} diff --git a/Asspp/Interface/Account/AccountView.swift b/Asspp/Interface/Account/AccountView.swift new file mode 100644 index 0000000..3207c42 --- /dev/null +++ b/Asspp/Interface/Account/AccountView.swift @@ -0,0 +1,63 @@ +// +// AccountView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import Combine +import SwiftUI + +struct AccountView: View { + @StateObject var vm = AppStore.this + @State var addAccount = false + + var body: some View { + NavigationView { + content + .background( + NavigationLink( + destination: AddAccountView(), + isActive: $addAccount, + label: { EmptyView() } + ) + ) + .navigationTitle("Account") + .toolbar { + ToolbarItem { + Button { + addAccount.toggle() + } label: { + Label("Add Account", systemImage: "plus") + } + } + } + } + .navigationViewStyle(.stack) + } + + var content: some View { + List { + Section { + ForEach(vm.accounts) { account in + NavigationLink(destination: AccountDetailView(account: account)) { + if vm.demoMode { + Text("88888888888") + .redacted(reason: .placeholder) + } else { + Text(account.email) + } + } + } + if vm.accounts.isEmpty { + Text("Sorry, nothing here.") + } + } header: { + Text("IDs") + } footer: { + Text("Your account is not encrypted on disk.") + } + } + } +} diff --git a/Asspp/Interface/Account/AddAccountView.swift b/Asspp/Interface/Account/AddAccountView.swift new file mode 100644 index 0000000..10647b3 --- /dev/null +++ b/Asspp/Interface/Account/AddAccountView.swift @@ -0,0 +1,96 @@ +// +// AddAccountView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import SwiftUI + +struct AddAccountView: View { + @StateObject var vm = AppStore.this + @Environment(\.dismiss) var dismiss + + @State var email: String = "" + @State var password: String = "" + + @State var codeRequired: Bool = false + @State var code: String = "" + + @State var error: Error? + @State var openProgress: Bool = false + + var body: some View { + List { + Section { + TextField("Email (Apple ID)", text: $email) + .disableAutocorrection(true) + .autocapitalization(.none) + SecureField("Password", text: $password) + } header: { + Text("ID") + } footer: { + Text("We will store your account and password on disk without encryption. Please do not connect your device to untrusted hardware or use this app on a open system like macOS.") + } + if codeRequired { + Section { + TextField("2FA Code (Optional)", text: $code) + .disableAutocorrection(true) + .autocapitalization(.none) + .keyboardType(.numberPad) + } header: { + Text("2FA Code") + } footer: { + Text("Although 2FA code is marked as optional, that is because we dont know if you have it or just incorrect password, you should provide it if you have it enabled.\n\nhttps://support.apple.com/102606") + } + .transition(.opacity) + } + Section { + if openProgress { + ForEach([UUID()], id: \.self) { _ in + ProgressView() + } + } else { + Button("Authenticate") { + authenticate() + } + .disabled(openProgress) + .disabled(email.isEmpty || password.isEmpty) + } + } footer: { + if let error { + Text(error.localizedDescription) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .foregroundStyle(.red) + .textSelection(.enabled) + .transition(.opacity) + } + } + } + .animation(.spring, value: codeRequired) + .listStyle(.insetGrouped) + .navigationTitle("Add Account") + } + + func authenticate() { + openProgress = true + DispatchQueue.global().async { + defer { DispatchQueue.main.async { openProgress = false } } + let auth = ApplePackage.Authenticator(email: email) + do { + let account = try auth.authenticate(password: password, code: code.isEmpty ? nil : code) + DispatchQueue.main.async { + vm.save(email: email, password: password, account: account) + dismiss() + } + } catch { + DispatchQueue.main.async { + self.error = error + codeRequired = true + } + } + } + } +} diff --git a/Asspp/Interface/Download/AddDownloadView.swift b/Asspp/Interface/Download/AddDownloadView.swift new file mode 100644 index 0000000..6ee8675 --- /dev/null +++ b/Asspp/Interface/Download/AddDownloadView.swift @@ -0,0 +1,137 @@ +// +// AddDownloadView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/13. +// + +import ApplePackage +import SwiftUI + +struct AddDownloadView: View { + @State var bundleID: String = "" + @State var searchType: EntityType = .iPhone + @State var selection: AppStore.Account.ID = .init() + @State var obtainDownloadURL = false + @State var hint = "" + + @FocusState var searchKeyFocused + + @StateObject var avm = AppStore.this + @StateObject var dvm = Downloads.this + + @Environment(\.dismiss) var dismiss + + var account: AppStore.Account? { + avm.accounts.first { $0.id == selection } + } + + var body: some View { + List { + Section { + TextField("Bundle ID", text: $bundleID) + .autocorrectionDisabled() + .textInputAutocapitalization(.none) + .focused($searchKeyFocused) + .onSubmit { startDownload() } + Picker("EntityType", selection: $searchType) { + ForEach(EntityType.allCases, id: \.self) { type in + Text(type.rawValue) + .tag(type) + } + } + .pickerStyle(.segmented) + } header: { + Text("Bundle ID") + } footer: { + Text("Tell us the bundle ID of the app to initial a direct download. Useful to download apps that are no longer available in App Store.") + } + + Section { + if avm.demoMode { + Text("Demo Mode Redacted") + .redacted(reason: .placeholder) + } else { + Picker("Account", selection: $selection) { + ForEach(avm.accounts) { account in + Text(account.email) + .id(account.id) + } + } + .pickerStyle(.menu) + .onAppear { selection = avm.accounts.first?.id ?? .init() } + } + } header: { + Text("Account") + } footer: { + Text("Select an account to download this app") + } + + Section { + Button(obtainDownloadURL ? "Communicating with Apple..." : "Request Download") { + startDownload() + } + .disabled(bundleID.isEmpty) + .disabled(obtainDownloadURL) + .disabled(account == nil) + } footer: { + if hint.isEmpty { + Text("Package can be installed later in download page.") + } else { + Text(hint) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Direct Download") + } + + func startDownload() { + guard let account else { return } + searchKeyFocused = false + obtainDownloadURL = true + DispatchQueue.global().async { + let httpClient = HTTPClient(urlSession: URLSession.shared) + let itunesClient = iTunesClient(httpClient: httpClient) + let storeClient = StoreClient(httpClient: httpClient) + + do { + let app = try itunesClient.lookup( + type: searchType, + bundleIdentifier: bundleID, + region: account.countryCode + ) + let item = try storeClient.item( + identifier: String(app.identifier), + directoryServicesIdentifier: account.storeResponse.directoryServicesIdentifier + ) + let id = Downloads.this.add(request: .init( + account: account, + package: app, + item: item + )) + Downloads.this.resume(requestID: id) + } catch { + DispatchQueue.main.async { + obtainDownloadURL = false + if (error as NSError).code == 9610 { + hint = NSLocalizedString("License Not Found, please acquire license first.", comment: "") + } else if (error as NSError).code == 2034 { + hint = NSLocalizedString("Password Token Expired, please re-authenticate within account page.", comment: "") + } else if (error as NSError).code == 2059 { + hint = NSLocalizedString("Temporarily Unavailable, please try again later.", comment: "") + } else { + hint = NSLocalizedString("Unable to retrieve download url, please try again later.", comment: "") + "\n" + error.localizedDescription + } + } + return + } + DispatchQueue.main.async { + hint = NSLocalizedString("Download Requested", comment: "") + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + dismiss() + } + } + } +} diff --git a/Asspp/Interface/Download/DownloadView.swift b/Asspp/Interface/Download/DownloadView.swift new file mode 100644 index 0000000..0154b64 --- /dev/null +++ b/Asspp/Interface/Download/DownloadView.swift @@ -0,0 +1,110 @@ +// +// DownloadView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import SwiftUI + +struct DownloadView: View { + @StateObject var vm = Downloads.this + + var body: some View { + NavigationView { + content + .navigationTitle("Download") + } + .navigationViewStyle(.stack) + } + + var content: some View { + List { + if vm.requests.isEmpty { + Section("Packages") { + Text("Sorry, nothing here.") + } + } else { + Section("Packages") { + packageList + } + } + } + .toolbar { + NavigationLink(destination: AddDownloadView()) { + Image(systemName: "plus") + } + } + } + + var packageList: some View { + ForEach(vm.requests) { req in + NavigationLink(destination: PackageView(request: req)) { + VStack(spacing: 8) { + ArchivePreviewView(archive: req.package) + SimpleProgress(progress: req.runtime.progress) + .animation(.interactiveSpring, value: req.runtime.progress) + HStack { + Text(req.hint) + Spacer() + Text(req.creation.formatted()) + } + .font(.system(.footnote, design: .rounded)) + .foregroundStyle(.secondary) + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if vm.isCompleted(for: req) { + } else { + switch req.runtime.status { + case .stopped: + Button { + vm.resume(requestID: req.id) + } label: { + Label("Resume", systemImage: "play.fill") + } + case .pending, .downloading: + Button { + vm.suspend(requestID: req.id) + } label: { + Label("Puase", systemImage: "stop.fill") + } + default: Group {} + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + vm.delete(request: req) + } label: { + Label("Cancel", systemImage: "trash") + } + } + } + } +} + +extension Downloads.Request { + var hint: String { + if let error = runtime.error { + return error + } + return switch runtime.status { + case .stopped: + NSLocalizedString("Suspended", comment: "") + case .pending: + NSLocalizedString("Pending...", comment: "") + case .downloading: + [ + String(Int(runtime.progress.fractionCompleted * 100)) + "%", + runtime.speed.isEmpty ? "" : runtime.speed + "/s", + ] + .compactMap { $0 } + .joined(separator: " ") + case .verifying: + NSLocalizedString("Verifying...", comment: "") + case .completed: + NSLocalizedString("Completed", comment: "") + } + } +} diff --git a/Asspp/Interface/Download/InstallerView.swift b/Asspp/Interface/Download/InstallerView.swift new file mode 100644 index 0000000..3465920 --- /dev/null +++ b/Asspp/Interface/Download/InstallerView.swift @@ -0,0 +1,89 @@ +// +// InstallerView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import SwiftUI + +struct InstallerView: View { + @StateObject var installer: Installer + + var icon: String { + switch installer.status { + case .ready: + "app.gift" + case .sendingManifest: + "paperplane.fill" + case .sendingPayload: + "paperplane.fill" + case let .completed(result): + switch result { + case .success: + "app.badge.checkmark" + case .failure: + "exclamationmark.triangle.fill" + } + case .broken: + "exclamationmark.triangle.fill" + } + } + + var text: String { + switch installer.status { + case .ready: NSLocalizedString("Ready To Install", comment: "") + case .sendingManifest: NSLocalizedString("Sending Manifest...", comment: "") + case .sendingPayload: NSLocalizedString("Sending Payload...", comment: "") + case let .completed(result): + switch result { + case .success: + NSLocalizedString("Install Completed", comment: "") + case let .failure(failure): + failure.localizedDescription + } + case let .broken(error): + error.localizedDescription + } + } + + var body: some View { + ZStack { + VStack(spacing: 32) { + ForEach([icon], id: \.self) { icon in + Image(systemName: icon) + .font(.system(.largeTitle, design: .rounded)) + .transition(.opacity.combined(with: .scale)) + } + ForEach([text], id: \.self) { text in + Text(text) + .font(.system(.body, design: .rounded)) + .transition(.opacity) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if case .ready = installer.status { + UIApplication.shared.open(installer.iTunesLink) + } + } + .onAppear { + if case .ready = installer.status { + UIApplication.shared.open(installer.iTunesLink) + } + } + VStack { + Text("To install app, you need to grant local area network permission in order to communicate with system services.") + } + .font(.system(.footnote, design: .rounded)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding(32) + } + .animation(.spring, value: text) + .animation(.spring, value: icon) + .onDisappear { + installer.destroy() + } + } +} diff --git a/Asspp/Interface/Download/PackageView.swift b/Asspp/Interface/Download/PackageView.swift new file mode 100644 index 0000000..6f4957b --- /dev/null +++ b/Asspp/Interface/Download/PackageView.swift @@ -0,0 +1,227 @@ +// +// PackageView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import Kingfisher +import SwiftUI + +struct PackageView: View { + let request: Downloads.Request + var archive: iTunesResponse.iTunesArchive { + request.package + } + + var url: URL { request.targetLocation } + + @Environment(\.dismiss) var dismiss + @State var installer: Installer? + @State var error: String = "" + + @StateObject var vm = AppStore.this + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 8) { + KFImage(URL(string: archive.artworkUrl512 ?? "")) + .antialiased(true) + .resizable() + .cornerRadius(8) + .frame(width: 50, height: 50, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) + Text(archive.name) + .bold() + } + .padding(.vertical, 4) + } header: { + Text("Package") + } footer: { + Text("\(archive.bundleIdentifier) - \(archive.version) - \(archive.byteCountDescription)") + } + + if Downloads.this.isCompleted(for: request) { + Section { + Button("Install") { + do { + installer = try Installer(archive: archive, path: url) + } catch { + self.error = error.localizedDescription + } + } + .sheet(item: $installer) { + installer?.destroy() + installer = nil + } content: { + InstallerView(installer: $0) + } + + Button("Install via AirDrop") { + let newUrl = temporaryDirectory + .appendingPathComponent("\(archive.bundleIdentifier)-\(archive.version)") + .appendingPathExtension("ipa") + try? FileManager.default.removeItem(at: newUrl) + try? FileManager.default.copyItem(at: url, to: newUrl) + share(items: [newUrl]) + } + } header: { + Text("Control") + } footer: { + if error.isEmpty { + Text("Direct install may have limitations that is not able to bypass. Use AirDrop method if possible on another device.") + } else { + Text(error) + .foregroundStyle(.red) + } + } + } else { + Section { + switch request.runtime.status { + case .stopped: + Button("Continue Download") { + Downloads.this.resume(requestID: request.id) + } + case .downloading, + .pending: + Text("Download In Progress...") + case .verifying: + Text("Verification In Progress...") + case .completed: + Group {} + } + } header: { + Text("Incomplete Package") + } footer: { + switch request.runtime.status { + case .stopped: + Text("Either connection is lost or the download is interrupted. Tap to continue.") + case .downloading, + .pending: + Text("\(Int(request.runtime.percent * 100))%...") + case .verifying: + Text("\(Int(request.runtime.percent * 100))%...") + case .completed: + Group {} + } + } + } + + Section { + if vm.demoMode { + Text("88888888888") + .redacted(reason: .placeholder) + } else { + Text(request.account.email) + } + Text("\(request.account.countryCode) - \(ApplePackage.countryCodeMap[request.account.countryCode] ?? "-1")") + } header: { + Text("Account") + } footer: { + Text("This account is used to download this package. If you choose to AirDrop, your target device must sign in or previously signed in to this account and have at least one app installed.") + } + + Section { + Button("Delete") { + Downloads.this.delete(request: request) + dismiss() + } + .foregroundStyle(.red) + } header: { + Text("Danger Zone") + } footer: { + Text(url.path) + } + } + .navigationTitle(request.package.name) + } + + @discardableResult + func share( + items: [Any], + excludedActivityTypes: [UIActivity.ActivityType]? = nil + ) -> Bool { + guard let source = UIWindow.mainWindow?.rootViewController?.topMostController else { + return false + } + let newView = UIView() + source.view.addSubview(newView) + newView.frame = .init(origin: .zero, size: .init(width: 10, height: 10)) + newView.center = .init( + x: source.view.bounds.width / 2 - 5, + y: source.view.bounds.height / 2 - 5 + ) + let vc = UIActivityViewController( + activityItems: items, + applicationActivities: nil + ) + vc.excludedActivityTypes = excludedActivityTypes + vc.popoverPresentationController?.sourceView = source.view + vc.popoverPresentationController?.sourceRect = newView.frame + source.present(vc, animated: true) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + newView.removeFromSuperview() + } + } + return true + } +} + +extension UIWindow { + static var mainWindow: UIWindow? { + if let keyWindow = UIApplication + .shared + .value(forKey: "keyWindow") as? UIWindow + { + return keyWindow + } + // if apple remove this shit, we fall back to ugly solution + let keyWindow = UIApplication + .shared + .connectedScenes + .filter { $0.activationState == .foregroundActive } + .compactMap { $0 as? UIWindowScene } + .first? + .windows + .filter(\.isKeyWindow) + .first + return keyWindow + } +} + +extension UIViewController { + var topMostController: UIViewController? { + var result: UIViewController? = self + while true { + if let next = result?.presentedViewController, + !next.isBeingDismissed, + next as? UISearchController == nil + { + result = next + continue + } + if let tabBar = result as? UITabBarController, + let next = tabBar.selectedViewController + { + result = next + continue + } + if let split = result as? UISplitViewController, + let next = split.viewControllers.last + { + result = next + continue + } + if let navigator = result as? UINavigationController, + let next = navigator.viewControllers.last + { + result = next + continue + } + break + } + return result + } +} diff --git a/Asspp/Interface/Download/SimpleProgress.swift b/Asspp/Interface/Download/SimpleProgress.swift new file mode 100644 index 0000000..fa71270 --- /dev/null +++ b/Asspp/Interface/Download/SimpleProgress.swift @@ -0,0 +1,26 @@ +// +// SimpleProgress.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/13. +// + +import SwiftUI + +struct SimpleProgress: View { + let progress: Progress + var body: some View { + Rectangle() + .foregroundStyle(.gray) + .overlay { + GeometryReader { r in + Rectangle() + .foregroundStyle(.accent) + .frame(width: CGFloat(progress.fractionCompleted) * r.size.width) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(height: 4) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } +} diff --git a/Asspp/Interface/Search/ArchivePreviewView.swift b/Asspp/Interface/Search/ArchivePreviewView.swift new file mode 100644 index 0000000..9189169 --- /dev/null +++ b/Asspp/Interface/Search/ArchivePreviewView.swift @@ -0,0 +1,35 @@ +// +// ArchivePreviewView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import Kingfisher +import SwiftUI + +struct ArchivePreviewView: View { + let archive: iTunesResponse.iTunesArchive + + var body: some View { + HStack(spacing: 8) { + KFImage(URL(string: archive.artworkUrl512 ?? "")) + .antialiased(true) + .resizable() + .cornerRadius(8) + .frame(width: 32, height: 32, alignment: .center) + VStack(alignment: .leading, spacing: 2) { + Text(archive.name) + .font(.system(.body, design: .rounded)) + .bold() + Group { + Text("\(archive.bundleIdentifier) \(archive.version) \(archive.byteCountDescription)") + } + .font(.system(.footnote, design: .rounded)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Asspp/Interface/Search/ProductView.swift b/Asspp/Interface/Search/ProductView.swift new file mode 100644 index 0000000..ad01a26 --- /dev/null +++ b/Asspp/Interface/Search/ProductView.swift @@ -0,0 +1,260 @@ +// +// ProductView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import Kingfisher +import SwiftUI + +struct ProductView: View { + let archive: iTunesResponse.iTunesArchive + let region: String + + @StateObject var vm = AppStore.this + @StateObject var dvm = Downloads.this + + var eligibleAccounts: [AppStore.Account] { + vm.accounts.filter { $0.countryCode == region } + } + + var account: AppStore.Account? { + vm.accounts.first { $0.id == selection } + } + + @State var selection: AppStore.Account.ID = .init() + @State var obtainDownloadURL = false + @State var hint: String = "" + @State var licenseHint: String = "" + @State var acquiringLicense = false + + var body: some View { + List { + packageHeader + if account == nil { + Section { + Text("No account available for this region.") + .foregroundStyle(.red) + } header: { + Text("Error") + } footer: { + Text("Please add account in account page.") + } + } + pricing + accountSelector + buttons + descriptionField + } + .onAppear { + selection = eligibleAccounts.first?.id ?? .init() + } + .navigationTitle("Select Account") + } + + var packageHeader: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + KFImage(URL(string: archive.artworkUrl512 ?? "")) + .antialiased(true) + .resizable() + .cornerRadius(8) + .frame(width: 50, height: 50, alignment: .center) + .frame(maxWidth: .infinity, alignment: .leading) + Text(archive.name) + .bold() + if let realaseNote = archive.releaseNotes { + Text(realaseNote) + .font(.system(.footnote, design: .rounded)) + } + } + .padding(.vertical, 4) + } header: { + Text("Package") + } footer: { + Label("\(archive.bundleIdentifier) - \(archive.version) - \(archive.byteCountDescription)", systemImage: archive.displaySupportedDevicesIcon) + } + } + + var pricing: some View { + Section { + Text("\(archive.formattedPrice ?? NSLocalizedString("Unknown", comment: ""))") + .font(.system(.body, design: .rounded)) + if let price = archive.price, price.isZero { + Button("Acquire License") { + acquireLicense() + } + .disabled(acquiringLicense) + .disabled(account == nil) + } + } header: { + Text("Pricing - \(archive.currency ?? "?")") + } footer: { + if licenseHint.isEmpty { + Text("Acquire license is not available for paid apps. If so, make purchase from the real App Store before download from here. If you already purchased this app, this operation will fail.") + } else { + Text(licenseHint) + .foregroundStyle(.red) + } + } + } + + var accountSelector: some View { + Section { + if vm.demoMode { + Text("Demo Mode Redacted") + .redacted(reason: .placeholder) + } else { + Picker("Account", selection: $selection) { + ForEach(eligibleAccounts) { account in + Text(account.email) + .id(account.id) + } + } + .pickerStyle(.menu) + } + } header: { + Text("Account") + } footer: { + Text("You have searched this package with region \(region)") + } + } + + var buttons: some View { + Section { + if let req = dvm.downloadRequest(forArchive: archive) { + NavigationLink(destination: PackageView(request: req)) { + Text("Show Download") + } + } else { + Button(obtainDownloadURL ? "Communicating with Apple..." : "Request Download") { + startDownload() + } + .disabled(obtainDownloadURL) + .disabled(account == nil) + } + } header: { + Text("Download") + } footer: { + if hint.isEmpty { + Text("Package can be installed later in download page.") + } else { + Text(hint) + .foregroundStyle(.red) + } + } + } + + var descriptionField: some View { + Section { + Text(archive.description ?? "No description provided") + .font(.system(.footnote, design: .rounded)) + } header: { + Text("Description") + } + } + + func startDownload() { + guard let account else { return } + obtainDownloadURL = true + DispatchQueue.global().async { + let httpClient = HTTPClient(urlSession: URLSession.shared) + let itunesClient = iTunesClient(httpClient: httpClient) + let storeClient = StoreClient(httpClient: httpClient) + + do { + let app = try itunesClient.lookup( + type: archive.entityType ?? .iPhone, + bundleIdentifier: archive.bundleIdentifier, + region: account.countryCode + ) + let item = try storeClient.item( + identifier: String(app.identifier), + directoryServicesIdentifier: account.storeResponse.directoryServicesIdentifier + ) + let id = Downloads.this.add(request: .init( + account: account, + package: archive, + item: item + )) + Downloads.this.resume(requestID: id) + } catch { + DispatchQueue.main.async { + obtainDownloadURL = false + if (error as NSError).code == 9610 { + hint = NSLocalizedString("License Not Found, please acquire license first.", comment: "") + } else if (error as NSError).code == 2034 { + hint = NSLocalizedString("Password Token Expired, please re-authenticate within account page.", comment: "") + } else if (error as NSError).code == 2059 { + hint = NSLocalizedString("Temporarily Unavailable, please try again later.", comment: "") + } else { + hint = NSLocalizedString("Unable to retrieve download url, please try again later.", comment: "") + "\n" + error.localizedDescription + } + } + return + } + DispatchQueue.main.async { + obtainDownloadURL = false + hint = NSLocalizedString("Download Requested", comment: "") + } + } + } + + func acquireLicense() { + guard let account else { return } + acquiringLicense = true + DispatchQueue.global().async { + do { + guard let account = try AppStore.this.rotate(id: account.id) else { + throw NSError(domain: "AppStore", code: 401, userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString( + "Failed to rotate password token, please re-authenticate within account page.", + comment: "" + ), + ]) + } + try ApplePackage.purchase( + token: account.storeResponse.passwordToken, + directoryServicesIdentifier: account.storeResponse.directoryServicesIdentifier, + trackID: archive.identifier, + countryCode: account.countryCode + ) + DispatchQueue.main.async { + acquiringLicense = false + licenseHint = NSLocalizedString("Request Successes", comment: "") + } + } catch { + DispatchQueue.main.async { + acquiringLicense = false + licenseHint = error.localizedDescription + } + } + } + } +} + +extension iTunesResponse.iTunesArchive { + var displaySupportedDevicesIcon: String { + var supports_iPhone = false + var supports_iPad = false + for device in supportedDevices ?? [] { + if device.lowercased().contains("iphone") { + supports_iPhone = true + } + if device.lowercased().contains("ipad") { + supports_iPad = true + } + } + if supports_iPhone, supports_iPad { + return "ipad.and.iphone" + } else if supports_iPhone { + return "iphone" + } else if supports_iPad { + return "ipad" + } else { + return "questionmark" + } + } +} diff --git a/Asspp/Interface/Search/SearchView.swift b/Asspp/Interface/Search/SearchView.swift new file mode 100644 index 0000000..2fea95e --- /dev/null +++ b/Asspp/Interface/Search/SearchView.swift @@ -0,0 +1,133 @@ +// +// SearchView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ApplePackage +import Kingfisher +import SwiftUI + +struct SearchView: View { + @AppStorage("searchKey") var searchKey = "" + @AppStorage("searchRegion") var searchRegion = "US" + @FocusState var searchKeyFocused + @State var searchType = EntityType.iPhone + + @State var searching = false + let regionKeys = Array(ApplePackage.countryCodeMap.keys.sorted()) + + @State var searchInput: String = "" + @State var searchResult: [iTunesResponse.iTunesArchive] = [] + + @StateObject var vm = AppStore.this + + var possibleReigon: Set { + Set(vm.accounts.map(\.countryCode)) + } + + var body: some View { + NavigationView { + content + .navigationTitle("Search") + } + .navigationViewStyle(.stack) + } + + var content: some View { + List { + Section { + Picker("Type", selection: $searchType) { + ForEach(EntityType.allCases, id: \.self) { type in + Text(type.rawValue) + .tag(type) + } + } + .pickerStyle(.menu) + + buildRegionView() + + TextField("Keyword", text: $searchKey) + .focused($searchKeyFocused) + .onSubmit { search() } + } header: { + Text("Metadata") + } + Section { + Button(searching ? "Searching..." : "Search") { search() } + .disabled(searchKey.isEmpty) + .disabled(searching) + } + Section { + ForEach(searchResult) { item in + NavigationLink(destination: ProductView(archive: item, region: searchRegion)) { + ArchivePreviewView(archive: item) + } + .transition(.opacity) + } + } header: { + Text(searchInput) + } + } + .animation(.spring, value: searchResult) + } + + func buildRegionView() -> some View { + HStack { + Text("Region") + Spacer() + Menu { + Section("Account") { + buildPickView(for: regionKeys.filter { possibleReigon.contains($0) }) + } + Menu("All Region") { + buildPickView(for: regionKeys) + } + } label: { + HStack { + Text("\(searchRegion) - \(ApplePackage.countryCodeMap[searchRegion] ?? NSLocalizedString("Unknown", comment: ""))") + Image(systemName: "arrow.up.arrow.down") + } + } + } + } + + func buildPickView(for keys: [String]) -> some View { + ForEach(keys, id: \.self) { key in + Button("\(key) - \(ApplePackage.countryCodeMap[key] ?? NSLocalizedString("Unknown", comment: ""))") { + searchRegion = key + } + } + } + + func search() { + searchKeyFocused = false + searching = true + searchInput = "\(searchRegion) - \(searchKey)" + " ..." + DispatchQueue.global().async { + var result = (try? ApplePackage.search( + type: searchType, + term: searchKey, + limit: 32, + region: searchRegion + )) ?? [] + + let httpClient = HTTPClient(urlSession: URLSession.shared) + let itunesClient = iTunesClient(httpClient: httpClient) + if let app = try? itunesClient.lookup( + type: searchType, + bundleIdentifier: searchKey, + region: searchRegion + ) { + result.insert(app, at: 0) + } + + DispatchQueue.main.async { + searching = false + searchResult = result + searchInput = "\(searchRegion) - \(searchKey)" + } + } + } +} diff --git a/Asspp/Interface/Setting/SettingView.swift b/Asspp/Interface/Setting/SettingView.swift new file mode 100644 index 0000000..fe6c03b --- /dev/null +++ b/Asspp/Interface/Setting/SettingView.swift @@ -0,0 +1,94 @@ +// +// SettingView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import FLEX +import SwiftUI + +struct SettingView: View { + @StateObject var vm = AppStore.this + + var body: some View { + NavigationView { + List { + Section { + Toggle("Demo Mode", isOn: $vm.demoMode) + } header: { + Text("Demo Mode") + } footer: { + Text("By enabling this, all your account will be redacted.") + } + Section { + Text(vm.deviceSeedAddress) + .font(.system(.body, design: .monospaced)) + } header: { + Text("Device Seed") + } footer: { + Text("This address is used to be a MAC address from your hardware to identify your device with Apple. Here we use a random one.") + } + Section { + Button("Delete All Download", role: .destructive) { + Downloads.this.removeAll() + } + } header: { + Text("Downloads") + } footer: { + Text("Operating download manager.") + } + Section { + Text(ProcessInfo.processInfo.hostName) + Button("Open Setting") { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + } header: { + Text("Host Name") + } footer: { + Text("To install app, you need to grant local area network permission in order to communicate with system services. If your host name is empty, go to Settings.app to grant permission.") + } + Section { + Button("Show FLEX") { + FLEXManager.shared.showExplorer() + } + } header: { + Text("Debug") + } footer: { + Text("FLEX is a set of in-app debugging and exploration tools for iOS development.") + } + Section { + Button("@Lakr233") { + UIApplication.shared.open(URL(string: "https://twitter.com/Lakr233")!) + } + Button("Buy me a coffee! ☕️") { + UIApplication.shared.open(URL(string: "https://github.com/sponsors/Lakr233/")!) + } + Button("Feedback & Contact") { + UIApplication.shared.open(URL(string: "https://github.com/Lakr233/Asspp")!) + } + } header: { + Text("About") + } footer: { + Text("Hope my app helps you out.") + } + Section { + Button("Reset", role: .destructive) { + try? FileManager.default.removeItem(at: documentsDirectory) + try? FileManager.default.removeItem(at: temporaryDirectory) + UIApplication.shared.perform(#selector(NSXPCConnection.suspend)) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + exit(0) + } + } + } header: { + Text("Danger Zone") + } footer: { + Text("This will reset all your settings.") + } + } + .navigationTitle("Setting") + } + .navigationViewStyle(.stack) + } +} diff --git a/Asspp/Interface/Welcome/MainView.swift b/Asspp/Interface/Welcome/MainView.swift new file mode 100644 index 0000000..3c53bf9 --- /dev/null +++ b/Asspp/Interface/Welcome/MainView.swift @@ -0,0 +1,30 @@ +// +// MainView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import SwiftUI + +struct MainView: View { + @StateObject var dvm = Downloads.this + + var body: some View { + TabView { + WelcomeView() + .tabItem { Label("Home", systemImage: "house") } + AccountView() + .tabItem { Label("Account", systemImage: "person") } + SearchView() + .tabItem { Label("Search", systemImage: "magnifyingglass") } + DownloadView() + .tabItem { + Label("Download", systemImage: "arrow.down.circle") + .badge(dvm.runningTaskCount) + } + SettingView() + .tabItem { Label("Setting", systemImage: "gear") } + } + } +} diff --git a/Asspp/Interface/Welcome/WelcomeView.swift b/Asspp/Interface/Welcome/WelcomeView.swift new file mode 100644 index 0000000..760a346 --- /dev/null +++ b/Asspp/Interface/Welcome/WelcomeView.swift @@ -0,0 +1,71 @@ +// +// WelcomeView.swift +// Asspp +// +// Created by 秋星桥 on 2024/7/11. +// + +import ColorfulX +import SwiftUI + +struct WelcomeView: View { + var body: some View { + ZStack { + VStack(spacing: 32) { + Image(.avatar) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + Text("Welcome to Asspp") + .font(.system(.headline, design: .rounded)) + inst + .font(.system(.footnote, design: .rounded)) + .padding(.horizontal, 32) + Spacer().frame(height: 0) + } + + VStack(spacing: 16) { + Spacer() + Text(appVersion) + Text("App Store itself is unstable, retry if needed.") + } + .font(.footnote) + .foregroundStyle(.secondary) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + ColorfulView(color: .constant(ColorfulPreset.winter.colors)) + .opacity(0.25) + .ignoresSafeArea() + ) + } + + var inst: some View { + VStack(spacing: 16) { + HStack { + Image(systemName: "1.circle.fill") + Text("Sign in to your account.") + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Image(systemName: "2.circle.fill") + Text("Search for apps you want to install.") + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Image(systemName: "3.circle.fill") + Text("Download and save the ipa file.") + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Image(systemName: "4.circle.fill") + Text("Install or AirDrop to install.") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Foundation/ApplePackage/.gitignore b/Foundation/ApplePackage/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Foundation/ApplePackage/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Foundation/ApplePackage/Package.resolved b/Foundation/ApplePackage/Package.resolved new file mode 100644 index 0000000..625aa9c --- /dev/null +++ b/Foundation/ApplePackage/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "a3f5c2bae0f04b0bce9ef3c4ba6bd1031a0564c4", + "version" : "0.9.17" + } + } + ], + "version" : 2 +} diff --git a/Foundation/ApplePackage/Package.swift b/Foundation/ApplePackage/Package.swift new file mode 100644 index 0000000..72f5ab4 --- /dev/null +++ b/Foundation/ApplePackage/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ApplePackage", + products: [ + .library(name: "ApplePackage", targets: ["ApplePackage"]), + ], + dependencies: [ + .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0")), + ], + targets: [ + .target(name: "ApplePackage", dependencies: ["ZIPFoundation"]), + .testTarget(name: "ApplePackageTests", dependencies: ["ApplePackage"]), + ] +) diff --git a/Foundation/ApplePackage/Sources/ApplePackage/ApplePackage.swift b/Foundation/ApplePackage/Sources/ApplePackage/ApplePackage.swift new file mode 100644 index 0000000..1c8112f --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/ApplePackage.swift @@ -0,0 +1,15 @@ +// +// ApplePackage.swift +// IPATool +// +// Created by QAQ on 2023/10/4. +// + +import Foundation + +public enum ApplePackage { + public static var overrideGUID: String? + public static var countryCodeMap: [String: String] { + storeFrontCodeMap // TWO LETTER = NUMBER + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Commands/Authenticate.swift b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Authenticate.swift new file mode 100644 index 0000000..f460c2c --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Authenticate.swift @@ -0,0 +1,31 @@ +// +// Authenticate.swift +// +// +// Created by QAQ on 2023/10/4. +// + +import Foundation + +public extension ApplePackage { + class Authenticator { + public let email: String + public var authenticated: Bool { authenticatedAccount != nil } + + private let storeClient: StoreClient + private var authenticatedAccount: StoreResponse.Account? + + public init(email: String) { + self.email = email + let httpClient = HTTPClient(urlSession: URLSession.shared) + storeClient = StoreClient(httpClient: httpClient) + } + + public func authenticate(password: String, code: String?) throws -> StoreResponse.Account { + if let account = authenticatedAccount { return account } + let account = try storeClient.authenticate(email: email, password: password, code: code) + authenticatedAccount = account + return account + } + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Commands/Download.swift b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Download.swift new file mode 100644 index 0000000..d03f07e --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Download.swift @@ -0,0 +1,66 @@ +// +// Download.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +public extension ApplePackage { + class Downloader { + public let email: String + public let region: String + public let directoryServicesIdentifier: String + + private let httpClient: HTTPClient + private let itunesClient: iTunesClient + private let storeClient: StoreClient + private let downloadClient: HTTPDownloadClient + + public typealias ProgressBlock = (Float) -> Void + public var onProgress: ProgressBlock? + + public init(email: String, directoryServicesIdentifier: String, region: String) { + self.email = email + self.directoryServicesIdentifier = directoryServicesIdentifier + self.region = region + httpClient = HTTPClient(urlSession: URLSession.shared) + itunesClient = iTunesClient(httpClient: httpClient) + storeClient = StoreClient(httpClient: httpClient) + downloadClient = HTTPDownloadClient() + } + + public func download(type: EntityType, bundleIdentifier: String, saveToDirectory: URL, withFileName fileName: String?) throws -> URL { + let app = try itunesClient.lookup(type: type, bundleIdentifier: bundleIdentifier, region: region) + let item = try storeClient.item(identifier: String(app.identifier), directoryServicesIdentifier: directoryServicesIdentifier) + + if !FileManager.default.fileExists(atPath: saveToDirectory.path) { + try FileManager.default.createDirectory(at: saveToDirectory, withIntermediateDirectories: true) + } + var isDir = ObjCBool(false) + guard FileManager.default.fileExists(atPath: saveToDirectory.path, isDirectory: &isDir), + isDir.boolValue + else { + throw NSError(domain: "ApplePackageDownloader", code: 402, userInfo: ["description": "File permission denied"]) + } + + let name = fileName ?? "\(bundleIdentifier)_\(app.identifier)_v\(app.version).ipa" + let path = saveToDirectory.appendingPathComponent(name) + if FileManager.default.fileExists(atPath: path.path) { + try FileManager.default.removeItem(at: path) + } + + let signatureClient = SignatureClient(fileManager: .default, filePath: path.path) + + try downloadClient.download(from: item.url, to: path) { progress in + self.onProgress?(progress) + } + + try signatureClient.appendMetadata(item: item, email: email) + try signatureClient.appendSignature(item: item) + + return path + } + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Commands/Purchase.swift b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Purchase.swift new file mode 100644 index 0000000..be4f99e --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Purchase.swift @@ -0,0 +1,16 @@ +// +// Purchase.swift +// +// +// Created by 秋星桥 on 2024/7/12. +// + +import Foundation + +public extension ApplePackage { + static func purchase(token: String, directoryServicesIdentifier: String, trackID: Int, countryCode: String) throws { + let httpClient = HTTPClient(urlSession: URLSession.shared) + let storeClient = StoreClient(httpClient: httpClient) + try storeClient.buy(token: token, directoryServicesIdentifier: directoryServicesIdentifier, trackID: trackID, countryCode: countryCode) + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Commands/Search.swift b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Search.swift new file mode 100644 index 0000000..aad2ace --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Commands/Search.swift @@ -0,0 +1,16 @@ +// +// Search.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +public extension ApplePackage { + static func search(type: EntityType, term: String, limit: Int = 5, region: String) throws -> [iTunesResponse.iTunesArchive] { + let httpClient = HTTPClient(urlSession: URLSession.shared) + let itunesClient = iTunesClient(httpClient: httpClient) + return try itunesClient.search(type: type, term: term, limit: limit, region: region) + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPClient.swift b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPClient.swift new file mode 100644 index 0000000..1c2b8d3 --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPClient.swift @@ -0,0 +1,99 @@ +// +// HTTPClient.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +protocol HTTPClientInterface { + func send(_ request: HTTPRequest, completion: @escaping (Result) -> Void) +} + +extension HTTPClientInterface { + func send(_ request: HTTPRequest) throws -> HTTPResponse { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + send(request) { + result = $0 + semaphore.signal() + } + + _ = semaphore.wait(timeout: .distantFuture) + + switch result { + case .none: + throw HTTPClient.Error.timeout + case let .failure(error): + throw error + case let .success(response): + return response + } + } +} + +public final class HTTPClient: HTTPClientInterface { + private let urlSession: URLSession + + public init(urlSession: URLSession) { + self.urlSession = urlSession + } + + func send(_ request: HTTPRequest, completion: @escaping (Result) -> Void) { + do { + let urlRequest = try makeURLRequest(from: request) + + urlSession.dataTask(with: urlRequest) { data, response, error in + if let error { + return completion(.failure(error)) + } + + guard let response = response as? HTTPURLResponse else { + return completion(.failure(Error.invalidResponse(response))) + } + + completion(.success(.init(statusCode: response.statusCode, data: data, allHeaderFields: response.allHeaderFields))) + }.resume() + } catch { + completion(.failure(error)) + } + } + + private func makeURLRequest(from request: HTTPRequest) throws -> URLRequest { + var urlRequest = URLRequest(url: request.endpoint.url) + urlRequest.httpMethod = request.method.rawValue + + switch request.payload { + case .none: + urlRequest.httpBody = nil + case let .urlEncoding(propertyList): + urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var urlComponents = URLComponents(string: request.endpoint.url.absoluteString) + urlComponents?.queryItems = !propertyList.isEmpty ? propertyList.map { URLQueryItem(name: $0.0, value: $0.1.description) } : nil + + switch request.method { + case .get: + urlRequest.url = urlComponents?.url + case .post: + urlRequest.httpBody = urlComponents?.percentEncodedQuery?.data(using: .utf8, allowLossyConversion: false) + } + case let .xml(value): + urlRequest.setValue("application/xml", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try PropertyListSerialization.data(fromPropertyList: value, format: .xml, options: 0) + } + + request.headers.forEach { urlRequest.setValue($0.value, forHTTPHeaderField: $0.key) } + + return urlRequest + } +} + +extension HTTPClient { + enum Error: Swift.Error { + case invalidResponse(URLResponse?) + case timeout + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPDownloadClient.swift b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPDownloadClient.swift new file mode 100644 index 0000000..548bd76 --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPDownloadClient.swift @@ -0,0 +1,89 @@ +// +// HTTPDownloadClient.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +protocol HTTPDownloadClientInterface { + func download(from source: URL, to target: URL, progress: @escaping (Float) -> Void, completion: @escaping (Result) -> Void) +} + +extension HTTPDownloadClientInterface { + func download(from source: URL, to target: URL, progress: @escaping (Float) -> Void) throws { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + download(from: source, to: target, progress: progress) { + result = $0 + semaphore.signal() + } + + _ = semaphore.wait(timeout: .distantFuture) + + switch result { + case .none: + throw HTTPClient.Error.timeout + case let .failure(error): + throw error + default: + break + } + } +} + +final class HTTPDownloadClient: NSObject, HTTPDownloadClientInterface { + private var urlSession: URLSession! + private var progressHandler: ((Float) -> Void)? + private var completionHandler: ((Result) -> Void)? + private var targetURL: URL? + + override init() { + super.init() + urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + } + + func download(from source: URL, to target: URL, progress: @escaping (Float) -> Void, completion: @escaping (Result) -> Void) { + assert(progressHandler == nil) + assert(completionHandler == nil) + assert(targetURL == nil) + + progressHandler = progress + completionHandler = completion + targetURL = target + urlSession.downloadTask(with: source).resume() + } +} + +extension HTTPDownloadClient: URLSessionDownloadDelegate { + func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + progressHandler?(Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)) + } + + func urlSession(_: URLSession, downloadTask _: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + defer { + progressHandler = nil + completionHandler = nil + targetURL = nil + } + + guard let target = targetURL else { + return completionHandler?(.failure(Error.invalidTarget)) ?? () + } + + do { + try FileManager.default.moveItem(at: location, to: target) + completionHandler?(.success(())) + } catch { + completionHandler?(.failure(error)) + } + } +} + +extension HTTPDownloadClient { + enum Error: Swift.Error { + case invalidTarget + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPEndpoint.swift b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPEndpoint.swift new file mode 100644 index 0000000..57524e8 --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPEndpoint.swift @@ -0,0 +1,12 @@ +// +// HTTPEndpoint.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +protocol HTTPEndpoint { + var url: URL { get } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPMethod.swift b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPMethod.swift new file mode 100644 index 0000000..e356daf --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPMethod.swift @@ -0,0 +1,13 @@ +// +// HTTPMethod.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPPayload.swift b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPPayload.swift new file mode 100644 index 0000000..1885b9c --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPPayload.swift @@ -0,0 +1,13 @@ +// +// HTTPPayload.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +enum HTTPPayload { + case xml([String: String]) + case urlEncoding([String: String]) +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPRequest.swift b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPRequest.swift new file mode 100644 index 0000000..0d30ebb --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPRequest.swift @@ -0,0 +1,20 @@ +// +// HTTPRequest.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +protocol HTTPRequest { + var method: HTTPMethod { get } + var endpoint: HTTPEndpoint { get } + var headers: [String: String] { get } + var payload: HTTPPayload? { get } +} + +extension HTTPRequest { + var headers: [String: String] { [:] } + var payload: HTTPPayload? { nil } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPResponse.swift b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPResponse.swift new file mode 100644 index 0000000..d7715f5 --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Networking/HTTPResponse.swift @@ -0,0 +1,44 @@ +// +// HTTPResponse.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +struct HTTPResponse { + let statusCode: Int + let data: Data? + let allHeaderFields: [AnyHashable: Any] +} + +extension HTTPResponse { + func decode(_ type: T.Type, as decoder: Decoder) throws -> T { + guard let data else { + throw Error.noData + } + + switch decoder { + case .json: + let decoder = JSONDecoder() + decoder.userInfo = [.init(rawValue: "data")!: data] + return try decoder.decode(type, from: data) + case .xml: + let decoder = PropertyListDecoder() + decoder.userInfo = [.init(rawValue: "data")!: data] + return try decoder.decode(type, from: data) + } + } +} + +extension HTTPResponse { + enum Decoder { + case json + case xml + } + + enum Error: Swift.Error { + case noData + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Signature/SignatureClient.swift b/Foundation/ApplePackage/Sources/ApplePackage/Signature/SignatureClient.swift new file mode 100644 index 0000000..38bbca1 --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Signature/SignatureClient.swift @@ -0,0 +1,106 @@ +// +// SignatureClient.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation +import ZIPFoundation + +protocol SignatureClientInterface { + func appendMetadata(item: StoreResponse.Item, email: String) throws + func appendSignature(item: StoreResponse.Item) throws +} + +public final class SignatureClient: SignatureClientInterface { + private let fileManager: FileManager + private let filePath: String + + public init(fileManager: FileManager, filePath: String) { + self.fileManager = fileManager + self.filePath = filePath + } + + public func appendMetadata(item: StoreResponse.Item, email: String) throws { + let archive = try Archive(url: URL(fileURLWithPath: filePath), accessMode: .update) + + var metadata = item.metadata + metadata["apple-id"] = email + metadata["userName"] = email + + let metadataUrl = URL(fileURLWithPath: NSTemporaryDirectory().appending("\(UUID().uuidString)/iTunesMetadata.plist")) + + try fileManager.createDirectory(at: metadataUrl.deletingLastPathComponent(), withIntermediateDirectories: true) + try PropertyListSerialization.data(fromPropertyList: metadata, format: .xml, options: .zero).write(to: metadataUrl) + try archive.addEntry(with: metadataUrl.lastPathComponent, relativeTo: metadataUrl.deletingLastPathComponent()) + try? fileManager.removeItem(at: metadataUrl) + try? fileManager.removeItem(at: metadataUrl.deletingLastPathComponent()) + } + + public func appendSignature(item: StoreResponse.Item) throws { + let archive = try Archive(url: URL(fileURLWithPath: filePath), accessMode: .update) + + let manifest = try readPlist(archive: archive, matchingSuffix: ".app/SC_Info/Manifest.plist", type: Manifest.self) + + guard let infoEntry = archive.first(where: { $0.path.hasSuffix(".app/Info.plist") }) else { + throw Error.invalidAppBundle + } + + let appBundleName = URL(fileURLWithPath: infoEntry.path) + .deletingLastPathComponent() + .deletingPathExtension() + .lastPathComponent + + guard let signatureItem = item.signatures.first(where: { $0.id == 0 }), let signatureTargetPath = manifest.paths.first else { + throw Error.invalidSignature + } + + let signatureBaseUrl = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let signatureUrl = signatureBaseUrl + .appendingPathComponent("Payload") + .appendingPathComponent(appBundleName) + .appendingPathExtension("app") + .appendingPathComponent(signatureTargetPath) + + let signatureRelativePath = signatureUrl.path.replacingOccurrences(of: "\(signatureBaseUrl.path)/", with: "") + + try fileManager.createDirectory(at: signatureUrl.deletingLastPathComponent(), withIntermediateDirectories: true) + try signatureItem.sinf.write(to: signatureUrl) + try archive.addEntry(with: signatureRelativePath, relativeTo: signatureBaseUrl) + try? fileManager.removeItem(at: signatureBaseUrl) + } + + private func readPlist(archive: Archive, matchingSuffix: String, type: T.Type) throws -> T { + guard let entry = archive.first(where: { $0.path.hasSuffix(matchingSuffix) }) else { + throw Error.fileNotFound(matchingSuffix) + } + + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString).appendingPathExtension("plist") + _ = try archive.extract(entry, to: url) + + let data = try Data(contentsOf: url) + let plist = try PropertyListDecoder().decode(type, from: data) + + try? FileManager.default.removeItem(at: url) + + return plist + } +} + +extension SignatureClient { + struct Manifest: Codable { + let paths: [String] + + enum CodingKeys: String, CodingKey { + case paths = "SinfPaths" + } + } + + enum Error: Swift.Error { + case invalidArchive + case invalidAppBundle + case invalidSignature + case fileNotFound(String) + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreClient.swift b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreClient.swift new file mode 100644 index 0000000..68727ac --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreClient.swift @@ -0,0 +1,366 @@ +// +// StoreClient.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +let storeFrontCodeMap = [ + "AE": "143481", + "AG": "143540", + "AI": "143538", + "AL": "143575", + "AM": "143524", + "AO": "143564", + "AR": "143505", + "AT": "143445", + "AU": "143460", + "AZ": "143568", + "BB": "143541", + "BD": "143490", + "BE": "143446", + "BG": "143526", + "BH": "143559", + "BM": "143542", + "BN": "143560", + "BO": "143556", + "BR": "143503", + "BS": "143539", + "BW": "143525", + "BY": "143565", + "BZ": "143555", + "CA": "143455", + "CH": "143459", + "CI": "143527", + "CL": "143483", + "CN": "143465", + "CO": "143501", + "CR": "143495", + "CY": "143557", + "CZ": "143489", + "DE": "143443", + "DK": "143458", + "DM": "143545", + "DO": "143508", + "DZ": "143563", + "EC": "143509", + "EE": "143518", + "EG": "143516", + "ES": "143454", + "FI": "143447", + "FR": "143442", + "GB": "143444", + "GD": "143546", + "GE": "143615", + "GH": "143573", + "GR": "143448", + "GT": "143504", + "GY": "143553", + "HK": "143463", + "HN": "143510", + "HR": "143494", + "HU": "143482", + "ID": "143476", + "IE": "143449", + "IL": "143491", + "IN": "143467", + "IS": "143558", + "IT": "143450", + "JM": "143511", + "JO": "143528", + "JP": "143462", + "KE": "143529", + "KN": "143548", + "KR": "143466", + "KW": "143493", + "KY": "143544", + "KZ": "143517", + "LB": "143497", + "LC": "143549", + "LI": "143522", + "LK": "143486", + "LT": "143520", + "LU": "143451", + "LV": "143519", + "MD": "143523", + "MG": "143531", + "MK": "143530", + "ML": "143532", + "MN": "143592", + "MO": "143515", + "MS": "143547", + "MT": "143521", + "MU": "143533", + "MV": "143488", + "MX": "143468", + "MY": "143473", + "NE": "143534", + "NG": "143561", + "NI": "143512", + "NL": "143452", + "NO": "143457", + "NP": "143484", + "NZ": "143461", + "OM": "143562", + "PA": "143485", + "PE": "143507", + "PH": "143474", + "PK": "143477", + "PL": "143478", + "PT": "143453", + "PY": "143513", + "QA": "143498", + "RO": "143487", + "RS": "143500", + "RU": "143469", + "SA": "143479", + "SE": "143456", + "SG": "143464", + "SI": "143499", + "SK": "143496", + "SN": "143535", + "SR": "143554", + "SV": "143506", + "TC": "143552", + "TH": "143475", + "TN": "143536", + "TR": "143480", + "TT": "143551", + "TW": "143470", + "TZ": "143572", + "UA": "143492", + "UG": "143537", + "US": "143441", + "UY": "143514", + "UZ": "143566", + "VC": "143550", + "VE": "143502", + "VG": "143543", + "VN": "143471", + "YE": "143571", + "ZA": "143472", +] + +public protocol StoreClientInterface { + func authenticate(email: String, password: String, code: String?, completion: @escaping (Result) -> Void) + func item(identifier: String, directoryServicesIdentifier: String, completion: @escaping (Result) -> Void) + func buy(token: String, directoryServicesIdentifier: String, trackID: Int, countryCode: String, completion: @escaping (Result) -> Void) +} + +public extension StoreClientInterface { + func authenticate(email: String, + password: String, + code: String? = nil, + completion: @escaping (Result) -> Void) + { + authenticate(email: email, password: password, code: code, completion: completion) + } + + func authenticate(email: String, password: String, code: String? = nil) throws -> StoreResponse.Account { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + authenticate(email: email, password: password, code: code) { + result = $0 + semaphore.signal() + } + + _ = semaphore.wait(timeout: .distantFuture) + + switch result { + case .none: + throw StoreClient.Error.timeout + case let .failure(error): + throw error + case let .success(result): + return result + } + } + + func item(identifier: String, directoryServicesIdentifier: String) throws -> StoreResponse.Item { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + item(identifier: identifier, directoryServicesIdentifier: directoryServicesIdentifier) { + result = $0 + semaphore.signal() + } + + _ = semaphore.wait(timeout: .distantFuture) + + switch result { + case .none: + throw StoreClient.Error.timeout + case let .failure(error): + throw error + case let .success(result): + return result + } + } + + func buy(token: String, directoryServicesIdentifier: String, trackID: Int, countryCode: String) throws { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + buy(token: token, directoryServicesIdentifier: directoryServicesIdentifier, trackID: trackID, countryCode: countryCode) { + result = $0 + semaphore.signal() + } + + _ = semaphore.wait(timeout: .distantFuture) + + switch result { + case .none: + throw StoreClient.Error.timeout + case let .failure(error): + throw error + case let .success(result): + return result + } + } +} + +public final class StoreClient: StoreClientInterface { + private let httpClient: HTTPClient + + public init(httpClient: HTTPClient) { + self.httpClient = httpClient + } + + public func authenticate(email: String, password: String, code: String?, completion: @escaping (Result) -> Void) { + authenticate(email: email, + password: password, + code: code, + isFirstAttempt: true, + completion: completion) + } + + private func authenticate(email: String, + password: String, + code: String?, + isFirstAttempt: Bool, + completion: @escaping (Result) -> Void) + { + let request = StoreRequest.authenticate(email: email, password: password, code: code) + + httpClient.send(request) { [weak self] result in + switch result { + case let .success(response): + do { + let decoded = try response.decode(StoreResponse.self, as: .xml) + var countryCode = "" + if let storeFront = response.allHeaderFields["x-set-apple-store-front"] as? String, + let storeFrontCode = storeFront.components(separatedBy: "-").first + { + for (key, value) in storeFrontCodeMap where value == storeFrontCode { + countryCode = key + break + } + } + switch decoded { + case let .account(account): + var account = account + account.countryCode = countryCode + completion(.success(account)) + case .item: + completion(.failure(Error.invalidResponse)) + case let .failure(error): + switch error { + case StoreResponse.Error.invalidCredentials: + if isFirstAttempt { + return self?.authenticate(email: email, + password: password, + code: code, + isFirstAttempt: false, + completion: completion) ?? () + } + + completion(.failure(error)) + default: + completion(.failure(error)) + } + } + } catch { + completion(.failure(error)) + } + case let .failure(error): + completion(.failure(error)) + } + } + } + + public func item(identifier: String, directoryServicesIdentifier: String, completion: @escaping (Result) -> Void) { + let request = StoreRequest.download(appIdentifier: identifier, directoryServicesIdentifier: directoryServicesIdentifier) + + httpClient.send(request) { result in + switch result { + case let .success(response): + do { + let decoded = try response.decode(StoreResponse.self, as: .xml) + + switch decoded { + case let .item(item): + completion(.success(item)) + case .account: + completion(.failure(Error.invalidResponse)) + case let .failure(error): + completion(.failure(error)) + } + } catch { + completion(.failure(error)) + } + case let .failure(error): + completion(.failure(error)) + } + } + } + + public func buy(token: String, directoryServicesIdentifier: String, trackID: Int, countryCode: String, completion: @escaping (Result) -> Void) { + // STDQ for generic request and GAME for arcade, but we support STDQ for now + let request = StoreRequest.buy(token: token, directoryServicesIdentifier: directoryServicesIdentifier, trackID: trackID, countryCode: countryCode, pricingParameters: "STDQ") + + httpClient.send(request) { result in + switch result { + case let .success(response): + do { + let code = response.statusCode + if code == 200, + let data = response.data, + let content = String(data: data, encoding: .utf8), + content.contains("purchaseSuccess") + { + // TODO: DO DECODE + completion(.success(())) + } else { + throw Error.unknownResponse + } + } catch { + completion(.failure(error)) + } + case let .failure(error): + completion(.failure(error)) + } + } + } +} + +extension StoreClient { + enum Error: Swift.Error { + case timeout + case invalidResponse + case unknownResponse + } +} + +/* + + + + pings + + jingleDocTypepurchaseSuccess + jingleActionpurchaseProduct + status0 + */ diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreEndpoint.swift b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreEndpoint.swift new file mode 100644 index 0000000..9cb0cdf --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreEndpoint.swift @@ -0,0 +1,45 @@ +// +// StoreEndpoint.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +enum StoreEndpoint { + case authenticate(prefix: String, guid: String) + case download(guid: String) + case buy +} + +extension StoreEndpoint: HTTPEndpoint { + var url: URL { + var components = URLComponents(string: path)! + components.scheme = "https" + components.host = host + return components.url! + } + + private var host: String { + switch self { + case let .authenticate(prefix, _): + "\(prefix)-buy.itunes.apple.com" + case .buy: + "buy.itunes.apple.com" + case .download: + "p25-buy.itunes.apple.com" + } + } + + private var path: String { + switch self { + case let .authenticate(_, guid): + "/WebObjects/MZFinance.woa/wa/authenticate?guid=\(guid)" + case .buy: + "/WebObjects/MZBuy.woa/wa/buyProduct" + case let .download(guid): + "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct?guid=\(guid)" + } + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreRequest.swift b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreRequest.swift new file mode 100644 index 0000000..3581889 --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreRequest.swift @@ -0,0 +1,143 @@ +// +// StoreRequest.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +enum StoreRequest { + case authenticate(email: String, password: String, code: String? = nil) + case download(appIdentifier: String, directoryServicesIdentifier: String) + case buy(token: String, directoryServicesIdentifier: String, trackID: Int, countryCode: String, pricingParameters: String) +} + +extension StoreRequest: HTTPRequest { + var endpoint: HTTPEndpoint { + switch self { + case let .authenticate(_, _, code): + StoreEndpoint.authenticate(prefix: (code == nil) ? "p25" : "p71", guid: guid) + case .download: + StoreEndpoint.download(guid: guid) + case .buy: + StoreEndpoint.buy + } + } + + var method: HTTPMethod { + .post + } + + var headers: [String: String] { + var headers: [String: String] = [ + "User-Agent": "Configurator/2.0 (Macintosh; OS X 10.12.6; 16G29) AppleWebKit/2603.3.8", + "Content-Type": "application/x-www-form-urlencoded", + ] + + switch self { + case .authenticate: + break + case let .download(_, directoryServicesIdentifier): + headers["X-Dsid"] = directoryServicesIdentifier + headers["iCloud-DSID"] = directoryServicesIdentifier + case let .buy(token, directoryServicesIdentifier, _, countryCode, _): + headers["Content-Type"] = "application/x-apple-plist" + headers["iCloud-DSID"] = directoryServicesIdentifier + headers["X-Dsid"] = directoryServicesIdentifier + headers["X-Apple-Store-Front"] = storeFrontCodeMap[countryCode] ?? "" + headers["X-Token"] = token + } + return headers + } + + var payload: HTTPPayload? { + switch self { + case let .authenticate(email, password, code): + .xml([ + "appleId": email, + "attempt": "\(code == nil ? "4" : "2")", + "createSession": "true", + "guid": guid, + "password": "\(password)\(code ?? "")", + "rmp": "0", + "why": "signIn", + ]) + case let .download(appIdentifier, _): + .xml([ + "creditDisplay": "", + "guid": guid, + "salableAdamId": "\(appIdentifier)", + ]) + case let .buy(_, _, trackID, _, pricingParameters): + .xml([ + "appExtVrsId": "0", + "hasAskedToFulfillPreorder": "true", + "buyWithoutAuthorization": "true", + "hasDoneAgeCheck": "true", + "guid": guid, + "needDiv": "0", + "origPage": String(format: "Software-%d", trackID), + "origPageLocation": "Buy", + "price": "0", + "pricingParameters": pricingParameters, + "productType": "C", + "salableAdamId": String(trackID), + ]) + } + } +} + +extension StoreRequest { + // This identifier is calculated by reading the MAC address of the device and stripping the nonalphabetic characters from the string. + // https://stackoverflow.com/a/31838376 + private var guid: String { + if let id = ApplePackage.overrideGUID { + return id + } + + #if os(macOS) + let MAC_ADDRESS_LENGTH = 6 + let bsds: [String] = ["en0", "en1"] + var bsd: String = bsds[0] + + var length: size_t = 0 + var buffer: [CChar] + + var bsdIndex = Int32(if_nametoindex(bsd)) + if bsdIndex == 0 { + bsd = bsds[1] + bsdIndex = Int32(if_nametoindex(bsd)) + guard bsdIndex != 0 else { fatalError("Could not read MAC address") } + } + + let bsdData = Data(bsd.utf8) + var managementInfoBase = [CTL_NET, AF_ROUTE, 0, AF_LINK, NET_RT_IFLIST, bsdIndex] + + guard sysctl(&managementInfoBase, 6, nil, &length, nil, 0) >= 0 else { fatalError("Could not read MAC address") } + + buffer = [CChar](unsafeUninitializedCapacity: length, initializingWith: { buffer, initializedCount in + for x in 0 ..< length { + buffer[x] = 0 + } + initializedCount = length + }) + + guard sysctl(&managementInfoBase, 6, &buffer, &length, nil, 0) >= 0 else { fatalError("Could not read MAC address") } + + let infoData = Data(bytes: buffer, count: length) + let indexAfterMsghdr = MemoryLayout.stride + 1 + let rangeOfToken = infoData[indexAfterMsghdr...].range(of: bsdData)! + let lower = rangeOfToken.upperBound + let upper = lower + MAC_ADDRESS_LENGTH + let macAddressData = infoData[lower ..< upper] + let addressBytes = macAddressData.map { String(format: "%02x", $0) } + return addressBytes.joined().uppercased() + #else + return "06:02:18:bb:0a:0a" + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ":", with: "") + .uppercased() + #endif + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreResponse.swift b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreResponse.swift new file mode 100644 index 0000000..ee3817b --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/Store/StoreResponse.swift @@ -0,0 +1,142 @@ +// +// StoreResponse.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +public enum StoreResponse { + case failure(error: Swift.Error) + case account(Account) + case item(Item) +} + +public extension StoreResponse { + struct Account: Codable, Hashable, Equatable { + public let firstName: String + public let lastName: String + public let directoryServicesIdentifier: String + public let passwordToken: String + public var countryCode: String = "" + } + + struct Item { + public let url: URL + public let md5: String + public let signatures: [Signature] + public let metadata: [String: Any] + + public init(url: URL, md5: String, signatures: [Signature], metadata: [String: Any]) { + self.url = url + self.md5 = md5 + self.signatures = signatures + self.metadata = metadata + } + } + + enum Error: Int, Swift.Error { + case unknownError = 0 + case genericError = 5002 + case codeRequired = 1 + case invalidLicense = 9610 + case invalidCredentials = -5000 + case invalidAccount = 5001 + case invalidItem = -10000 + case lockedAccount = -10001 + } +} + +extension StoreResponse: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let error = try container.decodeIfPresent(String.self, forKey: .error) + let message = try container.decodeIfPresent(String.self, forKey: .message) + + if container.contains(.account) { + let directoryServicesIdentifier = try container.decode(String.self, forKey: .directoryServicesIdentifier) + let accountContainer = try container.nestedContainer(keyedBy: AccountInfoCodingKeys.self, forKey: .account) + let addressContainer = try accountContainer.nestedContainer(keyedBy: AddressCodingKeys.self, forKey: .address) + let firstName = try addressContainer.decode(String.self, forKey: .firstName) + let lastName = try addressContainer.decode(String.self, forKey: .lastName) + let passwordToken = try container.decode(String.self, forKey: .passwordToken) + + self = .account(.init(firstName: firstName, lastName: lastName, directoryServicesIdentifier: directoryServicesIdentifier, passwordToken: passwordToken)) + } else if let items = try container.decodeIfPresent([Item].self, forKey: .items), let item = items.first { + self = .item(item) + } else if let error, !error.isEmpty { + self = .failure(error: Error(rawValue: Int(error) ?? 0) ?? .unknownError) + } else { + switch message { + case "Your account information was entered incorrectly.": + self = .failure(error: Error.invalidCredentials) + case "An Apple ID verification code is required to sign in. Type your password followed by the verification code shown on your other devices.": + self = .failure(error: Error.codeRequired) + case "This Apple ID has been locked for security reasons. Visit iForgot to reset your account (https://iforgot.apple.com).": + self = .failure(error: Error.lockedAccount) + default: + self = .failure(error: Error.unknownError) + } + } + } + + private enum CodingKeys: String, CodingKey { + case directoryServicesIdentifier = "dsPersonId" + case message = "customerMessage" + case items = "songList" + case error = "failureType" + case account = "accountInfo" + case passwordToken + } + + private enum AccountInfoCodingKeys: String, CodingKey { + case address + } + + private enum AddressCodingKeys: String, CodingKey { + case firstName + case lastName + } +} + +extension StoreResponse.Item: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let md5 = try container.decode(String.self, forKey: .md5) + + guard let key = CodingUserInfoKey(rawValue: "data"), + let data = decoder.userInfo[key] as? Data, + let json = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let items = json["songList"] as? [[String: Any]], + let item = items.first(where: { $0["md5"] as? String == md5 }), + let metadata = item["metadata"] as? [String: Any] + else { throw StoreResponse.Error.invalidItem } + + let absoluteUrl = try container.decode(String.self, forKey: .url) + + self.md5 = md5 + self.metadata = metadata + signatures = try container.decode([Signature].self, forKey: .signatures) + + if let url = URL(string: absoluteUrl) { + self.url = url + } else { + let context = DecodingError.Context(codingPath: [CodingKeys.url], debugDescription: "URL contains illegal characters: \(absoluteUrl).") + throw DecodingError.keyNotFound(CodingKeys.url, context) + } + } + + public struct Signature: Codable, Hashable { + let id: Int + let sinf: Data + } + + enum CodingKeys: String, CodingKey { + case url = "URL" + case metadata + case md5 + case signatures = "sinfs" + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesClient.swift b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesClient.swift new file mode 100644 index 0000000..6ef5ef8 --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesClient.swift @@ -0,0 +1,114 @@ +// +// iTunesClient.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +public protocol iTunesClientInterface { + func lookup(type: EntityType, bundleIdentifier: String, region: String, completion: @escaping (Result) -> Void) + func search(type: EntityType, term: String, limit: Int, region: String, completion: @escaping (Result<[iTunesResponse.iTunesArchive], Error>) -> Void) +} + +public extension iTunesClientInterface { + func lookup(type: EntityType, bundleIdentifier: String, region: String) throws -> iTunesResponse.iTunesArchive { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + lookup(type: type, bundleIdentifier: bundleIdentifier, region: region) { + result = $0 + semaphore.signal() + } + + _ = semaphore.wait(timeout: .distantFuture) + + switch result { + case .none: + throw iTunesClient.Error.timeout + case let .failure(error): + throw error + case let .success(result): + return result + } + } + + func search(type: EntityType, term: String, limit: Int, region: String) throws -> [iTunesResponse.iTunesArchive] { + let semaphore = DispatchSemaphore(value: 0) + var result: Result<[iTunesResponse.iTunesArchive], Error>? + + search(type: type, term: term, limit: limit, region: region) { + result = $0 + semaphore.signal() + } + + _ = semaphore.wait(timeout: .distantFuture) + + switch result { + case .none: + throw iTunesClient.Error.timeout + case let .failure(error): + throw error + case let .success(result): + return result + } + } +} + +public final class iTunesClient: iTunesClientInterface { + private let httpClient: HTTPClient + + public init(httpClient: HTTPClient) { + self.httpClient = httpClient + } + + public func lookup(type: EntityType, bundleIdentifier: String, region: String, completion: @escaping (Result) -> Void) { + let request = iTunesRequest.lookup(type: type, bundleIdentifier: bundleIdentifier, region: region) + + httpClient.send(request) { result in + switch result { + case let .success(response): + do { + let decoded = try response.decode(iTunesResponse.self, as: .json) + guard var result = decoded.results.first else { return completion(.failure(Error.appNotFound)) } + result.entityType = type + completion(.success(result)) + } catch { + completion(.failure(error)) + } + case let .failure(error): + completion(.failure(error)) + } + } + } + + public func search(type: EntityType, term: String, limit: Int, region: String, completion: @escaping (Result<[iTunesResponse.iTunesArchive], Swift.Error>) -> Void) { + let request = iTunesRequest.search(type: type, term: term, limit: limit, region: region) + + httpClient.send(request) { result in + switch result { + case let .success(response): + do { + let decoded = try response.decode(iTunesResponse.self, as: .json) + completion(.success(decoded.results.map { + var archive = $0 + archive.entityType = type + return archive + })) + } catch { + completion(.failure(error)) + } + case let .failure(error): + completion(.failure(error)) + } + } + } +} + +extension iTunesClient { + enum Error: Swift.Error { + case timeout + case appNotFound + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesEndpoint.swift b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesEndpoint.swift new file mode 100644 index 0000000..2c0e8de --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesEndpoint.swift @@ -0,0 +1,63 @@ +// +// iTunesEndpoint.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +enum iTunesEndpoint { + case search + case lookup +} + +extension iTunesEndpoint: HTTPEndpoint { + var url: URL { + var components = URLComponents(string: path)! + components.scheme = "https" + components.host = "itunes.apple.com" + return components.url! + } + + private var path: String { + switch self { + case .search: + "/search" + case .lookup: + "/lookup" + } + } +} + +// +// func (t *appstore) purchaseRequest(acc Account, app App, storeFront, guid string, pricingParameters string) http.Request { +// return http.Request{ +// URL: fmt.Sprintf("https://%s%s", PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathPurchase), +// Method: http.MethodPOST, +// ResponseFormat: http.ResponseFormatXML, +// Headers: map[string]string{ +// "Content-Type": "application/x-apple-plist", +// "iCloud-DSID": acc.DirectoryServicesID, +// "X-Dsid": acc.DirectoryServicesID, +// "X-Apple-Store-Front": storeFront, +// "X-Token": acc.PasswordToken, +// }, +// Payload: &http.XMLPayload{ +// Content: map[string]interface{}{ +// "appExtVrsId": "0", +// "hasAskedToFulfillPreorder": "true", +// "buyWithoutAuthorization": "true", +// "hasDoneAgeCheck": "true", +// "guid": guid, +// "needDiv": "0", +// "origPage": fmt.Sprintf("Software-%d", app.ID), +// "origPageLocation": "Buy", +// "price": "0", +// "pricingParameters": pricingParameters, +// "productType": "C", +// "salableAdamId": app.ID, +// }, +// }, +// } +// } diff --git a/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesRequest.swift b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesRequest.swift new file mode 100644 index 0000000..6a59f7c --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesRequest.swift @@ -0,0 +1,53 @@ +// +// iTunesRequest.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +enum iTunesRequest { + case search(type: EntityType, term: String, limit: Int, region: String) + case lookup(type: EntityType, bundleIdentifier: String, region: String) +} + +public enum EntityType: String, Codable, CaseIterable, Hashable, Equatable { + case iPhone + case iPad +} + +extension EntityType { + var entityValue: String { + switch self { + case .iPhone: + "software" + case .iPad: + "iPadSoftware" + } + } +} + +extension iTunesRequest: HTTPRequest { + var method: HTTPMethod { + .get + } + + var endpoint: HTTPEndpoint { + switch self { + case .lookup: + iTunesEndpoint.lookup + case .search: + iTunesEndpoint.search + } + } + + var payload: HTTPPayload? { + switch self { + case let .lookup(type, bundleIdentifier, region): + .urlEncoding(["entity": type.entityValue, "bundleId": bundleIdentifier, "limit": "1", "country": region]) + case let .search(type, term, limit, region): + .urlEncoding(["entity": type.entityValue, "term": term, "limit": "\(limit)", "country": region]) + } + } +} diff --git a/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesResponse.swift b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesResponse.swift new file mode 100644 index 0000000..7d9a4dd --- /dev/null +++ b/Foundation/ApplePackage/Sources/ApplePackage/iTunes/iTunesResponse.swift @@ -0,0 +1,74 @@ +// +// iTunesResponse.swift +// IPATool +// +// Created by Majd Alfhaily on 22.05.21. +// + +import Foundation + +public struct iTunesResponse { + let results: [iTunesArchive] + let count: Int +} + +public extension iTunesResponse { + struct iTunesArchive: Identifiable, Equatable, Hashable { + public var id: String { bundleIdentifier } + + public let bundleIdentifier: String + public let version: String + public let identifier: Int + public let name: String + public let artworkUrl512: String? + public let fileSizeBytes: String? + + public let isGameCenterEnabled: Bool? + public let screenshotUrls: [String]? + public let currency: String? + public let artistName: String? + public let price: Double? + public let formattedPrice: String? + public let description: String? + public let releaseNotes: String? + public let supportedDevices: [String]? + + public var entityType: EntityType? + + public var byteCountDescription: String { + guard let fileSizeBytes, let bytes = Int64(fileSizeBytes) else { + return NSLocalizedString("Unknown", comment: "") + } + let fmt = ByteCountFormatter() + fmt.countStyle = .file + return fmt.string(fromByteCount: bytes) + } + } +} + +extension iTunesResponse: Codable { + enum CodingKeys: String, CodingKey { + case count = "resultCount" + case results + } +} + +extension iTunesResponse.iTunesArchive: Codable { + enum CodingKeys: String, CodingKey { + case identifier = "trackId" + case name = "trackName" + case bundleIdentifier = "bundleId" + case version + case artworkUrl512 + case fileSizeBytes + case isGameCenterEnabled + case screenshotUrls + case currency + case artistName + case price + case formattedPrice + case description + case releaseNotes + case supportedDevices + } +} diff --git a/Foundation/ApplePackage/Tests/ApplePackageTests/ApplePackageTests.swift b/Foundation/ApplePackage/Tests/ApplePackageTests/ApplePackageTests.swift new file mode 100644 index 0000000..bfd5bae --- /dev/null +++ b/Foundation/ApplePackage/Tests/ApplePackageTests/ApplePackageTests.swift @@ -0,0 +1,6 @@ +@testable import ApplePackage +import XCTest + +final class ApplePackageTests: XCTestCase { + func testExample() throws {} +} diff --git a/Foundation/Digger/LICENSE b/Foundation/Digger/LICENSE new file mode 100755 index 0000000..8864d4a --- /dev/null +++ b/Foundation/Digger/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Foundation/Digger/Package.swift b/Foundation/Digger/Package.swift new file mode 100644 index 0000000..af69cd6 --- /dev/null +++ b/Foundation/Digger/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version:5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Digger", + products: [ + .library( + name: "Digger", + targets: ["Digger"] + ), + ], + targets: [ + .target(name: "Digger"), + ] +) diff --git a/Foundation/Digger/Sources/Digger/Digger.swift b/Foundation/Digger/Sources/Digger/Digger.swift new file mode 100644 index 0000000..6738807 --- /dev/null +++ b/Foundation/Digger/Sources/Digger/Digger.swift @@ -0,0 +1,18 @@ +// +// Digger.swift +// Digger +// +// Created by ant on 2017/10/25. +// Copyright © 2017年 github.cornerant. All rights reserved. +// + +import Foundation + +public let digger = "Digger" + +/// start download with url + +@discardableResult +public func download(_ url: DiggerURL) -> DiggerSeed { + DiggerManager.shared.download(with: url) +} diff --git a/Foundation/Digger/Sources/Digger/DiggerCache.swift b/Foundation/Digger/Sources/Digger/DiggerCache.swift new file mode 100644 index 0000000..1c716bb --- /dev/null +++ b/Foundation/Digger/Sources/Digger/DiggerCache.swift @@ -0,0 +1,193 @@ +// +// DiggerCache.swift +// Digger +// +// Created by ant on 2017/10/25. +// Copyright © 2017年 github.cornerant. All rights reserved. +// + +import CommonCrypto +import Foundation + +public enum DiggerCache { + /// In the sandbox cactes directory, custom your cache directory + public static var cachesDirectory: String = digger { + willSet { + createDirectory(atPath: newValue.cacheDir) + } + } + + static func tempPath(url: URL) -> String { + url.absoluteString.sha1().tmpDir + } + + static func cachePath(url: URL) -> String { + cachesDirectory.cacheDir + "/" + url.lastPathComponent + } + + static func removeTempFile(with url: URL) { + let fileTempParh = tempPath(url: url) + if isFileExist(atPath: fileTempParh) { + removeItem(atPath: fileTempParh) + } + } + + static func removeCacheFile(with url: URL) { + let fileCachePath = cachePath(url: url) + if isFileExist(atPath: fileCachePath) { + removeItem(atPath: fileCachePath) + } + } + + /// The size of the downloaded files + public static func downloadedFilesSize() -> Int64 { + if !isFileExist(atPath: cachesDirectory.cacheDir) { + return 0 + } + do { + var filesSize: Int64 = 0 + + let subpaths = try FileManager.default.subpathsOfDirectory(atPath: cachesDirectory.cacheDir) + + _ = subpaths.map { + let filepath = cachesDirectory.cacheDir + "/" + $0 + filesSize += fileSize(filePath: filepath) + } + return filesSize + + } catch { + diggerLog(error) + return 0 + } + } + + /// delete all downloaded files + public static func cleanDownloadTempFiles() { + do { + let subpaths = try FileManager.default.subpathsOfDirectory(atPath: "".tmpDir) + _ = subpaths.map { + let tempFilepath = "".tmpDir + "/" + $0 + + removeItem(atPath: tempFilepath) + } + } catch { + diggerLog(error) + } + } + + /// delete all temp files + public static func cleanDownloadFiles() { + removeItem(atPath: cachesDirectory.cacheDir) + createDirectory(atPath: cachesDirectory.cacheDir) + } + + /// paths to the downloaded files + public static func pathsOfDownloadedfiles() -> [String] { + var paths = [String]() + do { + let subpaths = try FileManager.default.subpathsOfDirectory(atPath: cachesDirectory.cacheDir) + + _ = subpaths.map { + let filepath = cachesDirectory.cacheDir + "/" + $0 + paths.append(filepath) + } + } catch { + diggerLog(error) + } + + return paths + } +} + +// MARK: - fileHelper + +public extension DiggerCache { + /// isFileExist + static func isFileExist(atPath filePath: String) -> Bool { + FileManager.default.fileExists(atPath: filePath) + } + + /// fileSize + static func fileSize(filePath: String) -> Int64 { + guard isFileExist(atPath: filePath) else { return 0 } + let fileInfo = try! FileManager.default.attributesOfItem(atPath: filePath) + return fileInfo[FileAttributeKey.size] as! Int64 + } + + /// move file + static func moveItem(atPath: String, toPath: String) { + do { + try FileManager.default.moveItem(atPath: atPath, toPath: toPath) + } catch { + diggerLog(error) + } + } + + /// delete file + static func removeItem(atPath: String) { + guard isFileExist(atPath: atPath) else { + return + } + + do { + try FileManager.default.removeItem(atPath: atPath) + } catch { + diggerLog(error) + } + } + + /// createDirectory + static func createDirectory(atPath: String) { + if !isFileExist(atPath: atPath) { + do { + try FileManager.default.createDirectory(atPath: atPath, withIntermediateDirectories: true, attributes: nil) + } catch { + diggerLog(error) + } + } + } + + /// systemFreeSize + + static func systemFreeSize() -> Int64 { + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) + let freesize = attributes[FileAttributeKey.systemFreeSize] as? Int64 + + return freesize ?? 0 + + } catch { + diggerLog(error) + return 0 + } + } +} + +// MARK: - SandboxPath + +public extension String { + var cacheDir: String { + let path = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).last! + return (path as NSString).appendingPathComponent((self as NSString).lastPathComponent) + } + + var docDir: String { + let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last! + return (path as NSString).appendingPathComponent((self as NSString).lastPathComponent) + } + + var tmpDir: String { + let path = NSTemporaryDirectory() as NSString + return path.appendingPathComponent((self as NSString).lastPathComponent) + } + + func sha1() -> String { + let data = Data(utf8) + var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + data.withUnsafeBytes { + _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) + } + let hexBytes = digest.map { String(format: "%02hhx", $0) } + return hexBytes.joined() + } +} diff --git a/Foundation/Digger/Sources/Digger/DiggerDelegate.swift b/Foundation/Digger/Sources/Digger/DiggerDelegate.swift new file mode 100644 index 0000000..7d61938 --- /dev/null +++ b/Foundation/Digger/Sources/Digger/DiggerDelegate.swift @@ -0,0 +1,207 @@ +// +// DiggerDelegate.swift +// Digger +// +// Created by ant on 2017/10/25. +// Copyright © 2017年 github.cornerant. All rights reserved. +// + +import Foundation + +public class DiggerDelegate: NSObject { + var manager: DiggerManager? +} + +// MARK: - SessionDelegate + +extension DiggerDelegate: URLSessionDataDelegate, URLSessionDelegate { + public func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let manager, + let url = dataTask.originalRequest?.url, + let diggerSeed = manager.findDiggerSeed(with: url) + else { + completionHandler(.cancel) + return + } + + var completionHandlerCalled = false + defer { + if !completionHandlerCalled { + let error = NSError( + domain: DiggerErrorDomain, + code: DiggerError.downloadCanceled.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: "Unknown Error", + ] + ) + notifyCompletionCallback(Result.failure(error), diggerSeed) + completionHandler(.cancel) + } + } + + // the file has been downloaded + if DiggerCache.isFileExist(atPath: DiggerCache.cachePath(url: url)) { + let cachesURL = URL(fileURLWithPath: DiggerCache.cachePath(url: url)) + dataTask.cancel() + notifyCompletionCallback(.success(cachesURL), diggerSeed) + return + } + /// status code + if let statusCode = (response as? HTTPURLResponse)?.statusCode, + !(200 ..< 400).contains(statusCode) + { + let error = NSError( + domain: DiggerErrorDomain, + code: DiggerError.invalidStatusCode.rawValue, + userInfo: [ + "statusCode": statusCode, + NSLocalizedDescriptionKey: HTTPURLResponse.localizedString(forStatusCode: statusCode), + ] + ) + notifyCompletionCallback(Result.failure(error), diggerSeed) + return + } + + guard let responseHeaders = (response as? HTTPURLResponse)?.allHeaderFields as? [String: String] else { + return + } + + // rangeString String "bytes 9660646-72300329/72300330" + if let fullRange = responseHeaders["Content-Range"], + let total = fullRange.components(separatedBy: "/").last, + let value = Int64(total) + { + diggerSeed.progress.totalUnitCount = value + } else if diggerSeed.progress.completedUnitCount == 0 { + diggerSeed.progress.totalUnitCount = response.expectedContentLength + } + + if let completedBytesString = responseHeaders["Content-Range"]? + .components(separatedBy: "-") + .first? + .components(separatedBy: " ") + .last, + let completedBytes = Int64(completedBytesString) + { diggerSeed.progress.completedUnitCount = completedBytes } + + diggerSeed.outputStream = OutputStream(toFileAtPath: diggerSeed.tempPath, append: true) + diggerSeed.outputStream?.open() + diggerLog("start to download \n" + url.absoluteString) + completionHandlerCalled = true + completionHandler(.allow) + } + + public func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let manager else { return } + + guard let url = dataTask.originalRequest?.url, let diggerSeed = manager.findDiggerSeed(with: url) else { + return + } + + diggerSeed.progress.completedUnitCount += Int64((data as NSData).length) + let buffer = [UInt8](data) + + diggerSeed.outputStream?.write(buffer, maxLength: (data as NSData).length) + notifyProgressCallback(diggerSeed) + } + + public func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let manager else { return } + + guard let url = task.originalRequest?.url, let diggerSeed = manager.findDiggerSeed(with: url) else { + return + } + + if let errorInfo = error { + notifyCompletionCallback(Result.failure(errorInfo), diggerSeed) + + } else { + notifyCompletionCallback(Result.success(diggerSeed.cacheFileURL), diggerSeed) + } + + diggerSeed.outputStream?.close() + } +} + +// MARK: - notifyCallback + +extension DiggerDelegate { + func notifyProgressCallback(_ diggerSeed: DiggerSeed) { + if diggerSeed.progress.totalUnitCount < diggerSeed.progress.completedUnitCount { + diggerSeed.progress.totalUnitCount = diggerSeed.progress.completedUnitCount + } + + notifySpeedCallback(diggerSeed) + + DispatchQueue.main.safeAsync { + _ = diggerSeed.callbacks.map { $0.progress?(diggerSeed.progress) } + } + } + + func notifyCompletionCallback(_ result: Result, _ diggerSeed: DiggerSeed) { + guard let manager else { return } + + switch result { + case let .failure(error as NSError): + if error.code == DiggerError.downloadCanceled.rawValue { + // If a task is cancelled, the temporary file will be deleted + DiggerCache.removeItem(atPath: diggerSeed.tempPath) + } + + diggerLog(error) + + case let .success(url): + + DiggerCache.moveItem(atPath: diggerSeed.tempPath, toPath: diggerSeed.cachePath) + + diggerLog("download success \n" + url.absoluteString) + } + + manager.removeDigeerSeed(for: diggerSeed.url) + + DispatchQueue.main.safeAsync { + _ = diggerSeed.callbacks.map { $0.completion?(result) } + } + notifySpeedZeroCallback(diggerSeed) + } + + func notifySpeedCallback(_ diggerSeed: DiggerSeed) { + let progress = diggerSeed.progress + var dataCount = progress.completedUnitCount + let time = Double(NSDate().timeIntervalSince1970) + var lastData: Int64 = 0 + var lastTime: Double = 0 + + if progress.userInfo[.throughputKey] != nil { + lastData = progress.userInfo[.fileCompletedCountKey] as! Int64 + } else { + dataCount = 0 + } + + if progress.userInfo[.estimatedTimeRemainingKey] != nil { + lastTime = progress.userInfo[.estimatedTimeRemainingKey] as! Double + } + + if (time - lastTime) <= 1.0 { + return + } + let speed = Int64(Double(dataCount - lastData) / (time - lastTime)) + progress.setUserInfoObject(dataCount, forKey: .fileCompletedCountKey) + progress.setUserInfoObject(time, forKey: .estimatedTimeRemainingKey) + progress.setUserInfoObject(speed, forKey: .throughputKey) + + if let speed = progress.userInfo[.throughputKey] as? Int64 { + DispatchQueue.main.safeAsync { + _ = diggerSeed.callbacks.map { $0.speed?(speed) } + } + } + } + + /// speed should be zero, when cancel or suspend + + public func notifySpeedZeroCallback(_ diggerSeed: DiggerSeed) { + DispatchQueue.main.safeAsync { + _ = diggerSeed.callbacks.map { $0.speed?(0) } + } + } +} diff --git a/Foundation/Digger/Sources/Digger/DiggerHelper.swift b/Foundation/Digger/Sources/Digger/DiggerHelper.swift new file mode 100644 index 0000000..ee42f96 --- /dev/null +++ b/Foundation/Digger/Sources/Digger/DiggerHelper.swift @@ -0,0 +1,80 @@ +// +// DiggerHelper.swift +// Digger +// +// Created by ant on 2017/10/26. +// Copyright © 2017年 github.cornerant. All rights reserved. +// + +import Foundation + +// MARK: - result help + +public enum Result { + case failure(Error) + case success(T) +} + +// MARK: - error help + +public let DiggerErrorDomain = "DiggerError" +public enum DiggerError: Int { + case badURL = 9981 + case fileIsExist = 9982 + case fileInfoError = 9983 + case invalidStatusCode = 9984 + case diskOutOfSpace = 9985 + case downloadCanceled = -999 +} + +// MARK: - error help + +public enum LogLevel { + case high, low, none +} + +public func diggerLog(_ info: some Any, file: NSString = #file, method: String = #function, line: Int = #line) { + switch DiggerManager.shared.logLevel { + case .none: + _ = "" + + case .low: + print("*************** Digger Log ****************") + print("\(info)" + "\n") + + case .high: + print("*************** Digger Log ****************") + print("file : " + "\(file.lastPathComponent)" + "\n" + + "method : " + "\(method)" + "\n" + + "line : " + "[\(line)]:" + "\n" + + "info : " + "\(info)" + ) + } +} + +// MARK: - url helper + +public protocol DiggerURL { + func asURL() throws -> URL +} + +extension String: DiggerURL { + public func asURL() throws -> URL { + guard let url = URL(string: self) else { + throw NSError( + domain: DiggerErrorDomain, + code: DiggerError.badURL.rawValue, + userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("Invalid URL", comment: ""), + ] + ) + } + return url + } +} + +extension URL: DiggerURL { + public func asURL() throws -> URL { + self + } +} diff --git a/Foundation/Digger/Sources/Digger/DiggerManager.swift b/Foundation/Digger/Sources/Digger/DiggerManager.swift new file mode 100644 index 0000000..b34bdd6 --- /dev/null +++ b/Foundation/Digger/Sources/Digger/DiggerManager.swift @@ -0,0 +1,290 @@ + + +// +// DiggerManager.swift +// Digger +// +// Created by ant on 2017/10/25. +// Copyright © 2017年 github.cornerant. All rights reserved. +// + +import Foundation + +public protocol DiggerManagerProtocol { + /// logLevel hing,low,none + var logLevel: LogLevel { set get } + + /// Apple limit is per session,The default value is 6 in macOS, or 4 in iOS. + var maxConcurrentTasksCount: Int { set get } + + var allowsCellularAccess: Bool { set get } + + var additionalHTTPHeaders: [String: String] { set get } + + var timeout: TimeInterval { set get } + + /// Start the task at once,default is true + + var startDownloadImmediately: Bool { set get } + + func startTask(for diggerURL: DiggerURL) + + func stopTask(for diggerURL: DiggerURL) + + /// If the task is cancelled, the temporary file will be deleted + func cancelTask(for diggerURL: DiggerURL) + + func startAllTasks() + + func stopAllTasks() + + func cancelAllTasks() +} + +open class DiggerManager: DiggerManagerProtocol { + // MARK: - property + + public static var shared = DiggerManager(name: digger) + public var logLevel: LogLevel = .high + open var startDownloadImmediately = true + open var timeout: TimeInterval = 100 + fileprivate var diggerSeeds = [URL: DiggerSeed]() + fileprivate var session: URLSession + fileprivate var diggerDelegate: DiggerDelegate? + fileprivate let barrierQueue = DispatchQueue.barrier + fileprivate let delegateQueue = OperationQueue.downloadDelegateOperationQueue + private let accessLock = NSLock() + + public var maxConcurrentTasksCount: Int = 3 { + didSet { + let count = maxConcurrentTasksCount == 0 ? 1 : maxConcurrentTasksCount + session.invalidateAndCancel() + session = setupSession(allowsCellularAccess, count, additionalHTTPHeaders) + } + } + + public var allowsCellularAccess: Bool = true { + didSet { + session.invalidateAndCancel() + session = setupSession(allowsCellularAccess, maxConcurrentTasksCount, additionalHTTPHeaders) + } + } + + public var additionalHTTPHeaders: [String: String] = [:] { + didSet { + session.invalidateAndCancel() + session = setupSession(allowsCellularAccess, maxConcurrentTasksCount, additionalHTTPHeaders) + } + } + + // MARK: - lifeCycle + + private init(name: String) { + DiggerCache.cachesDirectory = digger + if name.isEmpty { + fatalError("DiggerManager must hava a name") + } + + diggerDelegate = DiggerDelegate() + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.allowsCellularAccess = allowsCellularAccess + sessionConfiguration.httpMaximumConnectionsPerHost = maxConcurrentTasksCount + sessionConfiguration.httpAdditionalHeaders = additionalHTTPHeaders + session = URLSession(configuration: sessionConfiguration, delegate: diggerDelegate, delegateQueue: delegateQueue) + } + + deinit { + session.invalidateAndCancel() + } + + private func setupSession(_ allowsCellularAccess: Bool, _ maxDownloadTasksCount: Int, _ additionalHTTPHeaders: [String: String]) -> URLSession { + diggerDelegate = DiggerDelegate() + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.allowsCellularAccess = allowsCellularAccess + sessionConfiguration.httpMaximumConnectionsPerHost = maxDownloadTasksCount + sessionConfiguration.httpAdditionalHeaders = additionalHTTPHeaders + let session = URLSession(configuration: sessionConfiguration, delegate: diggerDelegate, delegateQueue: delegateQueue) + + return session + } + + /// download file + /// DiggerSeed contains information about the file + /// - Parameter diggerURL: url + /// - Returns: the diggerSeed of file + @discardableResult + public func download(with diggerURL: DiggerURL) -> DiggerSeed { + switch isDiggerURLCorrect(diggerURL) { + case let .success(url): + createDiggerSeed(with: url) + case .failure: + fatalError("Please make sure the url or urlString is correct") + } + } +} + +// MARK: - diggerSeed control + +extension DiggerManager { + func createDiggerSeed(with url: URL) -> DiggerSeed { + if let seed = findDiggerSeed(with: url) { + return seed + } else { + barrierQueue.sync(flags: .barrier) { + let timeout = self.timeout == 0.0 ? 100 : self.timeout + let diggerSeed = DiggerSeed(session: session, url: url, timeout: timeout) + diggerSeeds[url] = diggerSeed + } + + let diggerSeed = findDiggerSeed(with: url)! + diggerDelegate?.manager = self + if startDownloadImmediately { + diggerSeed.downloadTask.resume() + } + return diggerSeed + } + } + + public func removeDigeerSeed(for url: URL) { + barrierQueue.sync(flags: .barrier) { + diggerSeeds.removeValue(forKey: url) + if diggerSeeds.isEmpty { diggerDelegate = nil } + } + } + + func isDiggerURLCorrect(_ diggerURL: DiggerURL) -> Result { + var correctURL: URL + do { + correctURL = try diggerURL.asURL() + return Result.success(correctURL) + } catch { + diggerLog(error) + return Result.failure(error) + } + } + + func findDiggerSeed(with diggerURL: DiggerURL) -> DiggerSeed? { + var diggerSeed: DiggerSeed? + switch isDiggerURLCorrect(diggerURL) { + case let .success(url): + barrierQueue.sync(flags: .barrier) { diggerSeed = diggerSeeds[url] } + return diggerSeed + case .failure: + return diggerSeed + } + } +} + +// MARK: - downloadTask control + +public extension DiggerManager { + func cancelTask(for diggerURL: DiggerURL) { + switch isDiggerURLCorrect(diggerURL) { + case .failure: return + case let .success(url): + barrierQueue.sync(flags: .barrier) { + guard let diggerSeed = diggerSeeds[url] else { return } + diggerSeed.downloadTask.cancel() + } + } + } + + func stopTask(for diggerURL: DiggerURL) { + switch isDiggerURLCorrect(diggerURL) { + case .failure: return + case let .success(url): + barrierQueue.sync(flags: .barrier) { + guard let diggerSeed = diggerSeeds[url] else { return } + if diggerSeed.downloadTask.state == .running { + diggerSeed.downloadTask.suspend() + diggerDelegate?.notifySpeedZeroCallback(diggerSeed) + } + } + } + } + + func startTask(for diggerURL: DiggerURL) { + switch isDiggerURLCorrect(diggerURL) { + case .failure: return + case let .success(url): + barrierQueue.sync(flags: .barrier) { + guard let diggerSeed = diggerSeeds[url] else { return } + if diggerSeed.downloadTask.state != .running { + diggerSeed.downloadTask.resume() + self.diggerDelegate?.notifySpeedCallback(diggerSeed) + } + } + } + } + + func startAllTasks() { + accessLock.lock() + let fetch = diggerSeeds + accessLock.unlock() + fetch.keys.forEach { startTask(for: $0) } + } + + func stopAllTasks() { + accessLock.lock() + let fetch = diggerSeeds + accessLock.unlock() + fetch.keys.forEach { stopTask(for: $0) } + } + + func cancelAllTasks() { + accessLock.lock() + let fetch = diggerSeeds + accessLock.unlock() + fetch.keys.forEach { cancelTask(for: $0) } + } + + func obtainAllTasks() -> [URL] { + accessLock.lock() + let result = [URL](diggerSeeds.keys) + accessLock.unlock() + return result + } +} + +// MARK: - URLSessionExtension + +public extension URLSession { + func dataTask(with url: URL, timeout: TimeInterval) -> URLSessionDataTask { + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) + let range = DiggerCache.fileSize(filePath: DiggerCache.tempPath(url: url)) + if range > 0 { + if isContentRangeSupportedOn(url: url, timeout: timeout) { + let headRange = "bytes=" + String(range) + "-" + request.setValue(headRange, forHTTPHeaderField: "Range") + } else { + DiggerCache.removeTempFile(with: url) + } + } + let task = dataTask(with: request) + task.priority = URLSessionTask.defaultPriority + return task + } + + func isContentRangeSupportedOn(url: URL, timeout: TimeInterval) -> Bool { + var preflightCheck = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: timeout + ) + preflightCheck.httpMethod = "HEAD" + var supportRange = false + let sem = DispatchSemaphore(value: 0) + URLSession.shared.dataTask(with: preflightCheck) { _, resp, _ in + if let httpResponse = resp as? HTTPURLResponse { + for (key, value) in httpResponse.allHeaderFields { + if let keyStr = key as? String, keyStr.lowercased() == "accept-ranges" { + supportRange = (value as? String)?.lowercased() != "none" + } + } + } + sem.signal() + }.resume() + sem.wait() + return supportRange + } +} diff --git a/Foundation/Digger/Sources/Digger/DiggerSeed.swift b/Foundation/Digger/Sources/Digger/DiggerSeed.swift new file mode 100644 index 0000000..bf23cda --- /dev/null +++ b/Foundation/Digger/Sources/Digger/DiggerSeed.swift @@ -0,0 +1,82 @@ +// +// DiggerSeed.swift +// Digger +// +// Created by ant on 2017/10/28. +// Copyright © 2017年 github.cornerant. All rights reserved. +// + +import Foundation + +public typealias ProgressCallback = (_ progress: Progress) -> Void + +public typealias SpeedCallback = (_ speedBytes: Int64) -> Void + +public typealias CompletionCallback = (_ completion: Result) -> Void + +public typealias Callback = (progress: ProgressCallback?, speed: SpeedCallback?, completion: CompletionCallback?) + +public class DiggerSeed { + var downloadTask: URLSessionDataTask + var url: URL + var progress = Progress() + var callbacks = [Callback]() + var cancelSemaphore: DispatchSemaphore? + var tempPath: String { + DiggerCache.tempPath(url: url) + } + + var cachePath: String { + DiggerCache.cachePath(url: url) + } + + var cacheFileURL: URL { + URL(fileURLWithPath: DiggerCache.cachePath(url: url)) + } + + var outputStream: OutputStream? + + init(session: URLSession, url: URL, timeout: TimeInterval) { + downloadTask = session.dataTask(with: url, timeout: timeout) + self.url = url + } + + /// downloading progress + /// + /// - Parameter progress: progress + /// - Returns: DiggerSeed + @discardableResult + public func progress(_ progress: @escaping ProgressCallback) -> Self { + var callback = Callback(nil, nil, nil) + callback.progress = progress + callbacks.append(callback) + + return self + } + + /// downloading speed + /// + /// - Parameter speed: downloading speed, Unit: Bytes + /// - Returns: DiggerSeed + @discardableResult + public func speed(_ speed: @escaping SpeedCallback) -> Self { + var callback = Callback(nil, nil, nil) + callback.speed = speed + callbacks.append(callback) + + return self + } + + /// download result + /// + /// - Parameter completion: Restult + /// - Returns: DiggerSeed + @discardableResult + public func completion(_ completion: @escaping CompletionCallback) -> Self { + var callback = Callback(nil, nil, nil) + callback.completion = completion + callbacks.append(callback) + + return self + } +} diff --git a/Foundation/Digger/Sources/Digger/DiggerThread.swift b/Foundation/Digger/Sources/Digger/DiggerThread.swift new file mode 100644 index 0000000..0df259f --- /dev/null +++ b/Foundation/Digger/Sources/Digger/DiggerThread.swift @@ -0,0 +1,32 @@ +// +// DiggerThread.swift +// Digger +// +// Created by ant on 2017/10/27. +// Copyright © 2017年 github.cornerant. All rights reserved. +// + +import Foundation + +extension DispatchQueue { + static let barrier = DispatchQueue(label: "wiki.qaq.diggerThread.Barrier", attributes: .concurrent) + static let cancel = DispatchQueue(label: "wiki.qaq.diggerThread.cancel", attributes: .concurrent) + static let download = DispatchQueue(label: "wiki.qaq.downloadSession.download", attributes: .concurrent) + static let forFun = DispatchQueue(label: "wiki.qaq.diggerThread.forFun", attributes: .concurrent) + + func safeAsync(_ block: @escaping () -> Void) { + if self === DispatchQueue.main, Thread.isMainThread { + block() + } else { + async { block() } + } + } +} + +extension OperationQueue { + static var downloadDelegateOperationQueue: OperationQueue { + let downloadDelegateOperationQueue = OperationQueue() + downloadDelegateOperationQueue.name = "wiki.qaq.diggerThread.downloadDelegateOperationQueue" + return downloadDelegateOperationQueue + } +} diff --git a/README.md b/README.md index d2d1677..37d2d3e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Download the latest version from [Releases](https://github.com/Lakr233/Asspp/rel ## Source Code -Source code is granted case by case, only requests from my most trusted friends will be considered. Please contact me if you need it. +1.2.10 is the only version that is open sourced. All future updates will not be open sourced. ## 🧑‍⚖️ License diff --git a/Resources/i18n/zh-Hans/README.md b/Resources/i18n/zh-Hans/README.md index df0e0a6..eb9eec3 100644 --- a/Resources/i18n/zh-Hans/README.md +++ b/Resources/i18n/zh-Hans/README.md @@ -1,4 +1,4 @@ -# 爱啪啪思道 +# 爱啪思道 就是有多账号支持的 App Store 辅助工具。不能替代 App Store 但是够你用了。 @@ -57,6 +57,10 @@ 从 [Releases](https://github.com/Lakr233/Asspp/releases) 下载最新版本。 +## 🔒 源码 + +1.2.10 是公开发布的唯一版本。如有后续更新,所有更新版本都不再开源。 + ## 🧑‍⚖️ 许可证 [GPLv3](./LICENSE) @@ -71,4 +75,4 @@ --- -版权所有 © 2024 Lakr Aream。保留所有权利。 +版权所有 © 2024 砍砍@标准件厂长。保留所有权利。