aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore26
-rw-r--r--Hotline.xcodeproj/project.pbxproj377
-rw-r--r--Hotline.xcodeproj/project.xcworkspace/contents.xcworkspacedata7
-rw-r--r--Hotline/Assets.xcassets/AccentColor.colorset/Contents.json11
-rw-r--r--Hotline/Assets.xcassets/AppIcon.appiconset/Contents.json13
-rw-r--r--Hotline/Assets.xcassets/Contents.json6
-rw-r--r--Hotline/HotlineApp.swift25
-rw-r--r--Hotline/Item.swift18
-rw-r--r--Hotline/Network/HotlineTracker.swift264
-rw-r--r--Hotline/Preview Content/Preview Assets.xcassets/Contents.json6
-rw-r--r--Hotline/TrackerView.swift69
-rw-r--r--Hotline/Utility/DataExtensions.swift54
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)
+ }
+}