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

Commit

Permalink
Merge pull request #4 from breezewish/use-native-view
Browse files Browse the repository at this point in the history
  • Loading branch information
spencerwooo authored Apr 30, 2022
2 parents 9266980 + 7c80e25 commit d9aaaa8
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 158 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
68 changes: 38 additions & 30 deletions PaimonMenuBar/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,34 @@ import Foundation
import SwiftUI

final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private(set) static 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 // This sets the resin icon in the statusbar as monochrome
button.image?.size.width = 14
button.image?.size.height = 14

let gameRecord = GameRecordViewModel.shared.gameRecord
if gameRecord.retcode == nil {
button.title = "" // Cookie Not configured
} else {
button.title = "\(gameRecord.data.current_resin)/\(gameRecord.data.max_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 lazy var contentView: NSView? = {
let view = (statusItem.value(forKey: "window") as? NSWindow)?.contentView
return view
}()
private var statusItem: NSStatusItem!
private var menuItemMain: NSHostingView<MenuExtrasView>!

@objc private func openSettingsView() {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
Expand All @@ -25,19 +47,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 +68,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 +86,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
156 changes: 130 additions & 26 deletions PaimonMenuBar/GameRecordViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
//

import Foundation
import Network
import SwiftUI

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,154 @@ 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()

@Published var hostingView: NSHostingView<AnyView>?
@Published var gameRecord: GameRecord = initGameRecord {
private var initialized = false

/** 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, 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 = .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
}

func saveGameRecord() {
// 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: - 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) { _ in
print("Scheduled update is triggered")
self.tryUpdateGameRecord()
}
}

// MARK: -

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

1 comment on commit d9aaaa8

@vercel
Copy link

@vercel vercel bot commented on d9aaaa8 Apr 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.