commit ab93bda2846c3947220779d17fee7ef08aa68146 Author: Lain Iwakura Date: Wed Aug 6 21:34:44 2025 +0300 first commit diff --git a/MobileMkch.xcodeproj/project.pbxproj b/MobileMkch.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b71e038 --- /dev/null +++ b/MobileMkch.xcodeproj/project.pbxproj @@ -0,0 +1,339 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 1D8926EB2E43CE4C00C5590A /* MobileMkch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MobileMkch; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1D8926E62E43CE4C00C5590A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1D8926E02E43CE4C00C5590A = { + isa = PBXGroup; + children = ( + 1D8926EB2E43CE4C00C5590A /* MobileMkch */, + 1D8926EA2E43CE4C00C5590A /* Products */, + ); + sourceTree = ""; + }; + 1D8926EA2E43CE4C00C5590A /* Products */ = { + isa = PBXGroup; + children = ( + 1D8926E92E43CE4C00C5590A /* MobileMkch.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1D8926E82E43CE4C00C5590A /* MobileMkch */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */; + buildPhases = ( + 1D8926E52E43CE4C00C5590A /* Sources */, + 1D8926E62E43CE4C00C5590A /* Frameworks */, + 1D8926E72E43CE4C00C5590A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 1D8926EB2E43CE4C00C5590A /* MobileMkch */, + ); + name = MobileMkch; + packageProductDependencies = ( + ); + productName = MobileMkch; + productReference = 1D8926E92E43CE4C00C5590A /* MobileMkch.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1D8926E12E43CE4C00C5590A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + 1D8926E82E43CE4C00C5590A = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = 1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1D8926E02E43CE4C00C5590A; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 1D8926EA2E43CE4C00C5590A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1D8926E82E43CE4C00C5590A /* MobileMkch */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1D8926E72E43CE4C00C5590A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1D8926E52E43CE4C00C5590A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1D8926F52E43CE4D00C5590A /* 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 = 18.2; + 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; + }; + 1D8926F62E43CE4D00C5590A /* 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 = 18.2; + 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; + }; + 1D8926F82E43CE4D00C5590A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "1-alpha"; + DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\""; + DEVELOPMENT_TEAM = 9U88M9D595; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = MobileMkch; + 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"; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "1.0.0-ios"; + PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1D8926F92E43CE4D00C5590A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "1-alpha"; + DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\""; + DEVELOPMENT_TEAM = 9U88M9D595; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = MobileMkch; + 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"; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "1.0.0-ios"; + PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D8926F52E43CE4D00C5590A /* Debug */, + 1D8926F62E43CE4D00C5590A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D8926F82E43CE4D00C5590A /* Debug */, + 1D8926F92E43CE4D00C5590A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1D8926E12E43CE4C00C5590A /* Project object */; +} diff --git a/MobileMkch.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MobileMkch.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MobileMkch.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MobileMkch.xcodeproj/xcuserdata/platon.xcuserdatad/xcschemes/xcschememanagement.plist b/MobileMkch.xcodeproj/xcuserdata/platon.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..90251dc --- /dev/null +++ b/MobileMkch.xcodeproj/xcuserdata/platon.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + MobileMkch.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/MobileMkch/APIClient.swift b/MobileMkch/APIClient.swift new file mode 100644 index 0000000..4862aa7 --- /dev/null +++ b/MobileMkch/APIClient.swift @@ -0,0 +1,512 @@ +import Foundation + +class APIClient: ObservableObject { + private let baseURL = "https://mkch.pooziqo.xyz" + private let apiURL = "https://mkch.pooziqo.xyz/api" + private let session = URLSession.shared + private var authKey: String = "" + private var passcode: String = "" + + func authenticate(authKey: String, completion: @escaping (Error?) -> Void) { + self.authKey = authKey + + let url = URL(string: "\(baseURL)/key/auth/")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + + session.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(error) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(APIError(message: "Ошибка получения формы аутентификации", code: 0)) + return + } + + guard let data = data, + let html = String(data: data, encoding: .utf8) else { + completion(APIError(message: "Ошибка чтения формы аутентификации", code: 0)) + return + } + + let csrfToken = self.extractCSRFToken(from: html) + + guard !csrfToken.isEmpty else { + completion(APIError(message: "Не удалось извлечь CSRF токен", code: 0)) + return + } + + var formData = URLComponents() + formData.queryItems = [ + URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), + URLQueryItem(name: "key", value: authKey) + ] + + var postRequest = URLRequest(url: url) + postRequest.httpMethod = "POST" + postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + postRequest.setValue("\(self.baseURL)/key/auth/", forHTTPHeaderField: "Referer") + postRequest.httpBody = formData.query?.data(using: .utf8) + + self.session.dataTask(with: postRequest) { _, postResponse, postError in + DispatchQueue.main.async { + if let postError = postError { + completion(postError) + return + } + + guard let postHttpResponse = postResponse as? HTTPURLResponse, + (postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else { + completion(APIError(message: "Ошибка аутентификации", code: 0)) + return + } + + completion(nil) + } + }.resume() + } + }.resume() + } + + func loginWithPasscode(passcode: String, completion: @escaping (Error?) -> Void) { + self.passcode = passcode + + let url = URL(string: "\(baseURL)/passcode/enter/")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + + session.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(error) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(APIError(message: "Ошибка получения формы passcode", code: 0)) + return + } + + guard let data = data, + let html = String(data: data, encoding: .utf8) else { + completion(APIError(message: "Ошибка чтения формы passcode", code: 0)) + return + } + + let csrfToken = self.extractCSRFToken(from: html) + + guard !csrfToken.isEmpty else { + completion(APIError(message: "Не удалось извлечь CSRF токен для passcode", code: 0)) + return + } + + var formData = URLComponents() + formData.queryItems = [ + URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), + URLQueryItem(name: "passcode", value: passcode) + ] + + var postRequest = URLRequest(url: url) + postRequest.httpMethod = "POST" + postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + postRequest.setValue("\(self.baseURL)/passcode/enter/", forHTTPHeaderField: "Referer") + postRequest.httpBody = formData.query?.data(using: .utf8) + + self.session.dataTask(with: postRequest) { _, postResponse, postError in + DispatchQueue.main.async { + if let postError = postError { + completion(postError) + return + } + + guard let postHttpResponse = postResponse as? HTTPURLResponse, + (postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else { + completion(APIError(message: "Ошибка входа с passcode", code: 0)) + return + } + + completion(nil) + } + }.resume() + } + }.resume() + } + + func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) { + if let cachedBoards = Cache.shared.getBoards() { + completion(.success(cachedBoards)) + return + } + + let url = URL(string: "\(apiURL)/boards/")! + + session.dataTask(with: url) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(.failure(APIError(message: "Ошибка получения досок", code: 0))) + return + } + + guard let data = data else { + completion(.failure(APIError(message: "Нет данных", code: 0))) + return + } + + do { + let boards = try JSONDecoder().decode([Board].self, from: data) + Cache.shared.setBoards(boards) + completion(.success(boards)) + } catch { + completion(.failure(error)) + } + } + }.resume() + } + + func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) { + if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) { + completion(.success(cachedThreads)) + return + } + + let url = URL(string: "\(apiURL)/board/\(boardCode)")! + + session.dataTask(with: url) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(.failure(APIError(message: "Ошибка получения тредов", code: 0))) + return + } + + guard let data = data else { + completion(.failure(APIError(message: "Нет данных", code: 0))) + return + } + + do { + let threads = try JSONDecoder().decode([Thread].self, from: data) + Cache.shared.setThreads(threads, forBoard: boardCode) + completion(.success(threads)) + } catch { + completion(.failure(error)) + } + } + }.resume() + } + + func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result) -> Void) { + if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) { + completion(.success(cachedThread)) + return + } + + let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)")! + + session.dataTask(with: url) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(.failure(APIError(message: "Ошибка получения треда", code: 0))) + return + } + + guard let data = data else { + completion(.failure(APIError(message: "Нет данных", code: 0))) + return + } + + do { + let thread = try JSONDecoder().decode(ThreadDetail.self, from: data) + Cache.shared.setThreadDetail(thread, forThreadId: threadId) + completion(.success(thread)) + } catch { + completion(.failure(error)) + } + } + }.resume() + } + + func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) { + if let cachedComments = Cache.shared.getComments(forThreadId: threadId) { + completion(.success(cachedComments)) + return + } + + let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)/comments")! + + session.dataTask(with: url) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0))) + return + } + + guard let data = data else { + completion(.failure(APIError(message: "Нет данных", code: 0))) + return + } + + do { + let comments = try JSONDecoder().decode([Comment].self, from: data) + Cache.shared.setComments(comments, forThreadId: threadId) + completion(.success(comments)) + } catch { + completion(.failure(error)) + } + } + }.resume() + } + + func getFullThread(boardCode: String, threadId: Int, completion: @escaping (Result<(ThreadDetail, [Comment]), Error>) -> Void) { + let group = DispatchGroup() + var threadDetail: ThreadDetail? + var comments: [Comment]? + var threadError: Error? + var commentsError: Error? + + group.enter() + getThreadDetail(boardCode: boardCode, threadId: threadId) { result in + switch result { + case .success(let detail): + threadDetail = detail + case .failure(let error): + threadError = error + } + group.leave() + } + + group.enter() + getComments(boardCode: boardCode, threadId: threadId) { result in + switch result { + case .success(let commentList): + comments = commentList + case .failure(let error): + commentsError = error + } + group.leave() + } + + group.notify(queue: .main) { + if let threadError = threadError { + completion(.failure(threadError)) + return + } + + if let commentsError = commentsError { + completion(.failure(commentsError)) + return + } + + guard let detail = threadDetail, let commentList = comments else { + completion(.failure(APIError(message: "Не удалось загрузить данные", code: 0))) + return + } + + completion(.success((detail, commentList))) + } + } + + func createThread(boardCode: String, title: String, text: String, passcode: String, completion: @escaping (Error?) -> Void) { + if !passcode.isEmpty { + loginWithPasscode(passcode: passcode) { error in + if let error = error { + completion(error) + return + } + self.performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion) + } + } else { + performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion) + } + } + + private func performCreateThread(boardCode: String, title: String, text: String, completion: @escaping (Error?) -> Void) { + let formURL = "\(baseURL)/boards/board/\(boardCode)/new" + let url = URL(string: formURL)! + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + session.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(error) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(APIError(message: "Ошибка получения формы", code: 0)) + return + } + + guard let data = data, + let html = String(data: data, encoding: .utf8) else { + completion(APIError(message: "Ошибка чтения формы", code: 0)) + return + } + + let csrfToken = self.extractCSRFToken(from: html) + + guard !csrfToken.isEmpty else { + completion(APIError(message: "Не удалось извлечь CSRF токен", code: 0)) + return + } + + var formData = URLComponents() + formData.queryItems = [ + URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), + URLQueryItem(name: "title", value: title), + URLQueryItem(name: "text", value: text) + ] + + var postRequest = URLRequest(url: url) + postRequest.httpMethod = "POST" + postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + postRequest.setValue(formURL, forHTTPHeaderField: "Referer") + postRequest.httpBody = formData.query?.data(using: .utf8) + + self.session.dataTask(with: postRequest) { _, postResponse, postError in + DispatchQueue.main.async { + if let postError = postError { + completion(postError) + return + } + + guard let postHttpResponse = postResponse as? HTTPURLResponse, + (postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else { + completion(APIError(message: "Ошибка создания треда", code: 0)) + return + } + + Cache.shared.delete("threads_\(boardCode)") + completion(nil) + } + }.resume() + } + }.resume() + } + + func addComment(boardCode: String, threadId: Int, text: String, passcode: String, completion: @escaping (Error?) -> Void) { + if !passcode.isEmpty { + loginWithPasscode(passcode: passcode) { error in + if let error = error { + completion(error) + return + } + self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion) + } + } else { + performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion) + } + } + + private func performAddComment(boardCode: String, threadId: Int, text: String, completion: @escaping (Error?) -> Void) { + let formURL = "\(baseURL)/boards/board/\(boardCode)/thread/\(threadId)/comment" + let url = URL(string: formURL)! + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + session.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(error) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(APIError(message: "Ошибка получения формы", code: 0)) + return + } + + guard let data = data, + let html = String(data: data, encoding: .utf8) else { + completion(APIError(message: "Ошибка чтения формы", code: 0)) + return + } + + let csrfToken = self.extractCSRFToken(from: html) + + guard !csrfToken.isEmpty else { + completion(APIError(message: "Не удалось извлечь CSRF токен", code: 0)) + return + } + + var formData = URLComponents() + formData.queryItems = [ + URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), + URLQueryItem(name: "text", value: text) + ] + + var postRequest = URLRequest(url: url) + postRequest.httpMethod = "POST" + postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + postRequest.setValue(formURL, forHTTPHeaderField: "Referer") + postRequest.httpBody = formData.query?.data(using: .utf8) + + self.session.dataTask(with: postRequest) { _, postResponse, postError in + DispatchQueue.main.async { + if let postError = postError { + completion(postError) + return + } + + guard let postHttpResponse = postResponse as? HTTPURLResponse, + (postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else { + completion(APIError(message: "Ошибка добавления комментария", code: 0)) + return + } + + Cache.shared.delete("comments_\(threadId)") + completion(nil) + } + }.resume() + } + }.resume() + } + + private func extractCSRFToken(from html: String) -> String { + let pattern = #"name=['"]csrfmiddlewaretoken['"]\s+value=['"]([^'"]+)['"]"# + let regex = try? NSRegularExpression(pattern: pattern) + + guard let match = regex?.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)) else { + return "" + } + + guard let range = Range(match.range(at: 1), in: html) else { + return "" + } + + return String(html[range]) + } +} \ No newline at end of file diff --git a/MobileMkch/AddCommentView.swift b/MobileMkch/AddCommentView.swift new file mode 100644 index 0000000..eb9fc57 --- /dev/null +++ b/MobileMkch/AddCommentView.swift @@ -0,0 +1,118 @@ +import SwiftUI + +struct AddCommentView: View { + let boardCode: String + let threadId: Int + @EnvironmentObject var settings: Settings + @EnvironmentObject var apiClient: APIClient + @Environment(\.dismiss) private var dismiss + + @State private var text = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showingSuccess = false + + var body: some View { + NavigationView { + Form { + Section { + TextField("Текст комментария", text: $text) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(minHeight: 100) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + + Section { + if !settings.passcode.isEmpty { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Passcode настроен") + .foregroundColor(.green) + } + } else { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading) { + Text("Passcode не настроен") + .foregroundColor(.orange) + Text("Постинг может быть ограничен") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + if let error = errorMessage { + Section { + Text(error) + .foregroundColor(.red) + } + } + } + .navigationTitle("Комментарий в тред \(threadId)") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(false) + .navigationBarItems( + leading: Button("Отмена") { + dismiss() + }, + trailing: Button("Добавить") { + addComment() + } + .disabled(text.isEmpty || isLoading) + ) + .overlay { + if isLoading { + VStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Text("Добавление комментария...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + } + } + .alert("Комментарий добавлен", isPresented: $showingSuccess) { + Button("OK") { + dismiss() + } + } message: { + Text("Комментарий успешно добавлен") + } + } + } + + private func addComment() { + guard !text.isEmpty else { return } + + isLoading = true + errorMessage = nil + + apiClient.addComment( + boardCode: boardCode, + threadId: threadId, + text: text, + passcode: settings.passcode + ) { error in + DispatchQueue.main.async { + self.isLoading = false + + if let error = error { + self.errorMessage = error.localizedDescription + } else { + self.showingSuccess = true + } + } + } + } +} + +#Preview { + AddCommentView(boardCode: "test", threadId: 1) + .environmentObject(Settings()) + .environmentObject(APIClient()) +} \ No newline at end of file diff --git a/MobileMkch/Assets.xcassets/AccentColor.colorset/Contents.json b/MobileMkch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/MobileMkch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MobileMkch/Assets.xcassets/AppIcon.appiconset/Contents.json b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ce66376 --- /dev/null +++ b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "Icon 1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Icon 2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "Icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 1.png b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 1.png new file mode 100644 index 0000000..59593bb Binary files /dev/null and b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 1.png differ diff --git a/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 2.png b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 2.png new file mode 100644 index 0000000..59593bb Binary files /dev/null and b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 2.png differ diff --git a/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon.png b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon.png new file mode 100644 index 0000000..59593bb Binary files /dev/null and b/MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon.png differ diff --git a/MobileMkch/Assets.xcassets/Contents.json b/MobileMkch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MobileMkch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MobileMkch/BoardsView.swift b/MobileMkch/BoardsView.swift new file mode 100644 index 0000000..fbaec6e --- /dev/null +++ b/MobileMkch/BoardsView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct BoardsView: View { + @EnvironmentObject var settings: Settings + @EnvironmentObject var apiClient: APIClient + @State private var boards: [Board] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showingSettings = false + + var body: some View { + List { + if isLoading { + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Text("Загрузка досок...") + .foregroundColor(.secondary) + } + } else if let error = errorMessage { + VStack(alignment: .leading, spacing: 8) { + Text("Ошибка загрузки") + .font(.headline) + .foregroundColor(.red) + Text(error) + .font(.body) + .foregroundColor(.secondary) + Button("Повторить") { + loadBoards() + } + .buttonStyle(.bordered) + } + .padding() + } else { + ForEach(boards) { board in + NavigationLink(destination: ThreadsView(board: board) + .environmentObject(settings) + .environmentObject(apiClient)) { + BoardRow(board: board) + } + } + } + } + .navigationTitle("Доски mkch") + .onAppear { + if boards.isEmpty { + loadBoards() + } + } + .sheet(isPresented: $showingSettings) { + SettingsView() + .environmentObject(settings) + .environmentObject(apiClient) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showingSettings = true + }) { + Image(systemName: "gearshape") + } + } + } + } + + private func loadBoards() { + isLoading = true + errorMessage = nil + + apiClient.getBoards { result in + DispatchQueue.main.async { + self.isLoading = false + + switch result { + case .success(let loadedBoards): + self.boards = loadedBoards + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + } +} + +struct BoardRow: View { + let board: Board + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("/\(board.code)/") + .font(.headline) + .foregroundColor(.primary) + + Text(board.description.isEmpty ? "Без описания" : board.description) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(2) + } + .padding(.vertical, 4) + } +} + +#Preview { + NavigationView { + BoardsView() + .environmentObject(Settings()) + .environmentObject(APIClient()) + } +} \ No newline at end of file diff --git a/MobileMkch/Cache.swift b/MobileMkch/Cache.swift new file mode 100644 index 0000000..629cc56 --- /dev/null +++ b/MobileMkch/Cache.swift @@ -0,0 +1,117 @@ +import Foundation + +class Cache { + static let shared = Cache() + + private var items: [String: CacheItem] = [:] + private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent) + + private init() { + startCleanupTimer() + } + + func set(_ data: T, forKey key: String, ttl: TimeInterval = 300) { + do { + let encodedData = try JSONEncoder().encode(data) + queue.async(flags: .barrier) { + self.items[key] = CacheItem( + data: encodedData, + timestamp: Date(), + ttl: ttl + ) + } + } catch { + print("Ошибка кодирования данных для кэша: \(error)") + } + } + + func get(_ type: T.Type, forKey key: String) -> T? { + return queue.sync { + guard let item = items[key] else { return nil } + + if Date().timeIntervalSince(item.timestamp) > item.ttl { + items.removeValue(forKey: key) + return nil + } + + guard let data = item.data as? Data else { return nil } + + do { + return try JSONDecoder().decode(type, from: data) + } catch { + print("Ошибка декодирования данных из кэша: \(error)") + return nil + } + } + } + + func delete(_ key: String) { + queue.async(flags: .barrier) { + self.items.removeValue(forKey: key) + } + } + + func clear() { + queue.async(flags: .barrier) { + self.items.removeAll() + } + } + + private func startCleanupTimer() { + Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in + self.cleanup() + } + } + + private func cleanup() { + queue.async(flags: .barrier) { + let now = Date() + self.items = self.items.filter { key, item in + if now.timeIntervalSince(item.timestamp) > item.ttl { + return false + } + return true + } + } + } +} + +struct CacheItem { + let data: Data + let timestamp: Date + let ttl: TimeInterval +} + +extension Cache { + func setBoards(_ boards: [Board]) { + set(boards, forKey: "boards", ttl: 600) + } + + func getBoards() -> [Board]? { + return get([Board].self, forKey: "boards") + } + + func setThreads(_ threads: [Thread], forBoard boardCode: String) { + set(threads, forKey: "threads_\(boardCode)", ttl: 300) + } + + func getThreads(forBoard boardCode: String) -> [Thread]? { + return get([Thread].self, forKey: "threads_\(boardCode)") + } + + func setThreadDetail(_ thread: ThreadDetail, forThreadId threadId: Int) { + set(thread, forKey: "thread_detail_\(threadId)", ttl: 180) + } + + func getThreadDetail(forThreadId threadId: Int) -> ThreadDetail? { + return get(ThreadDetail.self, forKey: "thread_detail_\(threadId)") + } + + func setComments(_ comments: [Comment], forThreadId threadId: Int) { + set(comments, forKey: "comments_\(threadId)", ttl: 180) + } + + func getComments(forThreadId threadId: Int) -> [Comment]? { + return get([Comment].self, forKey: "comments_\(threadId)") + } +} \ No newline at end of file diff --git a/MobileMkch/CreateThreadView.swift b/MobileMkch/CreateThreadView.swift new file mode 100644 index 0000000..1c9db75 --- /dev/null +++ b/MobileMkch/CreateThreadView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct CreateThreadView: View { + let boardCode: String + @EnvironmentObject var settings: Settings + @EnvironmentObject var apiClient: APIClient + @Environment(\.dismiss) private var dismiss + + @State private var title = "" + @State private var text = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showingSuccess = false + + var body: some View { + NavigationView { + Form { + Section { + TextField("Заголовок треда", text: $title) + .textFieldStyle(RoundedBorderTextFieldStyle()) + + TextField("Текст треда", text: $text) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(minHeight: 100) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + + Section { + if !settings.passcode.isEmpty { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Passcode настроен") + .foregroundColor(.green) + } + } else { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading) { + Text("Passcode не настроен") + .foregroundColor(.orange) + Text("Постинг может быть ограничен") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + if let error = errorMessage { + Section { + Text(error) + .foregroundColor(.red) + } + } + } + .navigationTitle("Создать тред /\(boardCode)/") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + leading: Button("Отмена") { + dismiss() + }, + trailing: Button("Создать") { + createThread() + } + .disabled(title.isEmpty || text.isEmpty || isLoading) + ) + .overlay { + if isLoading { + VStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Text("Создание треда...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + } + } + .alert("Тред создан", isPresented: $showingSuccess) { + Button("OK") { + dismiss() + } + } message: { + Text("Тред успешно создан") + } + } + } + + private func createThread() { + guard !title.isEmpty && !text.isEmpty else { return } + + isLoading = true + errorMessage = nil + + apiClient.createThread( + boardCode: boardCode, + title: title, + text: text, + passcode: settings.passcode + ) { error in + DispatchQueue.main.async { + self.isLoading = false + + if let error = error { + self.errorMessage = error.localizedDescription + } else { + self.showingSuccess = true + } + } + } + } +} + +#Preview { + CreateThreadView(boardCode: "test") + .environmentObject(Settings()) + .environmentObject(APIClient()) +} \ No newline at end of file diff --git a/MobileMkch/MobileMkchApp.swift b/MobileMkch/MobileMkchApp.swift new file mode 100644 index 0000000..a21d4da --- /dev/null +++ b/MobileMkch/MobileMkchApp.swift @@ -0,0 +1,25 @@ +// +// MobileMkchApp.swift +// MobileMkch +// +// Created by Platon on 06.08.2025. +// + +import SwiftUI + +@main +struct MobileMkchApp: App { + @StateObject private var settings = Settings() + @StateObject private var apiClient = APIClient() + + var body: some Scene { + WindowGroup { + NavigationView { + BoardsView() + .environmentObject(settings) + .environmentObject(apiClient) + } + .preferredColorScheme(settings.theme == "dark" ? .dark : .light) + } + } +} diff --git a/MobileMkch/Models.swift b/MobileMkch/Models.swift new file mode 100644 index 0000000..181f48d --- /dev/null +++ b/MobileMkch/Models.swift @@ -0,0 +1,89 @@ +import Foundation + +struct Board: Codable, Identifiable { + let code: String + let description: String + + var id: String { code } +} + +struct Thread: Codable, Identifiable { + let id: Int + let title: String + let text: String + let creation: String + let board: String + let rating: Int? + let pinned: Bool? + let files: [String] + + var creationDate: Date { + let formatter = ISO8601DateFormatter() + return formatter.date(from: creation) ?? Date() + } + + var ratingValue: Int { + return rating ?? 0 + } + + var isPinned: Bool { + return pinned ?? false + } +} + +struct ThreadDetail: Codable, Identifiable { + let id: Int + let creation: String + let title: String + let text: String + let board: String + let files: [String] + + var creationDate: Date { + let formatter = ISO8601DateFormatter() + return formatter.date(from: creation) ?? Date() + } +} + +struct Comment: Codable, Identifiable { + let id: Int + let text: String + let creation: String + let files: [String] + + var creationDate: Date { + let formatter = ISO8601DateFormatter() + return formatter.date(from: creation) ?? Date() + } + + var formattedText: String { + return text.replacingOccurrences(of: "#", with: ">>") + } +} + +struct FileInfo { + let url: String + let filename: String + let isImage: Bool + let isVideo: Bool + + init(filePath: String) { + self.url = "https://mkch.pooziqo.xyz" + filePath + self.filename = String(filePath.split(separator: "/").last ?? "") + + let ext = filePath.lowercased() + self.isImage = ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") || + ext.hasSuffix(".png") || ext.hasSuffix(".gif") || + ext.hasSuffix(".webp") + self.isVideo = ext.hasSuffix(".mp4") || ext.hasSuffix(".webm") + } +} + +struct APIError: Error { + let message: String + let code: Int + + var localizedDescription: String { + return message + } +} \ No newline at end of file diff --git a/MobileMkch/Preview Content/Preview Assets.xcassets/Contents.json b/MobileMkch/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MobileMkch/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MobileMkch/Settings.swift b/MobileMkch/Settings.swift new file mode 100644 index 0000000..f1cf70f --- /dev/null +++ b/MobileMkch/Settings.swift @@ -0,0 +1,73 @@ +import Foundation + +class Settings: ObservableObject { + @Published var theme: String = "dark" + @Published var lastBoard: String = "" + @Published var autoRefresh: Bool = true + @Published var showFiles: Bool = true + @Published var compactMode: Bool = false + @Published var pageSize: Int = 10 + @Published var passcode: String = "" + @Published var key: String = "" + + private let userDefaults = UserDefaults.standard + private let settingsKey = "MobileMkchSettings" + + init() { + loadSettings() + } + + func loadSettings() { + if let data = userDefaults.data(forKey: settingsKey), + let settings = try? JSONDecoder().decode(SettingsData.self, from: data) { + self.theme = settings.theme + self.lastBoard = settings.lastBoard + self.autoRefresh = settings.autoRefresh + self.showFiles = settings.showFiles + self.compactMode = settings.compactMode + self.pageSize = settings.pageSize + self.passcode = settings.passcode + self.key = settings.key + } + } + + func saveSettings() { + let settingsData = SettingsData( + theme: theme, + lastBoard: lastBoard, + autoRefresh: autoRefresh, + showFiles: showFiles, + compactMode: compactMode, + pageSize: pageSize, + passcode: passcode, + key: key + ) + + if let data = try? JSONEncoder().encode(settingsData) { + userDefaults.set(data, forKey: settingsKey) + } + } + + func resetSettings() { + theme = "dark" + lastBoard = "" + autoRefresh = true + showFiles = true + compactMode = false + pageSize = 10 + passcode = "" + key = "" + saveSettings() + } +} + +struct SettingsData: Codable { + let theme: String + let lastBoard: String + let autoRefresh: Bool + let showFiles: Bool + let compactMode: Bool + let pageSize: Int + let passcode: String + let key: String +} \ No newline at end of file diff --git a/MobileMkch/SettingsView.swift b/MobileMkch/SettingsView.swift new file mode 100644 index 0000000..40371d9 --- /dev/null +++ b/MobileMkch/SettingsView.swift @@ -0,0 +1,244 @@ +import SwiftUI +import Combine + +struct SettingsView: View { + @EnvironmentObject var settings: Settings + @EnvironmentObject var apiClient: APIClient + @Environment(\.dismiss) private var dismiss + + @State private var showingAbout = false + @State private var showingInfo = false + @State private var testKeyResult: String? + @State private var testPasscodeResult: String? + @State private var isTestingKey = false + @State private var isTestingPasscode = false + + var body: some View { + NavigationView { + Form { + Section("Внешний вид") { + Picker("Тема", selection: $settings.theme) { + Text("Темная").tag("dark") + Text("Светлая").tag("light") + } + .onReceive(Just(settings.theme)) { _ in + settings.saveSettings() + } + + Toggle("Авторефреш", isOn: $settings.autoRefresh) + .onReceive(Just(settings.autoRefresh)) { _ in + settings.saveSettings() + } + + Toggle("Показывать файлы", isOn: $settings.showFiles) + .onReceive(Just(settings.showFiles)) { _ in + settings.saveSettings() + } + + Toggle("Компактный режим", isOn: $settings.compactMode) + .onReceive(Just(settings.compactMode)) { _ in + settings.saveSettings() + } + + Picker("Размер страницы", selection: $settings.pageSize) { + Text("5").tag(5) + Text("10").tag(10) + Text("15").tag(15) + Text("20").tag(20) + } + .onReceive(Just(settings.pageSize)) { _ in + settings.saveSettings() + } + } + + Section("Последняя доска") { + Text(settings.lastBoard.isEmpty ? "Не выбрана" : settings.lastBoard) + .foregroundColor(.secondary) + } + + Section("Аутентификация") { + SecureField("Passcode для постинга", text: $settings.passcode) + .onReceive(Just(settings.passcode)) { _ in + settings.saveSettings() + } + + SecureField("Ключ аутентификации", text: $settings.key) + .onReceive(Just(settings.key)) { _ in + settings.saveSettings() + } + + HStack { + Button("Тест ключа") { + testKey() + } + .disabled(settings.key.isEmpty || isTestingKey) + + if isTestingKey { + ProgressView() + .scaleEffect(0.8) + } + + if let result = testKeyResult { + Text(result) + .font(.caption) + .foregroundColor(result.contains("успешно") ? .green : .red) + } + } + + HStack { + Button("Тест passcode") { + testPasscode() + } + .disabled(settings.passcode.isEmpty || isTestingPasscode) + + if isTestingPasscode { + ProgressView() + .scaleEffect(0.8) + } + + if let result = testPasscodeResult { + Text(result) + .font(.caption) + .foregroundColor(result.contains("успешно") ? .green : .red) + } + } + } + + Section("Управление кэшем") { + Button("Очистить кэш досок") { + Cache.shared.delete("boards") + } + + Button("Очистить кэш тредов") { + apiClient.getBoards { result in + if case .success(let boards) = result { + for board in boards { + Cache.shared.delete("threads_\(board.code)") + } + } + } + } + + Button("Очистить весь кэш") { + Cache.shared.clear() + } + } + + Section("Сброс") { + Button("Сбросить настройки") { + settings.resetSettings() + } + .foregroundColor(.red) + } + + Section { + Button("Об аппке") { + showingAbout = true + } + + Button("Я думаю тебя направили сюда") { + showingInfo = true + } + } + } + .navigationTitle("Настройки") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Готово") { + dismiss() + } + } + } + .sheet(isPresented: $showingAbout) { + AboutView() + } + .alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) { + Button("Закрыть") { } + } message: { + Text("Если тебя направили сюда, то значит ты попал на НЕИЗВЕДАННЫЕ ТЕРРИТОРИИ\n\nДА ДА, не ослышались, это не ошибка, это особенность\n\nУвы, разработчик имиджборда вставил палки в колеса\n\nИ без доната ему, например, постинг работать не будет\n\nУвы, постинг не работает без доната, а разработчик боится что на его сайте будут спам\n\nВкратце - на сайте работает капча, а наличие пасскода ее для вас отключает\n\nувы, конфет много, но на всех не хватит") + } + } + } + + private func testKey() { + guard !settings.key.isEmpty else { return } + + isTestingKey = true + testKeyResult = nil + + apiClient.authenticate(authKey: settings.key) { error in + DispatchQueue.main.async { + self.isTestingKey = false + + if let error = error { + self.testKeyResult = "Ошибка: \(error.localizedDescription)" + } else { + self.testKeyResult = "Аутентификация успешна" + } + } + } + } + + private func testPasscode() { + guard !settings.passcode.isEmpty else { return } + + isTestingPasscode = true + testPasscodeResult = nil + + apiClient.loginWithPasscode(passcode: settings.passcode) { error in + DispatchQueue.main.async { + self.isTestingPasscode = false + + if let error = error { + self.testPasscodeResult = "Ошибка: \(error.localizedDescription)" + } else { + self.testPasscodeResult = "Вход успешен" + } + } + } + } +} + +struct AboutView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack(spacing: 20) { + Text("MobileMkch") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Мобильный клиент для мкача") + .font(.title3) + .foregroundColor(.secondary) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Версия: 1.0.0-ios-alpha (Always in alpha lol)") + Text("Автор: w^x (лейн, платон, а похуй как угодно)") + Text("Разработано с ❤️ на Свифт") + } + .font(.body) + + Spacer() + + Button("Закрыть") { + dismiss() + } + .buttonStyle(.bordered) + } + .padding() + .navigationTitle("Об аппке") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +#Preview { + SettingsView() + .environmentObject(Settings()) + .environmentObject(APIClient()) +} \ No newline at end of file diff --git a/MobileMkch/ThreadDetailView.swift b/MobileMkch/ThreadDetailView.swift new file mode 100644 index 0000000..c7c800e --- /dev/null +++ b/MobileMkch/ThreadDetailView.swift @@ -0,0 +1,254 @@ +import SwiftUI + +struct ThreadDetailView: View { + let board: Board + let thread: Thread + @EnvironmentObject var settings: Settings + @EnvironmentObject var apiClient: APIClient + @State private var threadDetail: ThreadDetail? + @State private var comments: [Comment] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showingAddComment = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if isLoading { + VStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Text("Загрузка треда...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else if let error = errorMessage { + VStack(alignment: .leading, spacing: 8) { + Text("Ошибка загрузки") + .font(.headline) + .foregroundColor(.red) + Text(error) + .font(.body) + .foregroundColor(.secondary) + Button("Повторить") { + loadThreadDetail() + } + .buttonStyle(.bordered) + } + .padding() + } else if let detail = threadDetail { + ThreadContentView(thread: detail, showFiles: settings.showFiles) + + Divider() + + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Комментарии (\(comments.count))") + .font(.headline) + + Spacer() + + Button("Добавить") { + showingAddComment = true + } + .buttonStyle(.bordered) + } + + if comments.isEmpty { + Text("Комментариев пока нет") + .foregroundColor(.secondary) + .italic() + } else { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(comments) { comment in + CommentView(comment: comment, showFiles: settings.showFiles) + } + } + } + } + } + } + .padding() + } + .navigationTitle("#\(thread.id)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Обновить") { + loadThreadDetail() + } + } + } + .sheet(isPresented: $showingAddComment) { + AddCommentView(boardCode: board.code, threadId: thread.id) + .environmentObject(settings) + .environmentObject(apiClient) + } + .onAppear { + if threadDetail == nil { + loadThreadDetail() + } + } + } + + private func loadThreadDetail() { + isLoading = true + errorMessage = nil + + apiClient.getFullThread(boardCode: board.code, threadId: thread.id) { result in + DispatchQueue.main.async { + self.isLoading = false + + switch result { + case .success(let (detail, loadedComments)): + self.threadDetail = detail + self.comments = loadedComments + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + } +} + +struct ThreadContentView: View { + let thread: ThreadDetail + let showFiles: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(thread.title) + .font(.title2) + .fontWeight(.bold) + + HStack { + Text(thread.creationDate, style: .date) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } + + if showFiles && !thread.files.isEmpty { + FilesView(files: thread.files) + } + + if !thread.text.isEmpty { + Text(thread.text) + .font(.body) + } + } + } +} + +struct CommentView: View { + let comment: Comment + let showFiles: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("ID: \(comment.id)") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Text(comment.creationDate, style: .date) + .font(.caption) + .foregroundColor(.secondary) + } + + if showFiles && !comment.files.isEmpty { + FilesView(files: comment.files) + } + + if !comment.text.isEmpty { + Text(comment.formattedText) + .font(.body) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } +} + +struct FilesView: View { + let files: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "paperclip") + .foregroundColor(.blue) + Text("Файлы (\(files.count))") + .font(.caption) + .foregroundColor(.secondary) + } + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 8) { + ForEach(files, id: \.self) { filePath in + FileButton(fileInfo: FileInfo(filePath: filePath)) + } + } + } + } +} + +struct FileButton: View { + let fileInfo: FileInfo + + var body: some View { + Button(action: { + if let url = URL(string: fileInfo.url) { + UIApplication.shared.open(url) + } + }) { + HStack { + Image(systemName: fileIcon) + .foregroundColor(fileColor) + Text(fileInfo.filename) + .font(.caption) + .lineLimit(1) + Spacer() + } + .padding(8) + .background(Color(.systemGray5)) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + } + + private var fileIcon: String { + if fileInfo.isImage { + return "photo" + } else if fileInfo.isVideo { + return "video" + } else { + return "doc" + } + } + + private var fileColor: Color { + if fileInfo.isImage { + return .green + } else if fileInfo.isVideo { + return .red + } else { + return .blue + } + } +} + +#Preview { + NavigationView { + ThreadDetailView( + board: Board(code: "test", description: "Test board"), + thread: Thread(id: 1, title: "Test Thread", text: "Test content", creation: "2023-01-01T00:00:00Z", board: "test", rating: nil, pinned: nil, files: []) + ) + .environmentObject(Settings()) + .environmentObject(APIClient()) + } +} \ No newline at end of file diff --git a/MobileMkch/ThreadsView.swift b/MobileMkch/ThreadsView.swift new file mode 100644 index 0000000..8adb232 --- /dev/null +++ b/MobileMkch/ThreadsView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +struct ThreadsView: View { + let board: Board + @EnvironmentObject var settings: Settings + @EnvironmentObject var apiClient: APIClient + @State private var threads: [Thread] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var currentPage = 0 + @State private var showingCreateThread = false + + private var pageSize: Int { settings.pageSize } + private var totalPages: Int { (threads.count + pageSize - 1) / pageSize } + private var currentThreads: [Thread] { + let start = currentPage * pageSize + let end = min(start + pageSize, threads.count) + return Array(threads[start.. 1 { + HStack { + Button("←") { + if currentPage > 0 { + currentPage -= 1 + } + } + .disabled(currentPage == 0) + + Spacer() + + Text("Страница \(currentPage + 1) из \(totalPages)") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Button("→") { + if currentPage < totalPages - 1 { + currentPage += 1 + } + } + .disabled(currentPage >= totalPages - 1) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + } + .navigationTitle("/\(board.code)/") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Создать") { + showingCreateThread = true + } + } + } + .sheet(isPresented: $showingCreateThread) { + CreateThreadView(boardCode: board.code) + .environmentObject(settings) + .environmentObject(apiClient) + } + .onAppear { + settings.lastBoard = board.code + settings.saveSettings() + + if threads.isEmpty { + loadThreads() + } + } + } + + private func loadThreads() { + isLoading = true + errorMessage = nil + + apiClient.getThreads(forBoard: board.code) { result in + DispatchQueue.main.async { + self.isLoading = false + + switch result { + case .success(let loadedThreads): + self.threads = loadedThreads + self.currentPage = 0 + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } + } + } +} + +struct ThreadRow: View { + let thread: Thread + let showFiles: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("#\(thread.id): \(thread.title)") + .font(.headline) + .lineLimit(2) + + Spacer() + + if thread.isPinned { + Image(systemName: "pin.fill") + .foregroundColor(.orange) + } + } + + HStack { + Text(thread.creationDate, style: .date) + .font(.caption) + .foregroundColor(.secondary) + + if thread.ratingValue > 0 { + HStack(spacing: 2) { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + Text("\(thread.ratingValue)") + .font(.caption) + } + } + + if showFiles && !thread.files.isEmpty { + HStack(spacing: 2) { + Image(systemName: "paperclip") + .foregroundColor(.blue) + Text("\(thread.files.count)") + .font(.caption) + } + } + + Spacer() + } + + if !thread.text.isEmpty { + Text(thread.text) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(3) + } + } + .padding(.vertical, 4) + } +} + +#Preview { + NavigationView { + ThreadsView(board: Board(code: "test", description: "Test board")) + .environmentObject(Settings()) + .environmentObject(APIClient()) + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a413a21 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# MobileMkch iOS + +Мобильный клиент для борды mkch.pooziqo.xyz для iOS + +## Возможности + +- Просмотр всех досок Мкача +- Просмотр тредов в каждой доске с пагинацией +- Просмотр деталей треда и комментариев +- Поддержка изображений и видео +- Темная/светлая тема +- Система настроек с сохранением: + - Тема (темная/светлая) + - Последняя посещенная доска + - Автообновление + - Показ файлов + - Компактный режим + - Размер страницы (5-20 тредов) +- **Полная поддержка постинга:** + - Аутентификация по ключу + - Аутентификация по passcode + - Создание тредов + - Добавление комментариев + - Автоматическое обновление после постинга +- **Оптимизации для iOS:** + - Нативная SwiftUI интерфейс + - Кэширование данных + - Оптимизация потребления батареи + - Поддержка iOS 15.0+ + +## Аутентификация и постинг + +### Настройка аутентификации + +1. Откройте настройки в приложении +2. Введите ключ аутентификации +3. Введите passcode для постинга (По наличию) (P.S. Увы, но пока-что не доработал пасскод, уважте, первая версия.) +4. Используйте кнопки "Тест ключа" и "Тест passcode" для проверки + +### Создание тредов + +1. Перейдите в нужную доску +2. Нажмите "Создать" +3. Заполните заголовок и текст +4. Нажмите "Создать" + +### Добавление комментариев + +1. Откройте тред +2. Нажмите "Добавить" +3. Введите текст комментария +4. Нажмите "Добавить" + +## Сборка + +### Требования + +- Xcode 15.0+ +- iOS 15.0+ +- macOS 13.0+ + +### Сборка + +1. Откройте проект в Xcode: +```bash +open MobileMkch.xcodeproj +``` + +2. Выберите устройство или симулятор + +3. Нажмите Cmd+R для сборки и запуска + +### Распространение + +1. Выберите "Any iOS Device" в схеме сборки +2. Product и Archive +3. Distribute App через App Store Connect или Ad Hoc (Еще можно открыть архиве в файндер и там найти .app и закинув в Payload сжать папку в .ipa, но это слегка попердолинг увы) + +## Структура проекта + +- `MobileMkchApp.swift` - точка входа приложения +- `Models.swift` - структуры данных +- `APIClient.swift` - HTTP клиент для mkch API +- `Settings.swift` - система настроек +- `Cache.swift` - система кэширования +- `BoardsView.swift` - список досок +- `ThreadsView.swift` - треды доски с пагинацией +- `ThreadDetailView.swift` - детали треда +- `CreateThreadView.swift` - создание тредов +- `AddCommentView.swift` - добавление комментариев +- `SettingsView.swift` - экран настроек + +## Технологии + +- SwiftUI +- Combine +- Foundation +- UIKit (для совместимости) + +## Совместимость + +- iOS 15.0+ +- iPhone и iPad (айпад СЛЕГКА поломан, проверил, мб чет с этим сделаю, лень) +- Поддержка темной/светлой темы +- Адаптивный интерфейс \ No newline at end of file