├── .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 |

5 |
6 |
7 |
8 |
9 | [](https://github.com/ysgdbd/V2Bar/releases/latest)
10 | [](https://github.com/ysgdbd/V2Bar)
11 | [](https://tuist.io)
12 | [](https://developer.apple.com/xcode/)
13 | [](https://developer.apple.com/xcode/swiftui/)
14 |
15 |
16 |
17 | V2Bar 是一个简洁优雅的 macOS 菜单栏应用,为你提供快捷的 V2EX 访问体验。✨
18 |
19 | ## 预览 👀
20 |
21 |
22 |

23 |

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