Repository: spencerwooo/PaimonMenuBar Branch: main Commit: 31ac8443a399 Files: 66 Total size: 123.6 KB Directory structure: gitextract_kmzlkky9/ ├── .gitignore ├── Docs/ │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── components/ │ │ ├── AvailableNow.tsx │ │ ├── DownloadButton.tsx │ │ ├── Faq.tsx │ │ ├── Features.tsx │ │ ├── Footer.tsx │ │ ├── GitHubButton.tsx │ │ ├── Head.tsx │ │ ├── Hero.tsx │ │ ├── HowToGetMyCookie.tsx │ │ ├── PaimonCan.tsx │ │ ├── PaimonCookie.tsx │ │ ├── PaimonUses.tsx │ │ ├── ReleaseInfo.tsx │ │ └── Screenshot3D.tsx │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages/ │ │ ├── _app.tsx │ │ ├── api/ │ │ │ └── hello.ts │ │ ├── index.tsx │ │ └── types.d.ts │ ├── postcss.config.js │ ├── public/ │ │ └── site.webmanifest │ ├── styles/ │ │ └── globals.css │ ├── tailwind.config.js │ └── tsconfig.json ├── LICENSE ├── PaimonMenuBar/ │ ├── AppDelegate.swift │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Commision.imageset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Domain.imageset/ │ │ │ └── Contents.json │ │ ├── Expedition.imageset/ │ │ │ └── Contents.json │ │ ├── FragileResin.imageset/ │ │ │ └── Contents.json │ │ ├── JarOfRiches.imageset/ │ │ │ └── Contents.json │ │ ├── KokomiSad.imageset/ │ │ │ └── Contents.json │ │ └── ParametricTransformer.imageset/ │ │ └── Contents.json │ ├── Bundle.swift │ ├── Compatibility.swift │ ├── DataModels.swift │ ├── Defaults+Workaround.swift │ ├── Defaults.swift │ ├── GameRecordUpdater.swift │ ├── Info.plist │ ├── MenuExtrasView.swift │ ├── Networking.swift │ ├── PaimonMenuBar.entitlements │ ├── PaimonMenuBarApp.swift │ ├── Preview Content/ │ │ └── Preview Assets.xcassets/ │ │ └── Contents.json │ ├── SettingsView.swift │ ├── UpdaterViewModel.swift │ ├── en.lproj/ │ │ └── Localizable.strings │ └── zh-Hans.lproj/ │ └── Localizable.strings ├── PaimonMenuBar.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm/ │ │ └── Package.resolved │ └── xcshareddata/ │ └── xcschemes/ │ └── PaimonMenuBar.xcscheme ├── README.md └── appcast.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings xcuserdata/ ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint *.xccheckout ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ *.moved-aside *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 ## Obj-C/Swift specific *.hmap ## App packaging *.ipa *.dSYM.zip *.dSYM ## Playgrounds timeline.xctimeline playground.xcworkspace # Swift Package Manager # # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins # Package.resolved # *.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project # .swiftpm .build/ # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # # Pods/ # # Add this line if you want to avoid checking in source code from the Xcode workspace # *.xcworkspace # Carthage # # Add this line if you want to avoid checking in source code from Carthage dependencies. # Carthage/Checkouts Carthage/Build/ # Accio dependency management Dependencies/ .accio/ # fastlane # # It is recommended to not store the screenshots in the git repo. # Instead, use fastlane to re-generate the screenshots whenever they are needed. # For more information about the recommended setup visit: # https://docs.fastlane.tools/best-practices/source-control/#source-control fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output # Code Injection # # After new code Injection tools there's a generated folder /iOSInjectionProject # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ ================================================ FILE: Docs/.eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: Docs/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo ================================================ FILE: Docs/README.md ================================================ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: Docs/components/AvailableNow.tsx ================================================ import type { AppReleaseData } from '../pages/types' import Image from "next/image" import DownloadButton from './DownloadButton' import logo from '../images/logo.png' const AvailableNow = ({ latest }: { latest: AppReleaseData }) => (
logo
Available on GitHub
Requires macOS 11 Big Sur or later.
) export default AvailableNow ================================================ FILE: Docs/components/DownloadButton.tsx ================================================ import { RiDownloadCloud2Line } from 'react-icons/ri' const DownloadButton = ({ tagName, downloadUrl, tailwindStyles, }: { tagName: string downloadUrl: string tailwindStyles?: string }) => ( Download {tagName} ) export default DownloadButton ================================================ FILE: Docs/components/Faq.tsx ================================================ const Faq = () => (

FAQs

  1. Will I get banned from the game?
    No, I seriously do not think simply reading from an API (outside the game) constitutes as cheating.
  2. 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.
  3. Can I configure the refresh rate / data fetching frequency / polling interval?
    Yes - under Preferences.
  4. Can I revert back to the original colored resin icon?
    Yes - use either one, configured under Preferences.
  5. 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 }) => (
icon {label}
) const Features = () => (
) export default Features ================================================ FILE: Docs/components/Footer.tsx ================================================ import { RiHeartPulseLine, RiHeartsLine } from 'react-icons/ri' const Footer = () => (
Created with love by{' '} Spencer Woo {' '}
Love and Kisses from Hu Tao{' '}
) export default Footer ================================================ FILE: Docs/components/GitHubButton.tsx ================================================ import { RiGithubLine } from 'react-icons/ri' const GitHubButton = () => { return ( GitHub ) } export default GitHubButton ================================================ FILE: Docs/components/Head.tsx ================================================ import Head from 'next/head' const Meta = () => ( PaimonMenuBar ) export default Meta ================================================ FILE: Docs/components/Hero.tsx ================================================ import type { AppReleaseData } from '../pages/types' import Image from "next/image" import Screenshot3D from '../components/Screenshot3D' import DownloadButton from '../components/DownloadButton' import ReleaseInfo from '../components/ReleaseInfo' import GitHubButton from '../components/GitHubButton' import logo from '../images/logo.png' const Hero = ({ latest }: { latest: AppReleaseData }) => (
logo PaimonMenuBar
Track your Genshin Impact daily resin, expeditions, and more — straight in your macOS menu bar.
Made with SwiftUI, designed for macOS. Works with —
天空岛 | 世界树
🇨🇳 CN
&
NA | EU | Asia | SAR
🌍 Global
Requires macOS 11 Big Sur or later.
) export default Hero ================================================ FILE: Docs/components/HowToGetMyCookie.tsx ================================================ import Image from "next/image" import hutaoSleepy from '../images/hutao-sleepy.png' import cookieScreenshot from '../images/cookie.jpg' import configScreenshot from '../images/config-screenshot.jpg' const HowToGetMyCookie = () => (
emoji hutao
) export default HowToGetMyCookie ================================================ FILE: Docs/components/PaimonCan.tsx ================================================ import Image from "next/image" import paimonMighty from '../images/paimon-mighty.png' const PaimonCan = () => (
paimon emoji

Mighty Paimon!

Paimon can help you —

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.

) export default PaimonCan ================================================ FILE: Docs/components/PaimonCookie.tsx ================================================ import Image from "next/image" import luminePlease from '../images/lumine-please.png' const PaimonCookie = () => (
lumine emoji

Why does Paimon need your cookie?

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 PaimonCookie ================================================ FILE: Docs/components/PaimonUses.tsx ================================================ import Image from "next/image" import zhongliThink from '../images/zhongli-think.png' const PaimonUses = () => (
zhongli emoji

How does Paimon work?

Paimon uses the official Mihoyo / Hoyoverse API found in either{' '} 米游社 (for CN players) {' '} or{' '} HoYoLAB (for Global players) .

) export default PaimonUses ================================================ FILE: Docs/components/ReleaseInfo.tsx ================================================ import type { AppReleaseData } from '../pages/types' const reactionToEmoji = { '+1': '👍', '-1': '👎', laugh: '😂', confused: '😕', heart: '❤️', hooray: '🎉', rocket: '🚀', eyes: '👀', } as const type reactionKeys = keyof typeof reactionToEmoji const formatRelativeDate = (publishedAt: string) => { const publishedDate = new Date(publishedAt) const deltaTime = (publishedDate.getTime() - Date.now()) / 1000 const formatter = new Intl.RelativeTimeFormat() if (deltaTime > -60 * 60) { return formatter.format(Math.floor(deltaTime / 60), 'minute') } if (deltaTime > -24 * 60 * 60) { return formatter.format(Math.floor(deltaTime / 60 / 60), 'hour') } if (deltaTime > -7 * 24 * 60 * 60) { return formatter.format(Math.floor(deltaTime / 60 / 60 / 24), 'day') } return formatter.format(Math.floor(deltaTime / 60 / 60 / 24 / 7), 'week') } const ReleaseInfo = ({ htmlUrl, publishedAt, downloadCount, reactions, }: { htmlUrl: string publishedAt: string downloadCount: number reactions: AppReleaseData['reactions'] }) => { const reactionsNonZero = Object.entries(reactions ?? {}).filter( ([key, count]) => key !== 'total_count' && count > 0 ) as Array<[reactionKeys, number]> return ( <> Last updated {formatRelativeDate(publishedAt)}. Downloads:{' '} {downloadCount}.
{reactionsNonZero.map( ([key, val]: [key: reactionKeys, val: number]) => ( {reactionToEmoji[key]} {val} ) )}
) } 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 = () => ( PaimonMenuBar screenshot ) 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 <>
background
; } 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 ================================================ SUEnableInstallerLauncherService SUFeedURL https://raw.githubusercontent.com/spencerwooo/PaimonMenuBar/main/appcast.xml SUPublicEDKey AhJieofGr0gFEypC7KPkg3f37YZ2Pj9lo2+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-sandbox com.apple.security.files.user-selected.read-only com.apple.security.network.client com.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 ================================================
logo

PaimonMenuBar

Track real-time Genshin Impact stats in your macOS menubar

Use Swift macOS 11.0+ GitHub Release
## What's this? ![screenshot](Assets/screenshot.jpg) > 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 [![GitHub Release](https://img.shields.io/github/v/release/spencerwooo/PaimonMenuBar?labelColor=282c34&logo=GitHub&style=for-the-badge)](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)
made with ❤️ by spencer woo
================================================ FILE: appcast.xml ================================================ PaimonMenuBar 1.12 Thu, 08 Dec 2022 13:12:40 +0800 128 1.12 11.0 1.12 Thu, 08 Dec 2022 01:42:15 +0800 126 1.12 11.0 1.11 Sun, 09 Oct 2022 15:29:45 +0800 124 1.11 11.0 1.10 Thu, 11 Aug 2022 16:42:19 +0800 123 1.10 11.0 1.9 Thu, 09 Jun 2022 20:19:39 +0800 120 1.9 11.0 1.8 Tue, 07 Jun 2022 21:24:06 +0800 114 1.8 11.0 1.7 Fri, 13 May 2022 23:20:25 +0800 113 1.7 11.0 1.7 Mon, 09 May 2022 13:49:30 +0800 111 1.7 11.0 1.6 Sun, 01 May 2022 20:37:03 +0800 110 1.6 12.0 1.5 Sat, 30 Apr 2022 15:07:47 +0800 108 1.5 12.0 1.4 Thu, 28 Apr 2022 22:35:09 +0800 106 1.4 12.0 1.4 Tue, 26 Apr 2022 13:56:31 +0800 104 1.4 12.0 1.3 Tue, 26 Apr 2022 01:31:47 +0800 103 1.3 12.0 1.2 Thu, 31 Mar 2022 16:20:33 +0800 102 1.2 12.0 1.2 Thu, 31 Mar 2022 14:31:26 +0800 101 1.2 12.0 1.2 Thu, 31 Mar 2022 14:17:48 +0800 100 1.2 12.0