diff options
| -rw-r--r-- | .gitignore | 26 | ||||
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 377 | ||||
| -rw-r--r-- | Hotline.xcodeproj/project.xcworkspace/contents.xcworkspacedata | 7 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/AccentColor.colorset/Contents.json | 11 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json | 13 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | Hotline/HotlineApp.swift | 25 | ||||
| -rw-r--r-- | Hotline/Item.swift | 18 | ||||
| -rw-r--r-- | Hotline/Network/HotlineTracker.swift | 264 | ||||
| -rw-r--r-- | Hotline/Preview Content/Preview Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | Hotline/TrackerView.swift | 69 | ||||
| -rw-r--r-- | Hotline/Utility/DataExtensions.swift | 54 |
12 files changed, 876 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6a9734 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +.DS_Store + +build/ +dist/ +DerivedData/ + +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xccheckout +*.moved-aside +IDEWorkspaceChecks.plist +IDEWorkspaceSharedSettings.plist + +.idea/ +*.o + +*.swp +*.lock +*~.nib
\ No newline at end of file diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b4e7e85 --- /dev/null +++ b/Hotline.xcodeproj/project.pbxproj @@ -0,0 +1,377 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + DA9CAFBB2B126D5700CDA197 /* HotlineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */; }; + DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */; }; + DA9CAFBF2B126D5700CDA197 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBE2B126D5700CDA197 /* Item.swift */; }; + DA9CAFC12B126D5800CDA197 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC02B126D5800CDA197 /* Assets.xcassets */; }; + DA9CAFC42B126D5800CDA197 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */; }; + DA9CAFCB2B126E3300CDA197 /* HotlineTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFCA2B126E3300CDA197 /* HotlineTracker.swift */; }; + DABFCC292B1530DC009F40D2 /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* DataExtensions.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + DA9CAFB72B126D5700CDA197 /* Hotline.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hotline.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineApp.swift; sourceTree = "<group>"; }; + DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = "<group>"; }; + DA9CAFBE2B126D5700CDA197 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; }; + DA9CAFC02B126D5800CDA197 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; + DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; + DA9CAFCA2B126E3300CDA197 /* HotlineTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTracker.swift; sourceTree = "<group>"; }; + DABFCC282B1530DC009F40D2 /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = "<group>"; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DA9CAFB42B126D5700CDA197 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + DA9CAFAE2B126D5700CDA197 = { + isa = PBXGroup; + children = ( + DA9CAFB92B126D5700CDA197 /* Hotline */, + DA9CAFB82B126D5700CDA197 /* Products */, + ); + sourceTree = "<group>"; + }; + DA9CAFB82B126D5700CDA197 /* Products */ = { + isa = PBXGroup; + children = ( + DA9CAFB72B126D5700CDA197 /* Hotline.app */, + ); + name = Products; + sourceTree = "<group>"; + }; + DA9CAFB92B126D5700CDA197 /* Hotline */ = { + isa = PBXGroup; + children = ( + DA9CAFBA2B126D5700CDA197 /* HotlineApp.swift */, + DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */, + DA9CAFBE2B126D5700CDA197 /* Item.swift */, + DABFCC262B1530AE009F40D2 /* Network */, + DABFCC272B1530BE009F40D2 /* Utility */, + DA9CAFC02B126D5800CDA197 /* Assets.xcassets */, + DA9CAFC22B126D5800CDA197 /* Preview Content */, + ); + path = Hotline; + sourceTree = "<group>"; + }; + DA9CAFC22B126D5800CDA197 /* Preview Content */ = { + isa = PBXGroup; + children = ( + DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = "<group>"; + }; + DABFCC262B1530AE009F40D2 /* Network */ = { + isa = PBXGroup; + children = ( + DA9CAFCA2B126E3300CDA197 /* HotlineTracker.swift */, + ); + path = Network; + sourceTree = "<group>"; + }; + DABFCC272B1530BE009F40D2 /* Utility */ = { + isa = PBXGroup; + children = ( + DABFCC282B1530DC009F40D2 /* DataExtensions.swift */, + ); + path = Utility; + sourceTree = "<group>"; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DA9CAFB62B126D5700CDA197 /* Hotline */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA9CAFC72B126D5800CDA197 /* Build configuration list for PBXNativeTarget "Hotline" */; + buildPhases = ( + DA9CAFB32B126D5700CDA197 /* Sources */, + DA9CAFB42B126D5700CDA197 /* Frameworks */, + DA9CAFB52B126D5700CDA197 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Hotline; + productName = Hotline; + productReference = DA9CAFB72B126D5700CDA197 /* Hotline.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DA9CAFAF2B126D5700CDA197 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + DA9CAFB62B126D5700CDA197 = { + CreatedOnToolsVersion = 15.0.1; + }; + }; + }; + buildConfigurationList = DA9CAFB22B126D5700CDA197 /* Build configuration list for PBXProject "Hotline" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DA9CAFAE2B126D5700CDA197; + productRefGroup = DA9CAFB82B126D5700CDA197 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DA9CAFB62B126D5700CDA197 /* Hotline */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DA9CAFB52B126D5700CDA197 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA9CAFC42B126D5800CDA197 /* Preview Assets.xcassets in Resources */, + DA9CAFC12B126D5800CDA197 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DA9CAFB32B126D5700CDA197 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, + DA9CAFBF2B126D5700CDA197 /* Item.swift in Sources */, + DA9CAFCB2B126E3300CDA197 /* HotlineTracker.swift in Sources */, + DA9CAFBB2B126D5700CDA197 /* HotlineApp.swift in Sources */, + DABFCC292B1530DC009F40D2 /* DataExtensions.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + DA9CAFC52B126D5800CDA197 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DA9CAFC62B126D5800CDA197 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DA9CAFC82B126D5800CDA197 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; + DEVELOPMENT_TEAM = USME6DU5G6; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.Hotline; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DA9CAFC92B126D5800CDA197 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; + DEVELOPMENT_TEAM = USME6DU5G6; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.Hotline; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DA9CAFB22B126D5700CDA197 /* Build configuration list for PBXProject "Hotline" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA9CAFC52B126D5800CDA197 /* Debug */, + DA9CAFC62B126D5800CDA197 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DA9CAFC72B126D5800CDA197 /* Build configuration list for PBXNativeTarget "Hotline" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA9CAFC82B126D5800CDA197 /* Debug */, + DA9CAFC92B126D5800CDA197 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = DA9CAFAF2B126D5700CDA197 /* Project object */; +} diff --git a/Hotline.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Hotline.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Hotline.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Workspace + version = "1.0"> + <FileRef + location = "self:"> + </FileRef> +</Workspace> diff --git a/Hotline/Assets.xcassets/AccentColor.colorset/Contents.json b/Hotline/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Hotline/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json b/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Contents.json b/Hotline/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Hotline/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/HotlineApp.swift b/Hotline/HotlineApp.swift new file mode 100644 index 0000000..f351e67 --- /dev/null +++ b/Hotline/HotlineApp.swift @@ -0,0 +1,25 @@ +import SwiftUI +import SwiftData + +@main +struct HotlineApp: App { + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + Item.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + TrackerView() + } + .modelContainer(sharedModelContainer) + } +} diff --git a/Hotline/Item.swift b/Hotline/Item.swift new file mode 100644 index 0000000..4fa6e0c --- /dev/null +++ b/Hotline/Item.swift @@ -0,0 +1,18 @@ +// +// Item.swift +// Hotline +// +// Created by Dustin Mierau on 11/25/23. +// + +import Foundation +import SwiftData + +@Model +final class Item { + var timestamp: Date + + init(timestamp: Date) { + self.timestamp = timestamp + } +} diff --git a/Hotline/Network/HotlineTracker.swift b/Hotline/Network/HotlineTracker.swift new file mode 100644 index 0000000..9b21ab2 --- /dev/null +++ b/Hotline/Network/HotlineTracker.swift @@ -0,0 +1,264 @@ +import Foundation +import Network + +struct HotlineServer: Identifiable { + var id = UUID() + let address: String + let port: UInt16 + let users: UInt16 + let name: String? + let description: String? +} + +enum HotlineTrackerStatus: Int { + case disconnected + case connecting + case connected +} + +class HotlineTracker : ObservableObject { + let serverAddress: NWEndpoint.Host + let serverPort: NWEndpoint.Port = NWEndpoint.Port(rawValue: 5498)! + let callback: ([HotlineServer]) -> Void + + static let magicPacket = Data([ + 0x48, 0x54, 0x52, 0x4B, // 'HTRK' + 0x00, 0x01 // Version + ]) + + var connection: NWConnection? + + var bytes = Data() + var maxDataLength: Int = 0 + var serverCount: Int = 0 + + @Published var connectionStatus: HotlineTrackerStatus = .disconnected + @Published var servers: [HotlineServer] = [] + + init(address: String, callback: @escaping ([HotlineServer]) -> Void) { + self.serverAddress = NWEndpoint.Host(address) + self.callback = callback + } + + func fetch() { + self.reset() + self.connect() + } + + private func reset() { + self.maxDataLength = 0 + self.serverCount = 0 + } + + private func connect() { + self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + switch newState { + case .ready: + print("READY TO SEND AND RECEIVE DATA") + self?.connectionStatus = .connected + self?.sendMagic() + case .cancelled: + print("CONNECTION CANCELLED") + self?.connectionStatus = .disconnected + case .failed(let err): + print("CONNECTION ERROR \(err)") + self?.connectionStatus = .disconnected + default: + print("CONNECTION OTHER THING") + } + } + + self.connectionStatus = .connecting + self.connection?.start(queue: .global()) + } + + private func disconnect() { + guard let c = connection else { + print("HotlineTracker: already disconnected") + return + } + + c.cancel() + self.connection = nil + } + + private func sendMagic() { + guard let c = connection else { + print("HotlineTracker: invalid connection to send magic.") + return + } + +// let packet: [UInt8] = [0x48, 0x54, 0x52, 0x4B, 0x00, self.serverVersion] + + c.send(content: HotlineTracker.magicPacket, completion: .contentProcessed { [weak self] (error) in + if let err = error { + print("HotlineTracker: sending magic failed \(err)") + return + } + + print("HotlineTracker: sent magic!") + + self?.receiveMagic() + }) + } + + private func receiveMagic() { + guard let c = connection else { + print("HotlineTracker: invalid connection to receive magic.") + return + } + + print("HotlineTracker: receiving...") + c.receive(minimumIncompleteLength: 6, maximumLength: 6) { [weak self] (data, context, isComplete, error) in + guard let self = self, let data = data else { + return + } + + if data.isEmpty || !data.elementsEqual(HotlineTracker.magicPacket) { + print("HotlineTracker: invalid magic response") + self.disconnect() + return + } +// if let data = data, !data.isEmpty { + print("HotlineTracker: received magic response!") +// } + + if let error = error { + print("HotlineTracker: receive error \(error)") + } + else { + self.receiveHeader() + } + } + } + + private func receiveHeader() { + guard let c = connection else { + print("HotlineTracker: invalid connection to receive header.") + return + } + + print("HotlineTracker: receiving...") + c.receive(minimumIncompleteLength: 8, maximumLength: 8) { [weak self] (data, context, isComplete, error) in + guard let self = self else { + return + } + + if let error = error { + print("HotlineTracker: receive error \(error)") + self.disconnect() + return + } + + if let data = data, !data.isEmpty { + print("HotlineTracker: received \(data.count) header bytes") + + self.maxDataLength = Int(data[2]) * 0xFF + Int(data[3]) + self.maxDataLength -= 4 + print("HotlineTracker: message size = \(self.maxDataLength)") + + self.serverCount = Int(data[4]) * 256 + Int(data[5]) + print("HotlineTracker: server count = \(self.serverCount)") + } + + if let error = error { + print("HotlineTracker: receive error \(error)") + } + else { + self.receiveListing() + } + } + } + + private func receiveListing() { + guard let c = connection else { + print("HotlineTracker: invalid connection to receive data.") + return + } + + print("HotlineTracker: receiving...") + c.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] (data, context, isComplete, error) in + guard let self = self else { + return + } + + if let data = data, !data.isEmpty { + print("HotlineTracker: received \(data.count) bytes") + self.bytes.append(contentsOf: data) + + if bytes.count >= maxDataLength { + print("HotlineTracker: done with data, should close. \(self.bytes.count) \(self.maxDataLength)") + self.disconnect() + self.parseListing() + return + } + } + + if let error = error { + print("HotlineTracker: receive error \(error)") + self.disconnect() + } + else { + print("HotlineTracker: not complete") + self.receiveListing() + } + } + } + + private func parseListing() { + // IP address (4 bytes) + // Port number (2 bytes) + // Number of users (2 bytes) + // Unused (2 bytes) + // Name size (1 byte) + // Name (name size) + // Description size (1 byte) + // Description (description size) + + var servers: [HotlineServer] = [] + + var cursor = 0 + for _ in 1...self.serverCount { + if self.bytes.count < cursor + 12 { + print("HotlineTracker: Data isn't long enough for next server") + break + } + + if + let ip_1 = self.bytes.readUInt8(at: cursor), + let ip_2 = self.bytes.readUInt8(at: cursor + 1), + let ip_3 = self.bytes.readUInt8(at: cursor + 2), + let ip_4 = self.bytes.readUInt8(at: cursor + 3), + let port = self.bytes.readUInt16(at: cursor + 4), + let userCount = self.bytes.readUInt16(at: cursor + 6), + let nameLengthByte = self.bytes.readUInt8(at: cursor + 10) { + + let nameLength = Int(nameLengthByte) + if let name = self.bytes.readString(at: cursor + 11, length: nameLength, encoding: .utf8) ?? self.bytes.readString(at: cursor + 11, length: nameLength, encoding: .ascii) { + if let descLengthByte = self.bytes.readUInt8(at: cursor + 11 + nameLength) { + let descLength = Int(descLengthByte) + if let desc = self.bytes.readString(at: cursor + 11 + nameLength + 1, length: descLength, encoding: .utf8) ?? self.bytes.readString(at: cursor + 11 + nameLength + 1, length: descLength, encoding: .ascii) { + let server = HotlineServer(address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount, name: name, description: desc) + + print("SERVER: \(server)") + + servers.append(server) + + cursor += 11 + nameLength + 1 + descLength + } + } + } + } + + print(cursor) + } + + DispatchQueue.main.async { + print("CALLING CALLBACK") + self.servers = servers + self.callback(servers) + } + + } +} diff --git a/Hotline/Preview Content/Preview Assets.xcassets/Contents.json b/Hotline/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Hotline/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/TrackerView.swift b/Hotline/TrackerView.swift new file mode 100644 index 0000000..04498b1 --- /dev/null +++ b/Hotline/TrackerView.swift @@ -0,0 +1,69 @@ +import SwiftUI +import SwiftData + +struct TrackerView: View { + @Environment(\.modelContext) private var modelContext + @Query private var items: [Item] + + @StateObject var tracker = HotlineTracker(address: "hltracker.com", callback: { s in + print("ALL DONE") + }) + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + ForEach(tracker.servers) { server in + HStack { + VStack(alignment: .leading) { + Text(server.name!).bold() + Image(systemName: "person.fill").opacity(0.3) + Text("\(server.users)").font(.system(size: 12)) + Text(server.description!).foregroundColor(.secondary).font(.system(size: 14)) + } + .padding(EdgeInsets(top: 15, leading: 18, bottom: 15, trailing: 18)) + Spacer() + } + .background(.white) + .cornerRadius(16) + .padding(EdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) + .frame(minWidth: 0, maxWidth: .infinity) + } + } + .padding() + } + .background(Color(white: 0.9)) +// .toolbar { +// ToolbarItem(placement: .navigationBarTrailing) { +// EditButton() +// } +// ToolbarItem { +// Button(action: addItem) { +// Label("Add Item", systemImage: "plus") +// } +// } +// } + .task { + tracker.fetch() + } + } + + private func addItem() { + withAnimation { + let newItem = Item(timestamp: Date()) + modelContext.insert(newItem) + } + } + + private func deleteItems(offsets: IndexSet) { + withAnimation { + for index in offsets { + modelContext.delete(items[index]) + } + } + } +} + +#Preview { + TrackerView() + .modelContainer(for: Item.self, inMemory: true) +} diff --git a/Hotline/Utility/DataExtensions.swift b/Hotline/Utility/DataExtensions.swift new file mode 100644 index 0000000..9530416 --- /dev/null +++ b/Hotline/Utility/DataExtensions.swift @@ -0,0 +1,54 @@ +import Foundation + +enum Endianness { + case big + case little +} + +extension Data { + func readUInt8(at offset: Int) -> UInt8? { + guard offset >= 0, offset + MemoryLayout<UInt8>.size <= self.count else { + return nil + } + return self[offset] + } + + func readUInt16(at offset: Int, endianness: Endianness = .big) -> UInt16? { + guard offset >= 0, offset + MemoryLayout<UInt16>.size <= self.count else { + return nil + } + let value = self.subdata(in: offset..<(offset + MemoryLayout<UInt16>.size)).withUnsafeBytes { $0.load(as: UInt16.self) } + return (endianness == .big) ? value.bigEndian : value.littleEndian + } + + func readUInt32(at offset: Int, endianness: Endianness = .big) -> UInt32? { + guard offset >= 0, offset + MemoryLayout<UInt32>.size <= self.count else { + return nil + } + let value = self.subdata(in: offset..<(offset + MemoryLayout<UInt32>.size)).withUnsafeBytes { $0.load(as: UInt32.self) } + return (endianness == .big) ? value.bigEndian : value.littleEndian + } + + func read<T: FixedWidthInteger>(type: T.Type, at offset: Int) -> T? { + guard offset >= 0, offset + MemoryLayout<T>.size <= self.count else { + return nil // Ensure the offset is within the Data's range + } + + return self.withUnsafeBytes { rawBufferPointer in + let pointer = rawBufferPointer.baseAddress! + .advanced(by: offset) + .assumingMemoryBound(to: T.self) + +// switch endianness { +// case .big: + return pointer.pointee.bigEndian +// case .little: +// return pointer.pointee.littleEndian +// } + } + } + + func readString(at offset: Int, length: Int, encoding: String.Encoding) -> String? { + return String(data: self[offset..<(offset + length)], encoding: encoding) + } +} |