mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
Dump WIP
This commit is contained in:
parent
52d9771115
commit
762d18201f
61 changed files with 4229 additions and 1 deletions
|
|
@ -1 +1,3 @@
|
|||
# Xcodes.app
|
||||
# Xcodes.app
|
||||
|
||||
Like xcodes, but app-ier.
|
||||
|
|
|
|||
BIN
XcodesMac.xcodeproj/._project.xcworkspace
Normal file
BIN
XcodesMac.xcodeproj/._project.xcworkspace
Normal file
Binary file not shown.
552
XcodesMac.xcodeproj/project.pbxproj
Normal file
552
XcodesMac.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
XcodesMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
XcodesMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
141
XcodesMac/AppState.swift
Normal 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:
|
||||
}
|
||||
}
|
||||
64
XcodesMac/AppStoreButtonStyle.swift
Normal file
64
XcodesMac/AppStoreButtonStyle.swift
Normal 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
5
XcodesMac/AppleAPI/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
30
XcodesMac/AppleAPI/Package.swift
Normal file
30
XcodesMac/AppleAPI/Package.swift
Normal 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"]),
|
||||
]
|
||||
)
|
||||
3
XcodesMac/AppleAPI/README.md
Normal file
3
XcodesMac/AppleAPI/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# AppleAPI
|
||||
|
||||
A description of this package.
|
||||
327
XcodesMac/AppleAPI/Sources/AppleAPI/Client.swift
Normal file
327
XcodesMac/AppleAPI/Sources/AppleAPI/Client.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
XcodesMac/AppleAPI/Sources/AppleAPI/Environment.swift
Normal file
41
XcodesMac/AppleAPI/Sources/AppleAPI/Environment.swift
Normal 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) }
|
||||
}
|
||||
120
XcodesMac/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift
Normal file
120
XcodesMac/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
15
XcodesMac/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift
Normal file
15
XcodesMac/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift
Normal 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),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import XCTest
|
||||
|
||||
#if !canImport(ObjectiveC)
|
||||
public func allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(AppleAPITests.allTests),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
7
XcodesMac/AppleAPI/Tests/LinuxMain.swift
Normal file
7
XcodesMac/AppleAPI/Tests/LinuxMain.swift
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import XCTest
|
||||
|
||||
import AppleAPITests
|
||||
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += AppleAPITests.allTests()
|
||||
XCTMain(tests)
|
||||
58
XcodesMac/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
58
XcodesMac/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
XcodesMac/Assets.xcassets/Contents.json
Normal file
6
XcodesMac/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
15
XcodesMac/Assets.xcassets/install.imageset/Contents.json
vendored
Normal file
15
XcodesMac/Assets.xcassets/install.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "install.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
XcodesMac/Assets.xcassets/install.imageset/install.pdf
vendored
Normal file
BIN
XcodesMac/Assets.xcassets/install.imageset/install.pdf
vendored
Normal file
Binary file not shown.
402
XcodesMac/Base.lproj/Main.storyboard
Normal file
402
XcodesMac/Base.lproj/Main.storyboard
Normal 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>
|
||||
98
XcodesMac/ContentView.swift
Normal file
98
XcodesMac/ContentView.swift
Normal 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
34
XcodesMac/Info.plist
Normal 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>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
33
XcodesMac/SignIn/SignIn2FAView.swift
Normal file
33
XcodesMac/SignIn/SignIn2FAView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
42
XcodesMac/SignIn/SignInCredentialsView.swift
Normal file
42
XcodesMac/SignIn/SignInCredentialsView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
31
XcodesMac/SignIn/SignInPhoneListView.swift
Normal file
31
XcodesMac/SignIn/SignInPhoneListView.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
31
XcodesMac/SignIn/SignInSMSView.swift
Normal file
31
XcodesMac/SignIn/SignInSMSView.swift
Normal 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
18
XcodesMac/Tag.swift
Normal 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
11
XcodesMac/XcodesApp.swift
Normal 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
5
XcodesMac/XcodesKit/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
37
XcodesMac/XcodesKit/Package.swift
Normal file
37
XcodesMac/XcodesKit/Package.swift
Normal 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"]),
|
||||
]
|
||||
)
|
||||
3
XcodesMac/XcodesKit/README.md
Normal file
3
XcodesMac/XcodesKit/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# XcodesKit
|
||||
|
||||
A description of this package.
|
||||
125
XcodesMac/XcodesKit/Sources/XcodesKit/Aria2CError.swift
Normal file
125
XcodesMac/XcodesKit/Sources/XcodesKit/Aria2CError.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
XcodesMac/XcodesKit/Sources/XcodesKit/Configuration.swift
Normal file
21
XcodesMac/XcodesKit/Sources/XcodesKit/Configuration.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
17
XcodesMac/XcodesKit/Sources/XcodesKit/DateFormatter+.swift
Normal file
17
XcodesMac/XcodesKit/Sources/XcodesKit/DateFormatter+.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
20
XcodesMac/XcodesKit/Sources/XcodesKit/Entry+.swift
Normal file
20
XcodesMac/XcodesKit/Sources/XcodesKit/Entry+.swift
Normal 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
|
||||
}
|
||||
}
|
||||
278
XcodesMac/XcodesKit/Sources/XcodesKit/Environment.swift
Normal file
278
XcodesMac/XcodesKit/Sources/XcodesKit/Environment.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
17
XcodesMac/XcodesKit/Sources/XcodesKit/FileManager+.swift
Normal file
17
XcodesMac/XcodesKit/Sources/XcodesKit/FileManager+.swift
Normal 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
|
||||
}
|
||||
}
|
||||
28
XcodesMac/XcodesKit/Sources/XcodesKit/Foundation.swift
Normal file
28
XcodesMac/XcodesKit/Sources/XcodesKit/Foundation.swift
Normal 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] })
|
||||
}
|
||||
}
|
||||
17
XcodesMac/XcodesKit/Sources/XcodesKit/Migration.swift
Normal file
17
XcodesMac/XcodesKit/Sources/XcodesKit/Migration.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
89
XcodesMac/XcodesKit/Sources/XcodesKit/Models.swift
Normal file
89
XcodesMac/XcodesKit/Sources/XcodesKit/Models.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
8
XcodesMac/XcodesKit/Sources/XcodesKit/Path+.swift
Normal file
8
XcodesMac/XcodesKit/Sources/XcodesKit/Path+.swift
Normal 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"
|
||||
}
|
||||
41
XcodesMac/XcodesKit/Sources/XcodesKit/Process.swift
Normal file
41
XcodesMac/XcodesKit/Sources/XcodesKit/Process.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
XcodesMac/XcodesKit/Sources/XcodesKit/Promise+.swift
Normal file
40
XcodesMac/XcodesKit/Sources/XcodesKit/Promise+.swift
Normal 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()
|
||||
}
|
||||
28
XcodesMac/XcodesKit/Sources/XcodesKit/URLRequest+Apple.swift
Normal file
28
XcodesMac/XcodesKit/Sources/XcodesKit/URLRequest+Apple.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
51
XcodesMac/XcodesKit/Sources/XcodesKit/Version+.swift
Normal file
51
XcodesMac/XcodesKit/Sources/XcodesKit/Version+.swift
Normal 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 }
|
||||
}
|
||||
45
XcodesMac/XcodesKit/Sources/XcodesKit/Version+Gem.swift
Normal file
45
XcodesMac/XcodesKit/Sources/XcodesKit/Version+Gem.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
65
XcodesMac/XcodesKit/Sources/XcodesKit/Version+Xcode.swift
Normal file
65
XcodesMac/XcodesKit/Sources/XcodesKit/Version+Xcode.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
3
XcodesMac/XcodesKit/Sources/XcodesKit/Version.swift
Normal file
3
XcodesMac/XcodesKit/Sources/XcodesKit/Version.swift
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Version
|
||||
|
||||
public let version = Version("0.12.0")!
|
||||
733
XcodesMac/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift
Normal file
733
XcodesMac/XcodesKit/Sources/XcodesKit/XcodeInstaller.swift
Normal 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 can’t 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
103
XcodesMac/XcodesKit/Sources/XcodesKit/XcodeList.swift
Normal file
103
XcodesMac/XcodesKit/Sources/XcodesKit/XcodeList.swift
Normal 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))]
|
||||
}
|
||||
}
|
||||
134
XcodesMac/XcodesKit/Sources/XcodesKit/XcodeSelect.swift
Normal file
134
XcodesMac/XcodesKit/Sources/XcodesKit/XcodeSelect.swift
Normal 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")."
|
||||
}
|
||||
}
|
||||
}
|
||||
7
XcodesMac/XcodesKit/Tests/LinuxMain.swift
Normal file
7
XcodesMac/XcodesKit/Tests/LinuxMain.swift
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import XCTest
|
||||
|
||||
import XcodesKitTests
|
||||
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += XcodesKitTests.allTests()
|
||||
XCTMain(tests)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import XCTest
|
||||
|
||||
#if !canImport(ObjectiveC)
|
||||
public func allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(XcodesKitTests.allTests),
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
8
XcodesMac/XcodesMac.entitlements
Normal file
8
XcodesMac/XcodesMac.entitlements
Normal 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
22
XcodesMacTests/Info.plist
Normal 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>
|
||||
26
XcodesMacTests/XcodesMacTests.swift
Normal file
26
XcodesMacTests/XcodesMacTests.swift
Normal 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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue