├── .github └── workflows │ └── release.yml ├── .gitignore ├── .package.resolved ├── LICENSE ├── Project.swift ├── README.md ├── Screenshots ├── icon.png ├── preview-dark.png └── preview-light.png ├── Tuist └── Dependencies.swift └── V2Bar ├── Resources └── Assets.xcassets │ ├── AppIcon.appiconset │ ├── Contents.json │ ├── icon_128x128.png │ ├── icon_128x128@2x.png │ ├── icon_16x16.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_256x256@2x.png │ ├── icon_32x32.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ └── icon_512x512@2x.png │ └── Contents.json └── Sources ├── ContentView.swift ├── Models └── V2EXModels.swift ├── Network ├── V2EXRouter.swift └── V2EXService.swift ├── Utils ├── DateFormatter+Extensions.swift ├── DefaultsKeys.swift ├── LoadableObject.swift └── View+Extensions.swift ├── V2BarApp.swift ├── ViewModels └── V2EXViewModel.swift └── Views ├── BottomButtonsView.swift ├── LoadableView.swift ├── NotificationsView.swift ├── OnboardingView.swift ├── QuickLinksView.swift ├── SettingsView.swift ├── Styles └── HoverButtonStyle.swift └── UserProfileView.swift /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | discussions: write 11 | 12 | jobs: 13 | build: 14 | name: Build and Release 15 | runs-on: macos-14 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Xcode 24 | uses: maxim-lobanov/setup-xcode@v1 25 | with: 26 | xcode-version: '16.1' 27 | 28 | - name: Install Tuist 29 | run: | 30 | brew install tuist 31 | 32 | - name: Generate Xcode Project 33 | run: | 34 | # 生成基于时间戳的构建号(格式:YYYYMMDDHHmm) 35 | BUILD_NUMBER=$(date "+%Y%m%d%H%M") 36 | # 替换 Project.swift 中的占位符 37 | sed -i '' "s/@BUILD_NUMBER@/$BUILD_NUMBER/g" Project.swift 38 | echo "Build number set to: $BUILD_NUMBER" 39 | tuist generate --no-open 40 | 41 | - name: Build App 42 | run: | 43 | xcodebuild \ 44 | -workspace V2Bar.xcworkspace \ 45 | -scheme V2Bar \ 46 | -configuration Release \ 47 | -derivedDataPath ./DerivedData \ 48 | -arch arm64 -arch x86_64 \ 49 | clean build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO 50 | 51 | - name: Create DMG 52 | run: | 53 | brew install create-dmg 54 | create-dmg \ 55 | --volname "V2Bar" \ 56 | --window-size 500 300 \ 57 | --icon-size 100 \ 58 | --icon "V2Bar.app" 150 150 \ 59 | --app-drop-link 350 150 \ 60 | --no-internet-enable \ 61 | "V2Bar.dmg" \ 62 | "DerivedData/Build/Products/Release/V2Bar.app" 63 | 64 | - name: Generate Checksums 65 | run: | 66 | echo "### V2Bar ${{ github.ref_name }}" > checksums.txt 67 | echo "" >> checksums.txt 68 | echo "- Universal Binary (Apple Silicon + Intel)" >> checksums.txt 69 | echo "- macOS 13.0+" >> checksums.txt 70 | echo "" >> checksums.txt 71 | echo "### SHA-256 Checksums" >> checksums.txt 72 | echo "\`\`\`" >> checksums.txt 73 | shasum -a 256 V2Bar.dmg >> checksums.txt 74 | echo "\`\`\`" >> checksums.txt 75 | 76 | - name: Release 77 | uses: softprops/action-gh-release@v1 78 | if: startsWith(github.ref, 'refs/tags/v') 79 | with: 80 | files: | 81 | V2Bar.dmg 82 | checksums.txt 83 | body_path: checksums.txt 84 | draft: false 85 | prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') }} 86 | generate_release_notes: true 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | .build/ 31 | .swiftpm/ 32 | 33 | # CocoaPods 34 | # 35 | # We recommend against adding the Pods directory to your .gitignore. However 36 | # you should judge for yourself, the pros and cons are mentioned at: 37 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 38 | # 39 | # Pods/ 40 | # 41 | # Add this line if you want to avoid checking in source code from the Xcode workspace 42 | # *.xcworkspace 43 | 44 | # Carthage 45 | # 46 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 47 | # Carthage/Checkouts 48 | 49 | Carthage/Build/ 50 | 51 | # Accio dependency management 52 | Dependencies/ 53 | .accio/ 54 | 55 | # fastlane 56 | # 57 | # It is recommended to not store the screenshots in the git repo. 58 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 59 | # For more information about the recommended setup visit: 60 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 61 | 62 | fastlane/report.xml 63 | fastlane/Preview.html 64 | fastlane/screenshots/**/*.png 65 | fastlane/test_output 66 | 67 | # Code Injection 68 | # 69 | # After new code Injection tools there's a generated folder /iOSInjectionProject 70 | # https://github.com/johnno1962/injectionforxcode 71 | 72 | iOSInjectionProject/ 73 | 74 | # macOS 75 | .DS_Store 76 | .AppleDouble 77 | .LSOverride 78 | 79 | # Icon must end with two \r 80 | Icon 81 | 82 | # Thumbnails 83 | ._* 84 | 85 | # Files that might appear in the root of a volume 86 | .DocumentRevisions-V100 87 | .fseventsd 88 | .Spotlight-V100 89 | .TemporaryItems 90 | .Trashes 91 | .VolumeIcon.icns 92 | .com.apple.timemachine.donotpresent 93 | 94 | # Directories potentially created on remote AFP share 95 | .AppleDB 96 | .AppleDesktop 97 | Network Trash Folder 98 | Temporary Items 99 | .apdisk 100 | 101 | # Tuist 102 | Derived/ 103 | *.xcodeproj 104 | *.xcworkspace -------------------------------------------------------------------------------- /.package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "94f901ff49878d5b5de804979da77afbb800aabc6ee89ef185a57c34110d5d40", 3 | "pins" : [ 4 | { 5 | "identity" : "alamofire", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Alamofire/Alamofire", 8 | "state" : { 9 | "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", 10 | "version" : "5.10.2" 11 | } 12 | }, 13 | { 14 | "identity" : "atlantis", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/ProxymanApp/atlantis", 17 | "state" : { 18 | "revision" : "7246950a6a36454ed1d7830166284f202dc14ad2", 19 | "version" : "1.26.0" 20 | } 21 | }, 22 | { 23 | "identity" : "defaults", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/sindresorhus/Defaults", 26 | "state" : { 27 | "revision" : "ef1b2318fb549002bb533bec3a8ad98ae09f2cb6", 28 | "version" : "9.0.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-syntax", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/swiftlang/swift-syntax", 35 | "state" : { 36 | "revision" : "0687f71944021d616d34d922343dcef086855920", 37 | "version" : "600.0.1" 38 | } 39 | }, 40 | { 41 | "identity" : "swifterswift", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/SwifterSwift/SwifterSwift", 44 | "state" : { 45 | "revision" : "5a6de915bb80234e1e31b3e3e5f7a7995fc1f4db", 46 | "version" : "7.0.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swiftuix", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/SwiftUIX/SwiftUIX", 53 | "state" : { 54 | "revision" : "e984fd2e08140ad5a95d084be38fe02b774bc15d", 55 | "version" : "0.2.3" 56 | } 57 | } 58 | ], 59 | "version" : 3 60 | } 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ysgdbd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | // MARK: - Version 4 | let appVersion = "0.1.1" // 应用版本号 5 | let buildVersion = "@BUILD_NUMBER@" // 构建版本号占位符,会被 GitHub Actions 替换 6 | 7 | // 基础依赖 8 | let baseDependencies: [TargetDependency] = [ 9 | .package(product: "Alamofire"), 10 | .package(product: "SwiftUIX"), 11 | .package(product: "SwifterSwift"), 12 | .package(product: "Defaults") 13 | ] 14 | 15 | // 开发环境依赖 16 | let developmentDependencies: [TargetDependency] = baseDependencies + [ 17 | .package(product: "Atlantis") 18 | ] 19 | 20 | let baseSettings = Settings.settings( 21 | base: [ 22 | "SWIFT_VERSION": "5.9", 23 | "DEVELOPMENT_LANGUAGE": "zh-Hans", 24 | "MARKETING_VERSION": SettingValue(stringLiteral: appVersion), 25 | "CURRENT_PROJECT_VERSION": SettingValue(stringLiteral: buildVersion) 26 | ], 27 | configurations: [ 28 | .debug(name: "Debug"), 29 | .release(name: "Release") 30 | ] 31 | ) 32 | 33 | let baseInfoPlist: [String: Plist.Value] = [ 34 | "LSUIElement": .boolean(true), 35 | "CFBundleDevelopmentRegion": .string("zh-Hans"), 36 | "NSHumanReadableCopyright": .string("Copyright © 2024 ygsgdbd. All rights reserved."), 37 | "CFBundleShortVersionString": .string(appVersion), 38 | "CFBundleVersion": .string(buildVersion), 39 | "NSAppTransportSecurity": .dictionary([ 40 | "NSAllowsArbitraryLoads": .boolean(false) 41 | ]), 42 | "NSNetworkingUsageDescription": .string("V2Bar 需要访问网络以获取内容") 43 | ] 44 | 45 | // 开发环境额外的 Info.plist 配置 46 | let developmentInfoPlist: [String: Plist.Value] = baseInfoPlist.merging([ 47 | "NSLocalNetworkUsageDescription": .string("Atlantis uses Bonjour Service to send your recorded traffic to Proxyman app."), 48 | "NSBonjourServices": .array([ 49 | .string("_Proxyman._tcp") 50 | ]) 51 | ]) { (_, new) in new } 52 | 53 | let project = Project( 54 | name: "V2Bar", 55 | options: .options( 56 | defaultKnownRegions: ["zh-Hans"], 57 | developmentRegion: "zh-Hans" 58 | ), 59 | packages: [ 60 | .remote(url: "https://github.com/Alamofire/Alamofire", requirement: .upToNextMajor(from: "5.10.2")), 61 | .remote(url: "https://github.com/SwiftUIX/SwiftUIX", requirement: .upToNextMajor(from: "0.2.3")), 62 | .remote(url: "https://github.com/SwifterSwift/SwifterSwift", requirement: .upToNextMajor(from: "7.0.0")), 63 | .remote(url: "https://github.com/sindresorhus/Defaults", requirement: .upToNextMajor(from: "9.0.0")), 64 | .remote(url: "https://github.com/ProxymanApp/atlantis", requirement: .upToNextMajor(from: "1.26.0")) 65 | ], 66 | settings: baseSettings, 67 | targets: [ 68 | // 发布版本 Target 69 | .target( 70 | name: "V2Bar", 71 | destinations: .macOS, 72 | product: .app, 73 | bundleId: "top.ygsgdbd.V2Bar", 74 | deploymentTargets: .macOS("13.0"), 75 | infoPlist: .extendingDefault(with: baseInfoPlist), 76 | sources: ["V2Bar/Sources/**"], 77 | resources: ["V2Bar/Resources/**"], 78 | dependencies: baseDependencies, 79 | settings: baseSettings 80 | ), 81 | // 开发版本 Target 82 | .target( 83 | name: "V2Bar-Dev", 84 | destinations: .macOS, 85 | product: .app, 86 | bundleId: "top.ygsgdbd.V2Bar.dev", 87 | deploymentTargets: .macOS("13.0"), 88 | infoPlist: .extendingDefault(with: developmentInfoPlist), 89 | sources: ["V2Bar/Sources/**"], 90 | resources: ["V2Bar/Resources/**"], 91 | dependencies: developmentDependencies, 92 | settings: .settings( 93 | base: [ 94 | "SWIFT_VERSION": "5.9", 95 | "DEVELOPMENT_LANGUAGE": "zh-Hans", 96 | "OTHER_SWIFT_FLAGS": "-D DEBUG", 97 | "OTHER_LDFLAGS": "$(inherited) -ObjC" 98 | ], 99 | configurations: [ 100 | .debug(name: "Debug"), 101 | .release(name: "Release") 102 | ] 103 | ) 104 | ) 105 | ] 106 | ) 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V2Bar 🌟 2 | 3 |
4 | V2Bar Icon 5 |
6 | 7 |
8 | 9 | [![Platform](https://img.shields.io/badge/platform-macOS%2013%2B-brightgreen)](https://github.com/ysgdbd/V2Bar/releases/latest) 10 | [![Swift](https://img.shields.io/badge/Swift-5.9-orange)](https://github.com/ysgdbd/V2Bar) 11 | [![Tuist](https://img.shields.io/badge/Powered%20by-Tuist-blue)](https://tuist.io) 12 | [![Xcode](https://img.shields.io/badge/Xcode-15.0%2B-blue)](https://developer.apple.com/xcode/) 13 | [![SwiftUI](https://img.shields.io/badge/SwiftUI-4.0-blue)](https://developer.apple.com/xcode/swiftui/) 14 | 15 |
16 | 17 | V2Bar 是一个简洁优雅的 macOS 菜单栏应用,为你提供快捷的 V2EX 访问体验。✨ 18 | 19 | ## 预览 👀 20 | 21 |
22 | V2Bar Light Mode Preview 23 | V2Bar Dark Mode Preview 24 |
25 | 26 | ## 功能特点 ✨ 27 | 28 | - 🚀 原生 SwiftUI 开发,超低内存占用 29 | - 🌓 完美支持暗黑模式 30 | - ⚡️ 便捷的菜单栏操作体验 31 | - 👤 快速查看消息和个人信息 32 | - 🔗 一键导航到 V2EX 各版块 33 | - ✍️ 便捷创建和浏题 34 | - 🔒 简单可靠的本地数据存储 35 | - 📖 开源透明,安全可审计 36 | 37 | ## 为什么选择 V2Bar ✨ 38 | 39 | - 🚀 **轻量高效**: 原生 SwiftUI 开发,内存占用低至 40MB,安装包仅 8MB 40 | - 🔒 **简单可靠**: 本地数据存储,无需担心隐私泄露 41 | - 👀 **开源透明**: 源代码完全开放,欢迎审计 42 | 43 | ## 系统要求 🖥 44 | 45 | - 📱 macOS 13.0 或更高版本 46 | - 💪 完美支持 Apple Silicon 和 Intel 芯片 47 | 48 | ## 快速开始 🚀 49 | 50 | 1. 📥 完成安装后首次启动 V2Bar 51 | 2. 🔑 登录 V2EX 网站获取 Personal Access Token 52 | 3. 🔒 在 V2Bar 中填入 Token 完成授权 53 | 4. ✨ 开始享受便捷的 V2EX 浏览体验 54 | 55 | > 💡 提示: Personal Access Token 可以在 V2EX 网站的 [设置页面](https://v2ex.com/settings) 生成。请妥善保管你的 Token。 56 | 57 | ## 安装方式 📥 58 | 59 | ### 使用 Homebrew 安装 🍺 60 | 61 | ```bash 62 | # 安装 V2Bar 应用 63 | brew install ygsgdbd/tap/v2bar 64 | ``` 65 | 66 | ### 手动安装 📦 67 | 68 | 1. 🔍 从 [Releases](https://github.com/ysgdbd/V2Bar/releases) 页面下载最新版本的 DMG 文件 69 | 2. 💾 打开 DMG 文件并将 V2Bar 拖入 Applications 文件夹 70 | 3. 🚀 从 Applications 文件夹启动 V2Bar 71 | 72 | ## 开发指南 👨‍💻 73 | 74 | 本项目使用 [Tuist](https://tuist.io) 进行项目管理,开发前请确保安装以下依赖: 75 | 76 | ```bash 77 | # 安装 Tuist 项目管理工具 78 | brew install tuist 79 | ``` 80 | 81 | 克隆项目并生成 Xcode 工程: 82 | 83 | ```bash 84 | # 克隆 V2Bar 项目代码 85 | git clone https://github.com/ysgdbd/V2Bar.git 86 | 87 | # 进入项目目录 88 | cd V2Bar 89 | 90 | # 使用 Tuist 生成 Xcode 项目文件 91 | tuist generate 92 | ``` 93 | 94 | ### 项目结构 95 | 96 | ``` 97 | V2Bar/ 98 | ├── Sources/ 99 | │ ├── Network/ # 网络请求相关 100 | │ ├── Models/ # 数据模型 101 | │ ├── Views/ # UI 视图 102 | │ ├── ViewModels/ # 视图模型 103 | │ └── Utils/ # 工具类 104 | ``` 105 | 106 | ### 技术栈 🛠 107 | 108 | - 🎯 [SwiftUI 4.0](https://developer.apple.com/xcode/swiftui/) 109 | - 🌐 [Alamofire](https://github.com/Alamofire/Alamofire) 110 | - 📦 [Tuist](https://tuist.io) 111 | - 🔄 [Combine](https://developer.apple.com/documentation/combine) 112 | - 🛠 [SwiftUIX](https://github.com/SwiftUIX/SwiftUIX) 113 | - ⚡️ [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) 114 | - 💾 [Defaults](https://github.com/sindresorhus/Defaults) 115 | 116 | ## 问题反馈 💭 117 | 118 | 如果你发现了 bug 或有新功能建议,欢迎提交 [Issue](https://github.com/ysgdbd/V2Bar/issues) 进行反馈。我们会认真对待每一条反馈意见! 🙏 119 | 120 | ## 开源协议 📄 121 | 122 | 本项目采用 MIT 开源许可证 - 详见 [LICENSE](LICENSE) 文件 ⚖️ 123 | -------------------------------------------------------------------------------- /Screenshots/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/Screenshots/icon.png -------------------------------------------------------------------------------- /Screenshots/preview-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/Screenshots/preview-dark.png -------------------------------------------------------------------------------- /Screenshots/preview-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/Screenshots/preview-light.png -------------------------------------------------------------------------------- /Tuist/Dependencies.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let dependencies = Dependencies( 4 | swiftPackageManager: .init( 5 | [ 6 | .remote(url: "https://github.com/Alamofire/Alamofire", requirement: .upToNextMajor(from: "5.8.1")), 7 | .remote(url: "https://github.com/SwiftUIX/SwiftUIX", requirement: .upToNextMajor(from: "0.1.9")), 8 | .remote(url: "https://github.com/SwifterSwift/SwifterSwift", requirement: .upToNextMajor(from: "6.0.0")), 9 | .remote(url: "https://github.com/pointfreeco/swift-sharing", requirement: .upToNextMajor(from: "0.2.0")) 10 | ], 11 | baseSettings: .settings( 12 | configurations: [ 13 | .debug(name: .debug), 14 | .release(name: .release) 15 | ] 16 | ) 17 | ), 18 | platforms: [.macOS] 19 | ) -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ygsgdbd/V2Bar/3cc41d9d26c1ccacf0dd8d5d3e9c5c9a69fcf291/V2Bar/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /V2Bar/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /V2Bar/Sources/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftUIX 3 | import Defaults 4 | 5 | struct ContentView: View { 6 | @StateObject private var viewModel = V2EXViewModel() 7 | @Default(.token) private var token: String? 8 | 9 | var body: some View { 10 | Group { 11 | if let _ = token { 12 | // 已登录状态显示主界面 13 | VStack(spacing: 0) { 14 | UserProfileView() 15 | 16 | Divider() 17 | 18 | NotificationsView() 19 | .minHeight(320) 20 | 21 | Divider() 22 | 23 | SettingsView() 24 | 25 | Divider() 26 | 27 | QuickLinksView() 28 | 29 | Divider() 30 | 31 | BottomButtonsView() 32 | } 33 | .focusable(false) 34 | .task { 35 | // 初始化时加载所有数据 36 | await viewModel.refreshAll() 37 | } 38 | } else { 39 | // 未登录状态显示引导页面 40 | OnboardingView() 41 | } 42 | } 43 | .environmentObject(viewModel) 44 | .width(360) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /V2Bar/Sources/Models/V2EXModels.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwifterSwift 3 | 4 | // MARK: - 通用响应结构 5 | struct V2EXResponse: Codable { 6 | let success: Bool 7 | let message: String? 8 | let result: T? 9 | 10 | // 添加自定义解码逻辑 11 | func getResult() throws -> T { 12 | guard success else { 13 | throw V2EXService.V2EXError.apiError(message ?? "未知错误") 14 | } 15 | 16 | guard let result = result else { 17 | throw V2EXService.V2EXError.emptyResult 18 | } 19 | 20 | return result 21 | } 22 | } 23 | 24 | // MARK: - 通知模型 25 | struct V2EXNotification: Codable, Identifiable { 26 | let id: Int 27 | let memberId: Int 28 | let forMemberId: Int 29 | let text: String 30 | let payload: String? 31 | let payloadRendered: String 32 | let created: Int 33 | let member: NotificationMember 34 | 35 | var createdDate: Date { 36 | Date(timeIntervalSince1970: TimeInterval(created)) 37 | } 38 | 39 | // 移除 HTML 标签的纯文本 40 | var plainText: String { 41 | text.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) 42 | } 43 | 44 | // 提取链接 45 | var links: [(title: String, url: URL)] { 46 | var result: [(String, URL)] = [] 47 | 48 | // 匹配 HTML 的正则表达式 49 | let pattern = #"]*>([^<]+)"# 50 | let regex = try? NSRegularExpression(pattern: pattern) 51 | let nsRange = NSRange(text.startIndex..]+>", with: "", options: .regularExpression) 61 | 62 | // 构建完整的 URL 63 | if let url = URL(string: "https://v2ex.com\(path)") { 64 | result.append((title, url)) 65 | } 66 | } 67 | } 68 | } 69 | 70 | return result 71 | } 72 | } 73 | 74 | struct NotificationMember: Codable { 75 | let username: String 76 | } 77 | 78 | // MARK: - User Profile 79 | struct V2EXUserProfile: Codable, Identifiable { 80 | let id: Int 81 | let username: String 82 | let url: String 83 | let website: String? 84 | let twitter: String? 85 | let psn: String? 86 | let github: String? 87 | let btc: String? 88 | let location: String? 89 | let tagline: String? 90 | let bio: String? 91 | let avatarMini: String? 92 | let avatarNormal: String? 93 | let avatarLarge: String? 94 | let avatarXlarge: String? 95 | let avatarXxlarge: String? 96 | let created: Int 97 | let lastModified: Int 98 | 99 | // URL 计算属性 100 | var websiteURL: URL? { 101 | if website?.isWhitespace == true { 102 | nil 103 | } else { 104 | website?.url 105 | } 106 | } 107 | 108 | var githubURL: URL? { 109 | if github?.isWhitespace == true { 110 | nil 111 | } else { 112 | github.map { URL(string: "https://github.com/\($0)") } ?? nil 113 | } 114 | } 115 | 116 | var twitterURL: URL? { 117 | if twitter?.isWhitespace == true { 118 | nil 119 | } else { 120 | twitter.map { URL(string: "https://twitter.com/\($0)") } ?? nil 121 | } 122 | } 123 | } 124 | 125 | // MARK: - Token Info 126 | struct V2EXTokenInfo: Codable { 127 | let token: String 128 | let scope: String 129 | let expiration: Int 130 | let goodForDays: Int? 131 | let totalUsed: Int 132 | let lastUsed: Int 133 | let created: Int 134 | } 135 | 136 | extension V2EXTokenInfo { 137 | var formattedExpirationText: String { 138 | if expiration <= 0 { 139 | return "(已过期)" 140 | } 141 | 142 | let formatter = RelativeDateTimeFormatter() 143 | formatter.unitsStyle = .full 144 | 145 | let date = Date(timeIntervalSinceNow: TimeInterval(expiration)) 146 | let relativeTime = formatter.localizedString(for: date, relativeTo: Date()) 147 | .replacingOccurrences(of: "后", with: "后过期") 148 | 149 | return "(\(relativeTime))" 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /V2Bar/Sources/Network/V2EXRouter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Alamofire 3 | import Defaults 4 | 5 | enum V2EXRouter { 6 | case token 7 | case profile 8 | case notifications 9 | } 10 | 11 | extension V2EXRouter: URLRequestConvertible { 12 | private var baseURL: URL { 13 | URL(string: "https://www.v2ex.com/api/v2")! 14 | } 15 | 16 | private var method: HTTPMethod { 17 | switch self { 18 | case .token, .profile, .notifications: 19 | return .get 20 | } 21 | } 22 | 23 | private var path: String { 24 | switch self { 25 | case .token: 26 | return "/token" 27 | case .profile: 28 | return "/member" 29 | case .notifications: 30 | return "/notifications" 31 | } 32 | } 33 | 34 | func asURLRequest() throws -> URLRequest { 35 | let url = baseURL.appendingPathComponent(path) 36 | var request = URLRequest(url: url) 37 | request.method = method 38 | 39 | // 添加通用 headers 40 | request.headers = HTTPHeaders([ 41 | .accept("application/json"), 42 | .authorization(bearerToken: Defaults[.token] ?? "") 43 | ]) 44 | 45 | return request 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /V2Bar/Sources/Network/V2EXService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Alamofire 3 | import Defaults 4 | 5 | actor V2EXService { 6 | static let shared = V2EXService() 7 | private let session: Session 8 | private let decoder: JSONDecoder 9 | 10 | private init() { 11 | let configuration = URLSessionConfiguration.default 12 | configuration.timeoutIntervalForRequest = 30 13 | configuration.timeoutIntervalForResource = 300 14 | 15 | decoder = JSONDecoder() 16 | decoder.keyDecodingStrategy = .convertFromSnakeCase 17 | 18 | session = Session(configuration: configuration) 19 | } 20 | 21 | func request(_ router: V2EXRouter) async throws -> T { 22 | do { 23 | let response = try await session.request(router) 24 | .validate() 25 | .serializingDecodable(V2EXResponse.self, decoder: decoder) 26 | .value 27 | return try response.getResult() 28 | } catch { 29 | debugPrint("⚠️ 网络请求错误:", error) 30 | throw error 31 | } 32 | } 33 | } 34 | 35 | // MARK: - Error Types 36 | extension V2EXService { 37 | enum V2EXError: LocalizedError { 38 | case unauthorized 39 | case invalidResponse 40 | case serverError(statusCode: Int) 41 | case apiError(String) 42 | case emptyResult 43 | 44 | var errorDescription: String? { 45 | switch self { 46 | case .unauthorized: 47 | return "未授权,请检查访问令牌" 48 | case .invalidResponse: 49 | return "无效的响应" 50 | case .serverError(let statusCode): 51 | return "服务器错误(\(statusCode))" 52 | case .apiError(let message): 53 | return message 54 | case .emptyResult: 55 | return "响应数据为空" 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /V2Bar/Sources/Utils/DateFormatter+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension DateFormatter { 4 | static let v2exDateFormatter: DateFormatter = { 5 | let formatter = DateFormatter() 6 | formatter.dateFormat = "yyyy-MM-dd" 7 | return formatter 8 | }() 9 | } 10 | 11 | extension RelativeDateTimeFormatter { 12 | static let shared: RelativeDateTimeFormatter = { 13 | let formatter = RelativeDateTimeFormatter() 14 | formatter.locale = Locale.current 15 | return formatter 16 | }() 17 | } 18 | 19 | extension Date { 20 | static func fromUnixTimestamp(_ timestamp: Int) -> Date { 21 | return Date(timeIntervalSince1970: TimeInterval(timestamp)) 22 | } 23 | 24 | var formattedString: String { 25 | return DateFormatter.v2exDateFormatter.string(from: self) 26 | } 27 | 28 | var relativeString: String { 29 | return RelativeDateTimeFormatter.shared.localizedString(for: self, relativeTo: Date()) 30 | } 31 | } -------------------------------------------------------------------------------- /V2Bar/Sources/Utils/DefaultsKeys.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Defaults 3 | 4 | extension Defaults.Keys { 5 | static let token = Key("token", default: nil) 6 | } 7 | -------------------------------------------------------------------------------- /V2Bar/Sources/Utils/LoadableObject.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | @MainActor 5 | class LoadableObject: ObservableObject { 6 | @Published private(set) var value: T 7 | @Published private(set) var error: Error? 8 | @Published private(set) var isLoading = false 9 | 10 | init(defaultValue: T) { 11 | self.value = defaultValue 12 | } 13 | 14 | func load(_ operation: @escaping () async throws -> T) { 15 | isLoading = true 16 | error = nil 17 | 18 | Task { 19 | defer { isLoading = false } 20 | 21 | do { 22 | value = try await operation() 23 | error = nil 24 | } catch { 25 | self.error = error 26 | } 27 | } 28 | } 29 | 30 | func reset(_ defaultValue: T) { 31 | value = defaultValue 32 | error = nil 33 | isLoading = false 34 | } 35 | } -------------------------------------------------------------------------------- /V2Bar/Sources/Utils/View+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CursorModifier: ViewModifier { 4 | let cursor: NSCursor 5 | 6 | func body(content: Content) -> some View { 7 | content.onHover { inside in 8 | if inside { 9 | cursor.push() 10 | } else { 11 | NSCursor.pop() 12 | } 13 | } 14 | } 15 | } 16 | 17 | struct UsernameStyle: ViewModifier { 18 | @Binding var isHovered: Bool 19 | 20 | func body(content: Content) -> some View { 21 | content 22 | .fontWeight(.medium) 23 | .foregroundColor(.primary) 24 | .underline(isHovered) 25 | } 26 | } 27 | 28 | extension View { 29 | func cursor(_ cursor: NSCursor) -> some View { 30 | modifier(CursorModifier(cursor: cursor)) 31 | } 32 | 33 | var pointingCursor: some View { 34 | cursor(.pointingHand) 35 | } 36 | 37 | func usernameStyle(isHovered: Binding) -> some View { 38 | modifier(UsernameStyle(isHovered: isHovered)) 39 | } 40 | } -------------------------------------------------------------------------------- /V2Bar/Sources/V2BarApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | #if DEBUG 5 | import Atlantis 6 | #endif 7 | 8 | @main 9 | struct V2BarApp: App { 10 | @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate 11 | 12 | init() { 13 | #if DEBUG 14 | Atlantis.start() 15 | #endif 16 | } 17 | 18 | var body: some Scene { 19 | MenuBarExtra { 20 | ContentView() 21 | } label: { 22 | Text("V2") 23 | .font(.custom("Futura", size: 12)) 24 | .fontWeight(.medium) 25 | } 26 | .menuBarExtraStyle(.window) 27 | } 28 | } 29 | 30 | class AppDelegate: NSObject, NSApplicationDelegate { 31 | private var keyMonitor: Any? 32 | 33 | func applicationDidFinishLaunching(_ notification: Notification) { 34 | // 监听键盘事件 35 | keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 36 | if event.keyCode == 53 { // ESC 键 37 | if let window = NSApplication.shared.windows.first(where: { $0.isKeyWindow }) { 38 | window.close() 39 | return nil // 事件已处理 40 | } 41 | } 42 | return event 43 | } 44 | } 45 | 46 | func applicationWillTerminate(_ notification: Notification) { 47 | // 清理监听器 48 | if let monitor = keyMonitor { 49 | NSEvent.removeMonitor(monitor) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /V2Bar/Sources/ViewModels/V2EXViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Defaults 3 | import Combine 4 | import SwifterSwift 5 | 6 | @MainActor 7 | class V2EXViewModel: ObservableObject { 8 | // MARK: - States 9 | let tokenState = LoadableObject(defaultValue: nil) 10 | let profileState = LoadableObject(defaultValue: nil) 11 | let notificationsState = LoadableObject<[V2EXNotification]>(defaultValue: []) 12 | private var cancellables = Set() 13 | 14 | var maskedToken: String { 15 | guard let token = Defaults[.token] else { return "未设置" } 16 | let prefix = String(token.prefix(4)) 17 | let suffix = String(token.suffix(4)) 18 | return "\(prefix)****\(suffix)" 19 | } 20 | 21 | // MARK: - Initialization 22 | init() { 23 | // 转发所有状态变化 24 | tokenState.objectWillChange.sink(receiveValue: objectWillChange.send).store(in: &cancellables) 25 | profileState.objectWillChange.sink(receiveValue: objectWillChange.send).store(in: &cancellables) 26 | notificationsState.objectWillChange.sink(receiveValue: objectWillChange.send).store(in: &cancellables) 27 | 28 | // 监听 token 变化 29 | NotificationCenter.default 30 | .publisher(for: UserDefaults.didChangeNotification) 31 | .receive(on: RunLoop.main) 32 | .sink { [weak self] _ in 33 | guard let self = self else { return } 34 | Task { 35 | if Defaults[.token] != nil { 36 | await self.fetchTokenInfo() 37 | } else { 38 | await self.clearToken() 39 | } 40 | } 41 | } 42 | .store(in: &cancellables) 43 | } 44 | 45 | // MARK: - Public Methods 46 | /// 获取令牌信息 47 | func fetchTokenInfo() async { 48 | tokenState.load { 49 | try await V2EXService.shared.request(.token) 50 | } 51 | } 52 | 53 | /// 刷新用户资料 54 | func fetchProfile() async { 55 | profileState.load { 56 | try await V2EXService.shared.request(.profile) 57 | } 58 | } 59 | 60 | /// 刷新通知 61 | func fetchNotifications() async { 62 | notificationsState.load { 63 | try await V2EXService.shared.request(.notifications) 64 | } 65 | } 66 | 67 | /// 保存并验证新的访问令牌 68 | func saveToken(_ token: String) async throws { 69 | let trimmedToken = token.trimmed 70 | if trimmedToken.isWhitespace { throw TokenError.emptyToken } 71 | 72 | do { 73 | // 先保存 token 74 | Defaults[.token] = trimmedToken 75 | 76 | // 验证 token 是否有效 77 | _ = try await V2EXService.shared.request(.token) as V2EXTokenInfo 78 | 79 | // token 有效,获取信息 80 | await fetchTokenInfo() 81 | } catch { 82 | // token 无效,清理状态 83 | await clearToken() 84 | throw TokenError.invalidToken(error) 85 | } 86 | } 87 | 88 | /// 清除当前的访问令牌 89 | func clearToken() async { 90 | Defaults[.token] = nil 91 | tokenState.reset(nil) 92 | profileState.reset(nil) 93 | notificationsState.reset([]) 94 | } 95 | 96 | /// 刷新所有数据 97 | func refreshAll() async { 98 | await withTaskGroup(of: Void.self) { group in 99 | group.addTask { await self.fetchTokenInfo() } 100 | group.addTask { await self.fetchProfile() } 101 | group.addTask { await self.fetchNotifications() } 102 | 103 | await group.waitForAll() 104 | } 105 | } 106 | } 107 | 108 | // MARK: - Error Types 109 | extension V2EXViewModel { 110 | enum TokenError: LocalizedError { 111 | case emptyToken 112 | case invalidToken(Error) 113 | 114 | var errorDescription: String? { 115 | switch self { 116 | case .emptyToken: 117 | return "访问令牌不能为空" 118 | case .invalidToken(let error): 119 | return "无效的访问令牌:\(error.localizedDescription)" 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /V2Bar/Sources/Views/BottomButtonsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct BottomButtonsView: View { 5 | @EnvironmentObject private var viewModel: V2EXViewModel 6 | 7 | var body: some View { 8 | HStack(spacing: 12) { 9 | Button("刷新 (⌘R)") { 10 | Task { 11 | await viewModel.refreshAll() 12 | } 13 | } 14 | .buttonStyle(.borderless) 15 | .font(.system(size: 11)) 16 | .keyboardShortcut("r", modifiers: .command) 17 | 18 | Spacer() 19 | 20 | Button("退出 (⌘Q)") { 21 | NSApplication.shared.terminate(nil) 22 | } 23 | .buttonStyle(.borderless) 24 | .font(.system(size: 11)) 25 | .keyboardShortcut("q", modifiers: .command) 26 | } 27 | .padding(.horizontal, 12) 28 | .padding(.vertical, 8) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /V2Bar/Sources/Views/LoadableView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LoadableView: View { 4 | let state: LoadableObject 5 | let content: (T) -> Content 6 | let emptyText: String 7 | let isEmpty: (T) -> Bool 8 | 9 | init( 10 | state: LoadableObject, 11 | emptyText: String = "暂无数据", 12 | isEmpty: @escaping (T) -> Bool = { _ in false }, 13 | @ViewBuilder content: @escaping (T) -> Content 14 | ) { 15 | self.state = state 16 | self.content = content 17 | self.emptyText = emptyText 18 | self.isEmpty = isEmpty 19 | } 20 | 21 | var body: some View { 22 | Group { 23 | if let error = state.error { 24 | VStack(spacing: 8) { 25 | Image(systemName: "exclamationmark.triangle") 26 | .foregroundColor(.red) 27 | .font(.title2) 28 | Text(error.localizedDescription) 29 | .foregroundColor(.secondary) 30 | .font(.caption) 31 | .multilineTextAlignment(.center) 32 | } 33 | .frame(maxWidth: .infinity) 34 | .padding(.vertical, 12) 35 | } else if !isEmpty(state.value) { 36 | content(state.value) 37 | } else if state.isLoading { 38 | ProgressView() 39 | .controlSize(.small) 40 | .frame(maxWidth: .infinity) 41 | .padding(.vertical, 12) 42 | } else { 43 | Text(emptyText) 44 | .foregroundColor(.secondary) 45 | .frame(maxWidth: .infinity) 46 | .padding(.vertical, 12) 47 | } 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /V2Bar/Sources/Views/NotificationsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwifterSwift 3 | 4 | struct NotificationsView: View { 5 | @EnvironmentObject private var viewModel: V2EXViewModel 6 | @State private var hoveredNotificationId: Int? 7 | 8 | var body: some View { 9 | LoadableView( 10 | state: viewModel.notificationsState, 11 | emptyText: "暂无通知", 12 | isEmpty: { $0.isEmpty } 13 | ) { notifications in 14 | notificationsList(notifications) 15 | } 16 | } 17 | 18 | private func notificationsList(_ notifications: [V2EXNotification]) -> some View { 19 | List(notifications.enumerated().map { $0 }, id: \.element.id) { index, notification in 20 | NotificationRow(notification: notification, index: index + 1) 21 | .listRowInsets(EdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)) 22 | .listRowBackground( 23 | RoundedRectangle(cornerRadius: 0) 24 | .fill(hoveredNotificationId == notification.id ? Color.gray.opacity(0.1) : Color.clear) 25 | ) 26 | .listRowSeparator(.visible, edges: .bottom) 27 | .listRowSeparatorTint(Color.secondary.opacity(0.1)) 28 | .onHover { isHovered in 29 | hoveredNotificationId = isHovered ? notification.id : nil 30 | } 31 | .onTapGesture { 32 | if let topicLink = notification.links.first(where: { $0.url.path.hasPrefix("/t/") }) { 33 | NSWorkspace.shared.open(topicLink.url) 34 | } 35 | } 36 | } 37 | .listStyle(.plain) 38 | } 39 | } 40 | 41 | struct NotificationRow: View { 42 | let notification: V2EXNotification 43 | let index: Int 44 | @State private var isUsernameHovered = false 45 | 46 | var body: some View { 47 | VStack(alignment: .leading, spacing: 8) { 48 | // 通知标题 49 | HStack(alignment: .center, spacing: 6) { 50 | Link(destination: URL(string: "https://v2ex.com/member/\(notification.member.username)")!) { 51 | HStack(spacing: 0) { 52 | Image(systemName: "at") 53 | .foregroundColor(.secondary.opacity(0.8)) 54 | .font(.caption2) 55 | Text(notification.member.username) 56 | .usernameStyle(isHovered: $isUsernameHovered) 57 | } 58 | } 59 | .buttonStyle(.plain) 60 | .onHover { hovering in 61 | isUsernameHovered = hovering 62 | } 63 | .pointingCursor 64 | 65 | Text("•") 66 | .font(.caption2) 67 | .foregroundColor(.secondary) 68 | 69 | Text(Date.fromUnixTimestamp(notification.created).relativeString) 70 | .font(.caption) 71 | .foregroundColor(.secondary) 72 | 73 | Spacer() 74 | 75 | Text("#\(index)") 76 | .font(.caption) 77 | .foregroundColor(.secondary.opacity(0.8)) 78 | .monospacedDigit() 79 | } 80 | 81 | // 通知内容 82 | Text(notification.plainText) 83 | .font(.subheadline) 84 | .foregroundColor(.primary) 85 | .lineSpacing(4) 86 | 87 | // 回复内容 88 | if let payload = notification.payload, !payload.isWhitespace { 89 | Text(payload) 90 | .font(.callout) 91 | .foregroundColor(.secondary) 92 | .lineSpacing(4) 93 | .padding(.leading, 8) 94 | .padding(.vertical, 4) 95 | .overlay( 96 | Rectangle() 97 | .fill(Color.secondary.opacity(0.2)) 98 | .frame(width: 2) 99 | .padding(.vertical, 4), 100 | alignment: .leading 101 | ) 102 | } 103 | } 104 | .padding(.vertical, 6) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /V2Bar/Sources/Views/OnboardingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Defaults 3 | import AppKit 4 | 5 | struct OnboardingView: View { 6 | @EnvironmentObject private var viewModel: V2EXViewModel 7 | 8 | var body: some View { 9 | VStack(spacing: 20) { 10 | Image(systemName: "person.circle") 11 | .font(.system(size: 60)) 12 | .foregroundColor(.gray) 13 | 14 | Text("欢迎使用 V2Bar") 15 | .font(.title2) 16 | .fontWeight(.bold) 17 | 18 | Text("请先设置 V2EX 访问令牌") 19 | .foregroundColor(.secondary) 20 | 21 | Link("获取访问令牌", destination: URL(string: "https://www.v2ex.com/settings/tokens")!) 22 | .foregroundColor(.accentColor) 23 | 24 | Button(action: showTokenInputAlert) { 25 | Text("设置") 26 | .fontWeight(.medium) 27 | .padding(.horizontal, 16) 28 | .padding(.vertical, 8) 29 | .background(Color.accentColor) 30 | .foregroundColor(.white) 31 | .cornerRadius(6) 32 | } 33 | .buttonStyle(.plain) 34 | } 35 | .padding(.vertical, 32) 36 | .frame(maxWidth: .infinity, maxHeight: .infinity) 37 | } 38 | 39 | private func showTokenInputAlert() { 40 | let alert = NSAlert() 41 | alert.messageText = "设置访问令牌" 42 | alert.informativeText = "请输入您的 V2EX 访问令牌" 43 | 44 | let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) 45 | input.placeholderString = "请输入访问令牌" 46 | 47 | alert.accessoryView = input 48 | alert.addButton(withTitle: "确定") 49 | alert.addButton(withTitle: "取消") 50 | 51 | if alert.runModal() == .alertFirstButtonReturn { 52 | let inputToken = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) 53 | if !inputToken.isEmpty { 54 | Task { 55 | do { 56 | try await viewModel.saveToken(inputToken) 57 | } catch { 58 | let errorAlert = NSAlert() 59 | errorAlert.messageText = "设置失败" 60 | errorAlert.informativeText = error.localizedDescription 61 | errorAlert.alertStyle = .critical 62 | errorAlert.addButton(withTitle: "确定") 63 | errorAlert.runModal() 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /V2Bar/Sources/Views/QuickLinksView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwifterSwift 3 | 4 | struct QuickLink: Identifiable { 5 | let id = UUID() 6 | let title: String 7 | let url: URL 8 | let iconName: String 9 | } 10 | 11 | struct QuickLinksView: View { 12 | private var links: [QuickLink] { 13 | [ 14 | QuickLink( 15 | title: "V2EX 首页", 16 | url: URL(string: "https://www.v2ex.com/")!, 17 | iconName: "house" 18 | ), 19 | QuickLink( 20 | title: "关于 V2Bar", 21 | url: URL(string: "https://github.com/ygsgdbd/V2Bar")!, 22 | iconName: "info.circle" 23 | ) 24 | ] 25 | } 26 | 27 | var body: some View { 28 | VStack(spacing: 0) { 29 | ForEach(Array(links.enumerated()), id: \.element.id) { index, link in 30 | Button { 31 | NSWorkspace.shared.open(link.url) 32 | NSApplication.shared.hide(nil) 33 | } label: { 34 | HStack(spacing: 8) { 35 | Image(systemName: link.iconName) 36 | .font(.system(size: 12)) 37 | .foregroundColor(.secondary) 38 | .frame(width: 16, alignment: .center) 39 | Text(link.title) 40 | .font(.system(size: 12)) 41 | Spacer() 42 | } 43 | .padding(.horizontal, 12) 44 | .padding(.vertical, 8) 45 | .frame(maxWidth: .infinity) 46 | .contentShape(Rectangle()) 47 | } 48 | .buttonStyle(HoverButtonStyle()) 49 | 50 | if index < links.count - 1 { 51 | Divider() 52 | .padding(.horizontal, 12) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /V2Bar/Sources/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Defaults 3 | import AppKit 4 | 5 | struct SettingsView: View { 6 | @EnvironmentObject private var viewModel: V2EXViewModel 7 | @State private var editingToken = "" 8 | 9 | var body: some View { 10 | VStack(spacing: 0) { 11 | Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { 12 | // 访问令牌 13 | GridRow { 14 | VStack(alignment: .leading, spacing: 4) { 15 | HStack(spacing: 4) { 16 | Image(systemName: "key") 17 | .font(.system(size: 10)) 18 | .foregroundColor(.secondary) 19 | Text("Token") 20 | .font(.system(size: 12)) 21 | } 22 | 23 | HStack(spacing: 8) { 24 | Text(viewModel.maskedToken) 25 | .font(.system(size: 12)) 26 | 27 | if viewModel.tokenState.isLoading { 28 | ProgressView() 29 | .controlSize(.small) 30 | .scaleEffect(0.7) 31 | } else if let formatted = viewModel.tokenState.value?.formattedExpirationText { 32 | Text(formatted) 33 | .font(.system(size: 11)) 34 | } 35 | } 36 | .foregroundColor(.secondary) 37 | } 38 | .gridCellColumns(1) 39 | 40 | HStack(spacing: 8) { 41 | Button { 42 | showTokenAlert() 43 | } label: { 44 | Text("编辑") 45 | } 46 | 47 | Button { 48 | NSWorkspace.shared.open(URL(string: "https://www.v2ex.com/settings/tokens")!) 49 | NSApplication.shared.hide(nil) 50 | } label: { 51 | HStack(spacing: 2) { 52 | Text("管理") 53 | Image(systemName: "arrow.up.forward") 54 | .font(.system(size: 10)) 55 | } 56 | } 57 | } 58 | .gridCellColumns(1) 59 | .controlSize(.small) 60 | .buttonStyle(.plain) 61 | .frame(maxWidth: .infinity, alignment: .trailing) 62 | } 63 | } 64 | .padding(12) 65 | } 66 | } 67 | 68 | private func showTokenAlert() { 69 | let alert = NSAlert() 70 | alert.messageText = "设置访问令牌" 71 | alert.informativeText = "请输入您的 V2EX 访问令牌" 72 | 73 | let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) 74 | input.placeholderString = "请输入访问令牌" 75 | if let existingToken = Defaults[.token] { 76 | input.stringValue = existingToken 77 | input.selectText(nil) 78 | } 79 | 80 | alert.accessoryView = input 81 | alert.addButton(withTitle: "确定") 82 | alert.addButton(withTitle: "取消") 83 | 84 | if alert.runModal() == .alertFirstButtonReturn { 85 | let inputToken = input.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) 86 | if !inputToken.isEmpty { 87 | Task { 88 | do { 89 | try await viewModel.saveToken(inputToken) 90 | } catch { 91 | let errorAlert = NSAlert() 92 | errorAlert.messageText = "设置失败" 93 | errorAlert.informativeText = error.localizedDescription 94 | errorAlert.alertStyle = .critical 95 | errorAlert.addButton(withTitle: "确定") 96 | errorAlert.runModal() 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /V2Bar/Sources/Views/Styles/HoverButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct HoverButtonStyle: ButtonStyle { 4 | @State private var isHovered = false 5 | 6 | func makeBody(configuration: Configuration) -> some View { 7 | configuration.label 8 | .background( 9 | configuration.isPressed ? Color.gray.opacity(0.15) : 10 | (isHovered ? Color.gray.opacity(0.1) : Color.clear) 11 | ) 12 | .onHover { hovering in 13 | isHovered = hovering 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /V2Bar/Sources/Views/UserProfileView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwifterSwift 3 | 4 | struct QuickAction: Identifiable { 5 | let id = UUID() 6 | let title: String 7 | let icon: String 8 | let action: Action 9 | 10 | enum Action { 11 | case url(String) 12 | case logout 13 | } 14 | 15 | static let allActions: [QuickAction] = [ 16 | QuickAction(title: "时间轴", icon: "clock", action: .url("https://www.v2ex.com/t")), 17 | QuickAction(title: "创建主题", icon: "square.and.pencil", action: .url("https://www.v2ex.com/new/create")), 18 | QuickAction(title: "消息中心", icon: "bell", action: .url("https://www.v2ex.com/notifications")), 19 | QuickAction(title: "个人设置", icon: "gearshape", action: .url("https://www.v2ex.com/settings")), 20 | QuickAction(title: "退出登录", icon: "rectangle.portrait.and.arrow.right", action: .logout) 21 | ] 22 | } 23 | 24 | enum SocialLinkType { 25 | case website(URL) 26 | case github(URL) 27 | case twitter(URL) 28 | 29 | var title: String { 30 | switch self { 31 | case .website: return "WebSite" 32 | case .github: return "Github" 33 | case .twitter: return "Twitter" 34 | } 35 | } 36 | 37 | var icon: String { 38 | switch self { 39 | case .website: return "globe" 40 | case .github: return "chevron.left.forwardslash.chevron.right" 41 | case .twitter: return "bird" 42 | } 43 | } 44 | 45 | var url: URL { 46 | switch self { 47 | case .website(let url): return url 48 | case .github(let url): return url 49 | case .twitter(let url): return url 50 | } 51 | } 52 | } 53 | 54 | struct SocialLink: View { 55 | let type: SocialLinkType 56 | 57 | var body: some View { 58 | Link(destination: type.url) { 59 | HStack(spacing: 2) { 60 | Image(systemName: type.icon) 61 | Text(type.title) 62 | } 63 | .font(.caption) 64 | .foregroundColor(.secondary) 65 | .lineLimit(1) 66 | } 67 | .buttonStyle(.link) 68 | } 69 | } 70 | 71 | struct UserProfileView: View { 72 | @EnvironmentObject private var viewModel: V2EXViewModel 73 | @State private var isUsernameHovered = false 74 | 75 | var body: some View { 76 | LoadableView( 77 | state: viewModel.profileState, 78 | emptyText: "暂无数据", 79 | isEmpty: { $0 == nil } 80 | ) { profile in 81 | if let profile = profile { 82 | profileContent(profile) 83 | } 84 | } 85 | } 86 | 87 | @ViewBuilder 88 | private func profileContent(_ profile: V2EXUserProfile) -> some View { 89 | VStack(spacing: 0) { 90 | // 用户信息 91 | VStack(spacing: 8) { 92 | // 头像和用户名区域 93 | HStack(spacing: 12) { 94 | Link(destination: URL(string: profile.url)!) { 95 | AsyncImage(url: profile.avatarNormal?.url) { image in 96 | image 97 | .resizable() 98 | .scaledToFit() 99 | } placeholder: { 100 | Color.gray.opacity(0.2) 101 | } 102 | .frame(width: 40, height: 40) 103 | .clipShape(Circle()) 104 | .overlay(Circle().stroke(Color.secondary.opacity(0.1), lineWidth: 1)) 105 | } 106 | .buttonStyle(.link) 107 | .focusable(false) 108 | 109 | VStack(alignment: .leading, spacing: 4) { 110 | HStack { 111 | Link(destination: URL(string: profile.url)!) { 112 | Text(profile.username) 113 | .usernameStyle(isHovered: $isUsernameHovered) 114 | } 115 | .buttonStyle(.link) 116 | .onHover { hovering in 117 | isUsernameHovered = hovering 118 | } 119 | 120 | Spacer() 121 | 122 | Text("加入于 \(Date.fromUnixTimestamp(profile.created).formattedString)") 123 | .font(.caption) 124 | .foregroundColor(.secondary) 125 | } 126 | 127 | // 预留底部链接域 128 | HStack(spacing: 8) { 129 | if let websiteURL = profile.websiteURL { 130 | SocialLink(type: .website(websiteURL)) 131 | } 132 | 133 | if let githubURL = profile.githubURL { 134 | SocialLink(type: .github(githubURL)) 135 | } 136 | 137 | if let twitterURL = profile.twitterURL { 138 | SocialLink(type: .twitter(twitterURL)) 139 | } 140 | } 141 | } 142 | } 143 | 144 | 145 | } 146 | .padding(.horizontal) 147 | .padding(.vertical, 12) 148 | 149 | Divider() 150 | 151 | // 快速操作 152 | HStack(spacing: 0) { 153 | ForEach(Array(QuickAction.allActions.enumerated()), id: \.element.id) { index, action in 154 | Button { 155 | switch action.action { 156 | case .url(let urlString): 157 | NSWorkspace.shared.open(URL(string: urlString)!) 158 | NSApplication.shared.hide(nil) 159 | case .logout: 160 | Task { await viewModel.clearToken() } 161 | } 162 | } label: { 163 | VStack(spacing: 4) { 164 | Image(systemName: action.icon) 165 | .font(.system(size: 14)) 166 | Text(action.title) 167 | .font(.system(size: 10)) 168 | } 169 | .frame(maxWidth: .infinity) 170 | .padding(.vertical, 8) 171 | .contentShape(Rectangle()) 172 | } 173 | .buttonStyle(HoverButtonStyle()) 174 | 175 | if index < QuickAction.allActions.count - 1 { 176 | Divider() 177 | } 178 | } 179 | } 180 | } 181 | } 182 | } 183 | --------------------------------------------------------------------------------