This commit is contained in:
Brandon Evans 2020-11-24 21:06:16 -07:00
parent 52d9771115
commit 762d18201f
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
61 changed files with 4229 additions and 1 deletions

View file

@ -1 +1,3 @@
# Xcodes.app
# Xcodes.app
Like xcodes, but app-ier.

Binary file not shown.

View file

@ -0,0 +1,552 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 52;
objects = {
/* Begin PBXBuildFile section */
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; };
CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; };
CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2E255A5262003FD669 /* XcodesKit */; };
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; };
CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; };
CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */; };
CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */; };
CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7A12449574E00113D76 /* XcodesApp.swift */; };
CAD2E7A42449574E00113D76 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7A32449574E00113D76 /* ContentView.swift */; };
CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A52449575000113D76 /* Assets.xcassets */; };
CAD2E7A92449575000113D76 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A82449575000113D76 /* Preview Assets.xcassets */; };
CAD2E7AC2449575000113D76 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7AA2449575000113D76 /* Main.storyboard */; };
CAD2E7B82449575100113D76 /* XcodesMacTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* XcodesMacTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
CAD2E7B42449575100113D76 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = CAD2E7962449574E00113D76 /* Project object */;
proxyType = 1;
remoteGlobalIDString = CAD2E79D2449574E00113D76;
remoteInfo = XcodesMac;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = "<group>"; };
CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = XcodesMac/AppleAPI; sourceTree = "<group>"; };
CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = XcodesMac/XcodesKit; sourceTree = "<group>"; };
CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = "<group>"; };
CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = "<group>"; };
CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = "<group>"; };
CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInPhoneListView.swift; sourceTree = "<group>"; };
CAD2E79E2449574E00113D76 /* Xcodes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Xcodes.app; sourceTree = BUILT_PRODUCTS_DIR; };
CAD2E7A12449574E00113D76 /* XcodesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesApp.swift; sourceTree = "<group>"; };
CAD2E7A32449574E00113D76 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
CAD2E7A52449575000113D76 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
CAD2E7A82449575000113D76 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
CAD2E7AB2449575000113D76 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
CAD2E7AD2449575000113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CAD2E7AE2449575000113D76 /* XcodesMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesMac.entitlements; sourceTree = "<group>"; };
CAD2E7B32449575100113D76 /* XcodesMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XcodesMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
CAD2E7B72449575100113D76 /* XcodesMacTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesMacTests.swift; sourceTree = "<group>"; };
CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
CAD2E79B2449574E00113D76 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */,
CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CAD2E7B02449575100113D76 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
CA538A12255A4F7C00E64DD7 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
CAA1CB50255A5D16003FD669 /* SignIn */ = {
isa = PBXGroup;
children = (
CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */,
CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */,
CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */,
CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */,
);
path = SignIn;
sourceTree = "<group>";
};
CAD2E7952449574E00113D76 = {
isa = PBXGroup;
children = (
CA8FB5F8256E0F9400469DA5 /* README.md */,
CA538A0F255A4F3300E64DD7 /* XcodesKit */,
CA538A0C255A4F1A00E64DD7 /* AppleAPI */,
CAD2E7A02449574E00113D76 /* XcodesMac */,
CAD2E7B62449575100113D76 /* XcodesMacTests */,
CAD2E79F2449574E00113D76 /* Products */,
CA538A12255A4F7C00E64DD7 /* Frameworks */,
);
sourceTree = "<group>";
};
CAD2E79F2449574E00113D76 /* Products */ = {
isa = PBXGroup;
children = (
CAD2E79E2449574E00113D76 /* Xcodes.app */,
CAD2E7B32449575100113D76 /* XcodesMacTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
CAD2E7A02449574E00113D76 /* XcodesMac */ = {
isa = PBXGroup;
children = (
CAD2E7A12449574E00113D76 /* XcodesApp.swift */,
CAD2E7A32449574E00113D76 /* ContentView.swift */,
CAA1CB50255A5D16003FD669 /* SignIn */,
CA378F982466567600A58CE0 /* AppState.swift */,
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */,
CA44901E2463AD34003D8213 /* Tag.swift */,
CAD2E7A52449575000113D76 /* Assets.xcassets */,
CAD2E7AA2449575000113D76 /* Main.storyboard */,
CAD2E7AD2449575000113D76 /* Info.plist */,
CAD2E7AE2449575000113D76 /* XcodesMac.entitlements */,
CAD2E7A72449575000113D76 /* Preview Content */,
);
path = XcodesMac;
sourceTree = "<group>";
};
CAD2E7A72449575000113D76 /* Preview Content */ = {
isa = PBXGroup;
children = (
CAD2E7A82449575000113D76 /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
CAD2E7B62449575100113D76 /* XcodesMacTests */ = {
isa = PBXGroup;
children = (
CAD2E7B72449575100113D76 /* XcodesMacTests.swift */,
CAD2E7B92449575100113D76 /* Info.plist */,
);
path = XcodesMacTests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
CAD2E79D2449574E00113D76 /* XcodesMac */ = {
isa = PBXNativeTarget;
buildConfigurationList = CAD2E7BC2449575100113D76 /* Build configuration list for PBXNativeTarget "XcodesMac" */;
buildPhases = (
CAD2E79A2449574E00113D76 /* Sources */,
CAD2E79B2449574E00113D76 /* Frameworks */,
CAD2E79C2449574E00113D76 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = XcodesMac;
packageProductDependencies = (
CAA1CB2C255A5262003FD669 /* AppleAPI */,
CAA1CB2E255A5262003FD669 /* XcodesKit */,
);
productName = XcodesMac;
productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */;
productType = "com.apple.product-type.application";
};
CAD2E7B22449575100113D76 /* XcodesMacTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = CAD2E7BF2449575100113D76 /* Build configuration list for PBXNativeTarget "XcodesMacTests" */;
buildPhases = (
CAD2E7AF2449575100113D76 /* Sources */,
CAD2E7B02449575100113D76 /* Frameworks */,
CAD2E7B12449575100113D76 /* Resources */,
);
buildRules = (
);
dependencies = (
CAD2E7B52449575100113D76 /* PBXTargetDependency */,
);
name = XcodesMacTests;
productName = XcodesMacTests;
productReference = CAD2E7B32449575100113D76 /* XcodesMacTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
CAD2E7962449574E00113D76 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1140;
LastUpgradeCheck = 1140;
ORGANIZATIONNAME = "Robots and Pencils";
TargetAttributes = {
CAD2E79D2449574E00113D76 = {
CreatedOnToolsVersion = 11.4;
};
CAD2E7B22449575100113D76 = {
CreatedOnToolsVersion = 11.4;
TestTargetID = CAD2E79D2449574E00113D76;
};
};
};
buildConfigurationList = CAD2E7992449574E00113D76 /* Build configuration list for PBXProject "XcodesMac" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = CAD2E7952449574E00113D76;
packageReferences = (
);
productRefGroup = CAD2E79F2449574E00113D76 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
CAD2E79D2449574E00113D76 /* XcodesMac */,
CAD2E7B22449575100113D76 /* XcodesMacTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
CAD2E79C2449574E00113D76 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CAD2E7AC2449575000113D76 /* Main.storyboard in Resources */,
CAD2E7A92449575000113D76 /* Preview Assets.xcassets in Resources */,
CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CAD2E7B12449575100113D76 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
CAD2E79A2449574E00113D76 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CA44901F2463AD34003D8213 /* Tag.swift in Sources */,
CA378F992466567600A58CE0 /* AppState.swift in Sources */,
CAD2E7A42449574E00113D76 /* ContentView.swift in Sources */,
CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */,
CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */,
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */,
CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */,
CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */,
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CAD2E7AF2449575100113D76 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CAD2E7B82449575100113D76 /* XcodesMacTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
CAD2E7B52449575100113D76 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = CAD2E79D2449574E00113D76 /* XcodesMac */;
targetProxy = CAD2E7B42449575100113D76 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
CAD2E7AA2449575000113D76 /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
CAD2E7AB2449575000113D76 /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
CAD2E7BA2449575100113D76 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
CAD2E7BB2449575100113D76 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
CAD2E7BD2449575100113D76 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = XcodesMac/XcodesMac.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_ASSET_PATHS = "\"XcodesMac/Preview Content\"";
DEVELOPMENT_TEAM = Z2R9WCWER2;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = XcodesMac/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesMac;
PRODUCT_NAME = Xcodes;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
CAD2E7BE2449575100113D76 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = XcodesMac/XcodesMac.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_ASSET_PATHS = "\"XcodesMac/Preview Content\"";
DEVELOPMENT_TEAM = Z2R9WCWER2;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = XcodesMac/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesMac;
PRODUCT_NAME = Xcodes;
SWIFT_VERSION = 5.0;
};
name = Release;
};
CAD2E7C02449575100113D76 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = Z2R9WCWER2;
INFOPLIST_FILE = XcodesMacTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesMacTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/XcodesMac.app/Contents/MacOS/XcodesMac";
};
name = Debug;
};
CAD2E7C12449575100113D76 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = Z2R9WCWER2;
INFOPLIST_FILE = XcodesMacTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesMacTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/XcodesMac.app/Contents/MacOS/XcodesMac";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CAD2E7992449574E00113D76 /* Build configuration list for PBXProject "XcodesMac" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CAD2E7BA2449575100113D76 /* Debug */,
CAD2E7BB2449575100113D76 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CAD2E7BC2449575100113D76 /* Build configuration list for PBXNativeTarget "XcodesMac" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CAD2E7BD2449575100113D76 /* Debug */,
CAD2E7BE2449575100113D76 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CAD2E7BF2449575100113D76 /* Build configuration list for PBXNativeTarget "XcodesMacTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CAD2E7C02449575100113D76 /* Debug */,
CAD2E7C12449575100113D76 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
CAA1CB2C255A5262003FD669 /* AppleAPI */ = {
isa = XCSwiftPackageProductDependency;
productName = AppleAPI;
};
CAA1CB2E255A5262003FD669 /* XcodesKit */ = {
isa = XCSwiftPackageProductDependency;
productName = XcodesKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = CAD2E7962449574E00113D76 /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,70 @@
{
"object": {
"pins": [
{
"package": "PMKFoundation",
"repositoryURL": "https://github.com/PromiseKit/Foundation.git",
"state": {
"branch": null,
"revision": "1a276e598dac59489ed904887e0740fa75e571e0",
"version": "3.3.4"
}
},
{
"package": "KeychainAccess",
"repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git",
"state": {
"branch": null,
"revision": "8d33ffd6f74b3bcfc99af759d4204c6395a3f918",
"version": "3.2.1"
}
},
{
"package": "LegibleError",
"repositoryURL": "https://github.com/mxcl/LegibleError.git",
"state": {
"branch": null,
"revision": "909e9bab3ded97350b28a5ab41dd745dd8aa9710",
"version": "1.0.4"
}
},
{
"package": "Path.swift",
"repositoryURL": "https://github.com/mxcl/Path.swift.git",
"state": {
"branch": null,
"revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5",
"version": "0.16.3"
}
},
{
"package": "PromiseKit",
"repositoryURL": "https://github.com/mxcl/PromiseKit.git",
"state": {
"branch": null,
"revision": "aea48ea1855f5d82e2dffa6027afce3aab8f3dd7",
"version": "6.13.3"
}
},
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "774dc9c7213085db8aa59595e27c1cd22e428904",
"version": "2.3.2"
}
},
{
"package": "Version",
"repositoryURL": "https://github.com/mxcl/Version.git",
"state": {
"branch": null,
"revision": "a94b48f36763c05629fc102837398505032dead9",
"version": "2.0.0"
}
}
]
},
"version": 1
}

141
XcodesMac/AppState.swift Normal file
View file

@ -0,0 +1,141 @@
import AppKit
import AppleAPI
import Combine
import Path
import PromiseKit
import XcodesKit
class AppState: ObservableObject {
private let list = XcodeList()
private lazy var installer = XcodeInstaller(configuration: Configuration(), xcodeList: list)
struct XcodeVersion: Identifiable {
let title: String
let installState: InstallState
let selected: Bool
let path: String?
var id: String { title }
var installed: Bool { installState == .installed }
}
enum InstallState: Equatable {
case notInstalled
case installing(Progress)
case installed
}
@Published var allVersions: [XcodeVersion] = []
struct AlertContent: Identifiable {
var title: String
var message: String
var id: String { title + message }
}
@Published var error: AlertContent?
@Published var presentingSignInAlert = false
func load() {
// if list.shouldUpdate {
update()
.done { _ in
self.updateAllVersions()
}
.catch { error in
self.error = AlertContent(title: "Error",
message: error.localizedDescription)
}
// }
// else {
// updateAllVersions()
// }
}
func validateSession() -> Promise<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
.recover { _ in
self.presentingSignInAlert = true
}
}
func continueLogin(username: String, password: String) -> Promise<Void> {
firstly { () -> Promise<Void> in
self.installer.login(username, password: password)
}
.recover { error -> Promise<Void> in
XcodesKit.Current.logging.log(error.legibleLocalizedDescription)
if case Client.Error.invalidUsernameOrPassword = error {
self.presentingSignInAlert = true
}
return Promise(error: error)
}
}
public func update() -> Promise<[Xcode]> {
return firstly { () -> Promise<Void> in
validateSession()
}
.then { () -> Promise<[Xcode]> in
self.list.update()
}
}
private func updateAllVersions() {
let installedXcodes = Current.files.installedXcodes(Path.root/"Applications")
var allXcodeVersions = list.availableXcodes.map { $0.version }
for installedXcode in installedXcodes {
// If an installed version isn't listed online, add the installed version
if !allXcodeVersions.contains(where: { version in
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version)
}) {
allXcodeVersions.append(installedXcode.version)
}
// If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version with build metadata
else if let index = allXcodeVersions.firstIndex(where: { version in
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) &&
version.buildMetadataIdentifiers.isEmpty
}) {
allXcodeVersions[index] = installedXcode.version
}
}
allVersions = allXcodeVersions
.sorted(by: >)
.map { xcodeVersion in
let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) })
return XcodeVersion(
title: xcodeVersion.xcodeDescription,
installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled,
selected: installedXcode?.path.string.contains("11.4.1") == true,
path: installedXcode?.path.string
)
}
}
func install(id: String) {
// TODO:
}
func uninstall(id: String) {
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return }
// TODO: would be nice to have a version of this method that just took the InstalledXcode
installer.uninstallXcode(installedXcode.version.xcodeDescription, destination: Path.root/"Applications")
.done {
}
.catch { error in
}
}
func reveal(id: String) {
// TODO: show error if not
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return }
NSWorkspace.shared.activateFileViewerSelecting([installedXcode.path.url])
}
func select(id: String) {
// TODO:
}
}

View file

@ -0,0 +1,64 @@
import SwiftUI
struct AppStoreButtonStyle: ButtonStyle {
var installed: Bool
var highlighted: Bool
var textColor: Color {
if installed {
if highlighted {
return Color.white
}
else {
return Color.secondary
}
}
else {
if highlighted {
return Color.accentColor
}
else {
return Color.white
}
}
}
func background(isPressed: Bool) -> some View {
Group {
if installed {
EmptyView()
} else {
Capsule()
.fill(
highlighted ?
Color.white :
Color.accentColor
)
.brightness(isPressed ? -0.25 : 0)
}
}
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(Font.caption.weight(.medium))
.foregroundColor(textColor)
.padding(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
.frame(minWidth: 80)
.background(background(isPressed: configuration.isPressed))
.padding(1)
}
}
struct AppStoreButtonStyle_Previews: PreviewProvider {
static var previews: some View {
Group {
Button("INSTALL", action: {})
.buttonStyle(AppStoreButtonStyle(installed: true, highlighted: false))
.padding()
Button("UNINSTALLED", action: {})
.buttonStyle(AppStoreButtonStyle(installed: false, highlighted: false))
.padding()
}
}
}

5
XcodesMac/AppleAPI/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

View file

@ -0,0 +1,30 @@
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "AppleAPI",
platforms: [.macOS(.v10_13)],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "AppleAPI",
targets: ["AppleAPI"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")),
.package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "AppleAPI",
dependencies: ["PromiseKit", "PMKFoundation"]),
.testTarget(
name: "AppleAPITests",
dependencies: ["AppleAPI"]),
]
)

View file

@ -0,0 +1,3 @@
# AppleAPI
A description of this package.

View file

@ -0,0 +1,327 @@
import Foundation
import PromiseKit
import PMKFoundation
public class Client {
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
public init() {}
public enum Error: Swift.Error, LocalizedError, Equatable {
case invalidSession
case invalidUsernameOrPassword(username: String)
case invalidPhoneNumberIndex(min: Int, max: Int, given: String?)
case incorrectSecurityCode
case unexpectedSignInResponse(statusCode: Int, message: String?)
case appleIDAndPrivacyAcknowledgementRequired
case noTrustedPhoneNumbers
public var errorDescription: String? {
switch self {
case .invalidUsernameOrPassword(let username):
return "Invalid username and password combination. Attempted to sign in with username \(username)."
case .appleIDAndPrivacyAcknowledgementRequired:
return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement."
case .invalidPhoneNumberIndex(let min, let max, let given):
return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")."
case .noTrustedPhoneNumbers:
return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915."
default:
return String(describing: self)
}
}
}
/// Use the olympus session endpoint to see if the existing session is still valid
public func validateSession() -> Promise<Void> {
return Current.network.dataTask(with: URLRequest.olympusSession)
.done { data, response in
guard
let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
jsonObject["provider"] != nil
else { throw Error.invalidSession }
}
}
public func login(accountName: String, password: String) -> Promise<Void> {
var serviceKey: String!
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.itcServiceKey)
}
.then { (data, _) -> Promise<(data: Data, response: URLResponse)> in
struct ServiceKeyResponse: Decodable {
let authServiceKey: String
}
let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data)
serviceKey = response.authServiceKey
return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
}
.then { (data, response) -> Promise<Void> in
struct SignInResponse: Decodable {
let authType: String?
let serviceErrors: [ServiceError]?
struct ServiceError: Decodable, CustomStringConvertible {
let code: String
let message: String
var description: String {
return "\(code): \(message)"
}
}
}
let httpResponse = response as! HTTPURLResponse
let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data)
switch httpResponse.statusCode {
case 200:
return Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
case 401:
throw Error.invalidUsernameOrPassword(username: accountName)
case 409:
return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey)
case 412 where Client.authTypes.contains(responseBody.authType ?? ""):
throw Error.appleIDAndPrivacyAcknowledgementRequired
default:
throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode,
message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", "))
}
}
}
func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise<Void> {
let httpResponse = response as! HTTPURLResponse
let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String)
let scnt = (httpResponse.allHeaderFields["scnt"] as! String)
return firstly { () -> Promise<AuthOptionsResponse> in
return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.map { try JSONDecoder().decode(AuthOptionsResponse.self, from: $0.data) }
}
.then { authOptions -> Promise<Void> in
switch authOptions.kind {
case .twoStep:
Current.logging.log("Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new")
return Promise.value(())
case .twoFactor:
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
case .unknown:
Current.logging.log("Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:")
String(data: data, encoding: .utf8).map { Current.logging.log($0) }
return Promise.value(())
}
}
}
func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise<Void> {
let option: TwoFactorOption
// SMS was sent automatically
if authOptions.smsAutomaticallySent {
option = .smsSent(authOptions.securityCode.length, authOptions.trustedPhoneNumbers!.first!)
// SMS wasn't sent automatically because user needs to choose a phone to send to
} else if authOptions.canFallBackToSMS {
option = .smsPendingChoice(authOptions.securityCode.length, authOptions.trustedPhoneNumbers ?? [])
// Code is shown on trusted devices
} else {
option = .codeSent(authOptions.securityCode.length)
}
return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) -> Promise<Void> {
Current.logging.log("Two-factor authentication is enabled for this account.\n")
switch option {
case let .smsSent(codeLength, phoneNumber):
return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in
let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber)
return Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
.validateSecurityCodeResponse()
}
.then { (data, response) -> Promise<Void> in
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
case let .smsPendingChoice(codeLength, trustedPhoneNumbers):
return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
case let .codeSent(codeLength):
let code = Current.shell.readLine("""
Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to.
Enter the \(codeLength) digit code from one of your trusted devices:
""") ?? ""
if code == "sms" {
// return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
return firstly {
Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: .device(code: code)))
.validateSecurityCodeResponse()
}
.then { (data, response) -> Promise<Void> in
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
}
}
func updateSession(serviceKey: String, sessionID: String, scnt: String) -> Promise<Void> {
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.then { (data, response) -> Promise<Void> in
Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
}
}
func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> Promise<AuthOptionsResponse.TrustedPhoneNumber> {
return firstly { () throws -> Guarantee<AuthOptionsResponse.TrustedPhoneNumber> in
Current.logging.log("Trusted phone numbers:")
trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in
Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)")
}
let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ")
guard
let selectionNumberString = possibleSelectionNumberString,
let selectionNumber = Int(selectionNumberString) ,
trustedPhoneNumbers.indices.contains(selectionNumber - 1)
else {
throw Error.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString)
}
return .value(trustedPhoneNumbers[selectionNumber - 1])
}
.recover { error throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> in
guard case Error.invalidPhoneNumberIndex = error else { throw error }
Current.logging.log("\(error.localizedDescription)\n")
return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers)
}
}
func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode {
let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? ""
return .sms(code: code, phoneNumberId: trustedPhoneNumber.id)
}
func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> Promise<Void> {
return firstly { () throws -> Promise<AuthOptionsResponse.TrustedPhoneNumber> in
// I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number,
// but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing.
guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else {
throw Error.noTrustedPhoneNumbers
}
return selectPhoneNumberInteractively(from: trustedPhoneNumbers)
}
.then { trustedPhoneNumber in
Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id))
.map { _ in
self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber)
}
}
.then { code in
Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
.validateSecurityCodeResponse()
}
.then { (data, response) -> Promise<Void> in
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
}
}
enum TwoFactorOption {
case smsSent(Int, AuthOptionsResponse.TrustedPhoneNumber)
case codeSent(Int)
case smsPendingChoice(Int, [AuthOptionsResponse.TrustedPhoneNumber])
}
public extension Promise where T == (data: Data, response: URLResponse) {
func validateSecurityCodeResponse() -> Promise<T> {
validate()
.recover { error -> Promise<(data: Data, response: URLResponse)> in
switch error {
case PMKHTTPError.badStatusCode(let code, _, _):
if code == 401 {
throw Client.Error.incorrectSecurityCode
} else {
throw error
}
default:
throw error
}
}
}
}
struct AuthOptionsResponse: Decodable {
let trustedPhoneNumbers: [TrustedPhoneNumber]?
let trustedDevices: [TrustedDevice]?
let securityCode: SecurityCodeInfo
let noTrustedDevices: Bool?
let serviceErrors: [ServiceError]?
var kind: Kind {
if trustedDevices != nil {
return .twoStep
} else if trustedPhoneNumbers != nil {
return .twoFactor
} else {
return .unknown
}
}
// One time with a new testing account I had a response where noTrustedDevices was nil, but the account didn't have any trusted devices.
// This should have been a situation where an SMS security code was sent automatically.
// This resolved itself either after some time passed, or by signing into appleid.apple.com with the account.
// Not sure if it's worth explicitly handling this case or if it'll be really rare.
var canFallBackToSMS: Bool {
noTrustedDevices == true
}
var smsAutomaticallySent: Bool {
trustedPhoneNumbers?.count == 1 && canFallBackToSMS
}
struct TrustedPhoneNumber: Decodable {
let id: Int
let numberWithDialCode: String
}
struct TrustedDevice: Decodable {
let id: String
let name: String
let modelName: String
}
struct SecurityCodeInfo: Decodable {
let length: Int
let tooManyCodesSent: Bool
let tooManyCodesValidated: Bool
let securityCodeLocked: Bool
let securityCodeCooldown: Bool
}
enum Kind {
case twoStep, twoFactor, unknown
}
}
public struct ServiceError: Decodable, Equatable {
let code: String
let message: String
}
enum SecurityCode {
case device(code: String)
case sms(code: String, phoneNumberId: Int)
var urlPathComponent: String {
switch self {
case .device: return "trusteddevice"
case .sms: return "phone"
}
}
}

View file

@ -0,0 +1,41 @@
import Foundation
import PromiseKit
import PMKFoundation
/**
Lightweight dependency injection using global mutable state :P
- SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy
- SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable
- SeeAlso: https://vimeo.com/291588126
*/
public struct Environment {
public var shell = Shell()
public var network = Network()
public var logging = Logging()
}
public var Current = Environment()
public struct Shell {
public var readLine: (String) -> String? = { prompt in
print(prompt, terminator: "")
return Swift.readLine()
}
public func readLine(prompt: String) -> String? {
readLine(prompt)
}
}
public struct Network {
public var session = URLSession.shared
public var dataTask: (URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> = { Current.network.session.dataTask(.promise, with: $0) }
public func dataTask(with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> {
dataTask(convertible)
}
}
public struct Logging {
public var log: (String) -> Void = { print($0) }
}

View file

@ -0,0 +1,120 @@
import Foundation
extension URL {
static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")!
static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")!
static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")!
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")!
static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
}
extension URLRequest {
static var itcServiceKey: URLRequest {
return URLRequest(url: .itcServiceKey)
}
static func signIn(serviceKey: String, accountName: String, password: String) -> URLRequest {
struct Body: Encodable {
let accountName: String
let password: String
let rememberMe = true
}
var request = URLRequest(url: .signIn)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript"
request.httpMethod = "POST"
request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password))
return request
}
static func authOptions(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
var request = URLRequest(url: .authOptions)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["accept"] = "application/json"
return request
}
static func requestSecurityCode(serviceKey: String, sessionID: String, scnt: String, trustedPhoneID: Int) throws -> URLRequest {
struct Body: Encodable {
let phoneNumber: PhoneNumber
let mode = "sms"
struct PhoneNumber: Encodable {
let id: Int
}
}
var request = URLRequest(url: .requestSecurityCode)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["accept"] = "application/json"
request.httpMethod = "PUT"
request.httpBody = try JSONEncoder().encode(Body(phoneNumber: .init(id: trustedPhoneID)))
return request
}
static func submitSecurityCode(serviceKey: String, sessionID: String, scnt: String, code: SecurityCode) throws -> URLRequest {
struct DeviceSecurityCodeRequest: Encodable {
let securityCode: SecurityCode
struct SecurityCode: Encodable {
let code: String
}
}
struct SMSSecurityCodeRequest: Encodable {
let securityCode: SecurityCode
let phoneNumber: PhoneNumber
let mode = "sms"
struct SecurityCode: Encodable {
let code: String
}
struct PhoneNumber: Encodable {
let id: Int
}
}
var request = URLRequest(url: .submitSecurityCode(code))
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["Accept"] = "application/json"
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
request.httpMethod = "POST"
switch code {
case .device(let code):
request.httpBody = try JSONEncoder().encode(DeviceSecurityCodeRequest(securityCode: .init(code: code)))
case .sms(let code, let phoneNumberId):
request.httpBody = try JSONEncoder().encode(SMSSecurityCodeRequest(securityCode: .init(code: code), phoneNumber: .init(id: phoneNumberId)))
}
return request
}
static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
var request = URLRequest(url: .trust)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
request.allHTTPHeaderFields?["scnt"] = scnt
request.allHTTPHeaderFields?["Accept"] = "application/json"
return request
}
static var olympusSession: URLRequest {
return URLRequest(url: .olympusSession)
}
}

View file

@ -0,0 +1,15 @@
import XCTest
@testable import AppleAPI
final class AppleAPITests: XCTestCase {
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(AppleAPI().text, "Hello, World!")
}
static var allTests = [
("testExample", testExample),
]
}

View file

@ -0,0 +1,9 @@
import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(AppleAPITests.allTests),
]
}
#endif

View file

@ -0,0 +1,7 @@
import XCTest
import AppleAPITests
var tests = [XCTestCaseEntry]()
tests += AppleAPITests.allTests()
XCTMain(tests)

View file

@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "install.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View file

@ -0,0 +1,402 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="16096"/>
</dependencies>
<scenes>
<!--Application-->
<scene sceneID="JPo-4y-FX3">
<objects>
<application id="hnw-xV-0zn" sceneMemberID="viewController">
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="Xcodes" id="1Xt-HY-uBw" userLabel="Xcodes">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Xcodes" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About Xcodes" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="Ady-hI-5gd" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Services" id="NMo-om-nkz">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide Xcodes" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="Ady-hI-5gd" id="PnN-Uc-m68"/>
</connections>
</menuItem>
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="hideOtherApplications:" target="Ady-hI-5gd" id="VT4-aY-XCT"/>
</connections>
</menuItem>
<menuItem title="Show All" id="Kd2-mp-pUS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unhideAllApplications:" target="Ady-hI-5gd" id="Dhg-Le-xox"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Quit Xcodes" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="File" id="dMs-cI-mzQ">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="File" id="bib-Uj-vzu">
<items>
<menuItem title="New" keyEquivalent="n" id="Was-JA-tGl">
<connections>
<action selector="newDocument:" target="Ady-hI-5gd" id="4Si-XN-c54"/>
</connections>
</menuItem>
<menuItem title="Open…" keyEquivalent="o" id="IAo-SY-fd9">
<connections>
<action selector="openDocument:" target="Ady-hI-5gd" id="bVn-NM-KNZ"/>
</connections>
</menuItem>
<menuItem title="Open Recent" id="tXI-mr-wws">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Open Recent" systemMenu="recentDocuments" id="oas-Oc-fiZ">
<items>
<menuItem title="Clear Menu" id="vNY-rz-j42">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="clearRecentDocuments:" target="Ady-hI-5gd" id="Daa-9d-B3U"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="Close" keyEquivalent="w" id="DVo-aG-piG">
<connections>
<action selector="performClose:" target="Ady-hI-5gd" id="HmO-Ls-i7Q"/>
</connections>
</menuItem>
<menuItem title="Save…" keyEquivalent="s" id="pxx-59-PXV">
<connections>
<action selector="saveDocument:" target="Ady-hI-5gd" id="teZ-XB-qJY"/>
</connections>
</menuItem>
<menuItem title="Save As…" keyEquivalent="S" id="Bw7-FT-i3A">
<connections>
<action selector="saveDocumentAs:" target="Ady-hI-5gd" id="mDf-zr-I0C"/>
</connections>
</menuItem>
<menuItem title="Revert to Saved" keyEquivalent="r" id="KaW-ft-85H">
<connections>
<action selector="revertDocumentToSaved:" target="Ady-hI-5gd" id="iJ3-Pv-kwq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="aJh-i4-bef"/>
<menuItem title="Page Setup…" keyEquivalent="P" id="qIS-W8-SiK">
<modifierMask key="keyEquivalentModifierMask" shift="YES" command="YES"/>
<connections>
<action selector="runPageLayout:" target="Ady-hI-5gd" id="Din-rz-gC5"/>
</connections>
</menuItem>
<menuItem title="Print…" keyEquivalent="p" id="aTl-1u-JFS">
<connections>
<action selector="print:" target="Ady-hI-5gd" id="qaZ-4w-aoO"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Edit" id="5QF-Oa-p0T">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
<items>
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
<connections>
<action selector="undo:" target="Ady-hI-5gd" id="M6e-cu-g7V"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
<connections>
<action selector="redo:" target="Ady-hI-5gd" id="oIA-Rs-6OD"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
<connections>
<action selector="cut:" target="Ady-hI-5gd" id="YJe-68-I9s"/>
</connections>
</menuItem>
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
<connections>
<action selector="copy:" target="Ady-hI-5gd" id="G1f-GL-Joy"/>
</connections>
</menuItem>
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
<connections>
<action selector="paste:" target="Ady-hI-5gd" id="UvS-8e-Qdg"/>
</connections>
</menuItem>
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="pasteAsPlainText:" target="Ady-hI-5gd" id="cEh-KX-wJQ"/>
</connections>
</menuItem>
<menuItem title="Delete" id="pa3-QI-u2k">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="delete:" target="Ady-hI-5gd" id="0Mk-Ml-PaM"/>
</connections>
</menuItem>
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
<connections>
<action selector="selectAll:" target="Ady-hI-5gd" id="VNm-Mi-diN"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
<menuItem title="Find" id="4EN-yA-p0u">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Find" id="1b7-l0-nxx">
<items>
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="cD7-Qs-BN4"/>
</connections>
</menuItem>
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="WD3-Gg-5AJ"/>
</connections>
</menuItem>
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="NDo-RZ-v9R"/>
</connections>
</menuItem>
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="HOh-sY-3ay"/>
</connections>
</menuItem>
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="U76-nv-p5D"/>
</connections>
</menuItem>
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
<connections>
<action selector="centerSelectionInVisibleArea:" target="Ady-hI-5gd" id="IOG-6D-g5B"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
<items>
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
<connections>
<action selector="showGuessPanel:" target="Ady-hI-5gd" id="vFj-Ks-hy3"/>
</connections>
</menuItem>
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
<connections>
<action selector="checkSpelling:" target="Ady-hI-5gd" id="fz7-VC-reM"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleContinuousSpellChecking:" target="Ady-hI-5gd" id="7w6-Qz-0kB"/>
</connections>
</menuItem>
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleGrammarChecking:" target="Ady-hI-5gd" id="muD-Qn-j4w"/>
</connections>
</menuItem>
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticSpellingCorrection:" target="Ady-hI-5gd" id="2lM-Qi-WAP"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Substitutions" id="9ic-FL-obx">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
<items>
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontSubstitutionsPanel:" target="Ady-hI-5gd" id="oku-mr-iSq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleSmartInsertDelete:" target="Ady-hI-5gd" id="3IJ-Se-DZD"/>
</connections>
</menuItem>
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticQuoteSubstitution:" target="Ady-hI-5gd" id="ptq-xd-QOA"/>
</connections>
</menuItem>
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDashSubstitution:" target="Ady-hI-5gd" id="oCt-pO-9gS"/>
</connections>
</menuItem>
<menuItem title="Smart Links" id="cwL-P1-jid">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticLinkDetection:" target="Ady-hI-5gd" id="Gip-E3-Fov"/>
</connections>
</menuItem>
<menuItem title="Data Detectors" id="tRr-pd-1PS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDataDetection:" target="Ady-hI-5gd" id="R1I-Nq-Kbl"/>
</connections>
</menuItem>
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticTextReplacement:" target="Ady-hI-5gd" id="DvP-Fe-Py6"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Transformations" id="2oI-Rn-ZJC">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
<items>
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="uppercaseWord:" target="Ady-hI-5gd" id="sPh-Tk-edu"/>
</connections>
</menuItem>
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="lowercaseWord:" target="Ady-hI-5gd" id="iUZ-b5-hil"/>
</connections>
</menuItem>
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="capitalizeWord:" target="Ady-hI-5gd" id="26H-TL-nsh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Speech" id="xrE-MZ-jX0">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
<items>
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="startSpeaking:" target="Ady-hI-5gd" id="654-Ng-kyl"/>
</connections>
</menuItem>
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="stopSpeaking:" target="Ady-hI-5gd" id="dX8-6p-jy9"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="View" id="H8h-7b-M4v">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="View" id="HyV-fh-RgO">
<items>
<menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="toggleFullScreen:" target="Ady-hI-5gd" id="dU3-MA-1Rq"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="Ady-hI-5gd" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="Ady-hI-5gd" id="DIl-cC-cCs"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="Ady-hI-5gd" id="DRN-fu-gQh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="Xcodes Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
</connections>
</application>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="XcodesMac" customModuleProvider="target"/>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="75" y="0.0"/>
</scene>
</scenes>
</document>

View file

@ -0,0 +1,98 @@
import SwiftUI
import XcodesKit
import Version
import PromiseKit
struct ContentView: View {
@ObservedObject var appState = AppState()
@State private var selection = Set<String>()
@State private var rowBeingConfirmedForUninstallation: AppState.XcodeVersion?
var body: some View {
List(appState.allVersions, selection: $selection) { row in
VStack(alignment: .leading) {
HStack {
Text(row.title)
.font(.body)
if row.selected {
Tag(text: "SELECTED")
.foregroundColor(.green)
}
Spacer()
Button(row.installed ? "INSTALLED" : "INSTALL") {
print("Installing...")
}
.buttonStyle(AppStoreButtonStyle(installed: row.installed,
highlighted: self.selection.contains(row.id)))
.disabled(row.installed)
}
Text(verbatim: row.path ?? "")
.font(.caption)
.foregroundColor(self.selection.contains(row.id) ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor))
// if row.installed {
// HStack {
// Button(action: { row.installed ? self.rowBeingConfirmedForUninstallation = row : self.appState.install(id: row.id) }) {
// Text("Uninstall")
// }
// Button(action: { self.appState.reveal(id: row.id) }) {
// Text("Reveal in Finder")
// }
// Button(action: { self.appState.select(id: row.id) }) {
// Text("Select")
// }
// }
// .buttonStyle(PlainButtonStyle())
// .foregroundColor(
// self.selection.contains(row.id) ?
// Color(NSColor.selectedMenuItemTextColor) :
// .accentColor
// )
// }
}
.contextMenu {
Button(action: { row.installed ? self.rowBeingConfirmedForUninstallation = row : self.appState.install(id: row.id) }) {
Text(row.installed ? "Uninstall" : "Install")
}
if row.installed {
Button(action: { self.appState.reveal(id: row.id) }) {
Text("Reveal in Finder")
}
Button(action: { self.appState.select(id: row.id) }) {
Text("Select")
}
}
}
}
.frame(minWidth: 200, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity)
.onAppear(perform: appState.load)
.toolbar {
ToolbarItem {
Button(action: { appState.update().cauterize() }) {
Image(systemName: "arrow.clockwise")
}
.keyboardShortcut("r")
}
}
.alert(item: $appState.error) { error in
Alert(title: Text(error.title),
message: Text(verbatim: error.message),
dismissButton: .default(Text("OK")))
}
.alert(item: self.$rowBeingConfirmedForUninstallation) { row in
Alert(title: Text("Uninstall Xcode \(row.title)?"),
message: Text("It will be moved to the Trash, but won't be emptied."),
primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: row.id) }),
secondaryButton: .cancel(Text("Cancel")))
}
.sheet(isPresented: $appState.presentingSignInAlert, content: {
SignInCredentialsView(isPresented: $appState.presentingSignInAlert)
.environmentObject(appState)
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

34
XcodesMac/Info.plist Normal file
View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2020 Robots and Pencils. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,33 @@
import SwiftUI
import AppleAPI
struct SignIn2FAView: View {
@EnvironmentObject var appState: AppState
@Binding var isPresented: Bool
@State private var code: String = ""
var body: some View {
VStack(alignment: .leading) {
Text("Enter the \(6) digit code from one of your trusted devices:")
HStack {
TextField("\(6) digit code", text: $code)
}
HStack {
Button("Cancel", action: { isPresented = false })
Button("Send SMS", action: {})
Spacer()
Button("Continue", action: {})
}
}
.padding()
}
}
struct SignIn2FAView_Previews: PreviewProvider {
static var previews: some View {
SignIn2FAView(isPresented: .constant(true))
.environmentObject(AppState())
}
}

View file

@ -0,0 +1,42 @@
import SwiftUI
struct SignInCredentialsView: View {
@EnvironmentObject var appState: AppState
@Binding var isPresented: Bool
@State private var username: String = ""
@State private var password: String = ""
var body: some View {
VStack {
HStack {
Text("Apple ID")
TextField("Apple ID", text: $username)
}
HStack {
Text("Password")
SecureField("Password", text: $password)
}
HStack {
Button("Cancel") {
isPresented = false
}
.keyboardShortcut(.cancelAction)
Spacer()
Button("Sign In") {
appState.continueLogin(username: username, password: password)
}
.keyboardShortcut(.defaultAction)
}
}
.padding()
}
}
struct SignInCredentialsView_Previews: PreviewProvider {
static var previews: some View {
SignInCredentialsView(isPresented: .constant(true))
.environmentObject(AppState())
}
}

View file

@ -0,0 +1,31 @@
import SwiftUI
struct SignInPhoneListView: View {
@EnvironmentObject var appState: AppState
@Binding var isPresented: Bool
var phoneNumbers: [String]
var body: some View {
VStack(alignment: .leading) {
Text("Select a trusted phone number to receive a code via SMS: ")
List(phoneNumbers, id: \.self) {
Text($0)
}
.frame(height: 200)
HStack {
Button("Cancel", action: { isPresented = false })
Spacer()
Button("Continue", action: {})
}
}
.padding()
}
}
struct SignInPhoneListView_Previews: PreviewProvider {
static var previews: some View {
SignInPhoneListView(isPresented: .constant(true), phoneNumbers: ["123-456-7890"])
}
}

View file

@ -0,0 +1,31 @@
import SwiftUI
struct SignInSMSView: View {
@EnvironmentObject var appState: AppState
@Binding var isPresented: Bool
@State private var code: String = ""
var body: some View {
VStack(alignment: .leading) {
Text("Enter the \(6) digit code sent to \("phone number"): ")
HStack {
TextField("\(6) digit code", text: $code)
}
HStack {
Button("Cancel", action: { isPresented = false })
Spacer()
Button("Continue", action: {})
}
}
.padding()
}
}
struct SignInSMSView_Previews: PreviewProvider {
static var previews: some View {
SignInSMSView(isPresented: .constant(true))
.environmentObject(AppState())
}
}

18
XcodesMac/Tag.swift Normal file
View file

@ -0,0 +1,18 @@
import SwiftUI
struct Tag: View {
var text: String
var body: some View {
Text(text)
.foregroundColor(.white)
.background(RoundedRectangle(cornerRadius: 3).padding([.leading, .trailing], -3))
}
}
struct Tag_Previews: PreviewProvider {
static var previews: some View {
Tag(text: "SELECTED")
.foregroundColor(.green)
.padding()
}
}

11
XcodesMac/XcodesApp.swift Normal file
View file

@ -0,0 +1,11 @@
import Cocoa
import SwiftUI
@main
struct XcodesApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

5
XcodesMac/XcodesKit/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

View file

@ -0,0 +1,37 @@
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "XcodesKit",
platforms: [.macOS(.v10_13)],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "XcodesKit",
targets: ["XcodesKit"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(path: "../AppleAPI"),
.package(url: "https://github.com/mxcl/Path.swift.git", .upToNextMajor(from: "0.16.0")),
.package(url: "https://github.com/mxcl/Version.git", .upToNextMajor(from: "2.0.0")),
.package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")),
.package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")),
.package(url: "https://github.com/scinfu/SwiftSoup.git", .upToNextMajor(from: "2.3.2")),
.package(url: "https://github.com/mxcl/LegibleError.git", .upToNextMajor(from: "1.0.1")),
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMajor(from: "3.2.0")),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "XcodesKit",
dependencies: ["AppleAPI", .product(name: "Path", package: "Path.swift"), "Version", "PromiseKit", "PMKFoundation", "SwiftSoup", "LegibleError", "KeychainAccess"]),
.testTarget(
name: "XcodesKitTests",
dependencies: ["XcodesKit"]),
]
)

View file

@ -0,0 +1,3 @@
# XcodesKit
A description of this package.

View file

@ -0,0 +1,125 @@
import Foundation
/// A LocalizedError that represents a non-zero exit code from running aria2c.
struct Aria2CError: LocalizedError {
var code: Code
init?(exitStatus: Int32) {
guard let code = Code(rawValue: exitStatus) else { return nil }
self.code = code
}
var errorDescription: String? {
"aria2c error: \(code.description)"
}
// https://github.com/aria2/aria2/blob/master/src/error_code.h
enum Code: Int32, CustomStringConvertible {
case undefined = -1
// Ignoring, not an error
// case finished = 0
case unknownError = 1
case timeOut
case resourceNotFound
case maxFileNotFound
case tooSlowDownloadSpeed
case networkProblem
case inProgress
case cannotResume
case notEnoughDiskSpace
case pieceLengthChanged
case duplicateDownload
case duplicateInfoHash
case fileAlreadyExists
case fileRenamingFailed
case fileOpenError
case fileCreateError
case fileIoError
case dirCreateError
case nameResolveError
case metalinkParseError
case ftpProtocolError
case httpProtocolError
case httpTooManyRedirects
case httpAuthFailed
case bencodeParseError
case bittorrentParseError
case magnetParseError
case optionError
case httpServiceUnavailable
case jsonParseError
case removed
case checksumError
var description: String {
switch self {
case .undefined:
return "Undefined"
case .unknownError:
return "Unknown error"
case .timeOut:
return "Timed out"
case .resourceNotFound:
return "Resource not found"
case .maxFileNotFound:
return "Maximum number of file not found errors reached"
case .tooSlowDownloadSpeed:
return "Download speed too slow"
case .networkProblem:
return "Network problem"
case .inProgress:
return "Unfinished downloads in progress"
case .cannotResume:
return "Remote server did not support resume when resume was required to complete download"
case .notEnoughDiskSpace:
return "Not enough disk space available"
case .pieceLengthChanged:
return "Piece length was different from one in .aria2 control file"
case .duplicateDownload:
return "Duplicate download"
case .duplicateInfoHash:
return "Duplicate info hash torrent"
case .fileAlreadyExists:
return "File already exists"
case .fileRenamingFailed:
return "Renaming file failed"
case .fileOpenError:
return "Could not open existing file"
case .fileCreateError:
return "Could not create new file or truncate existing file"
case .fileIoError:
return "File I/O error"
case .dirCreateError:
return "Could not create directory"
case .nameResolveError:
return "Name resolution failed"
case .metalinkParseError:
return "Could not parse Metalink document"
case .ftpProtocolError:
return "FTP command failed"
case .httpProtocolError:
return "HTTP response header was bad or unexpected"
case .httpTooManyRedirects:
return "Too many redirects occurred"
case .httpAuthFailed:
return "HTTP authorization failed"
case .bencodeParseError:
return "Could not parse bencoded file (usually \".torrent\" file)"
case .bittorrentParseError:
return "\".torrent\" file was corrupted or missing information"
case .magnetParseError:
return "Magnet URI was bad"
case .optionError:
return "Bad/unrecognized option was given or unexpected option argument was given"
case .httpServiceUnavailable:
return "HTTP service unavailable"
case .jsonParseError:
return "Could not parse JSON-RPC request"
case .removed:
return "Reserved. Not used."
case .checksumError:
return "Checksum validation failed"
}
}
}
}

View file

@ -0,0 +1,21 @@
import Foundation
import Path
public struct Configuration: Codable {
public var defaultUsername: String?
public init() {
self.defaultUsername = nil
}
public mutating func load() throws {
guard let data = Current.files.contents(atPath: Path.configurationFile.string) else { return }
self = try JSONDecoder().decode(Configuration.self, from: data)
}
public func save() throws {
let data = try JSONEncoder().encode(self)
try Current.files.createDirectory(at: Path.configurationFile.url.deletingLastPathComponent(), withIntermediateDirectories: true)
Current.files.createFile(atPath: Path.configurationFile.string, contents: data)
}
}

View file

@ -0,0 +1,17 @@
import Foundation
extension DateFormatter {
/// Date format used in JSON returned from `URL.downloads`
static let downloadsDateModified: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yy HH:mm"
return formatter
}()
/// Date format used in HTML returned from `URL.download`
static let downloadsReleaseDate: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM d, yyyy"
return formatter
}()
}

View file

@ -0,0 +1,20 @@
import Foundation
import Path
extension Entry {
var isAppBundle: Bool {
kind == .directory &&
path.extension == "app" &&
!path.isSymlink
}
var infoPlist: InfoPlist? {
let infoPlistPath = path.join("Contents").join("Info.plist")
guard
let infoPlistData = try? Data(contentsOf: infoPlistPath.url),
let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData)
else { return nil }
return infoPlist
}
}

View file

@ -0,0 +1,278 @@
import Foundation
import PromiseKit
import PMKFoundation
import Path
import AppleAPI
import KeychainAccess
/**
Lightweight dependency injection using global mutable state :P
- SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy
- SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable
- SeeAlso: https://vimeo.com/291588126
*/
public struct Environment {
public var shell = Shell()
public var files = Files()
public var network = Network()
public var logging = Logging()
public var keychain = Keychain()
}
public var Current = Environment()
public struct Shell {
public var unxip: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") }
public var spctlAssess: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") }
public var codesignVerify: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") }
public var devToolsSecurityEnable: (String?) -> Promise<ProcessOutput> = { Process.sudo(password: $0, Path.root.usr.sbin.DevToolsSecurity, "-enable") }
public var addStaffToDevelopersGroup: (String?) -> Promise<ProcessOutput> = { Process.sudo(password: $0, Path.root.usr.sbin.dseditgroup, "-o", "edit", "-t", "group", "-a", "staff", "_developer") }
public var acceptXcodeLicense: (InstalledXcode, String?) -> Promise<ProcessOutput> = { Process.sudo(password: $1, $0.path.join("/Contents/Developer/usr/bin/xcodebuild"), "-license", "accept") }
public var runFirstLaunch: (InstalledXcode, String?) -> Promise<ProcessOutput> = { Process.sudo(password: $1, $0.path.join("/Contents/Developer/usr/bin/xcodebuild"),"-runFirstLaunch") }
public var buildVersion: () -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") }
public var xcodeBuildVersion: (InstalledXcode) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.libexec.PlistBuddy, "-c", "Print :ProductBuildVersion", "\($0.path.string)/Contents/version.plist") }
public var getUserCacheDir: () -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.getconf, "DARWIN_USER_CACHE_DIR") }
public var touchInstallCheck: (String, String, String) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") }
public var validateSudoAuthentication: () -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.sudo, "-nv") }
public var authenticateSudoerIfNecessary: (@escaping () -> Promise<String>) -> Promise<String?> = { passwordInput in
firstly { () -> Promise<String?> in
Current.shell.validateSudoAuthentication().map { _ in return nil }
}
.recover { _ -> Promise<String?> in
return passwordInput().map(Optional.init)
}
}
public func authenticateSudoerIfNecessary(passwordInput: @escaping () -> Promise<String>) -> Promise<String?> {
authenticateSudoerIfNecessary(passwordInput)
}
public var xcodeSelectPrintPath: () -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") }
public var xcodeSelectSwitch: (String?, String) -> Promise<ProcessOutput> = { Process.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) }
public func xcodeSelectSwitch(password: String?, path: String) -> Promise<ProcessOutput> {
xcodeSelectSwitch(password, path)
}
public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, Promise<Void>) = { aria2Path, url, destination, cookies in
let process = Process()
process.executableURL = aria2Path.url
process.arguments = [
"--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))",
"--max-connection-per-server=16",
"--split=16",
"--summary-interval=1",
"--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)",
"--dir=\(destination.parent.string)",
"--out=\(destination.basename())",
url.absoluteString,
]
let stdOutPipe = Pipe()
process.standardOutput = stdOutPipe
let stdErrPipe = Pipe()
process.standardError = stdErrPipe
var progress = Progress(totalUnitCount: 100)
let observer = NotificationCenter.default.addObserver(
forName: .NSFileHandleDataAvailable,
object: nil,
queue: OperationQueue.main
) { note in
guard
// This should always be the case for Notification.Name.NSFileHandleDataAvailable
let handle = note.object as? FileHandle,
handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading
else { return }
defer { handle.waitForDataInBackgroundAndNotify() }
let string = String(decoding: handle.availableData, as: UTF8.self)
let regex = try! NSRegularExpression(pattern: #"((?<percent>\d+)%\))"#)
let range = NSRange(location: 0, length: string.utf16.count)
guard
let match = regex.firstMatch(in: string, options: [], range: range),
let matchRange = Range(match.range(withName: "percent"), in: string),
let percentCompleted = Int64(string[matchRange])
else { return }
progress.completedUnitCount = percentCompleted
}
stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
do {
try process.run()
} catch {
return (progress, Promise(error: error))
}
let promise = Promise<Void> { seal in
DispatchQueue.global(qos: .default).async {
process.waitUntilExit()
NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) {
return seal.reject(aria2cError)
} else {
return seal.reject(Process.PMKError.execution(process: process, standardOutput: "", standardError: ""))
}
}
seal.fulfill(())
}
}
return (progress, promise)
}
public var readLine: (String) -> String? = { prompt in
print(prompt, terminator: "")
return Swift.readLine()
}
public func readLine(prompt: String) -> String? {
readLine(prompt)
}
public var readSecureLine: (String, Int) -> String? = { prompt, maximumLength in
let buffer = UnsafeMutablePointer<Int8>.allocate(capacity: maximumLength)
buffer.initialize(repeating: 0, count: maximumLength)
defer {
buffer.deinitialize(count: maximumLength)
buffer.initialize(repeating: 0, count: maximumLength)
buffer.deinitialize(count: maximumLength)
buffer.deallocate()
}
guard let passwordData = readpassphrase(prompt, buffer, maximumLength, 0) else {
return nil
}
return String(validatingUTF8: passwordData)
}
/**
Like `readLine()`, but doesn't echo the user's input to the screen.
- Parameter prompt: Prompt printed on the line preceding user input
- Parameter maximumLength: The maximum length to read, in bytes
- Returns: The entered password, or nil if an error occurred.
Buffer is zeroed after use.
- SeeAlso: [readpassphrase man page](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/readpassphrase.3.html)
*/
public func readSecureLine(prompt: String, maximumLength: Int = 8192) -> String? {
readSecureLine(prompt, maximumLength)
}
public var env: (String) -> String? = { key in
ProcessInfo.processInfo.environment[key]
}
public func env(_ key: String) -> String? {
env(key)
}
public var exit: (Int32) -> Void = { Darwin.exit($0) }
}
public struct Files {
public var fileExistsAtPath: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }
public func fileExists(atPath path: String) -> Bool {
return fileExistsAtPath(path)
}
public var moveItem: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) }
public func moveItem(at srcURL: URL, to dstURL: URL) throws {
try moveItem(srcURL, dstURL)
}
public var contentsAtPath: (String) -> Data? = { FileManager.default.contents(atPath: $0) }
public func contents(atPath path: String) -> Data? {
return contentsAtPath(path)
}
public var removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }
public func removeItem(at URL: URL) throws {
try removeItem(URL)
}
public var trashItem: (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) }
@discardableResult
public func trashItem(at URL: URL) throws -> URL {
return try trashItem(URL)
}
public var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) }
@discardableResult
public func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? = nil) -> Bool {
return createFile(path, data, attr)
}
public var createDirectory: (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = FileManager.default.createDirectory(at:withIntermediateDirectories:attributes:)
public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws {
try createDirectory(url, createIntermediates, attributes)
}
public var installedXcodes = XcodesKit.installedXcodes
}
private func installedXcodes(destination: Path) -> [InstalledXcode] {
((try? destination.ls()) ?? [])
.filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" }
.map { $0.path }
.compactMap(InstalledXcode.init)
}
public struct Network {
private static let client = AppleAPI.Client()
public var dataTask: (URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> = { AppleAPI.Current.network.session.dataTask(.promise, with: $0) }
public func dataTask(with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> {
dataTask(convertible)
}
public var downloadTask: (URLRequestConvertible, URL, Data?) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) }
public func downloadTask(with convertible: URLRequestConvertible, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, promise: Promise<(saveLocation: URL, response: URLResponse)>) {
return downloadTask(convertible, saveLocation, resumeData)
}
public var validateSession: () -> Promise<Void> = client.validateSession
public var login: (String, String) -> Promise<Void> = { client.login(accountName: $0, password: $1) }
public func login(accountName: String, password: String) -> Promise<Void> {
login(accountName, password)
}
}
public struct Logging {
public var log: (String) -> Void = { print($0) }
}
public struct Keychain {
private static let keychain = KeychainAccess.Keychain(service: "com.robotsandpencils.xcodes")
public var getString: (String) throws -> String? = keychain.getString(_:)
public func getString(_ key: String) throws -> String? {
try getString(key)
}
public var set: (String, String) throws -> Void = keychain.set(_:key:)
public func set(_ value: String, key: String) throws {
try set(value, key)
}
public var remove: (String) throws -> Void = keychain.remove(_:)
public func remove(_ key: String) throws -> Void {
try remove(key)
}
}

View file

@ -0,0 +1,17 @@
import Foundation
extension FileManager {
/**
Moves an item to the trash.
This implementation exists only to make the existing method more idiomatic by returning the resulting URL instead of setting the value on an inout argument.
FB6735133: FileManager.trashItem(at:resultingItemURL:) is not an idiomatic Swift API
*/
@discardableResult
func trashItem(at url: URL) throws -> URL {
var resultingItemURL: NSURL!
try trashItem(at: url, resultingItemURL: &resultingItemURL)
return resultingItemURL as URL
}
}

View file

@ -0,0 +1,28 @@
import Foundation
public extension BidirectionalCollection where Element: Equatable {
func suffix(fromLast delimiter: Element) -> Self.SubSequence {
guard
let lastIndex = lastIndex(of: delimiter),
index(after: lastIndex) < endIndex
else { return suffix(0) }
return suffix(from: index(after: lastIndex))
}
}
public extension NumberFormatter {
convenience init(numberStyle: NumberFormatter.Style) {
self.init()
self.numberStyle = numberStyle
}
func string<N: Numeric>(from number: N) -> String? {
return string(from: number as! NSNumber)
}
}
extension Sequence {
func sorted<Value: Comparable>(_ keyPath: KeyPath<Element, Value>) -> [Element] {
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
}
}

View file

@ -0,0 +1,17 @@
import Path
/// Migrates any application support files from Xcodes < v0.4 if application support files from >= v0.4 don't exist
public func migrateApplicationSupportFiles() {
if Current.files.fileExistsAtPath(Path.oldXcodesApplicationSupport.string) {
if Current.files.fileExistsAtPath(Path.xcodesApplicationSupport.string) {
Current.logging.log("Removing old support files...")
try? Current.files.removeItem(Path.oldXcodesApplicationSupport.url)
Current.logging.log("Done")
}
else {
Current.logging.log("Migrating old support files...")
try? Current.files.moveItem(Path.oldXcodesApplicationSupport.url, Path.xcodesApplicationSupport.url)
Current.logging.log("Done")
}
}
}

View file

@ -0,0 +1,89 @@
import Foundation
import Path
import Version
public struct InstalledXcode: Equatable {
public let path: Path
/// Composed of the bundle short version from Info.plist and the product build version from version.plist
public let version: Version
public init?(path: Path) {
self.path = path
let infoPlistPath = path.join("Contents").join("Info.plist")
let versionPlistPath = path.join("Contents").join("version.plist")
guard
let infoPlistData = Current.files.contents(atPath: infoPlistPath.string),
let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData),
let bundleShortVersion = infoPlist.bundleShortVersion,
let bundleVersion = Version(tolerant: bundleShortVersion),
let versionPlistData = Current.files.contents(atPath: versionPlistPath.string),
let versionPlist = try? PropertyListDecoder().decode(VersionPlist.self, from: versionPlistData)
else { return nil }
// Installed betas don't include the beta number anywhere, so try to parse it from the filename or fall back to simply "beta"
var prereleaseIdentifiers = bundleVersion.prereleaseIdentifiers
if let filenameVersion = Version(path.basename(dropExtension: true).replacingOccurrences(of: "Xcode-", with: "")) {
prereleaseIdentifiers = filenameVersion.prereleaseIdentifiers
}
else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") {
prereleaseIdentifiers = ["beta"]
}
self.version = Version(major: bundleVersion.major,
minor: bundleVersion.minor,
patch: bundleVersion.patch,
prereleaseIdentifiers: prereleaseIdentifiers,
buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 })
}
}
public struct Xcode: Codable {
public let version: Version
public let url: URL
public let filename: String
public let releaseDate: Date?
public init(version: Version, url: URL, filename: String, releaseDate: Date?) {
self.version = version
self.url = url
self.filename = filename
self.releaseDate = releaseDate
}
}
struct Downloads: Codable {
let downloads: [Download]
}
public struct Download: Codable {
public let name: String
public let files: [File]
public let dateModified: Date
public struct File: Codable {
public let remotePath: String
}
}
public struct InfoPlist: Decodable {
public let bundleID: String?
public let bundleShortVersion: String?
public let bundleIconName: String?
public enum CodingKeys: String, CodingKey {
case bundleID = "CFBundleIdentifier"
case bundleShortVersion = "CFBundleShortVersionString"
case bundleIconName = "CFBundleIconName"
}
}
public struct VersionPlist: Decodable {
public let productBuildVersion: String
public enum CodingKeys: String, CodingKey {
case productBuildVersion = "ProductBuildVersion"
}
}

View file

@ -0,0 +1,8 @@
import Path
extension Path {
static let oldXcodesApplicationSupport = Path.applicationSupport/"ca.brandonevans.xcodes"
static let xcodesApplicationSupport = Path.applicationSupport/"com.robotsandpencils.xcodes"
static let cacheFile = xcodesApplicationSupport/"available-xcodes.json"
static let configurationFile = xcodesApplicationSupport/"configuration.json"
}

View file

@ -0,0 +1,41 @@
import Foundation
import PromiseKit
import PMKFoundation
import Path
public typealias ProcessOutput = (status: Int32, out: String, err: String)
extension Process {
@discardableResult
static func sudo(password: String? = nil, _ executable: Path, workingDirectory: URL? = nil, _ arguments: String...) -> Promise<ProcessOutput> {
var arguments = [executable.string] + arguments
if password != nil {
arguments.insert("-S", at: 0)
}
return run(Path.root.usr.bin.sudo.url, workingDirectory: workingDirectory, input: password, arguments)
}
@discardableResult
static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> Promise<ProcessOutput> {
return run(executable.url, workingDirectory: workingDirectory, input: input, arguments)
}
@discardableResult
static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) -> Promise<ProcessOutput> {
let process = Process()
process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent()
process.executableURL = executable
process.arguments = arguments
if let input = input {
let inputPipe = Pipe()
process.standardInput = inputPipe.fileHandleForReading
inputPipe.fileHandleForWriting.write(Data(input.utf8))
inputPipe.fileHandleForWriting.closeFile()
}
return process.launch(.promise).map { std in
let output = String(data: std.out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let error = String(data: std.err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return (process.terminationStatus, output, error)
}
}
}

View file

@ -0,0 +1,40 @@
import Foundation
import PromiseKit
/// Attempt and retry a task that fails with resume data up to `maximumRetryCount` times
func attemptResumableTask<T>(
maximumRetryCount: Int = 3,
delayBeforeRetry: DispatchTimeInterval = .seconds(2),
_ body: @escaping (Data?) -> Promise<T>
) -> Promise<T> {
var attempts = 0
func attempt(with resumeData: Data? = nil) -> Promise<T> {
attempts += 1
return body(resumeData).recover { error -> Promise<T> in
guard
attempts < maximumRetryCount,
let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data
else { throw error }
return after(delayBeforeRetry).then(on: nil) { attempt(with: resumeData) }
}
}
return attempt()
}
/// Attempt and retry a task up to `maximumRetryCount` times
func attemptRetryableTask<T>(
maximumRetryCount: Int = 3,
delayBeforeRetry: DispatchTimeInterval = .seconds(2),
_ body: @escaping () -> Promise<T>
) -> Promise<T> {
var attempts = 0
func attempt() -> Promise<T> {
attempts += 1
return body().recover { error -> Promise<T> in
guard attempts < maximumRetryCount else { throw error }
return after(delayBeforeRetry).then(on: nil) { attempt() }
}
}
return attempt()
}

View file

@ -0,0 +1,28 @@
import Foundation
extension URL {
static let download = URL(string: "https://developer.apple.com/download")!
static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")!
static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")!
}
extension URLRequest {
static var download: URLRequest {
return URLRequest(url: .download)
}
static var downloads: URLRequest {
var request = URLRequest(url: .downloads)
request.httpMethod = "POST"
return request
}
static func downloadXcode(path: String) -> URLRequest {
var components = URLComponents(url: .downloadXcode, resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "path", value: path)]
var request = URLRequest(url: components.url!)
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
request.allHTTPHeaderFields?["Accept"] = "*/*"
return request
}
}

View file

@ -0,0 +1,47 @@
import Foundation
import PromiseKit
import PMKFoundation
extension URLSession {
/**
- Parameter convertible: A URL or URLRequest.
- Parameter saveLocation: A URL to move the downloaded file to after it completes. Apple deletes the temporary file immediately after the underyling completion handler returns.
- Parameter resumeData: Data describing the state of a previously cancelled or failed download task. See the Discussion section for `downloadTask(withResumeData:completionHandler:)` https://developer.apple.com/documentation/foundation/urlsession/1411598-downloadtask#
- Returns: Tuple containing a Progress object for the task and a promise containing the save location and response.
- Note: We do not create the destination directory for you, because we move the file with FileManager.moveItem which changes its behavior depending on the directory status of the URL you provide. So create your own directory first!
*/
public func downloadTask(with convertible: URLRequestConvertible, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, promise: Promise<(saveLocation: URL, response: URLResponse)>) {
var progress: Progress!
let promise = Promise<(saveLocation: URL, response: URLResponse)> { seal in
let completionHandler = { (temporaryURL: URL?, response: URLResponse?, error: Error?) in
if let error = error {
seal.reject(error)
} else if let response = response, let temporaryURL = temporaryURL {
do {
try FileManager.default.moveItem(at: temporaryURL, to: saveLocation)
seal.fulfill((saveLocation, response))
} catch {
seal.reject(error)
}
} else {
seal.reject(PMKError.invalidCallingConvention)
}
}
let task: URLSessionDownloadTask
if let resumeData = resumeData {
task = downloadTask(withResumeData: resumeData, completionHandler: completionHandler)
}
else {
task = downloadTask(with: convertible.pmkRequest, completionHandler: completionHandler)
}
progress = task.progress
task.resume()
}
return (progress, promise)
}
}

View file

@ -0,0 +1,51 @@
import Version
public extension Version {
func isEqualWithoutBuildMetadataIdentifiers(to other: Version) -> Bool {
return major == other.major &&
minor == other.minor &&
patch == other.patch &&
prereleaseIdentifiers == other.prereleaseIdentifiers
}
/// If release versions, don't compare build metadata because that's not provided in the /downloads/more list
/// if beta versions, compare build metadata because it's available in versions.plist
func isEquivalentForDeterminingIfInstalled(toInstalled installed: Version) -> Bool {
let isBeta = !prereleaseIdentifiers.isEmpty
let otherIsBeta = !installed.prereleaseIdentifiers.isEmpty
if isBeta && otherIsBeta {
if buildMetadataIdentifiers.isEmpty {
return major == installed.major &&
minor == installed.minor &&
patch == installed.patch &&
prereleaseIdentifiers == installed.prereleaseIdentifiers
}
else {
return major == installed.major &&
minor == installed.minor &&
patch == installed.patch &&
prereleaseIdentifiers == installed.prereleaseIdentifiers &&
buildMetadataIdentifiers.map { $0.lowercased() } == installed.buildMetadataIdentifiers.map { $0.lowercased() }
}
}
else if !isBeta && !otherIsBeta {
return major == installed.major &&
minor == installed.minor &&
patch == installed.patch
}
return false
}
var descriptionWithoutBuildMetadata: String {
var base = "\(major).\(minor).\(patch)"
if !prereleaseIdentifiers.isEmpty {
base += "-" + prereleaseIdentifiers.joined(separator: ".")
}
return base
}
var isPrerelease: Bool { prereleaseIdentifiers.isEmpty == false }
var isNotPrerelease: Bool { prereleaseIdentifiers.isEmpty == true }
}

View file

@ -0,0 +1,45 @@
import Foundation
import Version
public extension Version {
/**
Attempts to parse Gem::Version representations.
E.g.:
9.2b3
9.1.2
9.2
9
Doesn't handle GM prerelease identifier
*/
init?(gemVersion: String) {
let nsrange = NSRange(gemVersion.startIndex..<gemVersion.endIndex, in: gemVersion)
let pattern = "^(?<major>\\d+)\\.?(?<minor>\\d?)?\\.?(?<patch>\\d?)?\\.?(?<prereleaseType>\\w?)?\\.?(?<prereleaseVersion>\\d?)"
guard
let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: gemVersion, options: [], range: nsrange),
let majorString = match.groupNamed("major", in: gemVersion),
let major = Int(majorString),
let minorString = match.groupNamed("minor", in: gemVersion),
let patchString = match.groupNamed("patch", in: gemVersion)
else { return nil }
let minor = Int(minorString) ?? 0
let patch = Int(patchString) ?? 0
let prereleaseIdentifiers = [match.groupNamed("prereleaseType", in: gemVersion),
match.groupNamed("prereleaseVersion", in: gemVersion)]
.compactMap { $0 }
.filter { !$0.isEmpty }
.map { identifier -> String in
switch identifier.lowercased() {
case "a": return "Alpha"
case "b": return "Beta"
default: return identifier
}
}
self = Version(major: major, minor: minor, patch: patch, prereleaseIdentifiers: prereleaseIdentifiers)
}
}

View file

@ -0,0 +1,65 @@
import Foundation
import Version
public extension Version {
/**
E.g.:
Xcode 10.2 Beta 4
Xcode 10.2 GM
Xcode 10.2 GM seed 2
Xcode 10.2
Xcode 10.2.1
10.2 Beta 4
10.2 GM
10.2
10.2.1
*/
init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) {
let nsrange = NSRange(xcodeVersion.startIndex..<xcodeVersion.endIndex, in: xcodeVersion)
// https://regex101.com/r/dLLvsz/1
let pattern = "^(Xcode )?(?<major>\\d+)\\.?(?<minor>\\d?)\\.?(?<patch>\\d?) ?(?<prereleaseType>[a-zA-Z ]+)? ?(?<prereleaseVersion>\\d?)"
guard
let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: xcodeVersion, options: [], range: nsrange),
let majorString = match.groupNamed("major", in: xcodeVersion),
let major = Int(majorString),
let minorString = match.groupNamed("minor", in: xcodeVersion),
let patchString = match.groupNamed("patch", in: xcodeVersion)
else { return nil }
let minor = Int(minorString) ?? 0
let patch = Int(patchString) ?? 0
let prereleaseIdentifiers = [match.groupNamed("prereleaseType", in: xcodeVersion),
match.groupNamed("prereleaseVersion", in: xcodeVersion)]
.compactMap { $0?.lowercased().trimmingCharacters(in: .whitespaces).replacingOccurrences(of: " ", with: "-") }
.filter { !$0.isEmpty }
self = Version(major: major, minor: minor, patch: patch, prereleaseIdentifiers: prereleaseIdentifiers, buildMetadataIdentifiers: [buildMetadataIdentifier].compactMap { $0 })
}
var xcodeDescription: String {
var base = "\(major).\(minor)"
if patch != 0 {
base += ".\(patch)"
}
if !prereleaseIdentifiers.isEmpty {
base += " " + prereleaseIdentifiers
.map { $0.replacingOccurrences(of: "-", with: " ").capitalized.replacingOccurrences(of: "Gm", with: "GM") }
.joined(separator: " ")
if !buildMetadataIdentifiers.isEmpty {
base += " (\(buildMetadataIdentifiers.joined(separator: " ")))"
}
}
return base
}
}
extension NSTextCheckingResult {
func groupNamed(_ name: String, in string: String) -> String? {
let nsrange = range(withName: name)
guard let range = Range(nsrange, in: string) else { return nil }
return String(string[range])
}
}

View file

@ -0,0 +1,3 @@
import Version
public let version = Version("0.12.0")!

View file

@ -0,0 +1,733 @@
import Foundation
import PromiseKit
import Path
import AppleAPI
import Version
import LegibleError
/// Downloads and installs Xcodes
public final class XcodeInstaller {
static let XcodeTeamIdentifier = "59GAB85EFG"
static let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]
public enum Error: LocalizedError, Equatable {
case damagedXIP(url: URL)
case failedToMoveXcodeToDestination(Path)
case failedSecurityAssessment(xcode: InstalledXcode, output: String)
case codesignVerifyFailed(output: String)
case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String])
case unsupportedFileFormat(extension: String)
case missingSudoerPassword
case unavailableVersion(Version)
case noNonPrereleaseVersionAvailable
case noPrereleaseVersionAvailable
case missingUsernameOrPassword
case versionAlreadyInstalled(InstalledXcode)
case invalidVersion(String)
case versionNotInstalled(Version)
public var errorDescription: String? {
switch self {
case .damagedXIP(let url):
return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded."
case .failedToMoveXcodeToDestination(let destination):
return "Failed to move Xcode to the \(destination.string) directory."
case .failedSecurityAssessment(let xcode, let output):
return """
Xcode \(xcode.version) failed its security assessment with the following output:
\(output)
It remains installed at \(xcode.path) if you wish to use it anyways.
"""
case .codesignVerifyFailed(let output):
return """
The downloaded Xcode failed code signing verification with the following output:
\(output)
"""
case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority):
return """
The downloaded Xcode doesn't have the expected code signing identity.
Got:
\(identity)
\(certificateAuthority)
Expected:
\(XcodeInstaller.XcodeTeamIdentifier)
\(XcodeInstaller.XcodeCertificateAuthority)
"""
case .unsupportedFileFormat(let fileExtension):
return "xcodes doesn't (yet) support installing Xcode from the \(fileExtension) file format."
case .missingSudoerPassword:
return "Missing password. Please try again."
case let .unavailableVersion(version):
return "Could not find version \(version.xcodeDescription)."
case .noNonPrereleaseVersionAvailable:
return "No non-prerelease versions available."
case .noPrereleaseVersionAvailable:
return "No prerelease versions available."
case .missingUsernameOrPassword:
return "Missing username or a password. Please try again."
case let .versionAlreadyInstalled(installedXcode):
return "\(installedXcode.version.xcodeDescription) is already installed at \(installedXcode.path)"
case let .invalidVersion(version):
return "\(version) is not a valid version number."
case let .versionNotInstalled(version):
return "\(version.xcodeDescription) is not installed."
}
}
}
/// A numbered step
enum InstallationStep: CustomStringConvertible {
case downloading(version: String, progress: String)
case unarchiving
case moving(destination: String)
case trashingArchive(archiveName: String)
case checkingSecurity
case finishing
var description: String {
"(\(stepNumber)/\(stepCount)) \(message)"
}
var message: String {
switch self {
case .downloading(let version, let progress):
return "Downloading Xcode \(version): \(progress)"
case .unarchiving:
return "Unarchiving Xcode (This can take a while)"
case .moving(let destination):
return "Moving Xcode to \(destination)"
case .trashingArchive(let archiveName):
return "Moving Xcode archive \(archiveName) to the Trash"
case .checkingSecurity:
return "Checking security assessment and code signing"
case .finishing:
return "Finishing installation"
}
}
var stepNumber: Int {
switch self {
case .downloading: return 1
case .unarchiving: return 2
case .moving: return 3
case .trashingArchive: return 4
case .checkingSecurity: return 5
case .finishing: return 6
}
}
var stepCount: Int { 6 }
}
private var configuration: Configuration
private var xcodeList: XcodeList
public init(configuration: Configuration, xcodeList: XcodeList) {
self.configuration = configuration
self.xcodeList = xcodeList
}
public enum InstallationType {
case version(String)
case url(String, Path)
case latest
case latestPrerelease
}
public enum Downloader {
case urlSession
case aria2(Path)
}
public func install(_ installationType: InstallationType, downloader: Downloader, destination: Path) -> Promise<Void> {
return firstly { () -> Promise<InstalledXcode> in
return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: 0)
}
.done { xcode in
Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)")
Current.shell.exit(0)
}
}
private func install(_ installationType: InstallationType, downloader: Downloader, destination: Path, attemptNumber: Int) -> Promise<InstalledXcode> {
return firstly { () -> Promise<(Xcode, URL)> in
return self.getXcodeArchive(installationType, downloader: downloader, destination: destination)
}
.then { xcode, url -> Promise<InstalledXcode> in
return self.installArchivedXcode(xcode, at: url, to: destination)
}
.recover { error -> Promise<InstalledXcode> in
switch error {
case XcodeInstaller.Error.damagedXIP(let damagedXIPURL):
guard attemptNumber < 1 else { throw error }
switch installationType {
case .url:
// If the user provided the URL, don't try to recover and leave it up to them.
throw error
default:
// If the XIP was just downloaded, remove it and try to recover.
return firstly { () -> Promise<InstalledXcode> in
Current.logging.log(error.legibleLocalizedDescription)
Current.logging.log("Removing damaged XIP and re-attempting installation.\n")
try Current.files.removeItem(at: damagedXIPURL)
return self.install(installationType, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1)
}
}
default:
throw error
}
}
}
private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader, destination: Path) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<(Xcode, URL)> in
switch installationType {
case .latest:
Current.logging.log("Updating...")
return update()
.then { availableXcodes -> Promise<(Xcode, URL)> in
guard let latestNonPrereleaseXcode = availableXcodes.filter(\.version.isNotPrerelease).sorted(\.version).last else {
throw Error.noNonPrereleaseVersionAvailable
}
Current.logging.log("Latest non-prerelease version available is \(latestNonPrereleaseXcode.version.xcodeDescription)")
if let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestNonPrereleaseXcode.version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(version: latestNonPrereleaseXcode.version, downloader: downloader)
}
case .latestPrerelease:
Current.logging.log("Updating...")
return update()
.then { availableXcodes -> Promise<(Xcode, URL)> in
guard let latestPrereleaseXcode = availableXcodes
.filter({ $0.version.isPrerelease })
.filter({ $0.releaseDate != nil })
.sorted(by: { $0.releaseDate! < $1.releaseDate! })
.last
else {
throw Error.noNonPrereleaseVersionAvailable
}
Current.logging.log("Latest prerelease version available is \(latestPrereleaseXcode.version.xcodeDescription)")
if let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: latestPrereleaseXcode.version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(version: latestPrereleaseXcode.version, downloader: downloader)
}
case .url(let versionString, let path):
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
throw Error.invalidVersion(versionString)
}
let xcode = Xcode(version: version, url: path.url, filename: String(path.string.suffix(fromLast: "/")), releaseDate: nil)
return Promise.value((xcode, path.url))
case .version(let versionString):
guard let version = Version(xcodeVersion: versionString) ?? versionFromXcodeVersionFile() else {
throw Error.invalidVersion(versionString)
}
if let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
throw Error.versionAlreadyInstalled(installedXcode)
}
return self.downloadXcode(version: version, downloader: downloader)
}
}
}
private func versionFromXcodeVersionFile() -> Version? {
let xcodeVersionFilePath = Path.cwd.join(".xcode-version")
let version = (try? Data(contentsOf: xcodeVersionFilePath.url))
.flatMap { String(data: $0, encoding: .utf8) }
.flatMap(Version.init(gemVersion:))
return version
}
private func downloadXcode(version: Version, downloader: Downloader) -> Promise<(Xcode, URL)> {
return firstly { () -> Promise<Version> in
loginIfNeeded().map { version }
}
.then { version -> Promise<Version> in
if self.xcodeList.shouldUpdate {
return self.xcodeList.update().map { _ in version }
}
else {
return Promise.value(version)
}
}
.then { version -> Promise<(Xcode, URL)> in
guard let xcode = self.xcodeList.availableXcodes.first(where: { version.isEqualWithoutBuildMetadataIdentifiers(to: $0.version) }) else {
throw Error.unavailableVersion(version)
}
// Move to the next line
Current.logging.log("")
let formatter = NumberFormatter(numberStyle: .percent)
var observation: NSKeyValueObservation?
let promise = self.downloadOrUseExistingArchive(for: xcode, downloader: downloader, progressChanged: { progress in
observation?.invalidate()
observation = progress.observe(\.fractionCompleted) { progress, _ in
// These escape codes move up a line and then clear to the end
Current.logging.log("\u{1B}[1A\u{1B}[K\(InstallationStep.downloading(version: xcode.version.description, progress: formatter.string(from: progress.fractionCompleted)!))")
}
})
return promise
.get { _ in observation?.invalidate() }
.map { return (xcode, $0) }
}
}
func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
.recover { error -> Promise<Void> in
guard
let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "),
let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ")
else { throw Error.missingUsernameOrPassword }
return firstly { () -> Promise<Void> in
self.login(username, password: password)
}
.recover { error -> Promise<Void> in
Current.logging.log(error.legibleLocalizedDescription)
if case Client.Error.invalidUsernameOrPassword = error {
Current.logging.log("Try entering your password again")
return self.loginIfNeeded(withUsername: username)
}
else {
return Promise(error: error)
}
}
}
}
public func login(_ username: String, password: String) -> Promise<Void> {
return firstly { () -> Promise<Void> in
Current.network.login(accountName: username, password: password)
}
.recover { error -> Promise<Void> in
if let error = error as? Client.Error {
switch error {
case .invalidUsernameOrPassword(_):
// remove any keychain password if we fail to log with an invalid username or password so it doesn't try again.
try? Current.keychain.remove(username)
default:
break
}
}
return Promise(error: error)
}
.done { _ in
try? Current.keychain.set(password, key: username)
if self.configuration.defaultUsername != username {
self.configuration.defaultUsername = username
try? self.configuration.save()
}
}
}
let xcodesUsername = "XCODES_USERNAME"
let xcodesPassword = "XCODES_PASSWORD"
func findUsername() -> String? {
if let username = Current.shell.env(xcodesUsername) {
return username
}
else if let username = configuration.defaultUsername {
return username
}
return nil
}
func findPassword(withUsername username: String) -> String? {
if let password = Current.shell.env(xcodesPassword) {
return password
}
else if let password = try? Current.keychain.getString(username){
return password
}
return nil
}
public func downloadOrUseExistingArchive(for xcode: Xcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
// Check to see if the archive is in the expected path in case it was downloaded but failed to install
let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).\(xcode.filename.suffix(fromLast: "."))"
// aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete
let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2")
var aria2DownloadIsIncomplete = false
if case .aria2 = downloader, aria2DownloadMetadataPath.exists {
aria2DownloadIsIncomplete = true
}
if Current.files.fileExistsAtPath(expectedArchivePath.string), aria2DownloadIsIncomplete == false {
Current.logging.log("(1/6) Found existing archive that will be used for installation at \(expectedArchivePath).")
return Promise.value(expectedArchivePath.url)
}
else {
let destination = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).\(xcode.filename.suffix(fromLast: "."))"
switch downloader {
case .aria2(let aria2Path):
return downloadXcodeWithAria2(
xcode,
to: destination,
aria2Path: aria2Path,
progressChanged: progressChanged
)
case .urlSession:
return downloadXcodeWithURLSession(
xcode,
to: destination,
progressChanged: progressChanged
)
}
}
}
public func downloadXcodeWithAria2(_ xcode: Xcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: xcode.url) ?? []
return attemptRetryableTask(maximumRetryCount: 3) {
let (progress, promise) = Current.shell.downloadWithAria2(
aria2Path,
xcode.url,
destination,
cookies
)
progressChanged(progress)
return promise.map { _ in destination.url }
}
}
public func downloadXcodeWithURLSession(_ xcode: Xcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(xcode.version).resumedata"
let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string)
return attemptResumableTask(maximumRetryCount: 3) { resumeData in
let (progress, promise) = Current.network.downloadTask(with: xcode.url,
to: destination.url,
resumingWith: resumeData ?? persistedResumeData)
progressChanged(progress)
return promise.map { $0.saveLocation }
}
.tap { result in
self.persistOrCleanUpResumeData(at: resumeDataPath, for: result)
}
}
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path) -> Promise<InstalledXcode> {
let passwordInput = {
Promise<String> { seal in
Current.logging.log("xcodes requires superuser privileges in order to finish installation.")
guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(Error.missingSudoerPassword); return }
seal.fulfill(password + "\n")
}
}
return firstly { () -> Promise<InstalledXcode> in
let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
switch archiveURL.pathExtension {
case "xip":
return unarchiveAndMoveXIP(at: archiveURL, to: destinationURL).map { xcodeURL in
guard
let path = Path(url: xcodeURL),
Current.files.fileExists(atPath: path.string),
let installedXcode = InstalledXcode(path: path)
else { throw Error.failedToMoveXcodeToDestination(destination) }
return installedXcode
}
case "dmg":
throw Error.unsupportedFileFormat(extension: "dmg")
default:
throw Error.unsupportedFileFormat(extension: archiveURL.pathExtension)
}
}
.then { xcode -> Promise<InstalledXcode> in
Current.logging.log(InstallationStep.trashingArchive(archiveName: archiveURL.lastPathComponent).description)
try Current.files.trashItem(at: archiveURL)
Current.logging.log(InstallationStep.checkingSecurity.description)
return when(fulfilled: self.verifySecurityAssessment(of: xcode),
self.verifySigningCertificate(of: xcode.path.url))
.map { xcode }
}
.then { xcode -> Promise<InstalledXcode> in
Current.logging.log(InstallationStep.finishing.description)
return self.enableDeveloperMode(passwordInput: passwordInput).map { xcode }
}
.then { xcode -> Promise<InstalledXcode> in
self.approveLicense(for: xcode, passwordInput: passwordInput).map { xcode }
}
.then { xcode -> Promise<InstalledXcode> in
self.installComponents(for: xcode, passwordInput: passwordInput).map { xcode }
}
}
public func uninstallXcode(_ versionString: String, destination: Path) -> Promise<Void> {
return firstly { () -> Promise<(InstalledXcode, URL)> in
guard let version = Version(xcodeVersion: versionString) else {
throw Error.invalidVersion(versionString)
}
guard let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) else {
throw Error.versionNotInstalled(version)
}
return Promise<URL> { seal in
seal.fulfill(try Current.files.trashItem(at: installedXcode.path.url))
}.map { (installedXcode, $0) }
}
.then { (installedXcode, trashURL) -> Promise<(InstalledXcode, URL)> in
// If we just uninstalled the selected Xcode, try to select the latest installed version so things don't accidentally break
Current.shell.xcodeSelectPrintPath()
.then { output -> Promise<(InstalledXcode, URL)> in
if output.out.hasPrefix(installedXcode.path.string),
let latestInstalledXcode = Current.files.installedXcodes(destination).sorted(by: { $0.version < $1.version }).last {
return selectXcodeAtPath(latestInstalledXcode.path.string)
.map { output in
Current.logging.log("Selected \(output.out)")
return (installedXcode, trashURL)
}
}
else {
return Promise.value((installedXcode, trashURL))
}
}
}
.done { (installedXcode, trashURL) in
Current.logging.log("Xcode \(installedXcode.version.xcodeDescription) moved to Trash: \(trashURL.path)")
Current.shell.exit(0)
}
}
public func update() -> Promise<[Xcode]> {
return firstly { () -> Promise<Void> in
loginIfNeeded()
}
.then { () -> Promise<[Xcode]> in
self.xcodeList.update()
}
}
public func updateAndPrint(destination: Path) -> Promise<Void> {
update()
.then { xcodes -> Promise<Void> in
self.printAvailableXcodes(xcodes, installed: Current.files.installedXcodes(destination))
}
.done {
Current.shell.exit(0)
}
}
public func printAvailableXcodes(_ xcodes: [Xcode], installed installedXcodes: [InstalledXcode]) -> Promise<Void> {
struct ReleasedVersion {
let version: Version
let releaseDate: Date?
}
var allXcodeVersions = xcodes.map { ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate) }
for installedXcode in installedXcodes {
// If an installed version isn't listed online, add the installed version
if !allXcodeVersions.contains(where: { releasedVersion in
releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version)
}) {
allXcodeVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil))
}
// If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version with build metadata
else if let index = allXcodeVersions.firstIndex(where: { releasedVersion in
releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) &&
releasedVersion.version.buildMetadataIdentifiers.isEmpty
}) {
allXcodeVersions[index] = ReleasedVersion(version: installedXcode.version, releaseDate: nil)
}
}
return Current.shell.xcodeSelectPrintPath()
.done { output in
let selectedInstalledXcodeVersion = installedXcodes.first { output.out.hasPrefix($0.path.string) }.map { $0.version }
allXcodeVersions
.sorted { first, second -> Bool in
// Sort prereleases by release date, otherwise sort by version
if first.version.isPrerelease, second.version.isPrerelease, let firstDate = first.releaseDate, let secondDate = second.releaseDate {
return firstDate < secondDate
}
return first.version < second.version
}
.forEach { releasedVersion in
var output = releasedVersion.version.xcodeDescription
if installedXcodes.contains(where: { releasedVersion.version.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) {
if releasedVersion.version == selectedInstalledXcodeVersion {
output += " (Installed, Selected)"
}
else {
output += " (Installed)"
}
}
Current.logging.log(output)
}
}
}
public func printInstalledXcodes(destination: Path) -> Promise<Void> {
Current.shell.xcodeSelectPrintPath()
.done { pathOutput in
Current.files.installedXcodes(destination)
.sorted { $0.version < $1.version }
.forEach { installedXcode in
var output = installedXcode.version.xcodeDescription
if pathOutput.out.hasPrefix(installedXcode.path.string) {
output += " (Selected)"
}
Current.logging.log(output)
}
}
}
func unarchiveAndMoveXIP(at source: URL, to destination: URL) -> Promise<URL> {
return firstly { () -> Promise<ProcessOutput> in
Current.logging.log(InstallationStep.unarchiving.description)
return Current.shell.unxip(source)
.recover { (error) throws -> Promise<ProcessOutput> in
if case Process.PMKError.execution(_, _, let standardError) = error,
standardError?.contains("damaged and cant be expanded") == true {
throw Error.damagedXIP(url: source)
}
throw error
}
}
.map { output -> URL in
Current.logging.log(InstallationStep.moving(destination: destination.path).description)
let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app")
let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app")
if Current.files.fileExists(atPath: xcodeURL.path) {
try Current.files.moveItem(at: xcodeURL, to: destination)
}
else if Current.files.fileExists(atPath: xcodeBetaURL.path) {
try Current.files.moveItem(at: xcodeBetaURL, to: destination)
}
return destination
}
}
public func verifySecurityAssessment(of xcode: InstalledXcode) -> Promise<Void> {
return Current.shell.spctlAssess(xcode.path.url)
.recover { (error: Swift.Error) throws -> Promise<ProcessOutput> in
var output = ""
if case let Process.PMKError.execution(_, possibleOutput, possibleError) = error {
output = [possibleOutput, possibleError].compactMap { $0 }.joined(separator: "\n")
}
throw Error.failedSecurityAssessment(xcode: xcode, output: output)
}
.asVoid()
}
func verifySigningCertificate(of url: URL) -> Promise<Void> {
return Current.shell.codesignVerify(url)
.recover { error -> Promise<ProcessOutput> in
var output = ""
if case let Process.PMKError.execution(_, possibleOutput, possibleError) = error {
output = [possibleOutput, possibleError].compactMap { $0 }.joined(separator: "\n")
}
throw Error.codesignVerifyFailed(output: output)
}
.map { output -> CertificateInfo in
// codesign prints to stderr
return self.parseCertificateInfo(output.err)
}
.done { cert in
guard
cert.teamIdentifier == XcodeInstaller.XcodeTeamIdentifier,
cert.authority == XcodeInstaller.XcodeCertificateAuthority
else { throw Error.unexpectedCodeSigningIdentity(identifier: cert.teamIdentifier, certificateAuthority: cert.authority) }
}
}
public struct CertificateInfo {
public var authority: [String]
public var teamIdentifier: String
public var bundleIdentifier: String
}
public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo {
var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "")
for part in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) {
if part.hasPrefix("Authority") {
info.authority.append(part.components(separatedBy: "=")[1])
}
if part.hasPrefix("TeamIdentifier") {
info.teamIdentifier = part.components(separatedBy: "=")[1]
}
if part.hasPrefix("Identifier") {
info.bundleIdentifier = part.components(separatedBy: "=")[1]
}
}
return info
}
func enableDeveloperMode(passwordInput: @escaping () -> Promise<String>) -> Promise<Void> {
return firstly { () -> Promise<String?> in
Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput)
}
.then { possiblePassword -> Promise<String?> in
return Current.shell.devToolsSecurityEnable(possiblePassword).map { _ in possiblePassword }
}
.then { possiblePassword in
return Current.shell.addStaffToDevelopersGroup(possiblePassword).asVoid()
}
}
func approveLicense(for xcode: InstalledXcode, passwordInput: @escaping () -> Promise<String>) -> Promise<Void> {
return firstly { () -> Promise<String?> in
Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput)
}
.then { possiblePassword in
return Current.shell.acceptXcodeLicense(xcode, possiblePassword).asVoid()
}
}
func installComponents(for xcode: InstalledXcode, passwordInput: @escaping () -> Promise<String>) -> Promise<Void> {
return firstly { () -> Promise<String?> in
Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput)
}
.then { possiblePassword -> Promise<Void> in
Current.shell.runFirstLaunch(xcode, possiblePassword).asVoid()
}
.then { () -> Promise<(String, String, String)> in
return when(fulfilled:
Current.shell.getUserCacheDir().map { $0.out },
Current.shell.buildVersion().map { $0.out },
Current.shell.xcodeBuildVersion(xcode).map { $0.out }
)
}
.then { cacheDirectory, macOSBuildVersion, toolsVersion -> Promise<Void> in
return Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion).asVoid()
}
}
}
private extension XcodeInstaller {
func persistOrCleanUpResumeData<T>(at path: Path, for result: Result<T>) {
switch result {
case .fulfilled:
try? Current.files.removeItem(at: path.url)
case .rejected(let error):
guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return }
Current.files.createFile(atPath: path.string, contents: resumeData)
}
}
}

View file

@ -0,0 +1,103 @@
import Foundation
import Path
import Version
import PromiseKit
import SwiftSoup
/// Provides lists of available and installed Xcodes
public final class XcodeList {
public init() {
try? loadCachedAvailableXcodes()
}
public private(set) var availableXcodes: [Xcode] = []
public var shouldUpdate: Bool {
return availableXcodes.isEmpty
}
public func update() -> Promise<[Xcode]> {
return when(fulfilled: releasedXcodes(), prereleaseXcodes())
.map { releasedXcodes, prereleaseXcodes in
// Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode.
// Previously pre-release versions only appeared on developer.apple.com/download.
// /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build.
// If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes.
let xcodes = releasedXcodes.filter { releasedXcode in
prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false
} + prereleaseXcodes
self.availableXcodes = xcodes
try? self.cacheAvailableXcodes(xcodes)
return xcodes
}
}
}
extension XcodeList {
private func loadCachedAvailableXcodes() throws {
guard let data = Current.files.contents(atPath: Path.cacheFile.string) else { return }
let xcodes = try JSONDecoder().decode([Xcode].self, from: data)
self.availableXcodes = xcodes
}
private func cacheAvailableXcodes(_ xcodes: [Xcode]) throws {
let data = try JSONEncoder().encode(xcodes)
try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: Path.cacheFile.url)
}
}
extension XcodeList {
private func releasedXcodes() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.downloads)
}
.map { (data, response) -> [Xcode] in
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(.downloadsDateModified)
let downloads = try decoder.decode(Downloads.self, from: data)
let xcodes = downloads
.downloads
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
.compactMap { download -> Xcode? in
let urlPrefix = URL(string: "https://download.developer.apple.com/")!
guard
let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }),
let version = Version(xcodeVersion: download.name)
else { return nil }
let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath)
return Xcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified)
}
return xcodes
}
}
private func prereleaseXcodes() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.download)
}
.map { (data, _) -> [Xcode] in
try self.parsePrereleaseXcodes(from: data)
}
}
func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] {
let body = String(data: data, encoding: .utf8)!
let document = try SwiftSoup.parse(body)
guard
let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(),
let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""),
let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""),
let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion),
let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"),
let url = URL(string: "https://developer.apple.com" + path)
else { return [] }
let filename = String(path.suffix(fromLast: "/"))
return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))]
}
}

View file

@ -0,0 +1,134 @@
import Foundation
import PromiseKit
import Path
import Version
public func selectXcode(shouldPrint: Bool, pathOrVersion: String, destination: Path) -> Promise<Void> {
firstly { () -> Promise<ProcessOutput> in
Current.shell.xcodeSelectPrintPath()
}
.then { output -> Promise<Void> in
if shouldPrint {
if output.out.isEmpty == false {
Current.logging.log(output.out)
Current.shell.exit(0)
return Promise.value(())
}
else {
Current.logging.log("No selected Xcode")
Current.shell.exit(0)
return Promise.value(())
}
}
if let version = Version(xcodeVersion: pathOrVersion),
let installedXcode = Current.files.installedXcodes(destination).first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: version) }) {
return selectXcodeAtPath(installedXcode.path.string)
.done { output in
Current.logging.log("Selected \(output.out)")
Current.shell.exit(0)
}
}
else {
return selectXcodeAtPath(pathOrVersion)
.done { output in
Current.logging.log("Selected \(output.out)")
Current.shell.exit(0)
}
.recover { _ in
try selectXcodeInteractively(currentPath: output.out, destination: destination)
.done { output in
Current.logging.log("Selected \(output.out)")
Current.shell.exit(0)
}
}
}
}
}
public func selectXcodeInteractively(currentPath: String, destination: Path, shouldRetry: Bool) -> Promise<ProcessOutput> {
if shouldRetry {
func selectWithRetry(currentPath: String) -> Promise<ProcessOutput> {
return firstly {
try selectXcodeInteractively(currentPath: currentPath, destination: destination)
}
.recover { error throws -> Promise<ProcessOutput> in
guard case XcodeSelectError.invalidIndex = error else { throw error }
Current.logging.log("\(error.legibleLocalizedDescription)\n")
return selectWithRetry(currentPath: currentPath)
}
}
return selectWithRetry(currentPath: currentPath)
}
else {
return firstly {
try selectXcodeInteractively(currentPath: currentPath, destination: destination)
}
}
}
public func selectXcodeInteractively(currentPath: String, destination: Path) throws -> Promise<ProcessOutput> {
let sortedInstalledXcodes = Current.files.installedXcodes(destination).sorted { $0.version < $1.version }
Current.logging.log("Available Xcode versions:")
sortedInstalledXcodes
.enumerated()
.forEach { index, installedXcode in
var output = "\(index + 1)) \(installedXcode.version.xcodeDescription)"
if currentPath.hasPrefix(installedXcode.path.string) {
output += " (Selected)"
}
Current.logging.log(output)
}
let possibleSelectionNumberString = Current.shell.readLine(prompt: "Enter the number of the Xcode to select: ")
guard
let selectionNumberString = possibleSelectionNumberString,
let selectionNumber = Int(selectionNumberString),
sortedInstalledXcodes.indices.contains(selectionNumber - 1)
else {
throw XcodeSelectError.invalidIndex(min: 1, max: sortedInstalledXcodes.count, given: possibleSelectionNumberString)
}
return selectXcodeAtPath(sortedInstalledXcodes[selectionNumber - 1].path.string)
}
public func selectXcodeAtPath(_ pathString: String) -> Promise<ProcessOutput> {
firstly { () -> Promise<String?> in
guard Current.files.fileExists(atPath: pathString) else {
throw XcodeSelectError.invalidPath(pathString)
}
let passwordInput = {
Promise<String> { seal in
Current.logging.log("xcodes requires superuser privileges to select an Xcode")
guard let password = Current.shell.readSecureLine(prompt: "macOS User Password: ") else { seal.reject(XcodeInstaller.Error.missingSudoerPassword); return }
seal.fulfill(password + "\n")
}
}
return Current.shell.authenticateSudoerIfNecessary(passwordInput: passwordInput)
}
.then { possiblePassword in
Current.shell.xcodeSelectSwitch(password: possiblePassword, path: pathString)
}
.then { _ in
Current.shell.xcodeSelectPrintPath()
}
}
public enum XcodeSelectError: LocalizedError {
case invalidPath(String)
case invalidIndex(min: Int, max: Int, given: String?)
public var errorDescription: String? {
switch self {
case .invalidPath(let pathString):
return "Not a valid Xcode path: \(pathString)"
case .invalidIndex(let min, let max, let given):
return "Not a valid number. Expecting a whole number between \(min)-\(max), but given \(given ?? "nothing")."
}
}
}

View file

@ -0,0 +1,7 @@
import XCTest
import XcodesKitTests
var tests = [XCTestCaseEntry]()
tests += XcodesKitTests.allTests()
XCTMain(tests)

View file

@ -0,0 +1,9 @@
import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(XcodesKitTests.allTests),
]
}
#endif

View file

@ -0,0 +1,15 @@
import XCTest
@testable import XcodesKit
final class XcodesKitTests: XCTestCase {
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
XCTAssertEqual(XcodesKit().text, "Hello, World!")
}
static var allTests = [
("testExample", testExample),
]
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array/>
</dict>
</plist>

22
XcodesMacTests/Info.plist Normal file
View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View file

@ -0,0 +1,26 @@
import XCTest
@testable import XcodesMac
class XcodesMacTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testExample() throws {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}