From 2b3d8445aa3dc215084e8c190e2838eb141ff253 Mon Sep 17 00:00:00 2001 From: Wish Date: Sat, 30 Apr 2022 11:17:22 +0800 Subject: [PATCH] fix: use native view in NSStatusItem --- PaimonMenuBar.xcodeproj/project.pbxproj | 12 +- PaimonMenuBar/AppDelegate.swift | 69 ++++---- .../FragileResin.imageset/fragile_resin@1.png | Bin 2301 -> 1049 bytes .../FragileResin.imageset/fragile_resin@2.png | Bin 3521 -> 3025 bytes .../FragileResin.imageset/fragile_resin@3.png | Bin 5283 -> 5881 bytes PaimonMenuBar/GameRecord.swift | 5 +- PaimonMenuBar/GameRecordViewModel.swift | 163 +++++++++++++++--- PaimonMenuBar/MenuBarResinView.swift | 49 ------ PaimonMenuBar/MenuExtrasView.swift | 24 +-- PaimonMenuBar/NetworkMonitor.swift | 25 --- PaimonMenuBar/Networking.swift | 2 + PaimonMenuBar/PaimonMenuBar.entitlements | 2 - PaimonMenuBar/SettingsView.swift | 8 +- 13 files changed, 198 insertions(+), 161 deletions(-) delete mode 100644 PaimonMenuBar/MenuBarResinView.swift delete mode 100644 PaimonMenuBar/NetworkMonitor.swift diff --git a/PaimonMenuBar.xcodeproj/project.pbxproj b/PaimonMenuBar.xcodeproj/project.pbxproj index 0e3abe4..181e0ed 100644 --- a/PaimonMenuBar.xcodeproj/project.pbxproj +++ b/PaimonMenuBar.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 76085E6427FC23EA00960915 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 76085E6327FC23EA00960915 /* Sparkle */; }; 7621675327F2FC080023F8B2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7621675527F2FC080023F8B2 /* Localizable.strings */; }; 7686474127EF082400BCC350 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7686474027EF082400BCC350 /* Bundle.swift */; }; - 76C2009027EE124B0026D6CC /* MenuBarResinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76C2008F27EE124B0026D6CC /* MenuBarResinView.swift */; }; 76C290F027EAFFB000A30C9F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76C290EF27EAFFB000A30C9F /* AppDelegate.swift */; }; 76CCDDDE27EAD1C4009CFC64 /* PaimonMenuBarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76CCDDDD27EAD1C4009CFC64 /* PaimonMenuBarApp.swift */; }; 76CCDDE027EAD1C4009CFC64 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76CCDDDF27EAD1C4009CFC64 /* SettingsView.swift */; }; @@ -19,7 +18,6 @@ 76CCDDE527EAD1C5009CFC64 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 76CCDDE427EAD1C5009CFC64 /* Preview Assets.xcassets */; }; 76D73BBF27EC650500CCDEA6 /* GameRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76D73BBE27EC650500CCDEA6 /* GameRecord.swift */; }; 76D73BC127EC67D300CCDEA6 /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76D73BC027EC67D300CCDEA6 /* Networking.swift */; }; - 76DD33FE27EF5CA400F0A563 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76DD33FD27EF5CA400F0A563 /* NetworkMonitor.swift */; }; 76E429A927EDDE000032313C /* GameRecordViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E429A827EDDE000032313C /* GameRecordViewModel.swift */; }; 76E986B627EDD5FC004ECC6C /* MenuExtrasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E986B527EDD5FC004ECC6C /* MenuExtrasView.swift */; }; 76F9AE6D27F570D90051CDC8 /* UpdaterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F9AE6C27F570D90051CDC8 /* UpdaterViewModel.swift */; }; @@ -30,7 +28,6 @@ 7621675627F2FC0B0023F8B2 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 7686474027EF082400BCC350 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 76B3F03127F2B76100833555 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - 76C2008F27EE124B0026D6CC /* MenuBarResinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarResinView.swift; sourceTree = ""; }; 76C290EF27EAFFB000A30C9F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 76CCDDDD27EAD1C4009CFC64 /* PaimonMenuBarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaimonMenuBarApp.swift; sourceTree = ""; }; 76CCDDDF27EAD1C4009CFC64 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -39,7 +36,6 @@ 76CCDDE627EAD1C5009CFC64 /* PaimonMenuBar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PaimonMenuBar.entitlements; sourceTree = ""; }; 76D73BBE27EC650500CCDEA6 /* GameRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecord.swift; sourceTree = ""; }; 76D73BC027EC67D300CCDEA6 /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; - 76DD33FD27EF5CA400F0A563 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 76E429A827EDDE000032313C /* GameRecordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecordViewModel.swift; sourceTree = ""; }; 76E986B527EDD5FC004ECC6C /* MenuExtrasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuExtrasView.swift; sourceTree = ""; }; 76F9AE6B27F570640051CDC8 /* PaimonMenuBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PaimonMenuBar.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -82,8 +78,6 @@ 76CCDDE627EAD1C5009CFC64 /* PaimonMenuBar.entitlements */, 76CCDDE327EAD1C5009CFC64 /* Preview Content */, 76C290EF27EAFFB000A30C9F /* AppDelegate.swift */, - 76C2008F27EE124B0026D6CC /* MenuBarResinView.swift */, - 76DD33FD27EF5CA400F0A563 /* NetworkMonitor.swift */, 76D73BBE27EC650500CCDEA6 /* GameRecord.swift */, 76D73BC027EC67D300CCDEA6 /* Networking.swift */, ); @@ -207,9 +201,7 @@ 76D73BC127EC67D300CCDEA6 /* Networking.swift in Sources */, 76F9AE6D27F570D90051CDC8 /* UpdaterViewModel.swift in Sources */, 76CCDDE027EAD1C4009CFC64 /* SettingsView.swift in Sources */, - 76DD33FE27EF5CA400F0A563 /* NetworkMonitor.swift in Sources */, 76CCDDDE27EAD1C4009CFC64 /* PaimonMenuBarApp.swift in Sources */, - 76C2009027EE124B0026D6CC /* MenuBarResinView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -278,7 +270,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.3; + MACOSX_DEPLOYMENT_TARGET = 12.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -332,7 +324,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.3; + MACOSX_DEPLOYMENT_TARGET = 12.2; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; diff --git a/PaimonMenuBar/AppDelegate.swift b/PaimonMenuBar/AppDelegate.swift index d908525..8c0a723 100644 --- a/PaimonMenuBar/AppDelegate.swift +++ b/PaimonMenuBar/AppDelegate.swift @@ -10,12 +10,35 @@ import Foundation import SwiftUI final class AppDelegate: NSObject, NSApplicationDelegate { + + static private(set) var shared: AppDelegate! + + /** Must be called in the main thread to avoid race condition. */ + func updateStatusBar() { + assert(Thread.isMainThread) + + guard let button = statusItem.button else { return } + + button.imagePosition = NSControl.ImagePosition.imageLeading + button.image = NSImage(named:NSImage.Name("FragileResin")) + button.image?.isTemplate = true + button.image?.size.width = 19 + button.image?.size.height = 19 + + let gameRecord = GameRecordViewModel.shared.gameRecord + if gameRecord.retcode == nil { + button.title = "" // Cookie Not configured + } else { + button.title = String(gameRecord.data.current_resin) + } + + let currentExpeditionNum = gameRecord.data.current_expedition_num + // 271 = 299 (ViewHeight with Padding) - 28 + menuItemMain.frame = NSRect(x: 0, y: 0, width: 280, height: 271 + currentExpeditionNum * 28) + } + private var statusItem: NSStatusItem! - - private lazy var contentView: NSView? = { - let view = (statusItem.value(forKey: "window") as? NSWindow)?.contentView - return view - }() + private var menuItemMain: NSHostingView! @objc private func openSettingsView() { NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) @@ -25,10 +48,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_: Notification) { + AppDelegate.shared = self + // Update game record on initial launch - Task { - await GameRecordViewModel.shared.updateGameRecord() - } + print("App is started") + GameRecordViewModel.shared.tryUpdateGameRecord() // Close main APP window on initial launch NSApp.setActivationPolicy(.accessory) @@ -36,8 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { window.close() } - setupStatusItem() - setupMenus() + setupStatusBar() } func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { @@ -46,30 +69,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return false } - private func setupStatusItem() { - statusItem = NSStatusBar.system.statusItem(withLength: 100) - - let hostingView = NSHostingView(rootView: MenuBarResinView()) - hostingView.translatesAutoresizingMaskIntoConstraints = false - guard let contentView = contentView else { return } - contentView.addSubview(hostingView) - - NSLayoutConstraint.activate([ - hostingView.topAnchor.constraint(equalTo: contentView.topAnchor), - hostingView.rightAnchor.constraint(equalTo: contentView.rightAnchor), - hostingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - hostingView.leftAnchor.constraint(equalTo: contentView.leftAnchor), - ]) - } - - private func setupMenus() { + private func setupStatusBar() { let menu = NSMenu() // Main menu area, render view as NSHostingView + menuItemMain = NSHostingView(rootView: MenuExtrasView()) let menuItem = NSMenuItem() - GameRecordViewModel.shared.hostingView = NSHostingView(rootView: AnyView(MenuExtrasView())) - GameRecordViewModel.shared.hostingView?.frame = NSRect(x: 0, y: 0, width: 280, height: 425) - menuItem.view = GameRecordViewModel.shared.hostingView + menuItem.view = menuItemMain menu.addItem(menuItem) // Submenu, preferences, and quit APP @@ -81,6 +87,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { .addItem(NSMenuItem(title: String(localized: "Quit"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem.menu = menu + + updateStatusBar() } } diff --git a/PaimonMenuBar/Assets.xcassets/FragileResin.imageset/fragile_resin@1.png b/PaimonMenuBar/Assets.xcassets/FragileResin.imageset/fragile_resin@1.png index 8afabb76d8acfe0b0d55773855bcde09d28e00a2..4263b2a4823a5c1bb86d46b3b9294b52ea3e3672 100644 GIT binary patch literal 1049 zcmV+!1m^pRP)Xh0>Xu^s0zwUhWUGxu>W zva3WTAt~SLZqDzXbMALPVy%Vkx_Grn8KsO+!U+&+0W4T+45B{!+ z5A-I$0E3x7Wnq7({kS(vCjnq@;KbD;O?Ymu(xfkygZ0l|KA!idXJUBbkqrENqgl+P z-OtAkvnj1)NlLjZYzydc?gDj>6jy^!Y^3}zrpr*8UxK9#TYU1-o;QsQe~BW!uC-Qg zJU_T|>Dz&fv-EORzT&#rSw{f_IS+zW4|==OS5QT>VH5@T0&M{&rV2EbOm#Hk#?uQc z5ga?(Z!>8|l42h?mMSQ+G99Rdz?GTUs#aHz+Hlur&*%95=qc1LXMFq8%?T4|-JFw~G00QoeBU_-)i z4?D($7!ZOxJ1Tb(1#8iusk;>BbThKG2q8oYH2CRSJL2M0fn1(0 zdtZIGoVqk!ZeQB|DPMoTk_+na_Uv!BK0S5%LE>-%YfS{D5f%c~81@tIyQscZTCN&x zLW78mQ6_}QFz$>3v2Piv-$N4zZQ$hfvg7;7hku=~TyZ_ypYKnDGGI5FiZF*kwT97& z!Ss(|WG_*~S&ODg8B1}OkN0W67TJRh#qHIxE!0TCOBXt%;)@_Jc#iNyVt z243#)@F@2Mq%$sXN4l=#-#P$Co?k@-nR`kIn)NlH~advX9b>;E&cTWa}^ zGv-pt5`@x3>kC?I(OMfLV$*0HN~O)Y7w>d-PvE95d{bzxt-ZQ-0Rg*fSOCC3Lqh|L Tko3dN00000NkvXXu0mjfAo1YT literal 2301 zcmai0dsGuw9v)h#f?`|L0xFmdk3x~j0|QB#Kp_bvXfV74R1O=GNisk(F&QMhYEfLv zQBbV%2oaEqD*{z2tO!C2MRY|$Dxz}aA*)ii5TaJmqf2)ZC}`E)KPJh&-}n3O?|COV zf{-9@e(msqtC(0rsg9cwYtp^V4udG#brcPZuF_P$)*?Ay6Y%VmJUW z{4`3TC=o(&@sJc&F!7VkKj3k&n2C>~@QHjS3zEUyG!+z{77`&!OBB(>ct2k&hK7y_ z$RSjS)5wz)YPyDrH|f%`-^OhM9%rJUiA;PnUw~sFDhNjbDIgK=YlUN|#1eWKJ8;$* zTQTu66jjm*gp`yNFvSZ*R8j(oMxzmkWCEG&i7`CYsR~r6@l>dtj1n^%Y)CCq!AcZH z6gZ=%Fdj)pnRq;g<7UMn4Lq-|P|t>sC4yi?2qch5c$JRAl6TOJ$QyK}3_%gK3{lPn z@Y)e`@Ee9$`rdA$5h~wC=JWqIT`qqu3^mHxh9zP)sjvC;#{ueyR3$_RgVac}N(6DX zK?>B#lnG;A=mJ;+B}KDgIiyfy*=6EMv_F9{?*Lgy5~2#nnhL^Mi zkn*}wiVVU8-yI%d);WT4=>G7IL3qP|hKcaXlwx)>d)V}2*9c+m9>ErKHxZ=3t{)Y4 z&sg@RPyoOz3&tw*MnwR?Ek8NG$AcZZ?1G;c%PDZ#`SwwlHQ9EQx=@Kt@XvPlZbt?9ht?prBTilNuV>~!V#qNpmJ4+v*t11?EPuzZdb~wG~!H~Mmfmf}K z>0PwRRhk{N^V0b3bEBD^tfi5CSM4MDnVN)z9p69Qy1OgSXK-8Xqp|4*ZDnKCj_P0y z_j<0*P-N&x2sx9wxiOT_Da_umt9kL+azkzHg;ihH(HTAy&FMKvyTny*bj(Qel+q-<-PIby&c=ba0WwWM-*PJ{q-JP1gzu~~4 zEs>JLj1Rh;dHLV2>swysSXlHC6LjJa>3<(?)#iNpL-?R=_N7m|(h?U;ev*M~>K{7- zWvrZp_}re>n7)ChM-&d-;GRuM3%Y&EpZo7y(Hr+qCw++X$P@E~vGO}B zPT1;7mp`So?!KtaxW0T`@4s7ja`Q_w9~bG>+JrA+u@{D=k{hE2fF&Kq$Lx^Dl9d4P zo`}cxkD%t>JK!fD0#S zAr-$kA7%@zDm$z2+_s;ua6UV~{ic<+ujn6eNwag|U2rP%xsH9!wLjEwYsj{A_=T?l zcxfAuTQK$a)A2zoEi#28j z#6D|lJKds=((PT+qxI?bfYo-365V<$*AuI@^bF+ax4CPlC66QWn1{^A^dr3^L7&41 ze_wWbN#M>VI6cu>SC^{e*7QIBHQt7Iic)4sqQy3WFDz?HA|oE&yspXx8Kr|diyLd2 zxc6+La!#a^I$h#ME#j!@HgyhV&^PW=O2#ASZ)&wC3m@&TcRSZcE?&9Syee>5HtjIe4;o{iiXCw8W znY(w{`!s)l-?}y3KEuy9?^#|E0C;XZQtx+k*oK&!Y%$Up_woJ!^Iz6F@8|60bR4Xl z%q#A$Teo?oeyptgZ)Ik{?GmM7$oGt`QogqTFQtBhQOX(Es~+whQ^B}j?+_5-p>*P V{i*eLiN=2|JWdGvyMVv$_%HQ*d7}UT diff --git a/PaimonMenuBar/Assets.xcassets/FragileResin.imageset/fragile_resin@2.png b/PaimonMenuBar/Assets.xcassets/FragileResin.imageset/fragile_resin@2.png index c2d392234e53234edfabe77849c14444c9d69278..e65938acccf0f6ccb3752c7437abfc32996a6b09 100644 GIT binary patch literal 3025 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 TE+4I;00000NkvXXu0mjfE7sl- literal 3521 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 Q$Nv^}Gz`^?RqaFn1?EvncmMzZ diff --git a/PaimonMenuBar/GameRecord.swift b/PaimonMenuBar/GameRecord.swift index 56c200b..9b86bbe 100644 --- a/PaimonMenuBar/GameRecord.swift +++ b/PaimonMenuBar/GameRecord.swift @@ -8,7 +8,10 @@ import Foundation struct GameRecord: Codable { - var retcode: Int + /** + We specifically use nil to mark that this GameRecord is valid. The server will always present this field in the response. + */ + var retcode: Int? var message: String var data: GameData diff --git a/PaimonMenuBar/GameRecordViewModel.swift b/PaimonMenuBar/GameRecordViewModel.swift index eb9b463..58bf250 100644 --- a/PaimonMenuBar/GameRecordViewModel.swift +++ b/PaimonMenuBar/GameRecordViewModel.swift @@ -7,13 +7,16 @@ import Foundation import SwiftUI +import Network -let initGameRecord = GameRecord( - retcode: 0, message: "OK", +private let emptyGameRecord = GameRecord( + retcode: nil, // Indicate that this is a mock record + + message: "OK", data: GameData( current_resin: 0, max_resin: 160, resin_recovery_time: "0", finished_task_num: 0, total_task_num: 4, is_extra_task_reward_received: false, remain_resin_discount_num: 0, - resin_discount_num_limit: 3, current_expedition_num: 0, max_expedition_num: 5, + resin_discount_num_limit: 3, current_expedition_num: 1, max_expedition_num: 5, expeditions: [Expeditions(status: "Finished", avatar_side_icon: "", remained_time: "0")], current_home_coin: 0, max_home_coin: 2400, home_coin_recovery_time: "0", calendar_url: "", transformer: Transformer( @@ -23,53 +26,157 @@ let initGameRecord = GameRecord( ) ) +/** + This ViewModel also drives itself to update continuously. + **/ class GameRecordViewModel: ObservableObject { - // Shared GameRecordVM across the application + /** Singleton GameRecordVM across the application **/ static let shared = GameRecordViewModel() + + private var initialized = false - @Published var hostingView: NSHostingView? - @Published var gameRecord: GameRecord = initGameRecord { + /** The cached game record in userdefaults */ + @Published private(set) var gameRecord: GameRecord = emptyGameRecord { didSet { - // Save game record to userdefaults on change - saveGameRecord() + onGameRecordChanged() } } - - // Game record key saved in userdefaults - let gameRecordKey = "game_record" - - init() { - // Try to load game record from user defaults - if let data = UserDefaults.standard.data(forKey: gameRecordKey), - let decodedGameRecord = try? JSONDecoder().decode(GameRecord.self, from: data) - { - gameRecord = decodedGameRecord + + /** The record update interval set in userdefaults */ + // Note: Resin restores every 8 minutes + @Published var recordUpdateInterval: Double = 60 * 8 { + didSet { + onRecordUpdateIntervalChanged() } } - - func updateGameRecord() async -> GameRecord? { - print("Fetching data...") + + func updateGameRecordNow() async -> GameRecord? { if let data = await getGameRecord() { DispatchQueue.main.async { self.gameRecord = data - // Update hostingView frame height on gameRecord change - let currentExpeditionNum = data.data.current_expedition_num - print(currentExpeditionNum) - self.hostingView?.frame = NSRect(x: 0, y: 0, width: 280, height: 265 + currentExpeditionNum * 32) } return data } else { return nil } } + + private var lastUpdateAt: DispatchTime = DispatchTime.init(uptimeNanoseconds: 0) + private var updateTask: Task? + + /** + Unlike updateGameRecordNow, this is throttle-protected so that not each call will cause an update. + Also it will return immediately, schedules an update in the background. + + Must be called in the main thread to avoid race condition. + **/ + func tryUpdateGameRecord() { + assert(Thread.isMainThread) + + guard updateTask == nil else { + // If there is an on-flying request, skip. + print("Fetch skipped, there is on-flying request") + return + } + let now = DispatchTime.now() + if now.uptimeNanoseconds - lastUpdateAt.uptimeNanoseconds < 60*UInt64(1e9) { + // If last request is started within 1 minute, skip. + print("Fetch skipped, a fetch was performed recently") + return + } + lastUpdateAt = now + updateTask = Task { + _ = await updateGameRecordNow() + updateTask = nil + } + } + /** Must be called in the main thread to avoid race condition. */ func clearGameRecord() { - gameRecord = initGameRecord + assert(Thread.isMainThread) + + gameRecord = emptyGameRecord + } + + // MARK: - Self-Update the record when network is actve + + private let networkActivityMon = NWPathMonitor() + + private func startNetworkActivityUpdater() { + assert(Thread.isMainThread) + + networkActivityMon.pathUpdateHandler = { [weak self] path in + if path.status != .satisfied { + return + } + print("Network is active") + self?.tryUpdateGameRecord() + } + networkActivityMon.start(queue: DispatchQueue.main) + } + + // MARK: - + + // MARK: - Self-Update the record according to the interval + + private var updateTimer: Timer? + + private func resetUpdateTimer() { + assert(Thread.isMainThread) + + if updateTimer != nil { + updateTimer?.invalidate() + } + updateTimer = Timer.scheduledTimer(withTimeInterval: recordUpdateInterval, repeats: true) { timer in + print("Scheduled update is triggered") + self.tryUpdateGameRecord() + } } + + // MARK: - - func saveGameRecord() { + /** Key to access userdefaults **/ + private let recordKeyGameRecord = "game_record" + private let recordKeySelfUpdateInterval = "update_interval" + + init() { + // Try to load game record from user defaults + if let data = UserDefaults.standard.data(forKey: recordKeyGameRecord), + let decodedGameRecord = try? JSONDecoder().decode(GameRecord.self, from: data) + { + gameRecord = decodedGameRecord + } + + if let interval = UserDefaults.standard.object(forKey: recordKeySelfUpdateInterval) as? Double { + recordUpdateInterval = interval + } + + initialized = true + + startNetworkActivityUpdater() + resetUpdateTimer() + } + + private func onGameRecordChanged() { + assert(Thread.isMainThread) + + guard initialized else { return } // Ignore any value change when init is not finished + + print("GameRecord is updated\n", gameRecord) if let encodedGameRecord = try? JSONEncoder().encode(gameRecord) { - UserDefaults.standard.set(encodedGameRecord, forKey: gameRecordKey) + UserDefaults.standard.set(encodedGameRecord, forKey: recordKeyGameRecord) } + + AppDelegate.shared.updateStatusBar() + } + + private func onRecordUpdateIntervalChanged() { + assert(Thread.isMainThread) + + guard initialized else { return } // Ignore any value change when init is not finished + + print("SelfUpdateInterval is changed to", recordUpdateInterval) + UserDefaults.standard.set(recordUpdateInterval, forKey: recordKeySelfUpdateInterval) + resetUpdateTimer() } } diff --git a/PaimonMenuBar/MenuBarResinView.swift b/PaimonMenuBar/MenuBarResinView.swift deleted file mode 100644 index bc01218..0000000 --- a/PaimonMenuBar/MenuBarResinView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// MenuBarResinView.swift -// PaimonMenuBar -// -// Created by Spencer Woo on 2022/3/25. -// - -import Combine -import SwiftUI - -struct MenuBarResinView: View { - @StateObject var gameRecordVM = GameRecordViewModel.shared - @ObservedObject var monitor = NetworkMonitor() - @AppStorage("update_interval") private var updateInterval: Double = 60 * 8 // Resin restores every 8 minutes - - // Init timer before view appears - @State var timer = Timer.publish(every: 60, tolerance: 10, on: .main, in: .common).autoconnect() - - var body: some View { - HStack(spacing: 4) { - Image("FragileResin") - .resizable() - .frame(width: 19, height: 19) - Text("\(gameRecordVM.gameRecord.data.current_resin)/\(gameRecordVM.gameRecord.data.max_resin)") - .font(.system(.body, design: .monospaced).bold()) - } - .onAppear { - // Update timer based on saved updateInterval after view loads - self.timer = Timer.publish(every: updateInterval, tolerance: 10, on: .main, in: .common).autoconnect() - } - .onReceive(timer) { _ in - Task { - await gameRecordVM.updateGameRecord() - } - } - .onChange(of: updateInterval) { interval in - // Update timer when updateInterval changes in settings - timer = Timer.publish(every: interval, tolerance: 10, on: .main, in: .common).autoconnect() - } - .onChange(of: monitor.isConnected) { connected in - Task { - // Update game record when application reconnects to the internet - if connected { - let _ = await gameRecordVM.updateGameRecord() - } - } - } - } -} diff --git a/PaimonMenuBar/MenuExtrasView.swift b/PaimonMenuBar/MenuExtrasView.swift index f274292..150dde8 100644 --- a/PaimonMenuBar/MenuExtrasView.swift +++ b/PaimonMenuBar/MenuExtrasView.swift @@ -71,8 +71,8 @@ struct MenuExtrasView: View { ParametricTransformerView(transformer: gameRecordVM.gameRecord.data.transformer) } - .padding([.horizontal, .top]) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding([.horizontal]) + .padding([.vertical], 8) } } @@ -121,23 +121,23 @@ struct ExpeditionView: View { let currentExpeditionNum: Int var body: some View { - HStack { - Text("Expeditions \(currentExpeditionNum)/\(maxExpeditionNum)") - .font(.subheadline) - .opacity(0.6) - Spacer() - } - - VStack { + VStack(spacing: 8) { + HStack { + Text("Expeditions \(currentExpeditionNum)/\(maxExpeditionNum)") + .font(.subheadline) + .opacity(0.6) + Spacer() + } + ForEach(expeditions, id: \.self) { expedition in ExpeditionItemView( status: expedition.status, avatar: expedition.avatar_side_icon, remainedTime: expedition.remained_time ) } + + Divider() } - - Divider() } } diff --git a/PaimonMenuBar/NetworkMonitor.swift b/PaimonMenuBar/NetworkMonitor.swift deleted file mode 100644 index 8d575fd..0000000 --- a/PaimonMenuBar/NetworkMonitor.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// NetworkMonitor.swift -// PaimonMenuBar -// -// Created by Spencer Woo on 2022/3/26. -// - -import Foundation -import Network - -final class NetworkMonitor: ObservableObject { - let monitor = NWPathMonitor() - let queue = DispatchQueue(label: "Monitor") - - @Published var isConnected = true - - init() { - monitor.pathUpdateHandler = { [weak self] path in - DispatchQueue.main.async { - self?.isConnected = path.status == .satisfied ? true : false - } - } - monitor.start(queue: queue) - } -} diff --git a/PaimonMenuBar/Networking.swift b/PaimonMenuBar/Networking.swift index aef2944..91c9711 100644 --- a/PaimonMenuBar/Networking.swift +++ b/PaimonMenuBar/Networking.swift @@ -45,6 +45,8 @@ func getGameRecord() async -> GameRecord? { let server: String = UserDefaults.standard.string(forKey: "server"), let cookie: String = UserDefaults.standard.string(forKey: "cookie") else { return nil } + + print("Fetching game record data...", uid, server) let api = isCnServer(server: server) ? apiCn : apiGlobal guard let url = URL(string: "\(api)?role_id=\(uid)&server=\(server)") else { return nil } diff --git a/PaimonMenuBar/PaimonMenuBar.entitlements b/PaimonMenuBar/PaimonMenuBar.entitlements index 82d7078..95c1bcf 100644 --- a/PaimonMenuBar/PaimonMenuBar.entitlements +++ b/PaimonMenuBar/PaimonMenuBar.entitlements @@ -8,8 +8,6 @@ com.apple.security.network.client - com.apple.security.network.server - com.apple.security.temporary-exception.mach-lookup.global-name $(PRODUCT_BUNDLE_IDENTIFIER)-spks diff --git a/PaimonMenuBar/SettingsView.swift b/PaimonMenuBar/SettingsView.swift index c9e77ff..a81ddf1 100644 --- a/PaimonMenuBar/SettingsView.swift +++ b/PaimonMenuBar/SettingsView.swift @@ -20,7 +20,7 @@ struct CheckForUpdatesView: View { } struct PreferenceSettingsView: View { - @AppStorage("update_interval") private var updateInterval: Double = 60 * 8 // Resin restores every 6 minutes + @StateObject var gameRecordVM = GameRecordViewModel.shared @StateObject var updaterViewModel = UpdaterViewModel.shared @@ -39,14 +39,14 @@ struct PreferenceSettingsView: View { Text("Current version: \(Bundle.main.appVersion ?? "") (\(Bundle.main.buildNumber ?? ""))") .font(.caption).opacity(0.6) - Slider(value: $updateInterval, in: 60 ... 16 * 60, step: 60, label: { + Slider(value: $gameRecordVM.recordUpdateInterval, in: 60 ... 16 * 60, step: 60, label: { Text("Update interval:") }) { editing in isEditing = editing } .frame(width: 400) - Text("Paimon fetches data every \(updateInterval, specifier: "%.0f") seconds*") + Text("Paimon fetches data every \(gameRecordVM.recordUpdateInterval, specifier: "%.0f") seconds*") .font(.caption).opacity(0.6) } @@ -114,7 +114,7 @@ struct ConfigurationSettingsView: View { Button { Task { isLoading = true - if let _ = await GameRecordViewModel.shared.updateGameRecord() { + if let _ = await GameRecordViewModel.shared.updateGameRecordNow() { self.alertText = String(localized: "👌 It's working!") self.alertMessage = String(localized: "Your config is valid.") self.showConfigValidAlert.toggle()