first commit

This commit is contained in:
Lain Iwakura 2025-08-06 21:34:44 +03:00
commit ab93bda284
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
22 changed files with 2378 additions and 0 deletions

View File

@ -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 = "<group>";
};
/* 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 = "<group>";
};
1D8926EA2E43CE4C00C5590A /* Products */ = {
isa = PBXGroup;
children = (
1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View File

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

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>MobileMkch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

512
MobileMkch/APIClient.swift Normal file
View File

@ -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<ThreadDetail, Error>) -> 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])
}
}

View File

@ -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())
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

View File

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

109
MobileMkch/BoardsView.swift Normal file
View File

@ -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())
}
}

117
MobileMkch/Cache.swift Normal file
View File

@ -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<T: Codable>(_ 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<T: Codable>(_ 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)")
}
}

View File

@ -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())
}

View File

@ -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)
}
}
}

89
MobileMkch/Models.swift Normal file
View File

@ -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
}
}

View File

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

73
MobileMkch/Settings.swift Normal file
View File

@ -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
}

View File

@ -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())
}

View File

@ -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())
}
}

View File

@ -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..<end])
}
var body: some View {
VStack {
if isLoading {
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text("Загрузка тредов...")
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage {
VStack(alignment: .leading, spacing: 8) {
Text("Ошибка загрузки")
.font(.headline)
.foregroundColor(.red)
Text(error)
.font(.body)
.foregroundColor(.secondary)
Button("Повторить") {
loadThreads()
}
.buttonStyle(.bordered)
}
.padding()
} else {
List {
ForEach(currentThreads) { thread in
NavigationLink(destination: ThreadDetailView(board: board, thread: thread)
.environmentObject(settings)
.environmentObject(apiClient)) {
ThreadRow(thread: thread, showFiles: settings.showFiles)
}
}
}
if totalPages > 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())
}
}

105
README.md Normal file
View File

@ -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 (айпад СЛЕГКА поломан, проверил, мб чет с этим сделаю, лень)
- Поддержка темной/светлой темы
- Адаптивный интерфейс