mirror of
https://github.com/bolucat/Archive.git
synced 2025-12-24 13:28:37 +08:00
688 lines
25 KiB
Swift
688 lines
25 KiB
Swift
//
|
||
// AppVersion.swift
|
||
// V2rayU
|
||
//
|
||
// Created by yanue on 2024/6/30.
|
||
// Copyright © 2024 yanue. All rights reserved.
|
||
//
|
||
import SwiftUI
|
||
import ServiceManagement
|
||
|
||
// 手动实现检查版本下载更新 UI.
|
||
// 基于 SwiftUI + NSWindowController 实现
|
||
// 参考 UI: Sparkle(https://github.com/sparkle-project/Sparkle)
|
||
// 基于 https://github.com/yanue/V2rayU/releases 进行版本检查
|
||
|
||
struct GithubRelease: Codable {
|
||
let id: Int
|
||
let tagName: String
|
||
let name: String
|
||
let draft: Bool
|
||
let prerelease: Bool
|
||
let publishedAt: Date // 2024-06-30T09:00:00Z, 用于排序
|
||
let assets: [GithubAsset]
|
||
let body: String
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case id
|
||
case tagName = "tag_name"
|
||
case name
|
||
case draft
|
||
case prerelease
|
||
case publishedAt = "published_at"
|
||
case assets
|
||
case body
|
||
}
|
||
}
|
||
|
||
struct GithubAsset: Codable {
|
||
let name: String
|
||
let browserDownloadUrl: String
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case name
|
||
case browserDownloadUrl = "browser_download_url"
|
||
}
|
||
}
|
||
|
||
struct GithubError: Codable {
|
||
let message: String
|
||
let documentationUrl: String
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case message
|
||
case documentationUrl = "documentation_url"
|
||
}
|
||
}
|
||
|
||
let V2rayUpdater = AppCheckController()
|
||
|
||
// AppCheckController - 检查新版本页面
|
||
|
||
class AppCheckController: NSWindowController {
|
||
// Declare the contentView as a property to avoid using self before super.init
|
||
private var contentView: NSHostingView<ContentView>!
|
||
var bindData = BindData()
|
||
|
||
// Initialize the view and window
|
||
init() {
|
||
// Initialize the content view with a placeholder closure
|
||
let contentView = NSHostingView(rootView: ContentView(
|
||
bindData: bindData,
|
||
closeWindow: {}
|
||
))
|
||
|
||
// Create the window with specified dimensions and styles
|
||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
|
||
styleMask: [.titled, .closable, .resizable],
|
||
backing: .buffered, defer: false)
|
||
window.title = "Check V2rayU"
|
||
window.contentView = contentView
|
||
|
||
// Call the super init with the created window
|
||
super.init(window: window)
|
||
|
||
// Update the contentView with the actual closure after super.init
|
||
contentView.rootView = ContentView(
|
||
bindData: bindData,
|
||
closeWindow: closeWindow
|
||
)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func windowDidLoad() {
|
||
super.windowDidLoad()
|
||
}
|
||
|
||
func checkForUpdates(showWindow: Bool = false) {
|
||
if showWindow {
|
||
DispatchQueue.main.async {
|
||
self.window?.orderFrontRegardless()
|
||
self.window?.center()
|
||
self.window?.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
} else {
|
||
// close window
|
||
DispatchQueue.main.async {
|
||
self.window?.close()
|
||
}
|
||
}
|
||
guard let url = URL(string: "https://api.github.com/repos/yanue/V2rayU/releases") else {
|
||
return
|
||
}
|
||
print("checkForUpdates: \(url)")
|
||
let checkTask = URLSession.shared.dataTask(with: url) { data, response, error in
|
||
if let error = error {
|
||
print("Error fetching release: \(error)")
|
||
return
|
||
}
|
||
|
||
guard let data = data else {
|
||
print("No data returned")
|
||
return
|
||
}
|
||
|
||
print("checkForUpdates: \n \(data)")
|
||
|
||
do {
|
||
let decoder = JSONDecoder()
|
||
decoder.dateDecodingStrategy = .iso8601 // 解析日期
|
||
|
||
// try decode data
|
||
let data: [GithubRelease] = try decoder.decode([GithubRelease].self, from: data)
|
||
|
||
// 按日期倒序排序
|
||
let sortedData = data.sorted { $0.publishedAt > $1.publishedAt }
|
||
|
||
// 取第一个
|
||
if let release = sortedData.first {
|
||
print("release: \(release.tagName)")
|
||
DispatchQueue.main.async {
|
||
let releaseVersion = release.tagName.replacingOccurrences(of: "v", with: "").replacingOccurrences(of: "V", with: "").trimmingCharacters(in: .whitespaces) // v4.1.0 => 4.1.0
|
||
// get old version
|
||
let appVer = appVersion.versionToInt()
|
||
let releaseVer = releaseVersion.versionToInt()
|
||
|
||
// new version is bigger than old version
|
||
if appVer.lexicographicallyPrecedes(releaseVer) {
|
||
// 点击菜单栏检查新版本,不过滤
|
||
if !showWindow {
|
||
// 如果用户选择跳过版本更新, 则不显示新版本详情页面
|
||
if let skipVersion = UserDefaults.standard.string(forKey: "skipAppVersion") {
|
||
if skipVersion == release.tagName {
|
||
print("Skip version: \(skipVersion)")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
// 显示新版本详情页面
|
||
let versionController = AppVersionController()
|
||
versionController.show(release: release)
|
||
// close window
|
||
self.closeWindow()
|
||
} else {
|
||
var title = "You are up to date!"
|
||
var toast = "V2rayU \(appVersion) is currently the newest version available."
|
||
if isMainland {
|
||
title = "当前已经是最新版了"
|
||
toast = "V2rayU \(appVersion) 已经是当前最新版了.";
|
||
}
|
||
// open dialog
|
||
alertDialog(title: title, message: toast)
|
||
// close window
|
||
self.closeWindow()
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// 可能请求太频繁了
|
||
do {
|
||
let decoder = JSONDecoder()
|
||
|
||
// try decode data
|
||
let data: GithubError = try decoder.decode(GithubError.self, from: data)
|
||
DispatchQueue.main.async {
|
||
// update progress text
|
||
self.bindData.progressText = "Check failed: \(error)"
|
||
var title = "Check failed!"
|
||
if isMainland {
|
||
title = "检查失败"
|
||
}
|
||
var toast = "\(data.message)\n\(data.documentationUrl)";
|
||
// open dialog
|
||
alertDialog(title: title, message: toast)
|
||
// sleep 2s
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||
// close window
|
||
self.closeWindow()
|
||
}
|
||
}
|
||
} catch {
|
||
print("Error decoding JSON: \(error)")
|
||
DispatchQueue.main.async {
|
||
// update progress text
|
||
self.bindData.progressText = "Check failed: \(error)"
|
||
var title = "Check failed!"
|
||
var toast = "\(error)"
|
||
if isMainland {
|
||
title = "检查失败"
|
||
toast = "\(error)";
|
||
}
|
||
// open dialog
|
||
alertDialog(title: title, message: toast)
|
||
// sleep 2s
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||
// close window
|
||
self.closeWindow()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
checkTask.resume()
|
||
}
|
||
|
||
func closeWindow() {
|
||
DispatchQueue.main.async {
|
||
self.window?.close()
|
||
}
|
||
}
|
||
|
||
|
||
class BindData: ObservableObject {
|
||
@Published var progressText = "check for updates..."
|
||
}
|
||
|
||
struct ContentView: View {
|
||
@ObservedObject var bindData: BindData
|
||
|
||
var closeWindow: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 20) {
|
||
HStack {
|
||
Image("V2rayU")
|
||
.resizable()
|
||
.frame(width: 64, height: 64)
|
||
.cornerRadius(8)
|
||
|
||
Spacer()
|
||
|
||
VStack {
|
||
HStack {
|
||
ProgressView(bindData.progressText) .progressViewStyle(LinearProgressViewStyle()).padding(.horizontal)
|
||
}
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button(action: {
|
||
closeWindow()
|
||
}) {
|
||
Text("Cancel").font(.body)
|
||
}
|
||
.padding(.trailing, 20)
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
|
||
// AppVersionController - 新版本详情页面
|
||
|
||
class AppVersionController: NSWindowController {
|
||
var bindData = BindData()
|
||
private var contentView: NSHostingView<ContentView>!
|
||
private var release: GithubRelease!
|
||
|
||
init() {
|
||
let contentView = NSHostingView(rootView: ContentView(
|
||
bindData: bindData,
|
||
skipAction: { print("Skip action") },
|
||
installAction: { print("Install action") }
|
||
))
|
||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||
styleMask: [.titled, .closable, .resizable],
|
||
backing: .buffered, defer: false)
|
||
window.title = "V2rayU Update"
|
||
window.contentView = contentView
|
||
|
||
super.init(window: window)
|
||
|
||
// Update the contentView with the actual closure after super.init
|
||
contentView.rootView = ContentView(
|
||
bindData: bindData,
|
||
skipAction: self.skipAction,
|
||
installAction: self.installAction
|
||
)
|
||
}
|
||
|
||
func show(release: GithubRelease) {
|
||
DispatchQueue.main.async {
|
||
self.release = release
|
||
if !isMainland {
|
||
self.bindData.title = "A new version of V2rayU is available!"
|
||
if release.prerelease{
|
||
self.bindData.description = "V2rayU \(release.tagName) preview is now available, you have \(appVersion). Would you like to download it now?"
|
||
} else {
|
||
self.bindData.description = "V2rayU \(release.tagName) is now available, you have \(appVersion). Would you like to download it now?"
|
||
}
|
||
self.bindData.releaseNotes = release.name + "\n" + release.body
|
||
} else {
|
||
self.bindData.title = "V2rayU 有新版本上线了!"
|
||
if release.prerelease {
|
||
self.bindData.description = "V2rayU 已上线 \(release.tagName) 预览版,您有的版本 \(appVersion) —,需要立即下载吗?"
|
||
} else {
|
||
self.bindData.description = "V2rayU 已上线 \(release.tagName),您有的版本 \(appVersion) —,需要立即下载吗?"
|
||
}
|
||
self.bindData.releaseNotes = release.name + "\n" + release.body
|
||
self.bindData.releaseNodesTitle = "更新日志"
|
||
self.bindData.skipVersion = "跳过此版本"
|
||
self.bindData.installUpdate = "安装此版本"
|
||
}
|
||
// bring window to front
|
||
self.window?.orderFrontRegardless()
|
||
// center position
|
||
self.window?.center()
|
||
// make window key
|
||
self.window?.makeKeyAndOrderFront(nil)
|
||
// activate app
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func windowDidLoad() {
|
||
super.windowDidLoad()
|
||
}
|
||
|
||
// 安装新版本
|
||
func installAction() {
|
||
DispatchQueue.main.async {
|
||
// 显示下载页面
|
||
let downloadController = AppDownloadController()
|
||
downloadController.show(release: self.release)
|
||
// 关闭窗口
|
||
self.window?.close()
|
||
}
|
||
}
|
||
|
||
|
||
func skipAction() {
|
||
print("Skip action")
|
||
DispatchQueue.main.async {
|
||
// UserDefaults 记录是否跳过版本更新
|
||
UserDefaults.standard.set(self.release.tagName, forKey: "skipAppVersion")
|
||
// 关闭窗口
|
||
self.window?.close()
|
||
}
|
||
}
|
||
|
||
class BindData: ObservableObject {
|
||
@Published var title = "A new version of V2rayU App is available!"
|
||
@Published var description = ""
|
||
@Published var releaseNotes = ""
|
||
@Published var releaseNodesTitle = "Release Notes:"
|
||
@Published var skipVersion = "Skip This Version!"
|
||
@Published var installUpdate = "Install Update!"
|
||
}
|
||
|
||
struct ContentView: View {
|
||
@ObservedObject var bindData: BindData
|
||
var skipAction: () -> Void
|
||
var installAction: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
// use AppIcon.appiconset to Image
|
||
Image("V2rayU")
|
||
.resizable()
|
||
.frame(width: 64, height: 64)
|
||
.padding(.top, 20)
|
||
.padding(.leading, 20)
|
||
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text(bindData.title)
|
||
.font(.headline)
|
||
.padding(.top, 20)
|
||
|
||
Text(bindData.description)
|
||
.padding(.trailing, 20)
|
||
|
||
Text(bindData.releaseNodesTitle)
|
||
.font(.headline)
|
||
.bold()
|
||
.padding(.top, 20)
|
||
|
||
HStack {
|
||
|
||
// 文字可选中
|
||
TextEditor(text: $bindData.releaseNotes)
|
||
.lineSpacing(6) // 行间距
|
||
.frame(height: 120)
|
||
.border(Color.gray, width: 1) // 黑色边框,宽度为 2
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
|
||
Spacer(minLength: 20) // 右边 margin 40
|
||
}
|
||
|
||
HStack {
|
||
Button(bindData.skipVersion) {
|
||
skipAction()
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button(bindData.installUpdate) {
|
||
installAction()
|
||
}
|
||
.padding(.trailing, 20)
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
.padding(.top,20)
|
||
.padding(.bottom,20)
|
||
}
|
||
}
|
||
|
||
}
|
||
.frame(width: 500, height: 300)
|
||
}
|
||
}
|
||
}
|
||
|
||
// AppDownloadController - 下载安装页面
|
||
|
||
class AppDownloadController: NSWindowController, URLSessionDownloadDelegate {
|
||
private var contentView: NSHostingView<ContentView>!
|
||
var bindData = BindData()
|
||
private var downloadTask: URLSessionDownloadTask?
|
||
private var destinationURL: URL?
|
||
|
||
init() {
|
||
let contentView = NSHostingView(rootView: ContentView(
|
||
bindData: bindData,
|
||
cancelDownload: {},
|
||
doInstall: {}
|
||
))
|
||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
|
||
styleMask: [.titled, .closable, .resizable],
|
||
backing: .buffered, defer: false)
|
||
window.title = "Download V2rayU"
|
||
window.contentView = contentView
|
||
super.init(window: window)
|
||
|
||
// Update the contentView with the actual closure after super.init
|
||
contentView.rootView = ContentView(
|
||
bindData: bindData,
|
||
cancelDownload: cancelDownload,
|
||
doInstall: doInstall
|
||
)
|
||
self.contentView = contentView
|
||
}
|
||
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func windowDidLoad() {
|
||
super.windowDidLoad()
|
||
}
|
||
|
||
func show(release: GithubRelease) {
|
||
DispatchQueue.main.async {
|
||
self.window?.orderFrontRegardless()
|
||
self.window?.center()
|
||
self.window?.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
download(release: release)
|
||
}
|
||
|
||
func download(release: GithubRelease) {
|
||
DispatchQueue.main.async {
|
||
if let asset = release.assets.first {
|
||
self.bindData.dmgUrl = asset.browserDownloadUrl
|
||
print("download: \(self.bindData.dmgUrl)")
|
||
self.startDownload()
|
||
} else {
|
||
self.bindData.progressText = "No dmg asset found"
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startDownload() {
|
||
guard let url = URL(string: bindData.dmgUrl) else {
|
||
DispatchQueue.main.async {
|
||
self.bindData.isDownloading = true
|
||
self.bindData.progressText = "Invalid dmg url"
|
||
}
|
||
return
|
||
}
|
||
DispatchQueue.main.async {
|
||
self.bindData.isDownloading = true
|
||
self.bindData.progress = 0.0
|
||
self.bindData.progressText = "Downloading..."
|
||
}
|
||
let urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: OperationQueue())
|
||
downloadTask = urlSession.downloadTask(with: url)
|
||
downloadTask?.resume()
|
||
|
||
}
|
||
|
||
private func cancelDownload() {
|
||
DispatchQueue.main.async {
|
||
self.bindData.isDownloading = false
|
||
self.bindData.progress = 0.0
|
||
self.bindData.progressText = "Download canceled"
|
||
self.downloadTask?.cancel()
|
||
print("Download canceled")
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||
self.window?.close()
|
||
}
|
||
}
|
||
|
||
func doInstall() {
|
||
DispatchQueue.main.async {
|
||
if let destinationURL = self.destinationURL {
|
||
// open downloaded dmg
|
||
NSWorkspace.shared.open(destinationURL)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||
// close window
|
||
self.window?.close()
|
||
NSApplication.shared.terminate(self)
|
||
}
|
||
}
|
||
}
|
||
|
||
print("Installing V2rayU: \(String(describing: self.destinationURL))")
|
||
}
|
||
|
||
// ---------------------- ui 相关 --------------------------------
|
||
// MARK: - 下载进度数据
|
||
class BindData: ObservableObject {
|
||
@Published var progressText = "Downloading..."
|
||
@Published var dmgUrl: String = ""
|
||
@Published var progress: Float = 0.0
|
||
@Published var isDownloading: Bool = false
|
||
}
|
||
|
||
// MARK: - 下载进度视图
|
||
|
||
struct ContentView: View {
|
||
@ObservedObject var bindData: BindData
|
||
var cancelDownload: () -> Void
|
||
var doInstall: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 20) {
|
||
VStack(spacing: 20) {
|
||
HStack {
|
||
Image("V2rayU")
|
||
.resizable()
|
||
.frame(width: 64, height: 64)
|
||
.cornerRadius(8)
|
||
|
||
Spacer()
|
||
|
||
VStack {
|
||
HStack {
|
||
ProgressView(value: bindData.progress, total: 100) {
|
||
Text(bindData.progressText)
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
Spacer()
|
||
if bindData.isDownloading {
|
||
Button(action: {
|
||
cancelDownload()
|
||
}) {
|
||
Text("Cancel").font(.body)
|
||
}
|
||
} else {
|
||
Button(action: {
|
||
doInstall()
|
||
}) {
|
||
Text("Install V2rayU").font(.body)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
// ---------------------- 下载相关 --------------------------------
|
||
|
||
// MARK: - URLSessionDownloadDelegate
|
||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||
let fileManager = FileManager.default
|
||
let downloadsDirectory = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
||
let destUrl = downloadsDirectory.appendingPathComponent(downloadTask.response?.suggestedFilename ?? "V2rayU-macOS.dmg")
|
||
|
||
do {
|
||
print("destinationURL: \(destUrl)")
|
||
if fileManager.fileExists(atPath: destUrl.path) {
|
||
print("Download file already exists: \(destUrl.path) \(location)")
|
||
DispatchQueue.main.async {
|
||
self.bindData.isDownloading = false
|
||
self.bindData.progress = 100.0
|
||
self.bindData.progressText = "Download Completed"
|
||
self.destinationURL = destUrl
|
||
}
|
||
return
|
||
}
|
||
|
||
try fileManager.moveItem(at: location, to: destUrl)
|
||
|
||
DispatchQueue.main.async {
|
||
self.bindData.isDownloading = false
|
||
self.bindData.progress = 100.0
|
||
self.bindData.progressText = "Download Completed"
|
||
self.destinationURL = destUrl
|
||
}
|
||
|
||
print("Download finished: \(destUrl)")
|
||
} catch {
|
||
DispatchQueue.main.async {
|
||
self.bindData.isDownloading = false
|
||
self.bindData.progressText = "File move error: \(error.localizedDescription)"
|
||
self.destinationURL = destUrl
|
||
var title = "Download failed!"
|
||
var toast = "\(error)"
|
||
if isMainland {
|
||
title = "移动文件失败"
|
||
toast = "\(error)"
|
||
}
|
||
// Ensure alertDialog function displays an alert to the user
|
||
alertDialog(title: title, message: toast)
|
||
}
|
||
print("File move error: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||
DispatchQueue.main.async {
|
||
self.bindData.progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) * 100
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||
if let error = error {
|
||
DispatchQueue.main.async {
|
||
self.bindData.isDownloading = false
|
||
self.bindData.progressText = "Download Failed: \(error.localizedDescription)"
|
||
}
|
||
var title = "Download failed!"
|
||
var toast = "\(error)"
|
||
if isMainland {
|
||
title = "下载文件失败"
|
||
toast = "\(error)";
|
||
}
|
||
// open dialog
|
||
alertDialog(title: title, message: toast)
|
||
print("Download error: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|