Will I get banned from the game?
No, I seriously do not think simply reading from an API (outside the
game) constitutes as cheating.
I don't see a window? Where is the APP?
It is a menu bar APP, check for resin icons that should appear in
your menu bar.
Can I configure the refresh rate / data fetching frequency / polling
interval?
Yes - under Preferences.
Can I revert back to the original colored resin icon?
Yes - use either one, configured under Preferences.
It is not working! Why?
Most often it is your cookie problem. You need to make sure that you
have turned on Real-Time Notes under{' '}
Battle Chronicle inside{' '}
HoYoLAB
{' '}
or 实时便签 inside 米游社, depending on your server. You
would also have to set your profile to public (instructions for{' '}
HoYoLAB
{' '}
(a bit futher down the page) /{' '}
米游社
).
)
export default Faq
================================================
FILE: Docs/components/Features.tsx
================================================
import Image, { StaticImageData } from "next/image"
import daily from '../images/daily.png'
import expedition from '../images/expedition.png'
import fragileResin from '../images/fragile-resin.png'
import weeklyBoss from '../images/weekly-boss.png'
import jarOfRiches from '../images/jar-of-riches.png'
import parametric from '../images/parametric-transformer.png'
const IconCard = ({
icon,
label,
style,
}: {
icon: StaticImageData
label: string
style: string
}) => (
Monitor your daily expeditions and real-time realm currency.
Remind you about your daily commissions and weekly boss fights.
And notify you when your parametric transformer is ready to use.
Basically, Paimon lives in your macOS menu bar quietly, and offers you a
nice way of monitoring your in-game real-time stats when you need to
check them.
Cookies are sensitive information, and in some scenarios they function
as your login credentials. Paimon requires your cookie so that Paimon
can request said API on your behalf, and fetch those in-game stats
periodically.
Paimon will never-ever-ever-ever ask for your credentials to
Genshin Impact nor any account! The cookie is{' '}
only stored locally.
>
)
}
export default ReleaseInfo
================================================
FILE: Docs/components/Screenshot3D.tsx
================================================
import Image from "next/image"
import Atropos from 'atropos/react'
import screenshot from '../images/screenshot-transparent-light.png'
const Screenshot3D = () => (
)
export default Screenshot3D
================================================
FILE: Docs/next-env.d.ts
================================================
///
///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
================================================
FILE: Docs/next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
================================================
FILE: Docs/package.json
================================================
{
"name": "docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@fontsource/mulish": "^4.5.14",
"atropos": "^1.0.2",
"next": "13.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.8.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@types/node": "18.15.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"autoprefixer": "^10.4.14",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.7",
"typescript": "4.9.5"
}
}
================================================
FILE: Docs/pages/_app.tsx
================================================
import '../styles/globals.css'
import 'atropos/css'
import '@fontsource/mulish/400.css'
import '@fontsource/mulish/700.css'
import type { AppProps } from 'next/app'
function MyApp({ Component, pageProps }: AppProps) {
return
}
export default MyApp
================================================
FILE: Docs/pages/api/hello.ts
================================================
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
type Data = {
name: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.status(200).json({ name: 'Paimon says hi!' })
}
================================================
FILE: Docs/pages/index.tsx
================================================
import type { GetStaticProps } from 'next'
import type { AppReleaseData } from './types'
import Image from "next/image"
import Features from '../components/Features'
import PaimonCan from '../components/PaimonCan'
import PaimonUses from '../components/PaimonUses'
import PaimonCookie from '../components/PaimonCookie'
import HowToGetMyCookie from '../components/HowToGetMyCookie'
import Footer from '../components/Footer'
import Hero from '../components/Hero'
import Meta from '../components/Head'
import hutaoBackground from '../images/hutao-bg.jpg'
import AvailableNow from '../components/AvailableNow'
import Faq from '../components/Faq'
const Home = ({ latest }: { latest: AppReleaseData }) => {
return <>
>;
}
export const getStaticProps: GetStaticProps = async () => {
const resp = await fetch(
'https://api.github.com/repos/spencerwooo/PaimonMenuBar/releases/latest'
)
const latest = (await resp.json()) as AppReleaseData
return { props: { latest }, revalidate: 5 * 60 }
}
export default Home
================================================
FILE: Docs/pages/types.d.ts
================================================
export type AppReleaseData = {
html_url: string
tag_name: string
name: string
published_at: string
assets: Array<{
size: number
download_count: number
browser_download_url: string
}>
reactions?: {
total_count: number
'+1': number
'-1': number
laugh: number
confused: number
heart: number
hooray: number
rocket: number
eyes: number
}
}
================================================
FILE: Docs/postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
================================================
FILE: Docs/public/site.webmanifest
================================================
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
================================================
FILE: Docs/styles/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #2c3740;
}
================================================
FILE: Docs/tailwind.config.js
================================================
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
"mulish": ["Mulish", ...defaultTheme.fontFamily.sans],
}
},
},
plugins: [require('@tailwindcss/typography')],
}
================================================
FILE: Docs/tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 Spencer Woo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: PaimonMenuBar/AppDelegate.swift
================================================
//
// AppDelegate.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/23.
//
import AppKit
import Defaults
import Foundation
import SwiftUI
import UserNotifications
final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) static var shared: AppDelegate!
private var statusItem: NSStatusItem!
private var statusButton: NSStatusBarButton!
private var menuItemMain: NSHostingView!
/** updateStatusBar and updateStatusIcon must be called in the main thread to avoid race condition. */
func updateStatusBar() {
assert(Thread.isMainThread)
updateStatusIcon()
updateStatusButtonTitle()
let gameRecord = Defaults[.lastGameRecord]
let currentExpeditionNum = gameRecord.data.current_expedition_num
menuItemMain.frame = NSRect(x: 0, y: 0, width: 290, height: 292 + currentExpeditionNum * 36)
}
func updateStatusIcon() {
assert(Thread.isMainThread)
statusButton.imagePosition = NSControl.ImagePosition.imageLeading
statusButton.image = NSImage(named: NSImage.Name("FragileResin"))
// This sets the resin icon in the statusbar as monochrome if isTemplate == true
statusButton.image?.isTemplate = Defaults[.isStatusIconTemplate]
statusButton.image?.size.width = 19
statusButton.image?.size.height = 19
}
func updateStatusButtonTitle() {
if !Defaults[.isShowResinText] {
statusButton.title = ""
return
}
let gameRecord = Defaults[.lastGameRecord]
if gameRecord.fetchAt == nil {
statusButton.title = "-/160" // Cookie Not configured
} else {
statusButton.title = "\(gameRecord.data.current_resin)/\(gameRecord.data.max_resin)"
}
}
@objc private func openSettingsView() {
if #available(macOS 13, *) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
} else {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NSApp.windows.first?.makeKeyAndOrderFront(self)
}
func applicationDidFinishLaunching(_: Notification) {
AppDelegate.shared = self
// Request notification permissions
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, error in
if error != nil {
print("Notification permission not granted.")
} else {
print("Notification permission granted.")
}
}
// Update game record on initial launch
print("App is started")
GameRecordUpdater.shared.tryFetchGameRecordAndRender()
// Close main APP window on initial launch
NSApp.setActivationPolicy(.accessory)
if let window = NSApplication.shared.windows.first {
window.close()
}
setupStatusBar()
}
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
// Hide app icon in dock after all windows are closed
NSApp.setActivationPolicy(.accessory)
return false
}
private func setupStatusBar() {
let menu = NSMenu()
// Main menu area, render view as NSHostingView
menuItemMain = NSHostingView(rootView: MenuExtrasView())
let menuItem = NSMenuItem()
menuItem.view = menuItemMain
menu.addItem(menuItem)
// Submenu, preferences, and quit APP
menu.addItem(NSMenuItem.separator())
menu
.addItem(NSMenuItem(title: String.localized("Preferences"), action: #selector(openSettingsView),
keyEquivalent: ","))
menu
.addItem(NSMenuItem(title: String.localized("Quit"), action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q"))
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.menu = menu
statusButton = statusItem.button
updateStatusBar()
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/AccentColor.colorset/Contents.json
================================================
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/AppIcon.appiconset/Contents.json
================================================
{
"images" : [
{
"filename" : "hutao16@1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "hutao16@2.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "hutao16@2-1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "hutao@64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "hutao@128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "hutao@256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "hutao@256-1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "hutao@512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "hutao@512-1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "hutao.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/Commision.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "commision.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/Contents.json
================================================
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/Domain.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "Domain.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Domain@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Domain@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/Expedition.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "Expedition.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Expedition@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Expedition@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/FragileResin.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "fragile_resin@1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "fragile_resin@2.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "fragile_resin@3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/JarOfRiches.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "JarOfRiches.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "JarOfRiches@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "JarOfRiches@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/KokomiSad.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "kokomi_sad.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "kokomi_sad@2.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "kokomi_sad@3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Assets.xcassets/ParametricTransformer.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "ParametricTransformer@1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ParametricTransformer@2.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ParametricTransformer@3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/Bundle.swift
================================================
//
// Bundle.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/26.
//
import Foundation
extension Bundle {
var appName: String? {
return object(forInfoDictionaryKey: "CFBundleName") as? String
}
var appVersion: String? {
return object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
}
var buildNumber: String? {
return object(forInfoDictionaryKey: "CFBundleVersion") as? String
}
}
================================================
FILE: PaimonMenuBar/Compatibility.swift
================================================
//
// Compatibility.swift
// PaimonMenuBar
//
// Created by DreamPiggy on 2022/5/6.
//
import Foundation
import SwiftUI
extension URLSession {
@available(macOS, deprecated: 12.0, message: "This extension is no longer necessary. Use API built into SDK")
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await withCheckedThrowingContinuation { continuation in
let task = self.dataTask(with: request) { data, response, error in
guard let data = data, let response = response else {
let error = error ?? URLError(.badServerResponse)
return continuation.resume(throwing: error)
}
continuation.resume(returning: (data, response))
}
task.resume()
}
}
}
extension String {
@available(macOS, deprecated: 12.0, message: "This extension is no longer necessary. Use API built into SDK")
static func localized(
_ keyAndValue: LocalizedStringKey,
table: String? = nil,
bundle: Bundle? = nil,
locale: Locale = .current,
comment: StaticString? = nil
) -> String {
var language = "en"
// Region: CN
// ID: zh-Hans-CN
// Need: zh-Hans
if let localRegion = locale.regionCode, let localID = locale.collatorIdentifier,
let range = localID.range(of: localRegion)
{
language = String(localID[.. Bool in
let (label, _) = arg0
if label == "key" {
return true
}
return false
}
guard let key = attributeLabelAndValue?.value as? String else {
fatalError()
}
return NSLocalizedString(key, tableName: table, bundle: localBundle, value: "", comment: "\(comment ?? "")")
}
}
extension Date {
private static let shortenedFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
private static let defaultFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
var shortenedFormatted: String {
return Date.shortenedFormatter.string(from: self)
}
var defaultFormatted: String {
return Date.defaultFormatter.string(from: self)
}
}
================================================
FILE: PaimonMenuBar/DataModels.swift
================================================
//
// DataModels.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/24.
//
import Defaults
import Foundation
struct GameRecord: Codable, Defaults.Serializable {
/**
This field is not returned by server. Instead, it is set by us after fetching.
When this field is not present, it means the record is not a real record (e.g. empty record).
*/
var fetchAt: Date?
var retcode: Int
var message: String
var data: GameData
static let empty = GameRecord(
fetchAt: nil, // Indicate an empty record
retcode: 0,
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: 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(
obtained: true,
recovery_time: RecoveryTime(Day: 0, Hour: 0, Minute: 0, Second: 0, reached: false), wiki: ""
)
)
)
}
struct GameData: Codable, Defaults.Serializable {
var current_resin: Int
var max_resin: Int
var resin_recovery_time: String
var finished_task_num: Int
var total_task_num: Int
var is_extra_task_reward_received: Bool
var remain_resin_discount_num: Int
var resin_discount_num_limit: Int
var current_expedition_num: Int
var max_expedition_num: Int
var expeditions: [Expeditions]
var current_home_coin: Int
var max_home_coin: Int
var home_coin_recovery_time: String
var calendar_url: String
var transformer: Transformer
}
struct Expeditions: Codable, Hashable, Defaults.Serializable {
var status: String
var avatar_side_icon: String
var remained_time: String
}
struct Transformer: Codable, Defaults.Serializable {
var obtained: Bool
var recovery_time: RecoveryTime
var wiki: String
}
struct RecoveryTime: Codable, Defaults.Serializable {
var Day: Int
var Hour: Int
var Minute: Int
var Second: Int
var reached: Bool
}
enum GenshinServer: String, CaseIterable, Identifiable, Defaults.Serializable {
case cn_gf01 // 天空岛
case cn_qd01 // 世界树
case os_usa // Global (NA)
case os_euro // Global (EU)
case os_asia // Global (Asia)
case os_cht // Global (SAR)
var id: String { rawValue }
var serverName: String {
switch self {
case .cn_gf01:
return "天空岛"
case .cn_qd01:
return "世界树"
case .os_usa:
return "NA"
case .os_euro:
return "EU"
case .os_asia:
return "Asia"
case .os_cht:
return "SAR"
}
}
var isCNServer: Bool {
switch self {
case .cn_gf01, .cn_qd01:
return true
default:
return false
}
}
var cookieSiteUrl: String {
switch self {
case .cn_gf01, .cn_qd01:
return "https://bbs.mihoyo.com/ys"
case .os_usa, .os_euro, .os_asia, .os_cht:
return "https://www.hoyolab.com/home"
}
}
}
================================================
FILE: PaimonMenuBar/Defaults+Workaround.swift
================================================
// See https://github.com/sindresorhus/Defaults/blob/main/workaround.md
import Defaults
import Foundation
public extension Defaults.Serializable where Self: Codable {
static var bridge: Defaults.TopLevelCodableBridge { Defaults.TopLevelCodableBridge() }
}
public extension Defaults.Serializable where Self: Codable & NSSecureCoding {
static var bridge: Defaults.CodableNSSecureCodingBridge { Defaults.CodableNSSecureCodingBridge() }
}
public extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding {
static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() }
}
public extension Defaults.Serializable where Self: Codable & RawRepresentable {
static var bridge: Defaults.RawRepresentableCodableBridge { Defaults.RawRepresentableCodableBridge() }
}
public extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable {
static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() }
}
public extension Defaults.Serializable where Self: RawRepresentable {
static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() }
}
public extension Defaults.Serializable where Self: NSSecureCoding {
static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() }
}
public extension Defaults.CollectionSerializable where Element: Defaults.Serializable {
static var bridge: Defaults.CollectionBridge { Defaults.CollectionBridge() }
}
public extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable {
static var bridge: Defaults.SetAlgebraBridge { Defaults.SetAlgebraBridge() }
}
================================================
FILE: PaimonMenuBar/Defaults.swift
================================================
//
// Defaults.swift
// PaimonMenuBar
//
// Created by Breezewish on 2022/4/30.
//
import Defaults
import Foundation
extension Defaults.Keys {
static let uid = Key("uid", default: "")
static let server = Key("server", default: .cn_gf01)
static let cookie = Key("cookie", default: "")
// render the icon in the status menu view as template (white icon) or original (colored icon)
static let isStatusIconTemplate = Key("is_status_icon_template", default: true)
// whether or not to render the text next to the resin icon
static let isShowResinText = Key("is_show_resin_text", default: true)
// notify on parametric transformer ready
static let isNotifyParametricReady = Key("is_notify_parametric_ready", default: true)
// store a state of whether the notification has been sent, to avoid duplicated notifications
static let hasNotifiedParametricReady = Key("has_notified_parametric_ready", default: false)
// update every 2 hours to prevent captchas
static let recordUpdateInterval = Key("update_interval", default: 2)
// if the API encounters a failure (fetch failed, mostly because of the new captcha) ...
static let fetchFailed = Key("fetch_failed", default: false)
static let lastGameRecord = Key("game_record", default: GameRecord.empty)
}
================================================
FILE: PaimonMenuBar/GameRecordUpdater.swift
================================================
//
// GameRecordUpdater.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/25.
//
import Combine
import Defaults
import Foundation
import Network
import SwiftUI
import UserNotifications
class GameRecordUpdater {
static let shared = GameRecordUpdater()
/**
Fetch latest game record. After finished, UI will be updated accordingly.
*/
func fetchGameRecordAndRenderNow() async -> GameRecord? {
if let data = await getGameRecord() {
DispatchQueue.main.async {
Defaults[.lastGameRecord] = data
Defaults[.fetchFailed] = false
}
return data
} else {
// sendLocalNotification(context: "⚠️ Data fetch failed, check your configuration") {}
/**
The new API is more likely to fail due to captcha. Instead of sending notification
each time fetch fails, we update the UI when we encounter the captcha instead.
*/
DispatchQueue.main.async {
Defaults[.fetchFailed] = true
}
return nil
}
}
private var lastUpdateAt: DispatchTime = .init(uptimeNanoseconds: 0)
private var updateTask: Task?
/**
Unlike fetchGameRecordAndRenderNow, 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 tryFetchGameRecordAndRender() {
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 < 8 * 60 * UInt64(1e9) {
// If last request is started within 8 minutes, skip.
print("Fetch skipped, a fetch was performed recently")
return
}
print("Try to update game record now")
lastUpdateAt = now
updateTask = Task {
_ = await fetchGameRecordAndRenderNow()
updateTask = nil
}
}
/** Must be called in the main thread to avoid race condition. */
func clearGameRecord() {
assert(Thread.isMainThread)
Defaults[.lastGameRecord] = GameRecord.empty
}
// 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?.tryFetchGameRecordAndRender()
}
networkActivityMon.start(queue: DispatchQueue.main)
}
// MARK: - Make a request to the remote API and update the record according to the interval
private var apiUpdateTimer: Timer?
private func resetApiUpdateTimer() {
assert(Thread.isMainThread)
if apiUpdateTimer != nil {
apiUpdateTimer?.invalidate()
}
apiUpdateTimer = Timer
.scheduledTimer(withTimeInterval: Defaults[.recordUpdateInterval] * 3600, repeats: true) { _ in
print("Scheduled update is triggered")
self.tryFetchGameRecordAndRender()
}
resetLocalUpdateTimer()
}
// MARK: - Self-Update the record according to the interval (which is 8 mins)
private var localUpdateTimer: Timer?
private func resetLocalUpdateTimer() {
assert(Thread.isMainThread)
if localUpdateTimer != nil {
localUpdateTimer?.invalidate()
}
localUpdateTimer = Timer.scheduledTimer(withTimeInterval: 8 * 60, repeats: true, block: { _ in
print("Local updater triggered.")
guard self.updateTask == nil else {
// If there is an on-flying network request, then skip.
print("Local updater skipped, there is on-flying request")
return
}
var gameRecord = Defaults[.lastGameRecord]
guard gameRecord.fetchAt != nil else {
print("Local updater skipped as there has never been an update from the API")
return
}
// Update resin and recovery time
if gameRecord.data.current_resin < gameRecord.data.max_resin {
gameRecord.data.current_resin += 1
}
let resinRecoveryTime = Int(gameRecord.data.resin_recovery_time) ?? 0
if resinRecoveryTime > 0 {
let updatedTime = resinRecoveryTime - 8 * 60
gameRecord.data
.resin_recovery_time =
String(updatedTime > 0 ? updatedTime : 0)
}
// Update expedition and their status
for (index, expedition) in gameRecord.data.expeditions.enumerated() {
let expeditionRemainedTime = Int(expedition.remained_time) ?? 0
if expeditionRemainedTime > 0 {
let updatedRemainedTime = expeditionRemainedTime - 8 * 60
gameRecord.data.expeditions[index]
.remained_time = String(updatedRemainedTime > 0 ? updatedRemainedTime : 0)
if updatedRemainedTime <= 0 {
gameRecord.data.expeditions[index].status = "Finished"
}
}
}
Defaults[.lastGameRecord] = gameRecord
})
}
// MARK: - Record update at midnight to avoid today or tomorrow conflicts
private func setupDayChangeUpdater() {
assert(Thread.isMainThread)
NotificationCenter.default.addObserver(forName: .NSCalendarDayChanged, object: nil, queue: .main) { _ in
print("Day change (midnight) update is triggered")
self.tryFetchGameRecordAndRender()
}
}
// MARK: - Notification handler
private func sendLocalNotification(context: LocalizedStringKey, completion: @escaping () -> Void) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional
else { return }
let content = UNMutableNotificationContent()
content.title = String.localized(context)
content.sound = UNNotificationSound.default
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
)
center.add(request) { _ in
completion()
}
}
}
// MARK: -
private var initialized = false
init() {
startNetworkActivityUpdater()
resetApiUpdateTimer()
// resetLocalUpdateTimer()
setupDayChangeUpdater()
Defaults.observe(.recordUpdateInterval) { _ in
self.onRecordUpdateIntervalChanged()
}.tieToLifetime(of: self)
Defaults.observe(.lastGameRecord) { _ in
self.onGameRecordChanged()
}.tieToLifetime(of: self)
initialized = true
}
private func onGameRecordChanged() {
assert(Thread.isMainThread)
guard initialized else { return }
print("GameRecord is updated:", Defaults[.lastGameRecord])
AppDelegate.shared.updateStatusBar()
// Early return if user chooses not to push notifications
guard Defaults[.isNotifyParametricReady] else { return }
let parametricTransformerReady = Defaults[.lastGameRecord].data.transformer.recovery_time.reached
/**
Check for parametric transformer ready state - push notification only on:
1. Parametric transformer ready
2. User selected to notify when parametric transformer is ready
3. Notification for parametric transformer ready state has not been sent
*/
if parametricTransformerReady,
Defaults[.hasNotifiedParametricReady] == false
{
sendLocalNotification(context: "Parametric transformer is ready") {
// set .hasNotifiedParametricReady to true on notification delivery
Defaults[.hasNotifiedParametricReady] = true
}
}
// If parametric transformer is not ready but .hasNotifiedParametricReady is true, then reset the trigger
if parametricTransformerReady == false,
Defaults[.hasNotifiedParametricReady]
{
Defaults[.hasNotifiedParametricReady] = false
}
}
private func onRecordUpdateIntervalChanged() {
assert(Thread.isMainThread)
guard initialized else { return }
print("RecordUpdateInterval is changed: ", Defaults[.recordUpdateInterval])
resetApiUpdateTimer()
}
}
================================================
FILE: PaimonMenuBar/Info.plist
================================================
SUEnableInstallerLauncherServiceSUFeedURLhttps://raw.githubusercontent.com/spencerwooo/PaimonMenuBar/main/appcast.xmlSUPublicEDKeyAhJieofGr0gFEypC7KPkg3f37YZ2Pj9lo2+reSx1a20=
================================================
FILE: PaimonMenuBar/MenuExtrasView.swift
================================================
//
// MenuView.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/25.
//
import Defaults
import Foundation
import Kingfisher
import SwiftUI
class RelativeFormatter {
private let df = DateFormatter()
init() {
df.dateStyle = DateFormatter.Style.long
df.timeStyle = DateFormatter.Style.short
df.doesRelativeDateFormatting = true
}
func string(time: Date) -> String {
return df.string(from: time)
}
}
/// Return the formatted time interval in a human-readable string
/// - Parameter timeInterval: A time interval represented in seconds
/// - Returns: A human-readable string representing the time interval
private func formatTimeInterval(timeInterval: String) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute]
formatter.unitsStyle = .abbreviated
return formatter.string(from: (TimeInterval(timeInterval) ?? TimeInterval("0"))!) ?? ""
}
/// Format a date that is of 'timeInterval' seconds away from now
/// - Parameter timeInterval: The number of seconds away from current time
/// - Returns: A human-readable string describing the future date
private func formatFutureDate(timeInterval: String) -> String {
let currentTime = Date()
let futureTime = currentTime.addingTimeInterval((TimeInterval(timeInterval) ?? TimeInterval("0"))!)
if Calendar.current.isDateInToday(futureTime) {
return "\(String.localized("Today")) \(futureTime.shortenedFormatted)"
}
if Calendar.current.isDateInTomorrow(futureTime) {
return "\(String.localized("Tomorrow")) \(futureTime.shortenedFormatted)"
}
// This should not happen, but just in case.
return futureTime.defaultFormatted
}
struct MenuExtrasView: View {
@Default(.lastGameRecord) private var lastGameRecord
var body: some View {
ZStack {
VStack(spacing: 8) {
ResinView(
currentResin: lastGameRecord.data.current_resin,
maxResin: lastGameRecord.data.max_resin,
resinRecoveryTime: lastGameRecord.data.resin_recovery_time,
fetchAt: lastGameRecord.fetchAt
)
ExpeditionView(
expeditions: lastGameRecord.data.expeditions,
maxExpeditionNum: lastGameRecord.data.max_expedition_num,
currentExpeditionNum: lastGameRecord.data.current_expedition_num
)
DailyCommissionView(
finishedTaskNum: lastGameRecord.data.finished_task_num,
totalTaskNum: lastGameRecord.data.total_task_num
)
ExtraTaskRewardView(
remainResinDiscountNum: lastGameRecord.data.remain_resin_discount_num,
resinDiscountNumLimit: lastGameRecord.data.resin_discount_num_limit,
isExtraTaskRewardReceived: lastGameRecord.data.is_extra_task_reward_received
)
HomeCoinView(
currentHomeCoin: lastGameRecord.data.current_home_coin,
maxHomeCoin: lastGameRecord.data.max_home_coin,
homeCoinRecoveryTime: lastGameRecord.data.home_coin_recovery_time
)
ParametricTransformerView(transformer: lastGameRecord.data.transformer)
}
.padding([.horizontal])
.padding([.vertical], 8)
.opacity(lastGameRecord.fetchAt == nil ? 0.4 : 1.0)
.blur(radius: lastGameRecord.fetchAt == nil ? 4 : 0)
if lastGameRecord.fetchAt == nil {
VStack(spacing: 16) {
Spacer()
Image("KokomiSad").resizable().frame(width: 72, height: 72)
Text("NOT INITIALIZED")
.font(.custom("Avenir Next Bold", size: 24, relativeTo: .largeTitle).italic())
Text(
"Please go to _Preferences > Configuration_ and setup your in-game **_UID_**, **_server_**, and **_cookie_**."
)
Spacer()
HStack {
Label("Open preferences here", systemImage: "arrow.down")
Spacer()
Link(destination: URL(string: "https://paimon.swo.moe/#how-to-get-my-cookie")!) {
Button("?") {
print("Navigating to help page.")
}.clipShape(Circle()).shadow(radius: 1)
}
}
}
.padding()
}
}.onAppear {
GameRecordUpdater.shared.tryFetchGameRecordAndRender()
}
}
}
struct ResinView: View {
@Default(.fetchFailed) private var fetchFailed
let currentResin: Int
let maxResin: Int
let resinRecoveryTime: String
let fetchAt: Date?
private let formatter = RelativeFormatter()
var body: some View {
VStack(spacing: 8) {
HStack {
Label {
Text((fetchAt == nil) ? "Not updated" :
(fetchFailed ? "Update failed, check the official APP for captchas" :
"Update: \(formatter.string(time: fetchAt!))"))
.font(.caption)
} icon: {
Image(systemName: "circle.fill").scaleEffect(0.4).frame(width: 5)
.foregroundColor(fetchFailed ? Color.red : Color.green)
}.opacity(0.6)
}
HStack(spacing: 4) {
Image("FragileResin")
.resizable()
.frame(width: 16, height: 16)
Text("Current Resin")
.font(.subheadline)
.opacity(0.6)
Spacer()
}
Text("\(currentResin)/\(maxResin)")
.frame(maxWidth: .infinity, alignment: .leading)
.font(.custom("Avenir Next Bold", size: 25, relativeTo: .largeTitle).italic())
HStack {
Label("Fully replenished", systemImage: "moon.circle")
Spacer()
Text(formatTimeInterval(timeInterval: resinRecoveryTime))
.font(.custom("Avenir Next Demi Bold", size: 13, relativeTo: .body).italic())
}
HStack {
Label("ETA", systemImage: "clock")
Spacer()
Text(formatFutureDate(timeInterval: resinRecoveryTime))
.font(.custom("Avenir Next Demi Bold", size: 13, relativeTo: .body).italic())
}
Divider()
}
}
}
struct ExpeditionView: View {
let expeditions: [Expeditions]
let maxExpeditionNum: Int
let currentExpeditionNum: Int
var body: some View {
VStack(spacing: 10) {
HStack {
Text("Expeditions \(currentExpeditionNum)/\(maxExpeditionNum)")
.font(.subheadline)
.opacity(0.6)
Spacer()
Image("Expedition").resizable().frame(width: 18, height: 18)
}
ForEach(expeditions, id: \.self) { expedition in
ExpeditionItemView(
status: expedition.status, avatar: expedition.avatar_side_icon,
remainedTime: expedition.remained_time
)
}
Divider()
}
}
}
struct ExpeditionItemView: View {
let status: String
let avatar: String
let remainedTime: String
// 20 hours in seconds
let totalExpeditionTime: Float = 20 * 60 * 60
var body: some View {
HStack {
KFImage.url(URL(string: avatar))
.resizable()
.placeholder { Color.gray.opacity(0.3) }
.clipShape(Circle())
.overlay(Circle().stroke(status == "Finished" ? Color.green : Color.gray))
.frame(width: 22, height: 22)
VStack(spacing: 6) {
HStack {
Text(status == "Finished" ? String.localized("Complete") : String.localized("Exploring"))
Spacer()
Text(formatTimeInterval(timeInterval: remainedTime))
.font(.custom("Avenir Next Demi Bold", size: 13, relativeTo: .body).italic())
}
ProgressView(value: totalExpeditionTime - (Float(remainedTime) ?? 0), total: totalExpeditionTime)
.progressViewStyle(LinearProgressViewStyle(tint: status == "Finished" ?
Color.green : Color(red: 0.89, green: 0.90, blue: 0.92)))
.frame(height: 1).scaleEffect(x: 1, y: 0.4, anchor: .center)
}
}
}
}
struct DailyCommissionView: View {
let finishedTaskNum: Int
let totalTaskNum: Int
var body: some View {
HStack {
Image("Commision")
.resizable()
.frame(width: 20, height: 20, alignment: .leading)
Text("Daily commissions")
Spacer()
Text("\(totalTaskNum - finishedTaskNum) left")
.font(.custom("Avenir Next Demi Bold", size: 13, relativeTo: .body).italic())
}
}
}
struct HomeCoinView: View {
let currentHomeCoin: Int
let maxHomeCoin: Int
let homeCoinRecoveryTime: String
var body: some View {
HStack {
Image("JarOfRiches")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
Text("Realm currency")
Spacer()
Text("\(currentHomeCoin)/\(maxHomeCoin)")
.font(.custom("Avenir Next Demi Bold", size: 13, relativeTo: .body).italic())
}
}
}
struct ExtraTaskRewardView: View {
let remainResinDiscountNum: Int
let resinDiscountNumLimit: Int
let isExtraTaskRewardReceived: Bool
var body: some View {
HStack {
Image("Domain")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
Text("Weekly bosses")
Spacer()
Text("\(remainResinDiscountNum) left")
.font(.custom("Avenir Next Demi Bold", size: 13, relativeTo: .body).italic())
}
}
}
struct ParametricTransformerView: View {
let transformer: Transformer
func formatRecoveryTime(recoveryTime: RecoveryTime) -> String {
if recoveryTime.reached {
return "Ready"
} else {
return recoveryTime
.Day != 0 ? "\(recoveryTime.Day) \(String.localized(recoveryTime.Day == 1 ? "day" : "days"))" :
recoveryTime
.Hour != 0 ? "\(recoveryTime.Hour) \(String.localized(recoveryTime.Hour == 1 ? "hour" : "hours"))"
: "\(recoveryTime.Minute) \(String.localized(recoveryTime.Minute == 1 ? "min" : "mins"))"
}
}
var body: some View {
HStack {
Image("ParametricTransformer")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
Text("Parametric transformer")
Spacer()
Text(transformer.obtained ? formatRecoveryTime(recoveryTime: transformer.recovery_time) : "Unavailable")
.font(.custom("Avenir Next Demi Bold", size: 13, relativeTo: .body).italic())
}
}
}
struct MenuView_Previews: PreviewProvider {
static var previews: some View {
MenuExtrasView()
.frame(width: 290.0)
.frame(height: 472.0)
}
}
================================================
FILE: PaimonMenuBar/Networking.swift
================================================
//
// Networking.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/24.
//
import CryptoKit
import Defaults
import Foundation
extension String {
// MD5 hash from: https://powermanuscript.medium.com/swift-5-2-macos-md5-hash-for-some-simple-use-cases-66be9e274182
var MD5: String {
let computed = Insecure.MD5.hash(data: data(using: .utf8)!)
return computed.map { String(format: "%02hhx", $0) }.joined()
}
}
func getDS(uid: String, server: GenshinServer) -> String {
// Part 1: current unix timestamp
let timestamp = Int(Date().timeIntervalSince1970)
// Part 2: a random integer from 100,000 to 200,000
let randomString = Int.random(in: 100_000 ..< 200_000)
// Part 3: MD5 hash of salt
let salt = server.isCNServer ? "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" : "okr4obncj8bw5a65hbnn5oo6ixjc3l9w"
let sign = "salt=\(salt)&t=\(timestamp)&r=\(randomString)&b=&q=role_id=\(uid)&server=\(server)".MD5
return "\(timestamp),\(randomString),\(sign)"
}
func getGameRecord() async -> GameRecord? {
// API to query Genshin game record
let apiCn = "https://api-takumi-record.mihoyo.com/game_record/app/genshin/api/dailyNote"
let apiGlobal = "https://bbs-api-os.hoyolab.com/game_record/app/genshin/api/dailyNote"
let uid = Defaults[.uid]
let server = Defaults[.server]
let cookie = Defaults[.cookie]
guard !uid.isEmpty, !cookie.isEmpty else {
print("Fetch skipped, because cookie is not set")
return nil
}
print("Fetching game record data...", uid, server)
let api = server.isCNServer ? apiCn : apiGlobal
guard let url = URL(string: "\(api)?role_id=\(uid)&server=\(server)") else { return nil }
// Reverse engineering Mihoyo API ;)
let DS = getDS(uid: uid, server: server)
let appVersion = server.isCNServer ? "2.26.1" : "2.9.1"
let clientType = server.isCNServer ? "5" : "2"
// Construct request with query parameters and relevant headers
var req = URLRequest(url: url)
req.httpMethod = "GET"
// Add required headers
req.setValue(cookie, forHTTPHeaderField: "Cookie")
req.setValue(DS, forHTTPHeaderField: "DS")
req.setValue(appVersion, forHTTPHeaderField: "x-rpc-app_version")
req.setValue(clientType, forHTTPHeaderField: "x-rpc-client_type")
// Perform HTTP request
do {
let (data, _) = try await URLSession.shared.data(for: req)
// if let string = String(bytes: data, encoding: .utf8) {
// print(string)
// }
var gameRecord = try? JSONDecoder().decode(GameRecord.self, from: data)
gameRecord?.fetchAt = Date()
return gameRecord
} catch {
print("Invalid data")
return nil
}
}
================================================
FILE: PaimonMenuBar/PaimonMenuBar.entitlements
================================================
com.apple.security.app-sandboxcom.apple.security.files.user-selected.read-onlycom.apple.security.network.clientcom.apple.security.temporary-exception.mach-lookup.global-name$(PRODUCT_BUNDLE_IDENTIFIER)-spks$(PRODUCT_BUNDLE_IDENTIFIER)-spki
================================================
FILE: PaimonMenuBar/PaimonMenuBarApp.swift
================================================
//
// PaimonMenuBarApp.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/23.
//
import SwiftUI
@main
struct PaimonMenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
if false {}
}
.commands {
CommandGroup(after: .appInfo) {
CheckForUpdatesView(updaterViewModel: UpdaterViewModel.shared)
}
}
Settings {
SettingsView()
}
}
}
================================================
FILE: PaimonMenuBar/Preview Content/Preview Assets.xcassets/Contents.json
================================================
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: PaimonMenuBar/SettingsView.swift
================================================
//
// ContentView.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/23.
//
import Defaults
import LaunchAtLogin
import SwiftUI
import UserNotifications
// This additional view is needed for the disabled state on the menu item to work properly before Monterey.
// See https://stackoverflow.com/questions/68553092/menu-not-updating-swiftui-bug for more information
struct CheckForUpdatesView: View {
@ObservedObject var updaterViewModel: UpdaterViewModel
var body: some View {
Button("Check for Updates", action: updaterViewModel.checkForUpdates)
.disabled(!updaterViewModel.canCheckForUpdates)
}
}
func getNotificationPermission(completion: @escaping (Bool) -> Void) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
completion(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional)
}
}
struct PreferenceSettingsView: View {
@Default(.recordUpdateInterval) private var recordUpdateInterval
@Default(.isStatusIconTemplate) private var isStatusIconTemplate
@Default(.isShowResinText) private var isShowResinText
@Default(.isNotifyParametricReady) private var isNotifyParametricReady
@Default(.lastGameRecord) private var lastGameRecord
@StateObject var updaterViewModel = UpdaterViewModel.shared
@State private var isEditing = false
@State private var isNotNotificationAuthorized = false
var body: some View {
VStack(spacing: 20) {
Form {
LaunchAtLogin.Toggle {
Text("Launch at Login")
}
.formLabel(Text("Start:"))
CheckForUpdatesView(updaterViewModel: updaterViewModel)
.formLabel(Text("Updates:"))
Text("Current version: \(Bundle.main.appVersion ?? "") (\(Bundle.main.buildNumber ?? ""))")
.font(.caption).opacity(0.6)
HStack {
Defaults.Toggle(key: .isStatusIconTemplate) {
Image("FragileResin")
.renderingMode(isStatusIconTemplate ? .template : .original)
.frame(width: 19, height: 19)
}
.onChange { _ in
AppDelegate.shared.updateStatusIcon()
}
Defaults.Toggle(key: .isShowResinText) {
Text("\(lastGameRecord.data.current_resin)/\(lastGameRecord.data.max_resin)")
.opacity(isShowResinText ? 1 : 0.4)
}
.onChange { _ in
AppDelegate.shared.updateStatusButtonTitle()
}
}
.formLabel(Text("Menubar icon:"))
HStack(spacing: 2) {
Text(isStatusIconTemplate ? "Native macOS adaptive icon." : "Colored icon.").font(.caption)
.opacity(0.6)
Text(isShowResinText ? "With resin counter." : "Resin counter hidden.").font(.caption)
.opacity(0.6)
}
Defaults.Toggle(key: .isNotifyParametricReady) {
Image(
systemName: isNotifyParametricReady ? "bell.badge" : "bell.slash"
)
if isNotNotificationAuthorized {
Label("⚠️ Notification unauthorized.", systemImage: "arrow.left")
.font(.caption2).opacity(0.8)
}
}
.formLabel(Text("Notify:"))
.onAppear {
getNotificationPermission { authorized in
isNotNotificationAuthorized = !authorized
}
}
.onChange(of: isNotifyParametricReady) { _ in
getNotificationPermission { authorized in
isNotNotificationAuthorized = !authorized
}
}
Text("... when parametric transformer is ready.").font(.caption).opacity(0.6)
Slider(value: $recordUpdateInterval, in: 1 ... 6, step: 1, label: {
Text("Update interval:")
}) { editing in
isEditing = editing
}
.frame(width: 360)
Text("Paimon fetches data every \(recordUpdateInterval, specifier: "%.0f") hour(s)*")
.font(.caption).opacity(0.6)
}
Divider()
Label(
"*Updating every 1-3 hours is sufficient, to prevent from being captcha-ed. Don't worry, as Paimon will auto-update the data offline as time passes.",
image: "FragileResin"
)
.font(.caption).opacity(0.6).frame(width: 400)
}
}
}
struct ConfigurationSettingsView: View {
@Default(.uid) private var uid
@Default(.server) private var server
@Default(.cookie) private var cookie
@State private var alertText = ""
@State private var alertMessage = ""
@State private var showConfigValidAlert = false
@State private var isLoading = false
var body: some View {
VStack {
Text("User")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Form {
TextField("UID:", text: $uid)
.textFieldStyle(.roundedBorder)
Picker("Server:", selection: $server) {
ForEach(GenshinServer.allCases, id: \.id) { value in
Text(value.serverName).tag(value)
}
}.pickerStyle(SegmentedPickerStyle())
}.padding([.bottom])
Text("Cookie")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
HStack {
Text("Paste your cookie from:")
.font(.subheadline)
Link(destination: URL(string: server.cookieSiteUrl)!) {
Text(server.cookieSiteUrl)
.font(.subheadline)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
TextEditor(text: $cookie)
.font(.system(.body, design: .monospaced))
.frame(height: 120).cornerRadius(6)
.background(Color.black.cornerRadius(6).shadow(radius: 0.5, y: 0.8).opacity(0.6))
Spacer()
HStack {
Label("This cookie is only stored locally.", systemImage: "exclamationmark.circle")
.font(.caption).opacity(0.6)
Spacer()
Button {
GameRecordUpdater.shared.clearGameRecord()
Task {
isLoading = true
if let _ = await GameRecordUpdater.shared.fetchGameRecordAndRenderNow() {
self.alertText = String.localized("👌 It's working!")
self.alertMessage = String.localized("Your config is valid.")
} else {
self.alertText = String.localized("🚫 Whoooops...")
self.alertMessage = String.localized("Failed to fetch, check your config.")
}
self.showConfigValidAlert.toggle()
isLoading = false
}
} label: {
Label {
Text("Test config")
} icon: {
Image(systemName: "bolt")
.opacity(isLoading ? 0 : 1)
.overlay(isLoading ? ProgressView().scaleEffect(0.4) : nil)
}
}
.alert(isPresented: self.$showConfigValidAlert, content: {
Alert(title: Text(alertText), message: Text(alertMessage))
})
.disabled(isLoading)
Link(destination: URL(string: "https://paimon.swo.moe/#how-to-get-my-cookie")!) {
Button("?") {
print("Navigating to help page.")
}.clipShape(Circle()).shadow(radius: 1)
}
}
}.padding()
}
}
struct AboutSettingsView: View {
var body: some View {
VStack(spacing: 8) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
Text(Bundle.main.appName ?? "").font(.headline.bold())
Text("Build \(Bundle.main.appVersion ?? "") (\(Bundle.main.buildNumber ?? ""))")
.font(.system(.subheadline, design: .monospaced))
Divider()
Text(
"Made with love @ [SpencerWoo](https://spencerwoo.com) | Check [Paimon's website](https://paimon.swo.moe)"
)
.font(.system(.caption, design: .monospaced))
Text(
"Icon by [Chawong](https://www.pixiv.net/en/artworks/92415888) | GitHub: [spencerwooo/PaimonMenuBar](https://github.com/spencerwooo/PaimonMenuBar)"
)
.font(.system(.caption, design: .monospaced))
}
}
}
struct SettingsView: View {
var body: some View {
TabView {
PreferenceSettingsView()
.tabItem {
Label("Preferences", systemImage: "gear")
}
ConfigurationSettingsView()
.tabItem {
Label("Configuration", systemImage: "square.and.pencil")
}
AboutSettingsView()
.tabItem {
Label("About", systemImage: "person")
}
}
.frame(width: 560, height: 360)
}
}
/// Alignment guide for aligning a text field in a `Form`.
/// Thanks for Jim Dovey https://developer.apple.com/forums/thread/126268
extension HorizontalAlignment {
private enum ControlAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
return context[HorizontalAlignment.center]
}
}
static let controlAlignment = HorizontalAlignment(ControlAlignment.self)
}
// Adapted from https://gist.github.com/marcprux/afd2f80baa5b6d60865182a828e83586
public extension View {
/// Attaches a label to this view for laying out in a `Form`
/// - Parameter view: the label view to use
/// - Returns: an `HStack` with an alignment guide for placing in a form
func formLabel(_ view: V) -> some View {
HStack {
view
self
.alignmentGuide(.controlAlignment) { $0[.leading] }
}
.alignmentGuide(.leading) { $0[.controlAlignment] }
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
Group {
SettingsView()
PreferenceSettingsView()
ConfigurationSettingsView()
AboutSettingsView()
}
}
}
================================================
FILE: PaimonMenuBar/UpdaterViewModel.swift
================================================
//
// UpdaterViewModel.swift
// PaimonMenuBar
//
// Created by Spencer Woo on 2022/3/31.
//
import Foundation
import Sparkle
final class UpdaterViewModel: ObservableObject {
private let updaterController: SPUStandardUpdaterController
static let shared = UpdaterViewModel()
@Published var canCheckForUpdates = false
init() {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
updaterController.updater.publisher(for: \.canCheckForUpdates)
.assign(to: &$canCheckForUpdates)
}
func checkForUpdates() {
updaterController.checkForUpdates(nil)
}
}
================================================
FILE: PaimonMenuBar/en.lproj/Localizable.strings
================================================
================================================
FILE: PaimonMenuBar/zh-Hans.lproj/Localizable.strings
================================================
"*Updating every 1-3 hours is sufficient, to prevent from being captcha-ed. Don't worry, as Paimon will auto-update the data offline as time passes." = "* 为了避免触发验证码,建议最短间隔 1 至 3 小时更新一次数据。不必担心,派蒙会自动在本地随着时间流逝更新数据。";
"About" = "关于";
"Check for Updates" = "检查更新";
"Complete" = "完成";
"Configuration" = "配置信息";
"Current Resin" = "当前树脂";
"Current version: %@ (%@)" = "当前版本:%@ (%@)";
"Daily commissions" = "每日委托";
"ETA" = "全部恢复于";
"Expeditions %lld/%lld" = "探索派遣 %lld/%lld";
"Fully replenished" = "距离全部恢复";
"Launch at Login" = "登录时启动";
"Paimon fetches data every %.0f hour(s)*" = "派蒙每隔 %.0f 小时更新一次数据 *";
"Paste your cookie from:" = "从这里粘贴你的 Cookie:";
"User" = "个人信息";
"Preferences" = "偏好设置";
"Realm currency" = "洞天宝钱";
"UID:" = "游戏内 UID:";
"Server:" = "服务器:";
"Start:" = "启动:";
"Test config" = "测试配置";
"Update interval:" = "更新频率:";
"Updates:" = "更新:";
"Check for Updates" = "检查更新";
"Tomorrow" = "明日";
"Today" = "今日";
"Exploring" = "剩余探索时间";
"Complete" = "探索完成";
"Weekly bosses" = "半价周本";
"Quit" = "退出";
"👌 It's working!" = "👌 成功!";
"Your config is valid." = "你的配置信息正确无误。";
"🚫 Whoooops..." = "🚫 啊哦……";
"Failed to fetch, check your config." = "数据获取失败,请检查配置。";
"Parametric transformer" = "参量质变仪";
"Update: %@" = "更新时间:%@";
"Not updated" = "未更新";
"Update failed, check the official APP for captchas" = "更新失败:检查官方应用中是否触发验证码";
"Menubar icon:" = "菜单栏图标:";
"Native macOS adaptive icon." = "原生 macOS 自适应图标。";
"Colored icon." = "彩色图标。";
"With resin counter." = "显示树脂。";
"Resin counter hidden." = "树脂隐藏。";
"%@ left" = "剩 %@ 个";
"Ready" = "冷却完成";
"Unavailable" = "未解锁";
"day" = "天";
"days" = "天";
"hour" = "时";
"hours" = "时";
"min" = "分";
"mins" = "分";
"NOT INITIALIZED" = "尚未配置";
"Please go to _Preferences > Configuration_ and setup your in-game **_UID_**, **_server_**, and **_cookie_**." = "请前往「偏好设置 > 配置信息」设置你的游戏内 **_UID_**、**_服务器_**、以及 **_米游社 cookie_**.";
"Open preferences here" = "在这里打开偏好设置";
"Parametric transformer is ready" = "参量质变仪已就绪";
"Notify:" = "通知:";
"... when parametric transformer is ready." = "… 当参量质变仪就绪时。";
"⚠️ Data fetch failed, check your configuration" = "⚠️ 数据获取失败,检查你的配置信息";
"⚠️ Notification unauthorized." = "⚠️ 没有推送通知权限。";
"This cookie is only stored locally." = "Cookie 仅保存于本地";
================================================
FILE: PaimonMenuBar.xcodeproj/project.pbxproj
================================================
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 55;
objects = {
/* Begin PBXBuildFile section */
320C466128254F8700C6932E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 320C466028254F8700C6932E /* Kingfisher */; };
323850E1282540EE0097B5C2 /* Compatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 323850E0282540EE0097B5C2 /* Compatibility.swift */; };
76085E6127FC23B000960915 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 76085E6027FC23B000960915 /* LaunchAtLogin */; };
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 */; };
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 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76D73BBE27EC650500CCDEA6 /* DataModels.swift */; };
76D73BC127EC67D300CCDEA6 /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76D73BC027EC67D300CCDEA6 /* Networking.swift */; };
76E429A927EDDE000032313C /* GameRecordUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E429A827EDDE000032313C /* GameRecordUpdater.swift */; };
76E986B627EDD5FC004ECC6C /* MenuExtrasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E986B527EDD5FC004ECC6C /* MenuExtrasView.swift */; };
76F9AE6D27F570D90051CDC8 /* UpdaterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F9AE6C27F570D90051CDC8 /* UpdaterViewModel.swift */; };
9F38F96F281D1BE90004D240 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F38F96E281D1BE90004D240 /* Defaults.swift */; };
9F38F972281D4D000004D240 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 9F38F971281D4D000004D240 /* Defaults */; };
9F38F974281D4D120004D240 /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F38F973281D4D120004D240 /* Defaults+Workaround.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
323850E0282540EE0097B5C2 /* Compatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Compatibility.swift; sourceTree = ""; };
7621675427F2FC080023F8B2 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; };
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 = ""; };
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 = ""; };
76CCDDE127EAD1C5009CFC64 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
76CCDDE427EAD1C5009CFC64 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
76CCDDE627EAD1C5009CFC64 /* PaimonMenuBar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PaimonMenuBar.entitlements; sourceTree = ""; };
76D73BBE27EC650500CCDEA6 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = ""; };
76D73BC027EC67D300CCDEA6 /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; };
76E429A827EDDE000032313C /* GameRecordUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecordUpdater.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; };
76F9AE6C27F570D90051CDC8 /* UpdaterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterViewModel.swift; sourceTree = ""; };
9F38F96E281D1BE90004D240 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; };
9F38F973281D4D120004D240 /* Defaults+Workaround.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Defaults+Workaround.swift"; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
76CCDDD727EAD1C4009CFC64 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
320C466128254F8700C6932E /* Kingfisher in Frameworks */,
9F38F972281D4D000004D240 /* Defaults in Frameworks */,
76085E6127FC23B000960915 /* LaunchAtLogin in Frameworks */,
76085E6427FC23EA00960915 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
76CCDDD127EAD1C4009CFC64 = {
isa = PBXGroup;
children = (
76B3F03127F2B76100833555 /* README.md */,
76CCDDDC27EAD1C4009CFC64 /* PaimonMenuBar */,
76F9AE6B27F570640051CDC8 /* PaimonMenuBar.app */,
);
sourceTree = "";
};
76CCDDDC27EAD1C4009CFC64 /* PaimonMenuBar */ = {
isa = PBXGroup;
children = (
7621675527F2FC080023F8B2 /* Localizable.strings */,
76CCDDDD27EAD1C4009CFC64 /* PaimonMenuBarApp.swift */,
76E986B527EDD5FC004ECC6C /* MenuExtrasView.swift */,
76E429A827EDDE000032313C /* GameRecordUpdater.swift */,
76CCDDDF27EAD1C4009CFC64 /* SettingsView.swift */,
76F9AE6C27F570D90051CDC8 /* UpdaterViewModel.swift */,
9F38F96E281D1BE90004D240 /* Defaults.swift */,
7686474027EF082400BCC350 /* Bundle.swift */,
76CCDDE127EAD1C5009CFC64 /* Assets.xcassets */,
76CCDDE627EAD1C5009CFC64 /* PaimonMenuBar.entitlements */,
76CCDDE327EAD1C5009CFC64 /* Preview Content */,
76C290EF27EAFFB000A30C9F /* AppDelegate.swift */,
76D73BBE27EC650500CCDEA6 /* DataModels.swift */,
76D73BC027EC67D300CCDEA6 /* Networking.swift */,
9F38F973281D4D120004D240 /* Defaults+Workaround.swift */,
323850E0282540EE0097B5C2 /* Compatibility.swift */,
);
path = PaimonMenuBar;
sourceTree = "";
};
76CCDDE327EAD1C5009CFC64 /* Preview Content */ = {
isa = PBXGroup;
children = (
76CCDDE427EAD1C5009CFC64 /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
76CCDDD927EAD1C4009CFC64 /* PaimonMenuBar */ = {
isa = PBXNativeTarget;
buildConfigurationList = 76CCDDE927EAD1C5009CFC64 /* Build configuration list for PBXNativeTarget "PaimonMenuBar" */;
buildPhases = (
76CCDDD627EAD1C4009CFC64 /* Sources */,
76CCDDD727EAD1C4009CFC64 /* Frameworks */,
76CCDDD827EAD1C4009CFC64 /* Resources */,
7686473F27EEED4500BCC350 /* ShellScript */,
);
buildRules = (
);
dependencies = (
);
name = PaimonMenuBar;
packageProductDependencies = (
76085E6027FC23B000960915 /* LaunchAtLogin */,
76085E6327FC23EA00960915 /* Sparkle */,
9F38F971281D4D000004D240 /* Defaults */,
320C466028254F8700C6932E /* Kingfisher */,
);
productName = PaimonMenuBar;
productReference = 76F9AE6B27F570640051CDC8 /* PaimonMenuBar.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
76CCDDD227EAD1C4009CFC64 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1330;
LastUpgradeCheck = 1410;
TargetAttributes = {
76CCDDD927EAD1C4009CFC64 = {
CreatedOnToolsVersion = 13.3;
};
};
};
buildConfigurationList = 76CCDDD527EAD1C4009CFC64 /* Build configuration list for PBXProject "PaimonMenuBar" */;
compatibilityVersion = "Xcode 13.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = 76CCDDD127EAD1C4009CFC64;
packageReferences = (
76085E5F27FC23B000960915 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */,
76085E6227FC23EA00960915 /* XCRemoteSwiftPackageReference "Sparkle" */,
9F38F970281D4D000004D240 /* XCRemoteSwiftPackageReference "Defaults" */,
320C465F28254F8700C6932E /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
productRefGroup = 76CCDDD127EAD1C4009CFC64;
projectDirPath = "";
projectRoot = "";
targets = (
76CCDDD927EAD1C4009CFC64 /* PaimonMenuBar */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
76CCDDD827EAD1C4009CFC64 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
76CCDDE527EAD1C5009CFC64 /* Preview Assets.xcassets in Resources */,
7621675327F2FC080023F8B2 /* Localizable.strings in Resources */,
76CCDDE227EAD1C5009CFC64 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
7686473F27EEED4500BCC350 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${BUILT_PRODUCTS_DIR}/LaunchAtLogin_LaunchAtLogin.bundle/Contents/Resources/copy-helper-swiftpm.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
76CCDDD627EAD1C4009CFC64 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
76E986B627EDD5FC004ECC6C /* MenuExtrasView.swift in Sources */,
7686474127EF082400BCC350 /* Bundle.swift in Sources */,
76E429A927EDDE000032313C /* GameRecordUpdater.swift in Sources */,
9F38F974281D4D120004D240 /* Defaults+Workaround.swift in Sources */,
323850E1282540EE0097B5C2 /* Compatibility.swift in Sources */,
76D73BBF27EC650500CCDEA6 /* DataModels.swift in Sources */,
76C290F027EAFFB000A30C9F /* AppDelegate.swift in Sources */,
76D73BC127EC67D300CCDEA6 /* Networking.swift in Sources */,
76F9AE6D27F570D90051CDC8 /* UpdaterViewModel.swift in Sources */,
9F38F96F281D1BE90004D240 /* Defaults.swift in Sources */,
76CCDDE027EAD1C4009CFC64 /* SettingsView.swift in Sources */,
76CCDDDE27EAD1C4009CFC64 /* PaimonMenuBarApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
7621675527F2FC080023F8B2 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
7621675427F2FC080023F8B2 /* en */,
7621675627F2FC0B0023F8B2 /* zh-Hans */,
);
name = Localizable.strings;
sourceTree = "";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
76CCDDE727EAD1C5009CFC64 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
76CCDDE827EAD1C5009CFC64 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
76CCDDEA27EAD1C5009CFC64 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = PaimonMenuBar/PaimonMenuBar.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 128;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"PaimonMenuBar/Preview Content\"";
DEVELOPMENT_TEAM = W2HGAU9MPP;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PaimonMenuBar/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.12;
PRODUCT_BUNDLE_IDENTIFIER = spencerwoo.PaimonMenuBar;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
76CCDDEB27EAD1C5009CFC64 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = PaimonMenuBar/PaimonMenuBar.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 128;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"PaimonMenuBar/Preview Content\"";
DEVELOPMENT_TEAM = W2HGAU9MPP;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = PaimonMenuBar/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.12;
PRODUCT_BUNDLE_IDENTIFIER = spencerwoo.PaimonMenuBar;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
76CCDDD527EAD1C4009CFC64 /* Build configuration list for PBXProject "PaimonMenuBar" */ = {
isa = XCConfigurationList;
buildConfigurations = (
76CCDDE727EAD1C5009CFC64 /* Debug */,
76CCDDE827EAD1C5009CFC64 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
76CCDDE927EAD1C5009CFC64 /* Build configuration list for PBXNativeTarget "PaimonMenuBar" */ = {
isa = XCConfigurationList;
buildConfigurations = (
76CCDDEA27EAD1C5009CFC64 /* Debug */,
76CCDDEB27EAD1C5009CFC64 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
320C465F28254F8700C6932E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 7.0.0;
};
};
76085E5F27FC23B000960915 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin";
requirement = {
branch = main;
kind = branch;
};
};
76085E6227FC23EA00960915 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
branch = 2.x;
kind = branch;
};
};
9F38F970281D4D000004D240 /* XCRemoteSwiftPackageReference "Defaults" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/Defaults";
requirement = {
branch = main;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
320C466028254F8700C6932E /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 320C465F28254F8700C6932E /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
76085E6027FC23B000960915 /* LaunchAtLogin */ = {
isa = XCSwiftPackageProductDependency;
package = 76085E5F27FC23B000960915 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */;
productName = LaunchAtLogin;
};
76085E6327FC23EA00960915 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 76085E6227FC23EA00960915 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
9F38F971281D4D000004D240 /* Defaults */ = {
isa = XCSwiftPackageProductDependency;
package = 9F38F970281D4D000004D240 /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 76CCDDD227EAD1C4009CFC64 /* Project object */;
}
================================================
FILE: PaimonMenuBar.xcodeproj/project.xcworkspace/contents.xcworkspacedata
================================================
================================================
FILE: PaimonMenuBar.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
================================================
IDEDidComputeMac32BitWarning
================================================
FILE: PaimonMenuBar.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
================================================
PreviewsEnabled
================================================
FILE: PaimonMenuBar.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
================================================
{
"pins" : [
{
"identity" : "defaults",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sindresorhus/Defaults",
"state" : {
"branch" : "main",
"revision" : "3535f3d088113cf24705014eec6a17f0fd73237f"
}
},
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "59eb199fdb8dd47733624e8b0277822d0232579e",
"version" : "7.2.2"
}
},
{
"identity" : "launchatlogin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sindresorhus/LaunchAtLogin",
"state" : {
"branch" : "main",
"revision" : "e8171b3e38a2816f579f58f3dac1522aa39efe41"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"branch" : "2.x",
"revision" : "f250bead4b943ef9711c61274a1f52e380afa0e8"
}
}
],
"version" : 2
}
================================================
FILE: PaimonMenuBar.xcodeproj/xcshareddata/xcschemes/PaimonMenuBar.xcscheme
================================================
================================================
FILE: README.md
================================================
PaimonMenuBar
Track real-time Genshin Impact stats in your macOS menubar
## What's this?

> Paimon helps you track your Genshin Impact daily resin, expeditions, and more — straight in your macOS menu bar.
Paimon can help you —
* 🌙 Keep track of your daily resin.
* 💰 Monitor your daily expeditions and real-time realm currency.
* 🏁 Remind you about your daily commissions and weekly boss fights.
* 🍯 And notify you when your parametric transformer is ready to use.
Basically, `PaimonMenuBar` lives in your macOS menu bar quietly, and offers you a nice way of monitoring your in-game real-time stats when you need to check them.
> **Note**
>
> `PaimonMenuBar` is made with SwiftUI, designed for and native to macOS.
## Download
[](https://github.com/spencerwooo/PaimonMenuBar/releases/latest)
## Things to know
1. Paimon uses the official Hoyoverse API found in either [米游社 (for CN players)](https://bbs.mihoyo.com/ys/) or [HoYoLAB (for Global players)](https://www.hoyolab.com/home).
2. Yes, Paimon needs your cookie. It is so that Paimon can request said API on your behalf, and fetch those in-game stats periodically. Rest assured that **the cookie is only stored locally.**
3. Check [FAQ](https://paimon.swo.moe/) if you have anymore questions.
## Credits
* Credits to [@Chawong](https://www.pixiv.net/en/artworks/92415888) for the logo. (Love from Hu Tao :heart:)
* iOS widget (Scriptable): [[闲聊杂谈][工具分享] iOS 快捷指令/小组件](https://bbs.nga.cn/read.php?tid=29801567)
* Friendly browser extension alternative: [daidr/paimon-webext](https://github.com/daidr/paimon-webext)
* Friendly Windows alternative: [ArvinZJC/PaimonTray](https://github.com/ArvinZJC/PaimonTray)
Development notes.
## TO-DO
* [x] Menu bar of varying height.
* [x] Configurable data refresh rate.
* [x] Start at login.
* [x] `i18n` support for at least Simplified Chinese and English.
* [x] Manual refresh button.
* [x] Code-sign and publish as `.dmg`.
* [x] Auto-updates and check for update.
* [x] Custom website and help for acquiring the cookie.
* [x] Help button beside the text field for entering the cookie.
* [x] Support for cn and global genshin accounts (米游社 and hoyolab).
* [x] Backward-compatibility for macOS 11.0.
* [x] Better first-time installation experience (guidance for initial setup).
* ~~[ ] Support for multiple accounts?~~
## Releasing a new version
* Create a build in Xcode, bump the build number, and notarize build.
* Create a new release on GitHub with a new version tag and increment the build number.
* Use `create-dmg` to create the `.dmg` file:
```bash
create-dmg PaimonMenuBar.app
```
* Update appcast.xml with the new version tag and build number:
```bash
cd /artifacts/sparkle/bin
./generate_appcast /PaimonMenuBar/Build/
```
* Profit.
## License
[MIT](LICENSE)