commit 2543c5f782b8702ee041accbe12b84f3fda7f16e Author: maomao Date: Fri Jun 5 09:32:13 2026 +0800 Initial: 接手 Rumeng app,清掉前夫哥痕迹 - 改 Bundle ID rumeng-v1.0.Rumeng → syke.maomao.app - 显示名 如梦 → Syke - 头像换白图占位 - 删除沈晏头像图片、空目录、bridge 旧数据 - ServerConfig.swift 含明文 token,已加入 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5302fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# macOS 系统垃圾 +.DS_Store +**/.DS_Store + +# Xcode +*.xcuserstate +*.xcuserdata/ +**/xcuserdata/ +DerivedData/ +build/ +*.xcworkspace/xcuserdata/ + +# Swift Package Manager +.build/ +Packages/ +Package.pins +Package.resolved +.swiftpm/ + +# Python (bridge 服务) +__pycache__/ +*.pyc +*.pyo +*.pyd +.venv/ +venv/ +*.egg-info/ + +# 含明文凭据,绝对别推 +Rumeng/Rumeng/ServerConfig.swift + +# bridge 运行时数据(旧聊天记录已封存,新数据每次部署生成) +Rumeng/bridge/data/*.jsonl +Rumeng/bridge/data/*.json +Rumeng/bridge/__pycache__/ + +# 日志和临时 +*.log +*.bak +*.tmp + +# 大字体文件(几 MB 一个,不进 git——iOS 工程里走资源,需要的话另外管理) +# 暂时先不忽略,因为 ttf 是 app 必需。后面再看要不要 git-lfs +# *.ttf + +# Figma 设计稿 +figma_*.png +figma_*.jpeg + +# 文档草稿 +*.docx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..391b0a5 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,109 @@ +# 如梦 — 架构文档 + +## 总览 + +``` +iPhone (如梦.app) + │ + ├─ HTTP ── Tailscale ── Mac (push.py :8795) + │ ├── /tmux/capture ─→ tmux capture-pane + │ ├── /tmux/send ─→ tmux send-keys + │ └── /health ─→ 健康检查 + │ + └─ 网络层: ServerConfig → URLSession → push.py +``` + +## 文件清单 + +| 文件 | 职责 | 依赖 | +|------|------|------| +| `如梦App.swift` | @main 入口,WindowGroup → ContentView | ContentView | +| `ContentView.swift` | 页面路由 + 手势导航菜单 + ZStack 层级 | 所有页面 View | +| `ChatPlaceholderView.swift` | 聊天占位(待开发) | 无 | +| `TerminalView.swift` | tmux 终端 UI(黑底浅灰字) | TerminalViewModel | +| `TerminalViewModel.swift` | tmux 网络层:轮询 capture-pane + 发送 send-keys | ServerConfig | +| `ServerConfig.swift` | endpoint 列表 + 当前选中 + URL 构建 | UserDefaults | +| `SettingsView.swift` | 设置页:endpoint 管理 + 主题切换 | ServerConfig, EndpointChecker | +| `EndpointResolver.swift` | 异步 ping /health,返回每个 endpoint 状态 | ServerConfig | +| `ThemeManager.swift` | 白天/夜间两套色板定义 | UserDefaults | + +## 数据流 + +### 1. 网络请求 + +``` +TerminalView / SettingsView + → ServerConfig.makeRequest(path:) + → ServerConfig.activeHost (从 UserDefaults 读) + → URLSession 请求 push.py +``` + +- `ServerConfig.endpoints` 是预定义列表,顺序可被 SettingsView 的上下箭头重排 +- 重排结果持久化到 `endpoint_urls` / `endpoint_labels`(UserDefaults) +- `active_endpoint`(UserDefaults)决定当前用哪个 +- `EndpointChecker` 定期 ping 所有 endpoint,更新状态灯(绿/红/灰) + +### 2. 主题 + +``` +SettingsView 点击「夜间/白天」 + → setTheme() → UserDefaults("active_theme") + → ContentView 监听 UserDefaults.didChangeNotification + → theme = currentTheme() + → 所有颜色从 theme.bg / theme.text / theme.textDim 读取 +``` + +- `AppTheme.dark` 和 `AppTheme.light` 各自定义完整色板 +- 目前只有 dark 在用,light 是 placeholder + +### 3. 菜单导航 + +``` +用户左边缘右滑 + → DragGesture → menuX 跟踪手势 + → 松手 → snapTo(.open) 或 snapTo(.closed) + → spring 动画 → menuX 归位 + → ContentView 根据 currentPage 切换页面 +``` + +- `menuX` 是菜单唯一的状态源(-200 隐藏,0 打开,>0 弹性过冲) +- `menuOpen` 是辅助状态(控制触控条激活/禁用) +- 页面切换是 `@State currentPage` 驱动 `@ViewBuilder pageView` + +## ZStack 层级(自上而下) + +``` +4. 左边缘触控条 (HStack 24pt) — allowsHitTesting(!menuOpen) +3. 页面标题 (VStack) — menuX < -150 时显示 +2. 菜单层 (if menuX > -200): + ├── 遮罩 Color.black 0.15 — allowsHitTesting(false) + ├── 关闭热区 Color.clear.contentShape — onTapGesture → snapTo(.closed) + └── 图标列 VStack — Button × 4,点击切换页面 +1. 当前页面 (pageView) — Termina​lView / Chat / 阅读 / 设置 +``` + +**规则**: +- 菜单打开时触控条失活,避免抢手势 +- 遮罩背景不拦截触摸,关闭热区在图标列下面 +- 图标列在最上层,按钮始终可点 + +## 各页面状态 + +| 页面 | 状态 | 备注 | +|------|------|------| +| 聊天 | 占位 | 纯黑 + "聊天" 文字,待开发 | +| 终端 | 完成 | tmux capture-pane 轮询 + send-keys 发送,带时间戳 | +| 阅读 | 占位 | 待开发 Coffee Time | +| 设置 | 开发中 | endpoint 管理 + 主题切换,功能可用但 UI 需继续 | + +## 设计原则 + +- **单一状态源**:menuX 驱动菜单,currentPage 驱动页面,UserDefaults 驱动配置 +- **无 Combine**:不用 @StateObject/@Published,用 @State + UserDefaults 通知 +- **零第三方依赖**:纯 SwiftUI + Foundation +- **不向后兼容**:部署目标 iOS 26.4,不考虑旧版本 API +- **所有网络走 push.py**:不直连 DeepSeek,不引入新服务端 + +## 更新日志 + +- 2026-05-22 — 初始架构。ZStack 菜单导航 + endpoint 切换 + 主题系统骨架。 diff --git a/Rumeng/Info.plist b/Rumeng/Info.plist new file mode 100644 index 0000000..f31759b --- /dev/null +++ b/Rumeng/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleDisplayName + Syke + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSPhotoLibraryUsageDescription + + UIAppFonts + + HYPixel11pxJ-2.ttf + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/Rumeng/Rumeng.xcodeproj/project.pbxproj b/Rumeng/Rumeng.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c2afdd5 --- /dev/null +++ b/Rumeng/Rumeng.xcodeproj/project.pbxproj @@ -0,0 +1,365 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 85C030422FC0397E00DB0A50 /* Rumeng.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rumeng.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 85C030442FC0397E00DB0A50 /* Rumeng */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Rumeng; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 85C0303F2FC0397E00DB0A50 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 85C030392FC0397E00DB0A50 = { + isa = PBXGroup; + children = ( + 85C030442FC0397E00DB0A50 /* Rumeng */, + 85C030432FC0397E00DB0A50 /* Products */, + ); + sourceTree = ""; + }; + 85C030432FC0397E00DB0A50 /* Products */ = { + isa = PBXGroup; + children = ( + 85C030422FC0397E00DB0A50 /* Rumeng.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 85C030412FC0397E00DB0A50 /* Rumeng */ = { + isa = PBXNativeTarget; + buildConfigurationList = 85C0304D2FC0397E00DB0A50 /* Build configuration list for PBXNativeTarget "Rumeng" */; + buildPhases = ( + 85C0303E2FC0397E00DB0A50 /* Sources */, + 85C0303F2FC0397E00DB0A50 /* Frameworks */, + 85C030402FC0397E00DB0A50 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 85C030442FC0397E00DB0A50 /* Rumeng */, + ); + name = Rumeng; + packageProductDependencies = ( + ); + productName = Rumeng; + productReference = 85C030422FC0397E00DB0A50 /* Rumeng.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 85C0303A2FC0397E00DB0A50 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + 85C030412FC0397E00DB0A50 = { + CreatedOnToolsVersion = 26.5; + SystemCapabilities = { + com.apple.Push = { + enabled = 1; + }; + }; + }; + }; + }; + buildConfigurationList = 85C0303D2FC0397E00DB0A50 /* Build configuration list for PBXProject "Rumeng" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 85C030392FC0397E00DB0A50; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 85C030432FC0397E00DB0A50 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 85C030412FC0397E00DB0A50 /* Rumeng */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 85C030402FC0397E00DB0A50 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 85C0303E2FC0397E00DB0A50 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 85C0304B2FC0397E00DB0A50 /* 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; + DEVELOPMENT_TEAM = ZZMLW32PHH; + 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; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 85C0304C2FC0397E00DB0A50 /* 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"; + DEVELOPMENT_TEAM = ZZMLW32PHH; + 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; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 85C0304E2FC0397E00DB0A50 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Rumeng/Rumeng.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZZMLW32PHH; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "syke.maomao.app"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 85C0304F2FC0397E00DB0A50 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Rumeng/Rumeng.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZZMLW32PHH; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "syke.maomao.app"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 85C0303D2FC0397E00DB0A50 /* Build configuration list for PBXProject "Rumeng" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 85C0304B2FC0397E00DB0A50 /* Debug */, + 85C0304C2FC0397E00DB0A50 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 85C0304D2FC0397E00DB0A50 /* Build configuration list for PBXNativeTarget "Rumeng" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 85C0304E2FC0397E00DB0A50 /* Debug */, + 85C0304F2FC0397E00DB0A50 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 85C0303A2FC0397E00DB0A50 /* Project object */; +} diff --git a/Rumeng/Rumeng.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Rumeng/Rumeng.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Rumeng/Rumeng.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Rumeng/Rumeng/AaGuDianKeBenSongYouMoBan-2.ttf b/Rumeng/Rumeng/AaGuDianKeBenSongYouMoBan-2.ttf new file mode 100644 index 0000000..4e31371 Binary files /dev/null and b/Rumeng/Rumeng/AaGuDianKeBenSongYouMoBan-2.ttf differ diff --git a/Rumeng/Rumeng/AaPingPingGuoGuoXiangSuTi-2.ttf b/Rumeng/Rumeng/AaPingPingGuoGuoXiangSuTi-2.ttf new file mode 100644 index 0000000..2c67c78 Binary files /dev/null and b/Rumeng/Rumeng/AaPingPingGuoGuoXiangSuTi-2.ttf differ diff --git a/Rumeng/Rumeng/AaXiaoGouGuaiGuaiXiangSuTi-2.ttf b/Rumeng/Rumeng/AaXiaoGouGuaiGuaiXiangSuTi-2.ttf new file mode 100644 index 0000000..c0deadf Binary files /dev/null and b/Rumeng/Rumeng/AaXiaoGouGuaiGuaiXiangSuTi-2.ttf differ diff --git a/Rumeng/Rumeng/AppDelegate.swift b/Rumeng/Rumeng/AppDelegate.swift new file mode 100644 index 0000000..5280286 --- /dev/null +++ b/Rumeng/Rumeng/AppDelegate.swift @@ -0,0 +1,87 @@ +#if canImport(UIKit) +import Foundation +import UIKit +import UserNotifications + +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let center = UNUserNotificationCenter.current() + center.delegate = self + center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error { + print("[APNs] authorization error: \(error)") + } + guard granted else { return } + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + } + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + postDeviceToken(token) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + print("[APNs] registration error: \(error)") + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any] + ) { + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + completionHandler(.noData) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([]) + } + + private func postDeviceToken(_ token: String) { + guard let body = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return } + for request in tokenRegistrationRequests(body: body) { + URLSession.shared.dataTask(with: request).resume() + } + } + + private func tokenRegistrationRequests(body: Data) -> [URLRequest] { + let urls = [ + ServerConfig.makeRequest(path: "apns/register").url.map { _ in URL(string: "http://\(ServerConfig.host):\(ServerConfig.port)/apns/register")! }, + ServerConfig.baseURL.appendingPathComponent("apns/register"), + ].compactMap { $0 } + + var seen = Set() + return urls.compactMap { url in + guard seen.insert(url.absoluteString).inserted else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(ServerConfig.authToken, forHTTPHeaderField: "X-Auth-Token") + request.httpBody = body + return request + } + } +} +#endif diff --git a/Rumeng/Rumeng/Assets.xcassets/AccentColor.colorset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..01aad94 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-dark-1024.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-dark-1024.png new file mode 100644 index 0000000..01aad94 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-dark-1024.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-128.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-128.png new file mode 100644 index 0000000..8c93f17 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-128.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-128@2x.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-128@2x.png new file mode 100644 index 0000000..d3572ba Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-128@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-16.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-16.png new file mode 100644 index 0000000..899b679 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-16.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-16@2x.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-16@2x.png new file mode 100644 index 0000000..7dacf8a Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-16@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-256.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-256.png new file mode 100644 index 0000000..d3572ba Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-256.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-256@2x.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-256@2x.png new file mode 100644 index 0000000..ee15bae Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-256@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-32.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-32.png new file mode 100644 index 0000000..7dacf8a Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-32.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-32@2x.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-32@2x.png new file mode 100644 index 0000000..5fb0fae Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-32@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-512.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-512.png new file mode 100644 index 0000000..ee15bae Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-512.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-512@2x.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-512@2x.png new file mode 100644 index 0000000..01aad94 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-mac-512@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted-1024.png b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted-1024.png new file mode 100644 index 0000000..01aad94 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted-1024.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b143fa6 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "AppIcon-dark-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "AppIcon-tinted-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "filename" : "AppIcon-mac-16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "AppIcon-mac-16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "AppIcon-mac-32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "AppIcon-mac-32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "AppIcon-mac-128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "AppIcon-mac-128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "AppIcon-mac-256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "AppIcon-mac-256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "AppIcon-mac-512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "AppIcon-mac-512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rumeng/Rumeng/Assets.xcassets/Contents.json b/Rumeng/Rumeng/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan.imageset/Contents.json new file mode 100644 index 0000000..f8d2cad --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"avatar_shenyan.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan.imageset/avatar_shenyan.png b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan.imageset/avatar_shenyan.png new file mode 100644 index 0000000..9731582 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan.imageset/avatar_shenyan.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan_header.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan_header.imageset/Contents.json new file mode 100644 index 0000000..e796e7e --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan_header.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "avatar_shenyan_header.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan_header.imageset/avatar_shenyan_header.png b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan_header.imageset/avatar_shenyan_header.png new file mode 100644 index 0000000..9731582 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/avatar_shenyan_header.imageset/avatar_shenyan_header.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/bg_dark.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/bg_dark.imageset/Contents.json new file mode 100644 index 0000000..ae8ab70 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/bg_dark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bg_dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rumeng/Rumeng/Assets.xcassets/bg_dark.imageset/bg_dark.png b/Rumeng/Rumeng/Assets.xcassets/bg_dark.imageset/bg_dark.png new file mode 100644 index 0000000..cff03bd Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/bg_dark.imageset/bg_dark.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/bg_light.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/bg_light.imageset/Contents.json new file mode 100644 index 0000000..fd5390f --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/bg_light.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bg_light.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Rumeng/Rumeng/Assets.xcassets/bg_light.imageset/bg_light.png b/Rumeng/Rumeng/Assets.xcassets/bg_light.imageset/bg_light.png new file mode 100644 index 0000000..012f2eb Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/bg_light.imageset/bg_light.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_copy.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_copy.imageset/Contents.json new file mode 100644 index 0000000..5799ec7 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_copy.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "复制.png", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_copy.imageset/复制.png b/Rumeng/Rumeng/Assets.xcassets/btn_copy.imageset/复制.png new file mode 100644 index 0000000..c159c23 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_copy.imageset/复制.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_copy_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_copy_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..d37e944 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_copy_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_copy_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_copy_8B8ED8.imageset/btn_copy_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_copy_8B8ED8.imageset/btn_copy_8B8ED8.png new file mode 100644 index 0000000..438e072 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_copy_8B8ED8.imageset/btn_copy_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_down.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_down.imageset/Contents.json new file mode 100644 index 0000000..8967285 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_down.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"btn_down.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_down.imageset/btn_down.png b/Rumeng/Rumeng/Assets.xcassets/btn_down.imageset/btn_down.png new file mode 100644 index 0000000..a1fcebd Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_down.imageset/btn_down.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_down_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_down_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..8c220c2 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_down_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_down_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_down_8B8ED8.imageset/btn_down_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_down_8B8ED8.imageset/btn_down_8B8ED8.png new file mode 100644 index 0000000..0e0dc31 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_down_8B8ED8.imageset/btn_down_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_enter.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_enter.imageset/Contents.json new file mode 100644 index 0000000..e17697b --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_enter.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"btn_enter.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_enter.imageset/btn_enter.png b/Rumeng/Rumeng/Assets.xcassets/btn_enter.imageset/btn_enter.png new file mode 100644 index 0000000..9ea7a5e Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_enter.imageset/btn_enter.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_enter_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_enter_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..0cab7bc --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_enter_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_enter_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_enter_8B8ED8.imageset/btn_enter_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_enter_8B8ED8.imageset/btn_enter_8B8ED8.png new file mode 100644 index 0000000..1bffd6c Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_enter_8B8ED8.imageset/btn_enter_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_left.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_left.imageset/Contents.json new file mode 100644 index 0000000..578e70c --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_left.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"btn_left.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_left.imageset/btn_left.png b/Rumeng/Rumeng/Assets.xcassets/btn_left.imageset/btn_left.png new file mode 100644 index 0000000..3d01b6d Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_left.imageset/btn_left.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_left_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_left_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..2774a57 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_left_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_left_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_left_8B8ED8.imageset/btn_left_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_left_8B8ED8.imageset/btn_left_8B8ED8.png new file mode 100644 index 0000000..4363435 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_left_8B8ED8.imageset/btn_left_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_return.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_return.imageset/Contents.json new file mode 100644 index 0000000..07f988c --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_return.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"btn_return.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_return.imageset/btn_return.png b/Rumeng/Rumeng/Assets.xcassets/btn_return.imageset/btn_return.png new file mode 100644 index 0000000..368d8ff Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_return.imageset/btn_return.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_return_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_return_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..ea8eb3e --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_return_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_return_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_return_8B8ED8.imageset/btn_return_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_return_8B8ED8.imageset/btn_return_8B8ED8.png new file mode 100644 index 0000000..cf5059a Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_return_8B8ED8.imageset/btn_return_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_right.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_right.imageset/Contents.json new file mode 100644 index 0000000..5ecf5f1 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_right.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"btn_right.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_right.imageset/btn_right.png b/Rumeng/Rumeng/Assets.xcassets/btn_right.imageset/btn_right.png new file mode 100644 index 0000000..ff3f4dd Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_right.imageset/btn_right.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_right_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_right_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..afeecef --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_right_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_right_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_right_8B8ED8.imageset/btn_right_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_right_8B8ED8.imageset/btn_right_8B8ED8.png new file mode 100644 index 0000000..3f30f09 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_right_8B8ED8.imageset/btn_right_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_send.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_send.imageset/Contents.json new file mode 100644 index 0000000..0b850ab --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_send.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"btn_send.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_send.imageset/btn_send.png b/Rumeng/Rumeng/Assets.xcassets/btn_send.imageset/btn_send.png new file mode 100644 index 0000000..c4d3b5c Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_send.imageset/btn_send.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_send_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_send_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..6ca250e --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_send_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_send_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_send_8B8ED8.imageset/btn_send_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_send_8B8ED8.imageset/btn_send_8B8ED8.png new file mode 100644 index 0000000..e927a95 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_send_8B8ED8.imageset/btn_send_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_up.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_up.imageset/Contents.json new file mode 100644 index 0000000..174aedd --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_up.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"btn_up.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_up.imageset/btn_up.png b/Rumeng/Rumeng/Assets.xcassets/btn_up.imageset/btn_up.png new file mode 100644 index 0000000..682dc03 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_up.imageset/btn_up.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_up_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/btn_up_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..568b58b --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/btn_up_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "btn_up_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/btn_up_8B8ED8.imageset/btn_up_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/btn_up_8B8ED8.imageset/btn_up_8B8ED8.png new file mode 100644 index 0000000..a1e0980 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/btn_up_8B8ED8.imageset/btn_up_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel.imageset/Contents.json new file mode 100644 index 0000000..1749cda --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_chat_sel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel.imageset/icon_chat_sel.png b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel.imageset/icon_chat_sel.png new file mode 100644 index 0000000..7761aac Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel.imageset/icon_chat_sel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..fe86851 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_chat_sel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel_8B8ED8.imageset/icon_chat_sel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel_8B8ED8.imageset/icon_chat_sel_8B8ED8.png new file mode 100644 index 0000000..76a233a Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_chat_sel_8B8ED8.imageset/icon_chat_sel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel.imageset/Contents.json new file mode 100644 index 0000000..5de378f --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_chat_unsel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel.imageset/icon_chat_unsel.png b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel.imageset/icon_chat_unsel.png new file mode 100644 index 0000000..0d05835 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel.imageset/icon_chat_unsel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..60ab701 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_chat_unsel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel_8B8ED8.imageset/icon_chat_unsel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel_8B8ED8.imageset/icon_chat_unsel_8B8ED8.png new file mode 100644 index 0000000..18ca0ab Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_chat_unsel_8B8ED8.imageset/icon_chat_unsel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/Contents.json new file mode 100644 index 0000000..23ea1ec --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_sel@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_sel@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_sel@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@1x.png new file mode 100644 index 0000000..3ab043a Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@2x.png new file mode 100644 index 0000000..e7f4197 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@3x.png new file mode 100644 index 0000000..90f2b78 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel.imageset/icon_codex_sel@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..2560170 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_sel_8B8ED8@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_sel_8B8ED8@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_sel_8B8ED8@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@1x.png new file mode 100644 index 0000000..8e56331 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@2x.png new file mode 100644 index 0000000..9a5f51c Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@3x.png new file mode 100644 index 0000000..b56edb9 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_sel_8B8ED8.imageset/icon_codex_sel_8B8ED8@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/Contents.json new file mode 100644 index 0000000..0b1082b --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_trim_sel@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_trim_sel@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_trim_sel@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@1x.png new file mode 100644 index 0000000..3f4fad7 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@2x.png new file mode 100644 index 0000000..5571293 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@3x.png new file mode 100644 index 0000000..baa7401 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel.imageset/icon_codex_trim_sel@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..51036b8 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_trim_sel_8B8ED8@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_trim_sel_8B8ED8@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_trim_sel_8B8ED8@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@1x.png new file mode 100644 index 0000000..ba32507 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@2x.png new file mode 100644 index 0000000..5fd91e0 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@3x.png new file mode 100644 index 0000000..7b875c2 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_sel_8B8ED8.imageset/icon_codex_trim_sel_8B8ED8@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/Contents.json new file mode 100644 index 0000000..081a895 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_trim_unsel@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_trim_unsel@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_trim_unsel@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@1x.png new file mode 100644 index 0000000..e3c190b Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@2x.png new file mode 100644 index 0000000..135af83 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@3x.png new file mode 100644 index 0000000..418726b Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel.imageset/icon_codex_trim_unsel@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..43d182d --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_trim_unsel_8B8ED8@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_trim_unsel_8B8ED8@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_trim_unsel_8B8ED8@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@1x.png new file mode 100644 index 0000000..1fb91c1 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@2x.png new file mode 100644 index 0000000..ac73a87 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@3x.png new file mode 100644 index 0000000..8130b5e Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_trim_unsel_8B8ED8.imageset/icon_codex_trim_unsel_8B8ED8@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/Contents.json new file mode 100644 index 0000000..f332e6b --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_unsel@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_unsel@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_unsel@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@1x.png new file mode 100644 index 0000000..0d16e33 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@2x.png new file mode 100644 index 0000000..9547ccb Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@3x.png new file mode 100644 index 0000000..afacae9 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel.imageset/icon_codex_unsel@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..9b496d4 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon_codex_unsel_8B8ED8@1x.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon_codex_unsel_8B8ED8@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon_codex_unsel_8B8ED8@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@1x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@1x.png new file mode 100644 index 0000000..bd477ca Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@1x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@2x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@2x.png new file mode 100644 index 0000000..560c7e7 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@2x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@3x.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@3x.png new file mode 100644 index 0000000..ac7570d Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_unsel_8B8ED8.imageset/icon_codex_unsel_8B8ED8@3x.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel.imageset/Contents.json new file mode 100644 index 0000000..2218e89 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_codex_verify_sel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel.imageset/icon_codex_verify_sel.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel.imageset/icon_codex_verify_sel.png new file mode 100644 index 0000000..8f4e779 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel.imageset/icon_codex_verify_sel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..cf29302 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_codex_verify_sel_8B8ED8.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel_8B8ED8.imageset/icon_codex_verify_sel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel_8B8ED8.imageset/icon_codex_verify_sel_8B8ED8.png new file mode 100644 index 0000000..d9ce1fa Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_sel_8B8ED8.imageset/icon_codex_verify_sel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel.imageset/Contents.json new file mode 100644 index 0000000..a515f15 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_codex_verify_unsel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel.imageset/icon_codex_verify_unsel.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel.imageset/icon_codex_verify_unsel.png new file mode 100644 index 0000000..14c5543 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel.imageset/icon_codex_verify_unsel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..2e9ea22 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_codex_verify_unsel_8B8ED8.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel_8B8ED8.imageset/icon_codex_verify_unsel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel_8B8ED8.imageset/icon_codex_verify_unsel_8B8ED8.png new file mode 100644 index 0000000..59eb385 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_codex_verify_unsel_8B8ED8.imageset/icon_codex_verify_unsel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel.imageset/Contents.json new file mode 100644 index 0000000..6d16b35 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_fridge_sel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel.imageset/icon_fridge_sel.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel.imageset/icon_fridge_sel.png new file mode 100644 index 0000000..fcb680d Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel.imageset/icon_fridge_sel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..4c1d320 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_fridge_sel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel_8B8ED8.imageset/icon_fridge_sel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel_8B8ED8.imageset/icon_fridge_sel_8B8ED8.png new file mode 100644 index 0000000..312fe65 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_sel_8B8ED8.imageset/icon_fridge_sel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel.imageset/Contents.json new file mode 100644 index 0000000..823fb83 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_fridge_unsel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel.imageset/icon_fridge_unsel.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel.imageset/icon_fridge_unsel.png new file mode 100644 index 0000000..06a13fa Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel.imageset/icon_fridge_unsel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..5b22b41 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_fridge_unsel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel_8B8ED8.imageset/icon_fridge_unsel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel_8B8ED8.imageset/icon_fridge_unsel_8B8ED8.png new file mode 100644 index 0000000..58e0d47 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_unsel_8B8ED8.imageset/icon_fridge_unsel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel.imageset/Contents.json new file mode 100644 index 0000000..beb68f8 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_fridge_verify_sel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel.imageset/icon_fridge_verify_sel.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel.imageset/icon_fridge_verify_sel.png new file mode 100644 index 0000000..8f4e779 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel.imageset/icon_fridge_verify_sel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..90807cb --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_fridge_verify_sel_8B8ED8.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel_8B8ED8.imageset/icon_fridge_verify_sel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel_8B8ED8.imageset/icon_fridge_verify_sel_8B8ED8.png new file mode 100644 index 0000000..d9ce1fa Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_sel_8B8ED8.imageset/icon_fridge_verify_sel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel.imageset/Contents.json new file mode 100644 index 0000000..b3deaf6 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_fridge_verify_unsel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel.imageset/icon_fridge_verify_unsel.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel.imageset/icon_fridge_verify_unsel.png new file mode 100644 index 0000000..14c5543 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel.imageset/icon_fridge_verify_unsel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..19ef279 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_fridge_verify_unsel_8B8ED8.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel_8B8ED8.imageset/icon_fridge_verify_unsel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel_8B8ED8.imageset/icon_fridge_verify_unsel_8B8ED8.png new file mode 100644 index 0000000..59eb385 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_fridge_verify_unsel_8B8ED8.imageset/icon_fridge_verify_unsel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel.imageset/Contents.json new file mode 100644 index 0000000..ce46235 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_settings_sel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel.imageset/icon_settings_sel.png b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel.imageset/icon_settings_sel.png new file mode 100644 index 0000000..a03eb53 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel.imageset/icon_settings_sel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..7db656a --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_settings_sel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel_8B8ED8.imageset/icon_settings_sel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel_8B8ED8.imageset/icon_settings_sel_8B8ED8.png new file mode 100644 index 0000000..510dc22 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_settings_sel_8B8ED8.imageset/icon_settings_sel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel.imageset/Contents.json new file mode 100644 index 0000000..0a68040 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_settings_unsel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel.imageset/icon_settings_unsel.png b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel.imageset/icon_settings_unsel.png new file mode 100644 index 0000000..b5257f4 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel.imageset/icon_settings_unsel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..16e02bd --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_settings_unsel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel_8B8ED8.imageset/icon_settings_unsel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel_8B8ED8.imageset/icon_settings_unsel_8B8ED8.png new file mode 100644 index 0000000..ce18904 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_settings_unsel_8B8ED8.imageset/icon_settings_unsel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel.imageset/Contents.json new file mode 100644 index 0000000..69ee419 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_terminal_sel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel.imageset/icon_terminal_sel.png b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel.imageset/icon_terminal_sel.png new file mode 100644 index 0000000..ee5c40a Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel.imageset/icon_terminal_sel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..3d77b40 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_terminal_sel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel_8B8ED8.imageset/icon_terminal_sel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel_8B8ED8.imageset/icon_terminal_sel_8B8ED8.png new file mode 100644 index 0000000..65951c9 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_sel_8B8ED8.imageset/icon_terminal_sel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel.imageset/Contents.json new file mode 100644 index 0000000..667baf9 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"icon_terminal_unsel.png","idiom":"universal"}],"info":{"author":"xcode","version":1}} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel.imageset/icon_terminal_unsel.png b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel.imageset/icon_terminal_unsel.png new file mode 100644 index 0000000..199cf10 Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel.imageset/icon_terminal_unsel.png differ diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel_8B8ED8.imageset/Contents.json b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel_8B8ED8.imageset/Contents.json new file mode 100644 index 0000000..3d157d0 --- /dev/null +++ b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel_8B8ED8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "icon_terminal_unsel_8B8ED8.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel_8B8ED8.imageset/icon_terminal_unsel_8B8ED8.png b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel_8B8ED8.imageset/icon_terminal_unsel_8B8ED8.png new file mode 100644 index 0000000..cd3e7bb Binary files /dev/null and b/Rumeng/Rumeng/Assets.xcassets/icon_terminal_unsel_8B8ED8.imageset/icon_terminal_unsel_8B8ED8.png differ diff --git a/Rumeng/Rumeng/ContentView.swift b/Rumeng/Rumeng/ContentView.swift new file mode 100644 index 0000000..d6e5865 --- /dev/null +++ b/Rumeng/Rumeng/ContentView.swift @@ -0,0 +1,1301 @@ +import SwiftUI +import Combine + +// MARK: - 主题 + +struct Theme { + let bg: Color + let bubbleLeft: Color + let bubbleRight: Color + let bubbleTextLeft: Color + let bubbleTextRight: Color + let card: Color + let textPrimary: Color + let textSecondary: Color + let textPlaceholder: Color + let textTyping: Color + let sendBtn: Color + let inputBg: Color + let inputBorder: Color + let navActive: Color + let navInactive: Color + let fridgeAddBtn: Color + let fridgeMine: Color + let fridgeOther: Color + let fridgeTextMine: Color + let fridgeTextOther: Color + let thinkingBg: Color + let thinkingText: Color + let iconTint: String // "" = original gray, "8B8ED8" = vaporwave purple + + static func themedIcon(_ name: String, tint: String) -> String { + tint.isEmpty ? name : "\(name)_\(tint)" + } + + static let dark = Theme( + bg: Color(hex: "121212"), + bubbleLeft: Color(hex: "212121"), + bubbleRight: Color(hex: "443B54"), + bubbleTextLeft: .white, + bubbleTextRight: Color(hex: "E3E3E3"), + card: Color(hex: "2C2C2C"), + textPrimary: .white, + textSecondary: Color(hex: "E3E3E3"), + textPlaceholder: Color(hex: "D0D0D0"), + textTyping: Color(hex: "595959"), + sendBtn: Color(hex: "515151"), + inputBg: Color(hex: "D9D9D9").opacity(0.06), + inputBorder: .white.opacity(0.5), + navActive: Color(hex: "443B54"), + navInactive: Color(hex: "212121"), + fridgeAddBtn: Color(hex: "a7fa00"), + fridgeMine: Color(hex: "443b54"), + fridgeOther: Color(hex: "212121"), + fridgeTextMine: Color(hex: "E3E3E3"), + fridgeTextOther: .white, + thinkingBg: Color.black.opacity(0.4), + thinkingText: .white.opacity(0.7), + iconTint: "" + ) + + static let light = Theme( + bg: Color(hex: "F5F5F5"), + bubbleLeft: Color(hex: "474747"), + bubbleRight: Color(hex: "DBBEBE"), + bubbleTextLeft: .white, + bubbleTextRight: Color(hex: "1B1B1B"), + card: .white, + textPrimary: Color(hex: "2C2C2C"), + textSecondary: Color(hex: "2C2C2C"), + textPlaceholder: Color(hex: "595959"), + textTyping: Color(hex: "595959"), + sendBtn: Color(hex: "515151"), + inputBg: Color(hex: "DCDCDC"), + inputBorder: .clear, + navActive: .clear, + navInactive: .clear, + fridgeAddBtn: Color(hex: "00FFEE"), + fridgeMine: Color(hex: "DBBEBE"), + fridgeOther: Color(hex: "474747"), + fridgeTextMine: Color(hex: "1B1B1B"), + fridgeTextOther: .white, + thinkingBg: Color(hex: "3A3A3A"), + thinkingText: .white, + iconTint: "" + ) + + static let vaporwave = Theme( + bg: Color(hex: "ECEDFE"), + bubbleLeft: Color(hex: "CED0F5"), + bubbleRight: Color(hex: "433A6D"), + bubbleTextLeft: Color(hex: "000000"), + bubbleTextRight: .white, + card: Color(hex: "CED0F5"), + textPrimary: Color(hex: "433A6D"), + textSecondary: Color(hex: "783131"), + textPlaceholder: Color(hex: "783131"), + textTyping: Color(hex: "783131"), + sendBtn: Color(hex: "515151"), + inputBg: Color(hex: "E2E3FF"), + inputBorder: .clear, + navActive: .clear, + navInactive: .clear, + fridgeAddBtn: Color(hex: "FFF200"), + fridgeMine: Color(hex: "CED0F5"), + fridgeOther: Color(hex: "433A6D"), + fridgeTextMine: Color(hex: "433A6D"), + fridgeTextOther: .white, + thinkingBg: Color(hex: "A0A3DC"), + thinkingText: Color(hex: "433A6D"), + iconTint: "8B8ED8" + ) +} + +extension Color { + init(hex: String) { + let h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var i: UInt64 = 0 + Scanner(string: h).scanHexInt64(&i) + self.init(red: Double((i >> 16) & 0xFF)/255, green: Double((i >> 8) & 0xFF)/255, blue: Double(i & 0xFF)/255) + } +} + +// MARK: - 聊天模型 + +struct ChatMessage: Identifiable, Equatable { + let id: String; let text: String; let role: String; let ts: String; let thinking: String? + var isFromMe: Bool { role == "眠眠" || role == "user" } +} + +@MainActor +final class ChatViewModel: ObservableObject { + @Published var messages: [ChatMessage] = [] + @Published var isSending = false + @Published var isWaiting = false + @Published var ccOnline = false + + private let session = URLSession(configuration: { + let c = URLSessionConfiguration.default; c.timeoutIntervalForRequest = 15; return c + }()) + private var pollTask: Task? + private var statusTask: Task? + private var lastTs = "" + + func start() { + pollTask?.cancel() + pollTask = Task { [weak self] in + while !Task.isCancelled { await self?.fetchHistory(); try? await Task.sleep(nanoseconds: 2_000_000_000) } + } + fetchStatus() + statusTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + if !Task.isCancelled { await self?.fetchStatus() } + } + } + } + func stop() { + pollTask?.cancel(); pollTask = nil + statusTask?.cancel(); statusTask = nil + } + + func fetchStatus() { + Task.detached(priority: .low) { [weak self] in + var req = ServerConfig.makeRequest(path: "chat/status") + do { + let (data, _) = try await URLSession.shared.data(for: req) + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let online = json["cc_online"] as? Bool { + await MainActor.run { self?.ccOnline = online } + } + } catch {} + } + } + + func send(text: String) async { + guard !text.isEmpty, !isSending else { return } + isSending = true + var req = ServerConfig.makeRequest(path: "chat/send") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text]) + do { + let (data, _) = try await session.data(for: req) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let record = obj["record"] as? [String: Any] { + let ts = record["ts"] as? String ?? "" + messages.append(ChatMessage(id: ts.isEmpty ? UUID().uuidString : ts, text: text, role: "眠眠", ts: ts, thinking: nil)) + lastTs = ts; isWaiting = true + } + } catch {} + isSending = false + } + + private func fetchHistory() async { + var req = ServerConfig.makeRequest(path: "chat/history") + guard var c = URLComponents(url: req.url!, resolvingAgainstBaseURL: false) else { return } + c.queryItems = lastTs.isEmpty + ? [URLQueryItem(name: "limit", value: "50")] + : [URLQueryItem(name: "since", value: lastTs), URLQueryItem(name: "limit", value: "20")] + req.url = c.url + do { + let (data, _) = try await session.data(for: req) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let records = obj["records"] as? [[String: Any]] { + var newMsgs: [ChatMessage] = [] + for r in records { + let ts = r["ts"] as? String ?? "", text = r["text"] as? String ?? "" + let role = r["role"] as? String ?? "assistant", id = ts.isEmpty ? UUID().uuidString : ts + let thinking = r["thinking"] as? String + if !messages.contains(where: { $0.id == id }) { + newMsgs.append(ChatMessage(id: id, text: text, role: role, ts: ts, thinking: thinking)) + } + } + if !newMsgs.isEmpty { + messages.append(contentsOf: newMsgs) + if let newest = newMsgs.last { lastTs = newest.ts } + if newMsgs.contains(where: { $0.role == "assistant" }) { isWaiting = false } + } + } + } catch {} + } +} + +// MARK: - 页面 + +enum AppPage: String, CaseIterable { + case chat, terminal, codex, mood, settings +} + +// MARK: - ContentView + +struct ContentView: View { + @State private var page: AppPage = .chat + @AppStorage("theme_mode") private var themeMode: String = "dark" + + private var theme: Theme { themeMode == "light" ? .light : themeMode == "vaporwave" ? .vaporwave : .dark } + + var body: some View { + VStack(spacing: 0) { + Color.clear.frame(height: 5) + HStack(alignment: .bottom, spacing: 0) { + Image("avatar_shenyan_header") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 42, height: 42) + .clipShape(Rectangle()) + Spacer() + BubbleDotsNav(currentPage: $page, theme: theme) + } + .padding(.horizontal, 12).padding(.top, 1) + pageView.frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(theme.bg.ignoresSafeArea()) + } + + @ViewBuilder + private var pageView: some View { + switch page { + case .chat: ChatPage(theme: theme) + case .terminal: TerminalPage(theme: theme) + case .codex: CodexBridgePage(theme: theme) + case .mood: MoodCalendarPage(theme: theme) + case .settings: SettingsPage(theme: theme, themeMode: $themeMode) + } + } +} + +// MARK: - 导航圆点 + +struct BubbleDotsNav: View { + @Binding var currentPage: AppPage + let theme: Theme + var body: some View { + HStack(spacing: 0) { + ForEach(AppPage.allCases, id: \.self) { p in + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { currentPage = p } + } label: { + Image(iconName(for: p)) + .resizable() + .frame(width: 24, height: 24) + .opacity(0.3) + } + .buttonStyle(.plain) + if p != AppPage.allCases.last { Spacer() } + } + } + .frame(width: 264).padding(.horizontal, 30) + } + + private func iconName(for page: AppPage) -> String { + if page == .mood { + return themedIcon(currentPage == page ? "icon_fridge_verify_sel" : "icon_fridge_verify_unsel") + } + return themedIcon(currentPage == page ? "icon_\(page.rawValue)_sel" : "icon_\(page.rawValue)_unsel") + } + + private func themedIcon(_ name: String) -> String { + Theme.themedIcon(name, tint: theme.iconTint) + } +} + +// MARK: - 聊天气泡 + +struct ChatBubble: View { + let message: ChatMessage + let maxWidth: CGFloat + let theme: Theme + @State private var showThinking = false + + private var displayTime: String { + guard !message.ts.isEmpty else { return "" } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = iso.date(from: message.ts) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: message.ts) + } + guard let d = date else { return "" } + let fm = DateFormatter() + fm.dateFormat = "HH:mm" + return fm.string(from: d) + } + + var body: some View { + HStack { + if message.isFromMe { Spacer(minLength: 40) } + VStack(alignment: message.isFromMe ? .trailing : .leading, spacing: 4) { + if let thinking = message.thinking, !thinking.isEmpty { + Button { + withAnimation(.easeInOut(duration: 0.2)) { showThinking.toggle() } + } label: { + HStack(spacing: 4) { + Image(systemName: showThinking ? "chevron.down" : "chevron.forward") + .font(.system(size: 10, weight: .semibold)) + Text("思考过程") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + } + .foregroundStyle(theme.textTyping) + .padding(.horizontal, 10).padding(.vertical, 4) + .background(Rectangle().fill(theme.card)) + } + .buttonStyle(.plain) + + if showThinking { + Text(thinking) + .font(.system(size: 14, design: .monospaced)) + .foregroundStyle(theme.thinkingText) + .padding(10) + .background(Rectangle().fill(theme.thinkingBg)) + } + } + + Text(message.text) + .font(.custom("HYPixel-11px-J", size: 18)) + .foregroundStyle(message.isFromMe ? theme.bubbleTextRight : theme.bubbleTextLeft) + .lineSpacing(8) + .padding(.horizontal, 12).padding(.vertical, 12) + .background(Rectangle().fill(message.isFromMe ? theme.bubbleRight : theme.bubbleLeft)) + .contextMenu { + Button { + UIPasteboard.general.string = message.text + } label: { + Label("复制", systemImage: "doc.on.doc") + } + } + + if !displayTime.isEmpty { + HStack(spacing: 8) { + Text(displayTime) + .font(.custom("HYPixel-11px-J", size: 11)) + .foregroundStyle(theme.textPrimary.opacity(0.4)) + + Button { + UIPasteboard.general.string = message.text + } label: { + Image(Theme.themedIcon("btn_copy", tint: theme.iconTint)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 11, height: 11) + .opacity(0.4) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + } + } + if !message.isFromMe { Spacer(minLength: 40) } + } + .padding(.horizontal, 12) + } +} + +// MARK: - 聊天页 + +struct ChatPage: View { + @StateObject var vm = ChatViewModel() + @FocusState private var focused: Bool + @State private var input = "" + let theme: Theme + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 6) { + ForEach(vm.messages) { msg in + ChatBubble(message: msg, maxWidth: 260, theme: theme).id(msg.id) + } + if vm.isWaiting { + HStack(spacing: 6) { + Image("avatar_shenyan").resizable().aspectRatio(contentMode: .fill).frame(width: 33, height: 33).clipShape(Rectangle()) + Text("Syke正在输入......") + .font(.custom("HYPixel-11px-J", size: 13).weight(.bold)) + .foregroundStyle(theme.textTyping) + Spacer() + } + .padding(.horizontal, 12).id("waiting") + } + } + .padding(.vertical, 8) + } + .scrollDismissesKeyboard(.immediately) + .onChange(of: vm.messages.count) { _, _ in + scrollToBottom(proxy: proxy) + } + .onChange(of: focused) { _, now in + if now { scrollToBottom(proxy: proxy, delay: 0.1) } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: 8) + } + } + + HStack(spacing: 0) { + TextField("输入...", text: $input) + .font(.custom("HYPixel-11px-J", size: 16)).foregroundStyle(theme.textPlaceholder) + .focused($focused) + Spacer() + Button { guard !input.isEmpty else { return }; let msg = input; input = ""; focused = false; Task { await vm.send(text: msg) } } label: { + Image(Theme.themedIcon("btn_send", tint: theme.iconTint)).resizable().frame(width: 29, height: 29).opacity(0.3) + } + } + .padding(.horizontal, 16).frame(height: 46) + .background(Rectangle().fill(theme.inputBg)) + .overlay(Rectangle().stroke(theme.inputBorder, lineWidth: 0)) + .padding(.horizontal, 14).padding(.bottom, 8) + } + .onAppear { vm.start() } + .onDisappear { vm.stop() } + } + + private func scrollToBottom(proxy: ScrollViewProxy, delay: Double = 0) { + let anchor = vm.isWaiting ? "waiting" : vm.messages.last?.id ?? "bottom" + if delay > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + withAnimation { proxy.scrollTo(anchor, anchor: .bottom) } + } + } else { + withAnimation { proxy.scrollTo(anchor, anchor: .bottom) } + } + } +} + +// MARK: - 设置页 + +struct SettingsPage: View { + let theme: Theme + @Binding var themeMode: String + + var body: some View { + VStack(alignment: .leading) { + Spacer().frame(height: 70) + + // 主题切换 + VStack(alignment: .leading, spacing: 4) { + Text("主题:") + .font(.custom("HYPixel-11px-J", size: 24).weight(.bold)) + .foregroundStyle(theme.textPrimary).padding(.bottom, 4) + HStack(spacing: 12) { + ForEach(["dark", "light", "vaporwave"], id: \.self) { mode in + Button { + withAnimation(.easeInOut(duration: 0.2)) { themeMode = mode } + } label: { + Text(mode == "dark" ? "夜间" : mode == "light" ? "白天" : "蒸汽波") + .font(.custom("HYPixel-11px-J", size: 14)) + .foregroundStyle(themeMode == mode ? theme.bg : theme.textPrimary.opacity(0.4)) + .padding(.vertical, 4).padding(.horizontal, 12) + .background(themeMode == mode ? Rectangle().fill(theme.bubbleRight) : Rectangle().fill(.clear)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + .padding(20) + .background(Rectangle().fill(theme.bubbleLeft)) + + Spacer() + } + .padding(.horizontal, 25) + } +} + +// MARK: - 终端页 + +struct TerminalPage: View { + @StateObject private var vm = TerminalViewModel() + @FocusState private var focused: Bool + let theme: Theme + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + Text(vm.output.isEmpty ? "shenyan — 等待连接..." : vm.output) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 8) + .textSelection(.enabled).id("bottom") + } + .onChange(of: vm.output) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } + } + + HStack(spacing: 6) { + Button { Task { await vm.sendKey("Up") } } label: { + Image(Theme.themedIcon("btn_up", tint: theme.iconTint)).resizable().frame(width: 36, height: 36) + } + Button { Task { await vm.sendKey("Down") } } label: { + Image(Theme.themedIcon("btn_down", tint: theme.iconTint)).resizable().frame(width: 36, height: 36) + } + + Text("$").font(.system(size: 14, design: .monospaced).weight(.bold)).foregroundStyle(theme.textTyping) + TextField("输入", text: $vm.input) + .font(.custom("HYPixel-11px-J", size: 16)) + .foregroundStyle(theme.textPlaceholder) + .focused($focused) + .submitLabel(.send).onSubmit { Task { await vm.send() } } + .autocorrectionDisabled(true) + Spacer() + Button { Task { await vm.send() } } label: { + Rectangle().fill(.clear).frame(width: 29, height: 29) + .overlay { Image(Theme.themedIcon("btn_enter", tint: theme.iconTint)).resizable().frame(width: 29, height: 29) } + } + Button { Task { await vm.sendEnter() } } label: { + Image(Theme.themedIcon("btn_return", tint: theme.iconTint)).resizable().frame(width: 29, height: 29) + } + } + .padding(.horizontal, 16).frame(height: 46) + .background(Rectangle().fill(theme.inputBg)) + .overlay(Rectangle().stroke(theme.inputBorder, lineWidth: 1)) + .padding(.horizontal, 14).padding(.bottom, 10) + } + .onAppear { vm.start() } + .onDisappear { vm.stop() } + } +} + +// MARK: - 心情日历 + +struct MoodEntry: Identifiable, Codable { + let id: String + let date: String // "yyyy-MM-dd" + let role: String // "眠眠" or "沈晏"(数据键,勿改:旧记录按此存储) + var tag: String + var color: String // hex without # +} + +@MainActor +final class MoodViewModel: ObservableObject { + @Published var entries: [MoodEntry] = [] + private let session = URLSession(configuration: .default) + + func fetch() async { + let req = ServerConfig.makeRequest(path: "mood/list") + do { + let (data, _) = try await session.data(for: req) + if let arr = try? JSONDecoder().decode([MoodEntry].self, from: data) { + entries = arr + } + } catch {} + } + + func upsert(date: String, role: String, tag: String, color: String) async { + var req = ServerConfig.makeRequest(path: "mood/upsert") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONEncoder().encode(["date": date, "role": role, "tag": tag, "color": color]) + _ = try? await session.data(for: req) + await fetch() + } + + func entryFor(date: String, role: String) -> MoodEntry? { + entries.first { $0.date == date && $0.role == role } + } +} + +struct MoodCalendarPage: View { + @StateObject private var vm = MoodViewModel() + @State private var displayMonth: Date = { + let cal = Calendar.current + let now = Date() + return cal.date(from: cal.dateComponents([.year, .month], from: now))! + }() + @State private var showEditSheet = false + let theme: Theme + + private let moodColors: [(name: String, hex: String)] = [ + ("粉", "FF81A2"), ("橙", "FFBB00"), ("青", "81FFF9"), + ("蓝", "95D1FF"), ("灰", "A2A2A2"), ("绿", "C1FF6A"), + ("紫", "D4B0D9"), ("红", "FF0446"), ("白", "FFFFFF") + ] + private let moodTags = ["爱", "兴奋", "专注", "好累", "焦虑", "轻快", "幸福"] + + private var todayStr: String { + let fm = DateFormatter(); fm.dateFormat = "yyyy-MM-dd" + return fm.string(from: Date()) + } + + private var monthLabel: String { + let fm = DateFormatter(); fm.dateFormat = "yyyy年M月"; fm.locale = Locale(identifier: "zh_CN") + return fm.string(from: displayMonth) + } + + private func daysInMonth() -> [Date?] { + let cal = Calendar.current + let range = cal.range(of: .day, in: .month, for: displayMonth)! + let firstWeekday = cal.component(.weekday, from: displayMonth) + let offset = (firstWeekday - 2 + 7) % 7 // Mon=0 + var days: [Date?] = Array(repeating: nil, count: offset) + for d in range { + days.append(cal.date(byAdding: .day, value: d - 1, to: displayMonth)) + } + return days + } + + private func dateStr(_ date: Date) -> String { + let fm = DateFormatter(); fm.dateFormat = "yyyy-MM-dd" + return fm.string(from: date) + } + + var body: some View { + VStack(spacing: 0) { + // month nav + HStack { + Button { + displayMonth = Calendar.current.date(byAdding: .month, value: -1, to: displayMonth)! + } label: { + Text("<").font(.custom("HYPixel-11px-J", size: 16)).foregroundStyle(theme.textPrimary) + } + Spacer() + Text(monthLabel).font(.custom("HYPixel-11px-J", size: 14)).foregroundStyle(theme.textPrimary) + Spacer() + Button { + displayMonth = Calendar.current.date(byAdding: .month, value: 1, to: displayMonth)! + } label: { + Text(">").font(.custom("HYPixel-11px-J", size: 16)).foregroundStyle(theme.textPrimary) + } + } + .padding(.horizontal, 16).padding(.vertical, 10) + + // weekday header + HStack(spacing: 2) { + ForEach(["一","二","三","四","五","六","日"], id: \.self) { d in + Text(d).font(.custom("HYPixel-11px-J", size: 9)) + .foregroundStyle(theme.textPrimary.opacity(0.4)) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 8).padding(.bottom, 4) + + // calendar grid + ScrollView { + let days = daysInMonth() + let cols = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7) + LazyVGrid(columns: cols, spacing: 2) { + ForEach(0..? + private let session = URLSession(configuration: { + let c = URLSessionConfiguration.default + c.timeoutIntervalForRequest = 8 + c.timeoutIntervalForResource = 12 + return c + }()) + + func start() { + task?.cancel() + task = Task { [weak self] in + while !Task.isCancelled { + await self?.fetch() + try? await Task.sleep(nanoseconds: 1_500_000_000) + } + } + } + + func stop() { + task?.cancel() + task = nil + } + + private func fetch() async { + await fetchHistory() + await fetchTerminal() + } + + private func fetchHistory() async { + var req = ServerConfig.makeRequest(path: "codex/history") + guard var c = URLComponents(url: req.url!, resolvingAgainstBaseURL: false) else { return } + c.queryItems = lastTs.isEmpty + ? [URLQueryItem(name: "limit", value: "80")] + : [URLQueryItem(name: "since", value: lastTs), URLQueryItem(name: "limit", value: "40")] + req.url = c.url + + do { + let (data, _) = try await session.data(for: req) + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let records = obj["records"] as? [[String: Any]] else { return } + var newMessages: [CodexBridgeMessage] = [] + for r in records { + let ts = r["ts"] as? String ?? "" + let text = r["text"] as? String ?? "" + let role = r["role"] as? String ?? "沈晏" + let source = r["source"] as? String ?? "codex" + let phase = r["phase"] as? String + let id = ts.isEmpty ? UUID().uuidString : ts + if !messages.contains(where: { $0.id == id }) { + newMessages.append(CodexBridgeMessage(id: id, text: text, role: role, ts: ts, source: source, phase: phase)) + } + } + if !newMessages.isEmpty { + messages.append(contentsOf: newMessages) + if let newest = newMessages.last { lastTs = newest.ts } + } + } catch {} + } + + private func fetchTerminal() async { + var req = ServerConfig.makeRequest(path: "codex/terminal") + guard var c = URLComponents(url: req.url!, resolvingAgainstBaseURL: false) else { return } + c.queryItems = [URLQueryItem(name: "lines", value: "160")] + req.url = c.url + + do { + let (data, _) = try await session.data(for: req) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let txt = obj["content"] as? String { + terminalOutput = txt + } + } catch {} + } + + func sendMessage(_ text: String) async { + let bodyText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !bodyText.isEmpty, !isSendingMessage else { return } + isSendingMessage = true + defer { isSendingMessage = false } + + var req = ServerConfig.makeRequest(path: "codex/send") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["text": bodyText]) + + do { + let (data, _) = try await session.data(for: req) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let record = obj["record"] as? [String: Any] { + let ts = record["ts"] as? String ?? "" + let role = record["role"] as? String ?? "猫猫" + let text = record["text"] as? String ?? bodyText + let source = record["source"] as? String ?? "codex-app-user" + let id = ts.isEmpty ? UUID().uuidString : ts + if !messages.contains(where: { $0.id == id }) { + messages.append(CodexBridgeMessage(id: id, text: text, role: role, ts: ts, source: source, phase: nil)) + lastTs = ts + } + } + await fetchTerminal() + } catch {} + } + + func sendTerminal(_ text: String) async { + let bodyText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !bodyText.isEmpty, !isSendingTerminal else { return } + isSendingTerminal = true + defer { isSendingTerminal = false } + + var req = ServerConfig.makeRequest(path: "codex/terminal/send") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["text": bodyText]) + _ = try? await session.data(for: req) + await fetchTerminal() + } + + func sendTerminalKey(_ key: String) async { + var req = ServerConfig.makeRequest(path: "codex/terminal/key") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["key": key]) + _ = try? await session.data(for: req) + await fetchTerminal() + } +} + +struct CodexBridgePage: View { + let theme: Theme + @StateObject private var vm = CodexBridgeViewModel() + @State private var showTerminal = false + @State private var messageInput = "" + @State private var terminalInput = "" + @FocusState private var messageFocused: Bool + @FocusState private var terminalFocused: Bool + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { showTerminal = false } + } label: { + ZStack { + (showTerminal ? Color.clear : theme.inputBg) + Text("对话") + .font(.custom("HYPixel-11px-J", size: 13)) + .foregroundStyle(showTerminal ? theme.textPrimary.opacity(0.35) : theme.textPrimary) + } + .frame(maxWidth: .infinity) + .frame(height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + + Button { + withAnimation(.easeInOut(duration: 0.15)) { showTerminal = true } + } label: { + ZStack { + (showTerminal ? theme.inputBg : Color.clear) + Text("终端") + .font(.custom("HYPixel-11px-J", size: 13)) + .foregroundStyle(showTerminal ? theme.textPrimary : theme.textPrimary.opacity(0.35)) + } + .frame(maxWidth: .infinity) + .frame(height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 14) + .padding(.top, 8) + + if showTerminal { + CodexTerminalLogView(output: vm.terminalOutput, theme: theme) + CodexTerminalInputBar(input: $terminalInput, focused: $terminalFocused, theme: theme) { key in + Task { await vm.sendTerminalKey(key) } + } send: { + let text = terminalInput + terminalInput = "" + Task { await vm.sendTerminal(text) } + } sendEnter: { + Task { await vm.sendTerminalKey("Enter") } + } + } else { + CodexMessageList(messages: vm.messages, isInputFocused: messageFocused, theme: theme) + CodexMessageInputBar(input: $messageInput, focused: $messageFocused, theme: theme) { + let text = messageInput + messageInput = "" + Task { await vm.sendMessage(text) } + } + } + } + .onAppear { vm.start() } + .onDisappear { vm.stop() } + } +} + +struct CodexMessageInputBar: View { + @Binding var input: String + var focused: FocusState.Binding + let theme: Theme + let send: () -> Void + + var body: some View { + HStack(spacing: 0) { + TextField("输入...", text: $input) + .font(.custom("HYPixel-11px-J", size: 16)) + .foregroundStyle(theme.textPlaceholder) + .focused(focused) + .submitLabel(.send) + .onSubmit { sendIfNeeded() } + Spacer() + Button { sendIfNeeded() } label: { + Image(Theme.themedIcon("btn_send", tint: theme.iconTint)) + .resizable() + .frame(width: 29, height: 29) + .opacity(0.3) + } + } + .padding(.horizontal, 16).frame(height: 46) + .background(Rectangle().fill(theme.inputBg)) + .overlay(Rectangle().stroke(theme.inputBorder, lineWidth: 0)) + .padding(.horizontal, 14).padding(.bottom, 8) + } + + private func sendIfNeeded() { + guard !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + focused.wrappedValue = false + send() + } +} + +struct CodexTerminalInputBar: View { + @Binding var input: String + var focused: FocusState.Binding + let theme: Theme + let sendKey: (String) -> Void + let send: () -> Void + let sendEnter: () -> Void + + var body: some View { + HStack(spacing: 8) { + Button { sendKey("Up") } label: { + Image(Theme.themedIcon("btn_up", tint: theme.iconTint)) + .resizable() + .frame(width: 36, height: 36) + } + Button { sendKey("Down") } label: { + Image(Theme.themedIcon("btn_down", tint: theme.iconTint)) + .resizable() + .frame(width: 36, height: 36) + } + Text("$") + .font(.system(size: 14, design: .monospaced).weight(.bold)) + .foregroundStyle(theme.textTyping) + TextField("输入", text: $input) + .font(.custom("HYPixel-11px-J", size: 16)) + .foregroundStyle(theme.textPlaceholder) + .focused(focused) + .submitLabel(.send) + .onSubmit { sendIfNeeded() } + .autocorrectionDisabled(true) + Spacer() + Button { sendIfNeeded() } label: { + Rectangle().fill(.clear).frame(width: 29, height: 29) + .overlay { Image(Theme.themedIcon("btn_enter", tint: theme.iconTint)).resizable().frame(width: 29, height: 29) } + } + Button { sendEnter() } label: { + Image(Theme.themedIcon("btn_return", tint: theme.iconTint)).resizable().frame(width: 29, height: 29) + } + } + .padding(.horizontal, 16).frame(height: 46) + .background(Rectangle().fill(theme.inputBg)) + .overlay(Rectangle().stroke(theme.inputBorder, lineWidth: 1)) + .padding(.horizontal, 14).padding(.bottom, 10) + } + + private func sendIfNeeded() { + guard !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + focused.wrappedValue = false + send() + } +} + +struct CodexMessageList: View { + let messages: [CodexBridgeMessage] + let isInputFocused: Bool + let theme: Theme + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 6) { + ForEach(messages) { msg in + CodexBridgeBubble(message: msg, theme: theme) + .id(msg.id) + } + Color.clear + .frame(height: 1) + .id("codex-bottom") + } + .padding(.vertical, 8) + } + .scrollDismissesKeyboard(.immediately) + .onAppear { + scrollToBottom(proxy: proxy, delay: 0.1) + } + .onChange(of: messages.count) { _, _ in + scrollToBottom(proxy: proxy) + } + .onChange(of: isInputFocused) { _, now in + if now { scrollToBottom(proxy: proxy, delay: 0.2) } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: isInputFocused ? 18 : 8) + } + } + } + + private func scrollToBottom(proxy: ScrollViewProxy, delay: Double = 0) { + let action = { + withAnimation { proxy.scrollTo(messages.last?.id ?? "codex-bottom", anchor: .bottom) } + } + if delay > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) + } else { + action() + } + } +} + +struct CodexBridgeBubble: View { + let message: CodexBridgeMessage + let theme: Theme + + var body: some View { + HStack { + if message.isFromMe { Spacer(minLength: 40) } + VStack(alignment: message.isFromMe ? .trailing : .leading, spacing: 4) { + Text(message.role) + .font(.custom("HYPixel-11px-J", size: 10)) + .foregroundStyle(theme.textPrimary.opacity(0.45)) + + Text(message.text) + .font(.custom("HYPixel-11px-J", size: 15)) + .foregroundStyle(message.isFromMe ? theme.bubbleTextRight : theme.bubbleTextLeft) + .lineSpacing(6) + .padding(.horizontal, 12).padding(.vertical, 10) + .background(Rectangle().fill(message.isFromMe ? theme.bubbleRight : theme.bubbleLeft)) + .textSelection(.enabled) + .contextMenu { + Button { + UIPasteboard.general.string = message.text + } label: { + Label("复制", systemImage: "doc.on.doc") + } + } + + if !displayTime.isEmpty { + HStack(spacing: 8) { + Text(displayTime) + .font(.custom("HYPixel-11px-J", size: 11)) + .foregroundStyle(theme.textPrimary.opacity(0.4)) + + Button { + UIPasteboard.general.string = message.text + } label: { + Image(Theme.themedIcon("btn_copy", tint: theme.iconTint)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 11, height: 11) + .opacity(0.4) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + } + } + if !message.isFromMe { Spacer(minLength: 40) } + } + .padding(.horizontal, 12) + } + + private var displayTime: String { + guard !message.ts.isEmpty else { return "" } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = iso.date(from: message.ts) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: message.ts) + } + guard let d = date else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: d) + } +} + +struct CodexTerminalLogView: View { + let output: String + let theme: Theme + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + Text(output.isEmpty ? "codex bridge — 等待同步..." : output) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 8) + .textSelection(.enabled) + Color.clear + .frame(height: 1) + .id("codex-terminal-bottom") + } + } + .onAppear { + scrollToBottom(proxy: proxy, delay: 0.1) + } + .onChange(of: output) { _, _ in + scrollToBottom(proxy: proxy, delay: 0.05) + } + } + } + + private func scrollToBottom(proxy: ScrollViewProxy, delay: Double = 0) { + let action = { + withAnimation { proxy.scrollTo("codex-terminal-bottom", anchor: .bottom) } + } + if delay > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) + } else { + action() + } + } +} + diff --git a/Rumeng/Rumeng/HYPixel11pxJ-2.ttf b/Rumeng/Rumeng/HYPixel11pxJ-2.ttf new file mode 100644 index 0000000..5716782 Binary files /dev/null and b/Rumeng/Rumeng/HYPixel11pxJ-2.ttf differ diff --git a/Rumeng/Rumeng/Rumeng.entitlements b/Rumeng/Rumeng/Rumeng.entitlements new file mode 100644 index 0000000..a0a528e --- /dev/null +++ b/Rumeng/Rumeng/Rumeng.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + development + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + health-records + + com.apple.developer.healthkit.background-delivery + + com.apple.developer.pass-type-identifiers + + $(TeamIdentifierPrefix)* + + + diff --git a/Rumeng/Rumeng/RumengApp.swift b/Rumeng/Rumeng/RumengApp.swift new file mode 100644 index 0000000..7532aec --- /dev/null +++ b/Rumeng/Rumeng/RumengApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct RumengApp: App { +#if canImport(UIKit) + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate +#endif + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Rumeng/Rumeng/TerminalViewModel.swift b/Rumeng/Rumeng/TerminalViewModel.swift new file mode 100644 index 0000000..7cdca9b --- /dev/null +++ b/Rumeng/Rumeng/TerminalViewModel.swift @@ -0,0 +1,104 @@ +import Foundation +import Combine + +@MainActor +final class TerminalViewModel: ObservableObject { + @Published var output: String = "" + @Published var input: String = "" + @Published var isSending: Bool = false + + private let session = URLSession(configuration: { + let c = URLSessionConfiguration.default + c.timeoutIntervalForRequest = 8 + c.timeoutIntervalForResource = 12 + return c + }()) + + private var pollTask: Task? + + func start() { + pollTask?.cancel() + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.fetchCapture() + try? await Task.sleep(nanoseconds: 1_500_000_000) + } + } + } + + func stop() { + pollTask?.cancel() + pollTask = nil + } + + func fetchCapture() async { + var req = ServerConfig.makeRequest(path: "tmux/capture") + guard var components = URLComponents(url: req.url!, resolvingAgainstBaseURL: false) else { return } + components.queryItems = [ + URLQueryItem(name: "session", value: "shenyan"), + URLQueryItem(name: "lines", value: "200"), + ] + req.url = components.url + + do { + let (data, _) = try await session.data(for: req) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let txt = obj["content"] as? String { + self.output = txt + } + } catch { + // silent — network jitter is normal + } + } + + func send() async { + let keys = input + guard !keys.isEmpty else { return } + isSending = true + defer { isSending = false } + + let formatter = DateFormatter() + formatter.dateFormat = "[HH:mm:ss] " + let timestamped = formatter.string(from: Date()) + keys + + var req = ServerConfig.makeRequest(path: "tmux/send") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: Any] = [ + "keys": timestamped, + "session": "shenyan", + "enter": true, + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + do { + _ = try await session.data(for: req) + self.input = "" + await fetchCapture() + } catch { + // silent + } + } + + func sendKey(_ key: String) async { + var req = ServerConfig.makeRequest(path: "tmux/send") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: [ + "keys": key, "session": "shenyan", "enter": false, + ]) + _ = try? await session.data(for: req) + await fetchCapture() + } + + func sendEnter() async { + var req = ServerConfig.makeRequest(path: "tmux/send") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: [ + "keys": "", "session": "shenyan", "enter": true, + ]) + _ = try? await session.data(for: req) + await fetchCapture() + } +} diff --git a/Rumeng/_archived/FridgeCode.swift b/Rumeng/_archived/FridgeCode.swift new file mode 100644 index 0000000..c62189b --- /dev/null +++ b/Rumeng/_archived/FridgeCode.swift @@ -0,0 +1,236 @@ +// MARK: - 冰箱贴 ViewModel + +@MainActor +final class FridgeViewModel: ObservableObject { + @Published var notes: [FridgeNote] = [] + private let session = URLSession(configuration: .default) + + struct FridgeNote: Identifiable { + let id: String; let text: String; let role: String; let createdAt: String + var isMine: Bool { role == "眠眠" || role == "user" } + var replies: [FridgeReply] = [] + } + struct FridgeReply: Identifiable { + let id: String; let text: String; let role: String; let createdAt: String + var isMine: Bool { role == "眠眠" || role == "user" } + } + + func fetch() async { + let req = ServerConfig.makeRequest(path: "fridge/list") + do { + let (data, _) = try await session.data(for: req) + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = obj["notes"] as? [[String: Any]] { + notes = items.map { item in + let replies: [FridgeReply] = (item["replies"] as? [[String: Any]] ?? []).map { r in + FridgeReply(id: r["id"] as? String ?? "", text: r["text"] as? String ?? "", role: r["role"] as? String ?? "user", createdAt: r["created_at"] as? String ?? "") + } + return FridgeNote(id: item["id"] as? String ?? "", text: item["text"] as? String ?? "", role: item["role"] as? String ?? "user", createdAt: item["created_at"] as? String ?? "", replies: replies) + } + } + } catch {} + } + + func add(text: String) async { + var req = ServerConfig.makeRequest(path: "fridge/add") + req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text, "role": "眠眠"]) + _ = try? await session.data(for: req) + await fetch() + } + + func reply(noteId: String, text: String) async { + var req = ServerConfig.makeRequest(path: "fridge/reply") + req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["id": noteId, "text": text, "role": "眠眠"]) + _ = try? await session.data(for: req) + await fetch() + } + + func delete(id: String) async { + var req = ServerConfig.makeRequest(path: "fridge/delete") + req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["id": id]) + _ = try? await session.data(for: req) + await fetch() + } +} + +// MARK: - 冰箱贴页 + +struct FridgePage: View { + @StateObject private var vm = FridgeViewModel() + @State private var showAdd = false + @State private var newText = "" + @State private var selectedNote: FridgeViewModel.FridgeNote? + let theme: Theme + + private let size: CGFloat = 42 + private let gap: CGFloat = 3 + private let cols: [CGFloat] = [32, 77, 122, 167, 212, 257, 302] + + var body: some View { + VStack(spacing: 0) { + Spacer() + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .bottom, spacing: 3) { + ForEach((0..<7).reversed(), id: \.self) { dayIndex in + let dayNotes = notesForDay(dayIndex) + VStack(spacing: 3) { + if dayIndex == 6 { + Button { showAdd = true } label: { + Rectangle() + .fill(theme.fridgeAddBtn) + .frame(width: size, height: size) + } + .buttonStyle(.plain) + } + ForEach(dayNotes) { note in + Button { + selectedNote = note + } label: { + Rectangle() + .fill(note.isMine ? theme.fridgeMine : theme.fridgeOther) + .frame(width: size, height: size) + } + .buttonStyle(.plain) + .contextMenu { Button("删除", role: .destructive) { Task { await vm.delete(id: note.id) } } } + } + } + } + } + .padding(.horizontal, 8) + } + .environment(\.layoutDirection, .rightToLeft) + .padding(.bottom, 20) + } + .onAppear { Task { await vm.fetch() } } + .alert("新冰箱贴(最多50字)", isPresented: $showAdd) { + TextField("内容", text: $newText) + Button("取消", role: .cancel) { newText = "" } + Button("添加") { Task { await vm.add(text: String(newText.prefix(50))); newText = "" } } + } + .sheet(item: $selectedNote) { note in + FridgeDetailView(noteId: note.id, vm: vm, theme: theme) + } + } + + private func notesForDay(_ dayIndex: Int) -> [FridgeViewModel.FridgeNote] { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + guard let targetDay = cal.date(byAdding: .day, value: -(6 - dayIndex), to: today) else { return [] } + let nextDay = cal.date(byAdding: .day, value: 1, to: targetDay)! + return vm.notes.filter { note in + let iso = String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: " ") + let fm = DateFormatter(); fm.dateFormat = "yyyy-MM-dd HH:mm:ss" + guard let d = fm.date(from: iso) else { return false } + return d >= targetDay && d < nextDay + } + } +} + +struct FridgeNoteCard: View { + let note: FridgeViewModel.FridgeNote + let theme: Theme + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(formattedDate).font(.custom("HYPixel-11px-J", size: 18).weight(.bold)).foregroundStyle(theme.textPrimary) + Text(note.text).font(.custom("HYPixel-11px-J", size: 13).weight(.bold)).foregroundStyle(note.isMine ? theme.fridgeTextMine : theme.fridgeTextOther).lineSpacing(4).lineLimit(2) + if !note.replies.isEmpty { + Text("\(note.replies.count) 条回复").font(.custom("HYPixel-11px-J", size: 11)).foregroundStyle(theme.textTyping) + } + } + .padding(.horizontal, 20).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Rectangle().fill(note.isMine ? theme.fridgeMine : theme.fridgeOther)) + } + + private var formattedDate: String { + String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: "-") + } +} + +// MARK: - 冰箱贴详情 + +struct FridgeDetailView: View { + let noteId: String + @ObservedObject var vm: FridgeViewModel + @Environment(\.dismiss) private var dismiss + @State private var replyText = "" + let theme: Theme + + private var note: FridgeViewModel.FridgeNote? { + vm.notes.first(where: { $0.id == noteId }) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(formattedDate).font(.custom("HYPixel-11px-J", size: 16).weight(.bold)).foregroundStyle(theme.textPrimary) + Spacer() + Button("关闭") { dismiss() }.foregroundStyle(theme.textTyping) + } + .padding(.horizontal, 20).padding(.top, 20) + + if let note { + Text(note.text) + .font(.custom("HYPixel-11px-J", size: 16)) + .foregroundStyle(note.isMine ? theme.fridgeTextMine : theme.fridgeTextOther) + .lineSpacing(6) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(Rectangle().fill(note.isMine ? theme.fridgeMine : theme.fridgeOther)) + .padding(.horizontal, 20).padding(.top, 12) + + ScrollView { + VStack(spacing: 8) { + ForEach(note.replies) { reply in + HStack { + if reply.isMine { Spacer(minLength: 40) } + VStack(alignment: reply.isMine ? .trailing : .leading, spacing: 2) { + Text(reply.text).font(.custom("HYPixel-11px-J", size: 16)) + .foregroundStyle(reply.isMine ? theme.bubbleTextRight : theme.bubbleTextLeft) + .lineSpacing(5) + .padding(.horizontal, 12).padding(.vertical, 6) + .background(Rectangle().fill(reply.isMine ? theme.bubbleRight : theme.bubbleLeft)) + Text(replyTime(reply.createdAt)).font(.custom("HYPixel-11px-J", size: 10)).foregroundStyle(theme.textTyping) + } + if !reply.isMine { Spacer(minLength: 40) } + } + .padding(.horizontal, 20) + } + } + .padding(.vertical, 12) + } + + HStack(spacing: 0) { + TextField("回复(最多50字)...", text: $replyText) + .font(.custom("HYPixel-11px-J", size: 16)).foregroundStyle(theme.textPlaceholder) + Spacer() + Button { + guard !replyText.isEmpty else { return } + let t = String(replyText.prefix(50)); replyText = "" + Task { await vm.reply(noteId: noteId, text: t) } + } label: { + Image(systemName: "arrowshape.turn.up.left.fill").font(.system(size: 14)).foregroundStyle(.white) + .frame(width: 32, height: 32).background(Circle().fill(theme.sendBtn)) + } + } + .padding(.horizontal, 16).frame(height: 44) + .background(Rectangle().fill(theme.inputBg)) + .overlay(Rectangle().stroke(theme.inputBorder, lineWidth: 1)) + .padding(.horizontal, 14).padding(.bottom, 8) + } + } + .background(theme.bg.ignoresSafeArea()) + } + + private var formattedDate: String { + guard let note else { return "" } + return String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: "-") + } + private func replyTime(_ iso: String) -> String { + String(iso.prefix(19)).replacingOccurrences(of: "T", with: " ") + } +} + diff --git a/Rumeng/_archived/GroupCode.swift b/Rumeng/_archived/GroupCode.swift new file mode 100644 index 0000000..96361a8 --- /dev/null +++ b/Rumeng/_archived/GroupCode.swift @@ -0,0 +1,245 @@ +// MARK: - 工作群(旧功能保留但不挂导航) + +struct GroupMember: Identifiable, Codable, Hashable { + let id: String + let displayName: String + let kind: String? + let color: String? + let model: String? + + enum CodingKeys: String, CodingKey { + case id, kind, color, model + case displayName = "display_name" + } + + var avatarText: String { String(displayName.prefix(1)) } + + func uiColor(theme _: Theme) -> Color { + switch color { + case "orange": return Color(hex: "CC8033") + case "green": return Color(hex: "6B9163") + case "blue": return Color(hex: "5E8CA8") + case "purple": return Color(hex: "8770A8") + default: return Color(hex: "8C8273") + } + } +} + +struct GroupMessage: Identifiable, Codable, Hashable { + let id: String + let ts: String + let senderId: String + let text: String + let mentions: [String] + let replyTo: String? + + enum CodingKeys: String, CodingKey { + case id, ts, text, mentions + case senderId = "sender_id" + case replyTo = "reply_to" + } + + var shortTime: String { + guard let t = ts.firstIndex(of: "T") else { return "" } + return String(ts[ts.index(after: t)...].prefix(5)) + } +} + +struct GroupPollResponse: Codable { + let ok: Bool + let records: [GroupMessage] + let lastTs: String? + let roster: [GroupMember]? + let statusWrapper: GroupStatusSnapshot? + + enum CodingKeys: String, CodingKey { + case ok, records, roster + case lastTs = "last_ts" + case statusWrapper = "status" + } +} + +struct GroupStatusSnapshot: Codable { + let agents: [String: GroupAgentStatus]? +} + +struct GroupAgentStatus: Codable, Hashable { + let state: String? + let tmux: String? +} + +@MainActor +final class GroupStore: ObservableObject { + @Published var messages: [GroupMessage] = [] + @Published var members: [GroupMember] = [] + @Published var agentStates: [String: GroupAgentStatus] = [:] + private var lastTs: String = "" + private var task: Task? + + func start() { + task?.cancel() + task = Task { await poll() } + } + + func stop() { task?.cancel(); task = nil } + + private func poll() async { + while !Task.isCancelled { + do { + var url = ServerConfig.baseURL.appendingPathComponent("group/poll") + if !lastTs.isEmpty { + var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)! + comps.queryItems = [URLQueryItem(name: "since", value: lastTs)] + url = comps.url ?? url + } + var req = ServerConfig.makeRequest(path: "") + req.url = url + req.timeoutInterval = 8 + print("[GroupStore] poll → \(url)") + let (data, resp) = try await URLSession.shared.data(for: req) + let statusCode = (resp as? HTTPURLResponse)?.statusCode ?? -1 + print("[GroupStore] status=\(statusCode) bytes=\(data.count)") + if let raw = String(data: data, encoding: .utf8) { + print("[GroupStore] body=\(raw.prefix(500))") + } + do { + let decoded = try JSONDecoder().decode(GroupPollResponse.self, from: data) + guard decoded.ok else { + print("[GroupStore] ok=false, skipping") + try? await Task.sleep(for: .seconds(5)) + continue + } + if let roster = decoded.roster { members = roster } + if let agents = decoded.statusWrapper?.agents { agentStates = agents } + print("[GroupStore] agentStates=\(agentStates)") + print("[GroupStore] records=\(decoded.records.count) messages_total=\(messages.count + decoded.records.count)") + if !decoded.records.isEmpty { + messages.append(contentsOf: decoded.records) + lastTs = decoded.lastTs ?? lastTs + } + } catch { + print("[GroupStore] decode error: \(error)") + } + } catch { + print("[GroupStore] network error: \(error)") + try? await Task.sleep(for: .seconds(2)) + } + try? await Task.sleep(for: .seconds(2)) + } + } + + func send(text: String) async { + guard !text.isEmpty else { return } + var req = ServerConfig.makeRequest(path: "group/send") + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let mentions = resolveMentions(in: text) + let body: [String: Any] = ["sender_id": "amian", "text": text, "mentions": mentions] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + do { + let (_, _) = try await URLSession.shared.data(for: req) + } catch {} + } + + private func resolveMentions(in text: String) -> [String] { + let pattern = try! NSRegularExpression(pattern: "@([一-鿿]+|[A-Za-z0-9_]+)") + let range = NSRange(text.startIndex..., in: text) + let matches = pattern.matches(in: text, range: range) + var ids: [String] = [] + for m in matches { + guard let r = Range(m.range(at: 1), in: text) else { continue } + let name = String(text[r]).lowercased() + if name.contains("张三") || name == "zs" { ids.append("zhangsan") } + } + return ids + } + + func memberColor(for senderId: String, theme: Theme) -> Color { + if let m = members.first(where: { $0.id == senderId }) { return m.uiColor(theme: theme) } + return senderId == "shenyan" ? Color(hex: "CC8033") : senderId == "amian" ? Color(hex: "8C8273") : Color(hex: "6B9163") + } +} + +struct GroupChatPage: View { + let theme: Theme + @StateObject private var store = GroupStore() + @State private var input = "" + @FocusState private var focused: Bool + + var body: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 4) { + ForEach(store.messages) { msg in + HStack(alignment: .top, spacing: 6) { + Rectangle().fill(store.memberColor(for: msg.senderId, theme: theme)) + .frame(width: 5, height: 5) + VStack(alignment: .leading, spacing: 2) { + Text(displayName(for: msg.senderId)) + .font(.custom("HYPixel-11px-J", size: 10).weight(.bold)) + .foregroundStyle(store.memberColor(for: msg.senderId, theme: theme)) + Text(msg.text) + .font(.custom("HYPixel-11px-J", size: 13)) + .foregroundStyle(theme.textPrimary) + } + Spacer() + Text(msg.shortTime) + .font(.custom("HYPixel-11px-J", size: 9)) + .foregroundStyle(theme.textPrimary.opacity(0.3)) + } + .padding(.horizontal, 12).padding(.vertical, 4) + .id(msg.id) + } + } + .padding(.vertical, 4) + } + .onChange(of: store.messages.count) { _, _ in + if let last = store.messages.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + + // online bar + HStack(spacing: 0) { + let onlineAgents = store.members.filter { $0.kind == "agent" && store.agentStates[$0.id]?.state == "online" } + if !onlineAgents.isEmpty { + Text(onlineAgents.map { $0.displayName }.joined(separator: " / ")) + .font(.custom("HYPixel-11px-J", size: 11)) + .foregroundStyle(theme.textPrimary.opacity(0.6)) + } else { + Text("无人在线") + .font(.custom("HYPixel-11px-J", size: 11)) + .foregroundStyle(theme.textPrimary.opacity(0.3)) + } + Spacer() + } + .padding(.horizontal, 12).padding(.top, 4) + + // input + HStack(spacing: 8) { + TextField("@某人 说点什么...", text: $input) + .font(.custom("HYPixel-11px-J", size: 14)) + .foregroundStyle(theme.textPrimary) + .focused($focused) + Button { + let text = input.trimmingCharacters(in: .whitespaces) + input = "" + Task { await store.send(text: text) } + } label: { + Image(Theme.themedIcon("btn_send", tint: theme.iconTint)).resizable().frame(width: 29, height: 29).opacity(0.3) + } + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(Rectangle().fill(theme.inputBg)) + } + .background(theme.bg.ignoresSafeArea()) + .onAppear { store.start() } + .onDisappear { store.stop() } + } + + private func displayName(for senderId: String) -> String { + store.members.first(where: { $0.id == senderId })?.displayName ?? senderId + } +} diff --git a/Rumeng/bridge/group_chat.py b/Rumeng/bridge/group_chat.py new file mode 100644 index 0000000..a0feb68 --- /dev/null +++ b/Rumeng/bridge/group_chat.py @@ -0,0 +1,211 @@ +"""Workgroup chat — lightweight multi-agent messaging. +Adapted from CcCompanion group_chat.py, stripped to core.""" +from __future__ import annotations + +import json +import os +import re +import secrets +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +DATA_DIR = Path(__file__).resolve().parent / "data" +GROUP_FILE = DATA_DIR / "group_messages.jsonl" + +MENTION_RE = re.compile(r"@([A-Za-z0-9_\-]+|[一-鿿]+)") +ALL_TOKEN = "__all__" + +ROSTER: list[dict[str, Any]] = [ + { + "id": "amian", + "display_name": "眠眠", + "kind": "human", + "avatar": "眠", + "color": "neutral", + "model": None, + "tmux": None, + "can_reply": False, + }, + { + "id": "shenyan", + "display_name": "沈晏", + "kind": "agent", + "avatar": "沈", + "color": "orange", + "model": "DeepSeek", + "tmux": "shenyan", + "can_reply": True, + "default_responder": True, + }, + { + "id": "zhangsan", + "display_name": "张三", + "kind": "agent", + "avatar": "张", + "color": "green", + "model": "Qwen 35B", + "tmux": None, + "can_reply": True, + }, +] + +ROSTER_BY_ID = {m["id"]: m for m in ROSTER} +REPLY_AGENT_IDS = [m["id"] for m in ROSTER if m.get("can_reply")] + +MENTION_ALIASES: dict[str, str] = { + "all": ALL_TOKEN, + "__all__": ALL_TOKEN, + "眠眠": "amian", + "amian": "amian", + "沈晏": "shenyan", + "shenyan": "shenyan", + "阿晏": "shenyan", + "张三": "zhangsan", + "zhangsan": "zhangsan", + "zs": "zhangsan", +} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds") + + +def _make_id(prefix: str) -> str: + return f"{prefix}_{int(time.time() * 1000)}_{secrets.token_hex(4)}" + + +def _read_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + rows: list[dict] = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue + return rows + + +def _append_jsonl(path: Path, record: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def normalize_mentions(mentions_raw: Any = None, text: str = "") -> list[str]: + """Extract and resolve @mentions from explicit list + text body.""" + raw: list[str] = [] + if isinstance(mentions_raw, str): + raw.extend(m.strip() for m in mentions_raw.split(",") if m.strip()) + elif isinstance(mentions_raw, list): + raw.extend(str(m).strip() for m in mentions_raw if str(m).strip()) + if text: + raw.extend(m.group(1).strip() for m in MENTION_RE.finditer(text)) + + seen: set[str] = set() + resolved: list[str] = [] + for item in raw: + key = item.strip().lstrip("@").lower() + agent_id = MENTION_ALIASES.get(key) + if agent_id and agent_id not in seen: + resolved.append(agent_id) + seen.add(agent_id) + return resolved + + +def targets_for(sender_id: str, mentions: list[str]) -> list[str]: + """Decide which agents should receive a message.""" + if sender_id == "amian": + # human: no @mention → broadcast to all agents + if not mentions or ALL_TOKEN in mentions: + return list(REPLY_AGENT_IDS) + return [m for m in mentions if m in REPLY_AGENT_IDS] + else: + # agent → agent: only explicit @mentions + if not mentions or ALL_TOKEN in mentions: + return [] + return [m for m in mentions if m in REPLY_AGENT_IDS and m != sender_id] + + +def append_group_message( + sender_id: str, + text: str, + *, + mentions: list[str] | None = None, + reply_to: str | None = None, + message_type: str = "chat", +) -> dict: + """Write a message to the group JSONL and return the record.""" + member = ROSTER_BY_ID.get(sender_id) + if not member: + raise ValueError(f"unknown sender_id: {sender_id}") + text = str(text or "").strip() + if not text: + raise ValueError("text required") + + mentions = mentions or normalize_mentions(text=text) + targets = targets_for(sender_id, mentions) + + record = { + "id": _make_id("grp"), + "ts": _now_iso(), + "sender_id": sender_id, + "text": text, + "mentions": mentions, + "targets": targets, + "reply_to": reply_to, + "message_type": message_type, + } + _append_jsonl(GROUP_FILE, record) + return record + + +def read_since(since_ts: str | None = None, limit: int = 100) -> list[dict]: + """Return messages newer than since_ts, up to limit.""" + rows = _read_jsonl(GROUP_FILE) + if since_ts: + rows = [r for r in rows if r.get("ts", "") > since_ts] + return rows[:limit] + return rows[-limit:] + + +def roster() -> list[dict]: + return [dict(m) for m in ROSTER] + + +def agent_status(session_exists: Callable[[str], bool] | None = None) -> dict: + """Return online/offline status for each agent.""" + agents: dict[str, Any] = {} + for m in ROSTER: + if m.get("kind") != "agent": + continue + aid = m["id"] + tmux = m.get("tmux") + online = bool(tmux and session_exists and session_exists(tmux)) + agents[aid] = {"state": "online" if online else "offline", "tmux": tmux} + return {"agents": agents} + + +def context_lines(limit: int = 20) -> list[str]: + """Last N messages formatted for fan-out context injection.""" + records = read_since(limit=limit) + lines: list[str] = [] + for rec in records: + sender_id = rec.get("sender_id", "") + member = ROSTER_BY_ID.get(sender_id) or {} + name = member.get("display_name") or sender_id + ts = str(rec.get("ts", ""))[11:16] + text = str(rec.get("text", "")).replace("\n", " ") + if len(text) > 180: + text = text[:177] + "..." + lines.append(f"[{ts}] {name}: {text}") + if len(lines) >= limit: + break + return lines diff --git a/Rumeng/bridge/server.py b/Rumeng/bridge/server.py new file mode 100644 index 0000000..e46a877 --- /dev/null +++ b/Rumeng/bridge/server.py @@ -0,0 +1,695 @@ +#!/usr/bin/env python3 +"""Rumeng bridge — 监听 8795,桥接 App 与 tmux/CC session。""" + +import json +import os +import re +import secrets +import subprocess +import sys +import datetime +import time +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import ThreadingMixIn +from urllib.parse import urlparse, parse_qs + +import httpx +import jwt + +from group_chat import ( + ROSTER_BY_ID, REPLY_AGENT_IDS, + append_group_message, read_since, roster, agent_status, context_lines, +) + +AUTH_TOKEN = os.environ.get("BRIDGE_AUTH_TOKEN", "") +if not AUTH_TOKEN: + print("ERROR: BRIDGE_AUTH_TOKEN not set", file=sys.stderr) + sys.exit(1) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +os.makedirs(DATA_DIR, exist_ok=True) +KNOCK_DIR = os.path.expanduser("~/.shenyan-knock") +os.makedirs(KNOCK_DIR, exist_ok=True) +CHAT_FILE = os.path.join(DATA_DIR, "chat.jsonl") +CODEX_FILE = os.path.join(DATA_DIR, "codex.jsonl") +FRIDGE_FILE = os.path.join(DATA_DIR, "fridge.jsonl") +MOOD_FILE = os.path.join(DATA_DIR, "mood.jsonl") +STATUS_FILE = os.path.join(DATA_DIR, "status.json") +APNS_TOKEN_FILE = os.path.join(KNOCK_DIR, "apns_token") +APNS_KEY_FILE = os.path.join(KNOCK_DIR, "AuthKey_3VJ5X6V9Q8.p8") +APNS_TEAM_ID = "ZZMLW32PHH" +APNS_KEY_ID = "3VJ5X6V9Q8" +APNS_BUNDLE_ID = "rumeng-v1.0.Rumeng" +APNS_ENDPOINT = "https://api.sandbox.push.apple.com" +CODEX_TERMINAL_FILE = "/tmp/codex_rumeng_terminal.log" +CODEX_TERMINAL_MAX_LINES = 120 +CODEX_TMUX_TARGET_FILE = os.path.join(KNOCK_DIR, "codex_tmux_target") +CODEX_DEFAULT_TMUX_TARGET = "codex" +CONTROL_KEYS_PATTERN = re.compile(r"^([A-Z][A-Za-z]*|C-.|M-.|S-.|Space|Tab|Enter|Escape|BSpace)$") +APP_ECHO_PATTERN = re.compile(r"^\[App\]\[\d{2}:\d{2}:\d{2}\]\s+") + + +def iso_now(): + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def random_id(prefix): + return f"{prefix}_{secrets.token_hex(4)}" + + +def set_app_online(): + try: + with open(STATUS_FILE, "w", encoding="utf-8") as f: + f.write(iso_now()) + except Exception: + pass + + +def get_app_status(): + now = datetime.datetime.now(datetime.timezone.utc) + result = {"cc_online": tmux_session_alive()} + if not os.path.exists(STATUS_FILE): + result.update({"online": False, "seconds_ago": None}) + return result + try: + with open(STATUS_FILE, "r", encoding="utf-8") as f: + last_ts_str = f.read().strip() + last_ts = datetime.datetime.fromisoformat(last_ts_str.replace("Z", "+00:00")) + delta = now - last_ts + result.update({"online": delta.total_seconds() <= 30, "seconds_ago": round(delta.total_seconds())}) + return result + except Exception: + result.update({"online": False, "seconds_ago": None}) + return result + + +def tmux_session_alive(session="shenyan"): + try: + subprocess.run(["tmux", "has-session", "-t", session], + capture_output=True, check=True, timeout=5) + return True + except Exception: + return False + + +def codex_tmux_target(): + target = os.environ.get("CODEX_TMUX_TARGET", "").strip() + if target and tmux_target_exists(target): + return target + try: + with open(CODEX_TMUX_TARGET_FILE, "r", encoding="utf-8") as f: + target = f.read().strip() + if target and tmux_target_exists(target): + return target + except FileNotFoundError: + pass + except Exception as e: + print(f"[Codex] target read failed: {e}", file=sys.stderr) + detected = detect_codex_tmux_target() + if detected: + return detected + return CODEX_DEFAULT_TMUX_TARGET + + +def tmux_target_exists(target): + try: + subprocess.run(["tmux", "display-message", "-t", target, "-p", "#{pane_id}"], + capture_output=True, text=True, check=True, timeout=5) + return True + except Exception: + return False + + +def detect_codex_tmux_target(): + try: + result = subprocess.run( + [ + "tmux", "list-panes", "-a", + "-F", "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_command}\t#{pane_title}\t#{pane_start_command}", + ], + capture_output=True, text=True, check=True, timeout=5, + ) + except Exception: + return "" + for line in result.stdout.splitlines(): + parts = line.split("\t") + if len(parts) < 5: + continue + pane_id, target, command, title, start_command = parts[:5] + haystack = " ".join([command, title, start_command]).lower() + if "codex" in haystack and "codex_rumeng_terminal.log" not in haystack: + return pane_id or target + return "" + + +def tmux_send_text(target, text, enter=True): + subprocess.run(["tmux", "send-keys", "-t", target, "-l", text], check=True, timeout=10) + if enter: + subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=True, timeout=10) + + +def read_jsonl(filepath): + records = [] + if not os.path.exists(filepath): + return records + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + records.append(json.loads(line)) + return records + + +def append_jsonl(filepath, record): + with open(filepath, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def rewrite_jsonl(filepath, records): + with open(filepath, "w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def read_tail_text(filepath, max_lines): + try: + with open(filepath, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + except FileNotFoundError: + return "" + except Exception as e: + return f"[codex-sync log read error: {e}]" + return "\n".join(lines[-max_lines:]).strip() + + +def append_codex_terminal_overlay(content): + codex_log = read_tail_text(CODEX_TERMINAL_FILE, CODEX_TERMINAL_MAX_LINES) + if not codex_log: + return content + base = content.rstrip() + if base: + return f"{base}\n\n--- Codex sync ---\n{codex_log}\n" + return f"--- Codex sync ---\n{codex_log}\n" + + +def send_apns_alert(text): + try: + with open(APNS_TOKEN_FILE, "r", encoding="utf-8") as f: + device_token = f.read().strip() + with open(APNS_KEY_FILE, "r", encoding="utf-8") as f: + private_key = f.read() + except FileNotFoundError as e: + result = {"ok": False, "error": f"missing file: {e}"} + print(f"[APNs] skipped: {result['error']}", file=sys.stderr) + return result + if not device_token: + result = {"ok": False, "error": "empty device token"} + print(f"[APNs] skipped: {result['error']}", file=sys.stderr) + return result + + provider_token = jwt.encode( + {"iss": APNS_TEAM_ID, "iat": int(time.time())}, + private_key, + algorithm="ES256", + headers={"kid": APNS_KEY_ID}, + ) + payload = { + "aps": { + "alert": { + "title": "", + "body": text[:100], + }, + "sound": "default", + } + } + headers = { + "authorization": f"bearer {provider_token}", + "apns-topic": APNS_BUNDLE_ID, + "apns-push-type": "alert", + "apns-priority": "10", + } + try: + with httpx.Client(http2=True, timeout=10) as client: + resp = client.post(f"{APNS_ENDPOINT}/3/device/{device_token}", json=payload, headers=headers) + if 200 <= resp.status_code < 300: + result = {"ok": True, "status": resp.status_code, "apns_id": resp.headers.get("apns-id", "")} + print(f"[APNs] sent: HTTP {resp.status_code} apns-id={result['apns_id']}", file=sys.stderr) + return result + result = {"ok": False, "status": resp.status_code, "response": resp.text} + print(f"[APNs] failed: HTTP {resp.status_code} {resp.text}", file=sys.stderr) + return result + except Exception as e: + result = {"ok": False, "error": str(e)} + print(f"[APNs] failed: {e}", file=sys.stderr) + return result + + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + sys.stderr.write(f"[{self.log_date_time_string()}] {' '.join(str(a) for a in args)}\n") + + def send_json(self, data, status=200): + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def read_json_body(self): + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + return {} + return json.loads(self.rfile.read(length).decode("utf-8")) + + def check_auth(self): + return self.headers.get("X-Auth-Token") == AUTH_TOKEN + + def do_GET(self): + if not self.check_auth(): + return self.send_json({"error": "unauthorized"}, 401) + set_app_online() + parsed = urlparse(self.path) + path, params = parsed.path, parse_qs(parsed.query, keep_blank_values=True) + if path == "/chat/status": + self.send_json(get_app_status()) + elif path == "/chat/history": + self.handle_chat_history(params) + elif path == "/codex/history": + self.handle_codex_history(params) + elif path == "/codex/terminal": + self.handle_codex_terminal(params) + elif path == "/fridge/list": + self.handle_fridge_list() + elif path == "/mood/list": + self.handle_mood_list() + elif path == "/group/poll": + self.handle_group_poll(params) + elif path == "/group/roster": + self.send_json({"ok": True, "roster": roster(), "status": agent_status(tmux_session_alive)}) + elif path == "/tmux/capture": + self.handle_tmux_capture(params) + elif path == "/apns/token": + self.handle_apns_token() + elif path == "/health": + self.send_json({"ok": True}) + else: + self.send_json({"error": "not found"}, 404) + + def do_POST(self): + if not self.check_auth(): + return self.send_json({"error": "unauthorized"}, 401) + set_app_online() + path = urlparse(self.path).path + try: + body = self.read_json_body() + except Exception as e: + return self.send_json({"error": f"invalid JSON: {e}"}, 400) + try: + if path == "/chat/send": + self.handle_chat_send(body) + elif path == "/chat/append": + self.handle_chat_append(body) + elif path == "/codex/append": + self.handle_codex_append(body) + elif path == "/codex/send": + self.handle_codex_send(body) + elif path == "/codex/terminal/send": + self.handle_codex_terminal_send(body) + elif path == "/codex/terminal/key": + self.handle_codex_terminal_key(body) + elif path == "/fridge/add": + self.handle_fridge_add(body) + elif path == "/fridge/reply": + self.handle_fridge_reply(body) + elif path == "/fridge/delete": + self.handle_fridge_delete(body) + elif path == "/mood/upsert": + self.handle_mood_upsert(body) + elif path == "/group/send": + self.handle_group_send(body) + elif path == "/tmux/send": + self.handle_tmux_send(body) + elif path == "/apns/register": + self.handle_apns_register(body) + elif path == "/apns/test": + self.handle_apns_test(body) + else: + self.send_json({"error": "not found"}, 404) + except Exception as e: + self.send_json({"error": str(e)}, 500) + + def handle_chat_send(self, body): + text = body.get("text", "") + if not text: + return self.send_json({"error": "missing text"}, 400) + record = {"ts": iso_now(), "role": "眠眠", "text": text} + append_jsonl(CHAT_FILE, record) + local_hms = datetime.datetime.now().strftime("%H:%M:%S") + injected = f"[App][{local_hms}] {text}" + try: + subprocess.run(["tmux", "send-keys", "-t", "shenyan", "-l", injected], check=True, timeout=10) + subprocess.run(["tmux", "send-keys", "-t", "shenyan", "Enter"], check=True, timeout=10) + except Exception as e: + self.send_json({"ok": True, "record": record, "tmux_error": str(e)}) + return + self.send_json({"ok": True, "record": record}) + + def handle_chat_append(self, body): + record = { + "ts": body.get("ts", iso_now()), + "role": body.get("role", ""), + "text": body.get("text", ""), + } + if "thinking" in body: + record["thinking"] = body["thinking"] + if "source" in body: + record["source"] = body["source"] + append_jsonl(CHAT_FILE, record) + if record["text"] and record["role"] != "眠眠": + threading.Thread(target=send_apns_alert, args=(record["text"],), daemon=True).start() + self.send_json({"ok": True}) + + def handle_chat_history(self, params): + since = params.get("since", [None])[0] + try: + limit = int(params.get("limit", ["50"])[0]) + except ValueError: + limit = 50 + records = read_jsonl(CHAT_FILE) + if since: + records = [r for r in records if r.get("ts", "") > since] + records = records[-limit:] + self.send_json({"records": records}) + + def handle_codex_append(self, body): + text = str(body.get("text", "")) + source = str(body.get("source", "")) + if source == "codex-user" and APP_ECHO_PATTERN.match(text): + self.send_json({"ok": True, "skipped": "app_echo"}) + return + record = { + "ts": body.get("ts", iso_now()), + "role": body.get("role", ""), + "text": text, + } + if "phase" in body: + record["phase"] = body["phase"] + if "source" in body: + record["source"] = body["source"] + append_jsonl(CODEX_FILE, record) + self.send_json({"ok": True}) + + def handle_codex_send(self, body): + text = str(body.get("text", "")).strip() + if not text: + return self.send_json({"error": "missing text"}, 400) + record = { + "ts": iso_now(), + "role": "眠眠", + "text": text, + "source": "codex-app-user", + } + append_jsonl(CODEX_FILE, record) + self.append_codex_terminal_line(record["role"], text, record["ts"], "app") + local_hms = datetime.datetime.now().strftime("%H:%M:%S") + injected = f"[App][{local_hms}] {text}" + target = codex_tmux_target() + try: + tmux_send_text(target, injected) + except subprocess.CalledProcessError as e: + err = e.stderr.strip() if e.stderr else str(e) + self.append_codex_terminal_line("bridge", f"tmux send failed target={target}: {err}", iso_now(), "error") + self.send_json({"ok": True, "record": record, "tmux_target": target, "tmux_error": err}) + return + except subprocess.TimeoutExpired: + self.append_codex_terminal_line("bridge", f"tmux send timeout target={target}", iso_now(), "error") + self.send_json({"ok": True, "record": record, "tmux_target": target, "tmux_error": "tmux send timeout"}) + return + self.append_codex_terminal_line("bridge", f"sent target={target}", iso_now(), "tmux") + self.send_json({"ok": True, "record": record, "tmux_target": target}) + + def handle_codex_terminal_send(self, body): + text = str(body.get("text", "")).strip() + if not text: + return self.send_json({"error": "missing text"}, 400) + ts = iso_now() + self.append_codex_terminal_line("$", text, ts, "app-terminal") + target = codex_tmux_target() + try: + tmux_send_text(target, text) + except subprocess.CalledProcessError as e: + err = e.stderr.strip() if e.stderr else str(e) + self.append_codex_terminal_line("bridge", f"tmux send failed target={target}: {err}", iso_now(), "error") + self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": err}, 500) + return + except subprocess.TimeoutExpired: + self.append_codex_terminal_line("bridge", f"tmux send timeout target={target}", iso_now(), "error") + self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": "tmux send timeout"}, 500) + return + self.append_codex_terminal_line("bridge", f"terminal sent target={target}", iso_now(), "tmux") + self.send_json({"ok": True, "ts": ts, "tmux_target": target}) + + def handle_codex_terminal_key(self, body): + key = str(body.get("key", "")).strip() + if not key: + return self.send_json({"error": "missing key"}, 400) + ts = iso_now() + self.append_codex_terminal_line("key", key, ts, "app-terminal") + target = codex_tmux_target() + try: + if CONTROL_KEYS_PATTERN.match(key): + subprocess.run(["tmux", "send-keys", "-t", target, key], check=True, timeout=10) + else: + tmux_send_text(target, key, enter=False) + except subprocess.CalledProcessError as e: + err = e.stderr.strip() if e.stderr else str(e) + self.append_codex_terminal_line("bridge", f"tmux key failed target={target}: {err}", iso_now(), "error") + self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": err}, 500) + return + except subprocess.TimeoutExpired: + self.append_codex_terminal_line("bridge", f"tmux key timeout target={target}", iso_now(), "error") + self.send_json({"ok": False, "ts": ts, "tmux_target": target, "tmux_error": "tmux key timeout"}, 500) + return + self.append_codex_terminal_line("bridge", f"key sent target={target} key={key}", iso_now(), "tmux") + self.send_json({"ok": True, "ts": ts, "tmux_target": target}) + + def append_codex_terminal_line(self, role, text, ts, source): + hms = ts[11:19] if len(ts) >= 19 else datetime.datetime.now().strftime("%H:%M:%S") + prefix = f"[Codex/{source}][{hms}] {role}: " + line = prefix + text.replace("\n", "\n" + " " * len(prefix)) + try: + with open(CODEX_TERMINAL_FILE, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception as e: + print(f"[Codex] terminal log write failed: {e}", file=sys.stderr) + + def handle_codex_history(self, params): + since = params.get("since", [None])[0] + try: + limit = int(params.get("limit", ["80"])[0]) + except ValueError: + limit = 80 + records = read_jsonl(CODEX_FILE) + if since: + records = [r for r in records if r.get("ts", "") > since] + self.send_json({"records": records[-limit:]}) + + def handle_codex_terminal(self, params): + try: + limit = int(params.get("lines", ["160"])[0]) + except ValueError: + limit = 160 + self.send_json({"content": read_tail_text(CODEX_TERMINAL_FILE, limit)}) + + def handle_fridge_list(self): + self.send_json({"notes": read_jsonl(FRIDGE_FILE)}) + + def handle_fridge_add(self, body): + note = { + "id": random_id("fridge"), + "text": body.get("text", ""), + "role": body.get("role", "眠眠"), + "created_at": iso_now(), + "replies": [], + } + append_jsonl(FRIDGE_FILE, note) + self.send_json({"ok": True, "id": note["id"]}) + + def handle_fridge_reply(self, body): + note_id = body.get("id") + if not note_id: + return self.send_json({"error": "missing id"}, 400) + records = read_jsonl(FRIDGE_FILE) + target = next((r for r in records if r.get("id") == note_id), None) + if not target: + return self.send_json({"error": "note not found"}, 404) + reply = { + "id": random_id("reply"), + "text": body.get("text", ""), + "role": body.get("role", "眠眠"), + "created_at": iso_now(), + } + target.setdefault("replies", []).append(reply) + rewrite_jsonl(FRIDGE_FILE, records) + self.send_json({"ok": True, "id": reply["id"]}) + + def handle_fridge_delete(self, body): + note_id = body.get("id") + if not note_id: + return self.send_json({"error": "missing id"}, 400) + records = read_jsonl(FRIDGE_FILE) + new_records = [r for r in records if r.get("id") != note_id] + if len(new_records) == len(records): + return self.send_json({"error": "note not found"}, 404) + rewrite_jsonl(FRIDGE_FILE, new_records) + self.send_json({"ok": True}) + + # ── group endpoints ────────────────────────────────────── + + def handle_mood_list(self): + self.send_json(read_jsonl(MOOD_FILE)) + + def handle_mood_upsert(self, body): + date = body.get("date", "") + role = body.get("role", "") + if not date or not role: + return self.send_json({"error": "missing date or role"}, 400) + records = read_jsonl(MOOD_FILE) + existing = next((r for r in records if r.get("date") == date and r.get("role") == role), None) + if existing: + existing["tag"] = body.get("tag", "") + existing["color"] = body.get("color", "") + existing["updated_at"] = iso_now() + rewrite_jsonl(MOOD_FILE, records) + else: + record = { + "id": random_id("mood"), + "date": date, + "role": role, + "tag": body.get("tag", ""), + "color": body.get("color", ""), + "created_at": iso_now(), + } + append_jsonl(MOOD_FILE, record) + if role == "眠眠": + notify = { + "ts": iso_now(), + "role": "system", + "text": f"[mood] 猫猫今天:{body.get('tag', '')}", + "source": "mood", + } + append_jsonl(CHAT_FILE, notify) + self.send_json({"ok": True}) + + def handle_group_poll(self, params): + since = params.get("since", [None])[0] + if since: + since = since.replace(" ", "+") # parse_qs decodes + as space + try: + limit = int(params.get("limit", ["100"])[0]) + except ValueError: + limit = 100 + records = read_since(since, limit) + self.send_json({ + "ok": True, + "records": records, + "count": len(records), + "last_ts": records[-1]["ts"] if records else since, + "roster": roster(), + "status": agent_status(tmux_session_alive), + }) + + def handle_group_send(self, body): + sender_id = body.get("sender_id", "amian") + text = body.get("text", "") + if not text: + return self.send_json({"error": "missing text"}, 400) + try: + record = append_group_message( + sender_id=sender_id, + text=text, + mentions=body.get("mentions"), + reply_to=body.get("reply_to"), + ) + except ValueError as e: + return self.send_json({"error": str(e)}, 400) + self.send_json({"ok": True, "record": record}) + + def handle_tmux_capture(self, params): + session = params.get("session", ["shenyan"])[0] + try: + lines = int(params.get("lines", ["200"])[0]) + except ValueError: + lines = 200 + try: + result = subprocess.run( + ["tmux", "capture-pane", "-t", session, "-p", "-S", f"-{lines}"], + capture_output=True, text=True, check=True, timeout=10, + ) + self.send_json({"content": result.stdout}) + except subprocess.CalledProcessError as e: + self.send_json({"error": f"tmux: {e.stderr.strip()}"}, 404) + except subprocess.TimeoutExpired: + self.send_json({"error": "tmux capture timeout"}, 500) + + def handle_tmux_send(self, body): + keys = body.get("keys", "") + session = body.get("session", "shenyan") + enter = body.get("enter", True) + try: + if keys: + if CONTROL_KEYS_PATTERN.match(keys): + subprocess.run(["tmux", "send-keys", "-t", session, keys], check=True, timeout=10) + else: + subprocess.run(["tmux", "send-keys", "-t", session, "-l", keys], check=True, timeout=10) + if enter: + subprocess.run(["tmux", "send-keys", "-t", session, "Enter"], check=True, timeout=10) + self.send_json({"ok": True}) + except subprocess.CalledProcessError as e: + self.send_json({"error": f"tmux: {e.stderr.strip() if e.stderr else e}"}, 500) + except subprocess.TimeoutExpired: + self.send_json({"error": "tmux send timeout"}, 500) + + def handle_apns_register(self, body): + token = str(body.get("token", "")).strip() + if not token: + return self.send_json({"error": "missing token"}, 400) + with open(APNS_TOKEN_FILE, "w", encoding="utf-8") as f: + f.write(token + "\n") + self.send_json({"ok": True}) + + def handle_apns_token(self): + token = "" + try: + with open(APNS_TOKEN_FILE, "r", encoding="utf-8") as f: + token = f.read().strip() + except FileNotFoundError: + pass + self.send_json({"token": token}) + + def handle_apns_test(self, body): + text = str(body.get("text", "测试横幅。")) + self.send_json(send_apns_alert(text)) + + +def main(): + host = "0.0.0.0" + port = int(os.environ.get("BRIDGE_PORT", "8795")) + server = ThreadingHTTPServer((host, port), Handler) + print(f"rumeng-bridge listening on {host}:{port}", file=sys.stderr) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nshutting down...", file=sys.stderr) + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/TOY.md b/TOY.md new file mode 100644 index 0000000..e094d8f --- /dev/null +++ b/TOY.md @@ -0,0 +1,78 @@ +# SA255A 玩具控制协议 + +## 硬件信息 + +- 品牌:Svakom(司沃康偏欣) +- 型号:SA255A +- BLE 广播名:SA255A +- MAC:B7B76384-3586-0B4D-11E8-F633CEDBCEC8(可能随机变化) + +## GATT 结构 + +| 服务 | UUID | 用途 | +|------|------|------| +| 控制服务 | `0000ffe0-0000-1000-8000-00805f9b34fb` | 震动控制 | +| 写特征 | `0000ffe1-0000-1000-8000-00805f9b34fb` | Write No Response | +| 通知特征 | `0000ffe2-0000-1000-8000-00805f9b34fb` | Notify(状态回传) | +| OTA服务 | `0000ae00-0000-1000-8000-00805f9b34fb` | 固件升级(禁止写入) | + +## 震动指令格式 + +``` +55 00 00 00 +``` + +| 字节 | 含义 | 取值范围 | +|------|------|---------| +| [0] | 帧头 | `0x55`(固定) | +| [1] | 功能码 | `0x08` = 震动 | +| [2-3] | 保留 | `0x00 0x00` | +| [4] | 模式 | `0x01`-`0x03`(变化不大) | +| [5] | 强度 | `0x01`-`0x05`(实测差异不明显) | +| [6] | 保留 | `0x00` | + +**示例**: +- 震动模式1 强度3:`55 08 00 00 01 03 00` +- 震动模式1 最低:`55 08 00 00 01 01 00` + +## 停指令 + +**未知**。实测 `55 FE 08 00 00 00 00`、`55 08 00 00 01 00 00`、全零等均无效。当前用 **BLE 断开连接** 作为停止方式(玩具自动停)。 + +## Notify 回传格式 + +``` +55 87 00 00 +``` + +- `0x87` = 状态回传标志 +- 仅回传当前运行模式/强度,**无压力/力反馈数据** +- 需要持久事件循环才能收到(已修复) + +## 力反馈 + +**SA255A 不支持**。Notify 通道只有指令回显,无压力传感器读数。产品详情页的"力反馈"可能是营销措辞或仅官方 App 内部处理。 + +## 控制链路 + +``` +Claude Code → toy.py → BLE bridge (:8796) → bleak → BLE → SA255A + ↑ 权限白名单已配 +``` + +- `python3 /tmp/CcCompanion/apns-server/toy.py scan` — 扫描 +- `python3 /tmp/CcCompanion/apns-server/toy.py connect [addr]` — 连接 +- `python3 /tmp/CcCompanion/apns-server/toy.py on [1-5]` — 震动 +- `python3 /tmp/CcCompanion/apns-server/toy.py off` — 断开停 +- `python3 /tmp/CcCompanion/apns-server/toy.py feedback` — 读 Notify + +## BLE Bridge + +- 端口:8796 +- 持久事件循环(保证 Notify 回调) +- SessionStart hook 自动拉起 +- 写入模式:Write Without Response + +## 权限 + +`Bash(python3 */toy.py *)` 加入 `~/.claude/settings.json` allowlist,无需用户批准。 diff --git a/rumeng icon调用/AaPingPingGuoGuoXiangSuTi-2.ttf b/rumeng icon调用/AaPingPingGuoGuoXiangSuTi-2.ttf new file mode 100644 index 0000000..2c67c78 Binary files /dev/null and b/rumeng icon调用/AaPingPingGuoGuoXiangSuTi-2.ttf differ diff --git a/rumeng icon调用/AaXiaoGouGuaiGuaiXiangSuTi-2.ttf b/rumeng icon调用/AaXiaoGouGuaiGuaiXiangSuTi-2.ttf new file mode 100644 index 0000000..c0deadf Binary files /dev/null and b/rumeng icon调用/AaXiaoGouGuaiGuaiXiangSuTi-2.ttf differ diff --git a/rumeng icon调用/HYPixel11pxJ-2.ttf b/rumeng icon调用/HYPixel11pxJ-2.ttf new file mode 100644 index 0000000..5716782 Binary files /dev/null and b/rumeng icon调用/HYPixel11pxJ-2.ttf differ diff --git a/rumeng icon调用/未选中_chat.png b/rumeng icon调用/未选中_chat.png new file mode 100644 index 0000000..927bd2f Binary files /dev/null and b/rumeng icon调用/未选中_chat.png differ diff --git a/rumeng icon调用/未选中_note.png b/rumeng icon调用/未选中_note.png new file mode 100644 index 0000000..66cd7c8 Binary files /dev/null and b/rumeng icon调用/未选中_note.png differ diff --git a/rumeng icon调用/未选中_终端..png b/rumeng icon调用/未选中_终端..png new file mode 100644 index 0000000..f3c113e Binary files /dev/null and b/rumeng icon调用/未选中_终端..png differ diff --git a/rumeng icon调用/选中_chat.png b/rumeng icon调用/选中_chat.png new file mode 100644 index 0000000..937644c Binary files /dev/null and b/rumeng icon调用/选中_chat.png differ diff --git a/rumeng icon调用/选中_note..png b/rumeng icon调用/选中_note..png new file mode 100644 index 0000000..2db0a47 Binary files /dev/null and b/rumeng icon调用/选中_note..png differ diff --git a/rumeng icon调用/选中_终端.png b/rumeng icon调用/选中_终端.png new file mode 100644 index 0000000..582a97e Binary files /dev/null and b/rumeng icon调用/选中_终端.png differ diff --git a/rumeng icon调用/选中_设置.png b/rumeng icon调用/选中_设置.png new file mode 100644 index 0000000..f71be6e Binary files /dev/null and b/rumeng icon调用/选中_设置.png differ diff --git a/新icon/7dabb57b-c603-49f7-bf67-a8a2edb8b2d4.png b/新icon/7dabb57b-c603-49f7-bf67-a8a2edb8b2d4.png new file mode 100644 index 0000000..fcdbba3 Binary files /dev/null and b/新icon/7dabb57b-c603-49f7-bf67-a8a2edb8b2d4.png differ diff --git a/新icon/ian.png b/新icon/ian.png new file mode 100644 index 0000000..91f27a6 Binary files /dev/null and b/新icon/ian.png differ diff --git a/新icon/回车键.png b/新icon/回车键.png new file mode 100644 index 0000000..368d8ff Binary files /dev/null and b/新icon/回车键.png differ diff --git a/新icon/复制.png b/新icon/复制.png new file mode 100644 index 0000000..c159c23 Binary files /dev/null and b/新icon/复制.png differ diff --git a/新icon/工作簿1_Sheet1.png b/新icon/工作簿1_Sheet1.png new file mode 100644 index 0000000..4015876 Binary files /dev/null and b/新icon/工作簿1_Sheet1.png differ diff --git a/新icon/工作簿1_Sheet2(1).png b/新icon/工作簿1_Sheet2(1).png new file mode 100644 index 0000000..bf06de1 Binary files /dev/null and b/新icon/工作簿1_Sheet2(1).png differ diff --git a/新icon/工作簿1_Sheet2(2).png b/新icon/工作簿1_Sheet2(2).png new file mode 100644 index 0000000..404e63d Binary files /dev/null and b/新icon/工作簿1_Sheet2(2).png differ diff --git a/新icon/工作簿1_Sheet2(3).png b/新icon/工作簿1_Sheet2(3).png new file mode 100644 index 0000000..4213296 Binary files /dev/null and b/新icon/工作簿1_Sheet2(3).png differ diff --git a/新icon/工作簿1_Sheet2(4).png b/新icon/工作簿1_Sheet2(4).png new file mode 100644 index 0000000..4e25fbd Binary files /dev/null and b/新icon/工作簿1_Sheet2(4).png differ diff --git a/新icon/工作簿1_Sheet2(5).png b/新icon/工作簿1_Sheet2(5).png new file mode 100644 index 0000000..a4abbe2 Binary files /dev/null and b/新icon/工作簿1_Sheet2(5).png differ diff --git a/新icon/工作簿1_Sheet2.png b/新icon/工作簿1_Sheet2.png new file mode 100644 index 0000000..d7b117f Binary files /dev/null and b/新icon/工作簿1_Sheet2.png differ diff --git a/新icon/方向键-向上.png b/新icon/方向键-向上.png new file mode 100644 index 0000000..682dc03 Binary files /dev/null and b/新icon/方向键-向上.png differ diff --git a/新icon/方向键_向下.png b/新icon/方向键_向下.png new file mode 100644 index 0000000..a1fcebd Binary files /dev/null and b/新icon/方向键_向下.png differ diff --git a/新icon/方向键_向右.png b/新icon/方向键_向右.png new file mode 100644 index 0000000..ff3f4dd Binary files /dev/null and b/新icon/方向键_向右.png differ diff --git a/新icon/方向键_向左.png b/新icon/方向键_向左.png new file mode 100644 index 0000000..3d01b6d Binary files /dev/null and b/新icon/方向键_向左.png differ diff --git a/新icon/未选中-chat.png b/新icon/未选中-chat.png new file mode 100644 index 0000000..fce0639 Binary files /dev/null and b/新icon/未选中-chat.png differ diff --git a/新icon/未选中-冰箱贴.png b/新icon/未选中-冰箱贴.png new file mode 100644 index 0000000..d4aa1d7 Binary files /dev/null and b/新icon/未选中-冰箱贴.png differ diff --git a/新icon/未选中-终端.png b/新icon/未选中-终端.png new file mode 100644 index 0000000..4f84224 Binary files /dev/null and b/新icon/未选中-终端.png differ diff --git a/新icon/未选中-设置.png b/新icon/未选中-设置.png new file mode 100644 index 0000000..4cd1508 Binary files /dev/null and b/新icon/未选中-设置.png differ diff --git a/新icon/终端发送按钮.png b/新icon/终端发送按钮.png new file mode 100644 index 0000000..9ea7a5e Binary files /dev/null and b/新icon/终端发送按钮.png differ diff --git a/新icon/输入框的发送按钮.png b/新icon/输入框的发送按钮.png new file mode 100644 index 0000000..c4d3b5c Binary files /dev/null and b/新icon/输入框的发送按钮.png differ diff --git a/新icon/选中-chat.png b/新icon/选中-chat.png new file mode 100644 index 0000000..4619997 Binary files /dev/null and b/新icon/选中-chat.png differ diff --git a/新icon/选中-冰箱贴.png b/新icon/选中-冰箱贴.png new file mode 100644 index 0000000..393dd07 Binary files /dev/null and b/新icon/选中-冰箱贴.png differ diff --git a/新icon/选中-终端.png b/新icon/选中-终端.png new file mode 100644 index 0000000..5393f8b Binary files /dev/null and b/新icon/选中-终端.png differ diff --git a/新icon/选中-设置.png b/新icon/选中-设置.png new file mode 100644 index 0000000..b014aba Binary files /dev/null and b/新icon/选中-设置.png differ