Skip to content
This repository has been archived by the owner on Jan 23, 2024. It is now read-only.

Commit

Permalink
fix: use native view in NSStatusItem
Browse files Browse the repository at this point in the history
  • Loading branch information
breezewish committed Apr 30, 2022
1 parent 6a32eff commit 2b3d844
Show file tree
Hide file tree
Showing 13 changed files with 198 additions and 161 deletions.
12 changes: 2 additions & 10 deletions PaimonMenuBar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@
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 */; };
76CCDDE227EAD1C5009CFC64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 76CCDDE127EAD1C5009CFC64 /* Assets.xcassets */; };
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 */; };
Expand All @@ -30,7 +28,6 @@
7621675627F2FC0B0023F8B2 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
7686474027EF082400BCC350 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
76B3F03127F2B76100833555 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
76C2008F27EE124B0026D6CC /* MenuBarResinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarResinView.swift; sourceTree = "<group>"; };
76C290EF27EAFFB000A30C9F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
76CCDDDD27EAD1C4009CFC64 /* PaimonMenuBarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaimonMenuBarApp.swift; sourceTree = "<group>"; };
76CCDDDF27EAD1C4009CFC64 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
Expand All @@ -39,7 +36,6 @@
76CCDDE627EAD1C5009CFC64 /* PaimonMenuBar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PaimonMenuBar.entitlements; sourceTree = "<group>"; };
76D73BBE27EC650500CCDEA6 /* GameRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecord.swift; sourceTree = "<group>"; };
76D73BC027EC67D300CCDEA6 /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = "<group>"; };
76DD33FD27EF5CA400F0A563 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
76E429A827EDDE000032313C /* GameRecordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecordViewModel.swift; sourceTree = "<group>"; };
76E986B527EDD5FC004ECC6C /* MenuExtrasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuExtrasView.swift; sourceTree = "<group>"; };
76F9AE6B27F570640051CDC8 /* PaimonMenuBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PaimonMenuBar.app; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -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 */,
);
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
69 changes: 39 additions & 30 deletions PaimonMenuBar/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<MenuExtrasView>!

@objc private func openSettingsView() {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
Expand All @@ -25,19 +48,19 @@ 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)
if let window = NSApplication.shared.windows.first {
window.close()
}

setupStatusItem()
setupMenus()
setupStatusBar()
}

func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
Expand All @@ -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
Expand All @@ -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()
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion PaimonMenuBar/GameRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 135 additions & 28 deletions PaimonMenuBar/GameRecordViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<AnyView>?
@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<Void, Never>?

/**
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()
}
}
Loading

0 comments on commit 2b3d844

Please sign in to comment.