├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.md └── workflows │ ├── build-mac.yml │ ├── build-win.yml │ ├── codeql-analysis.yml │ ├── prettier.yml │ └── virustotal.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── settings.json ├── @types └── zephra │ └── index.d.ts ├── LICENSE ├── README.md ├── builder ├── I18n.d.ts ├── stable-macos.json ├── stable-win-store.json └── stable.json ├── crowdin.yml ├── i18n ├── af-ZA.json ├── de-DE.json ├── en-US.json ├── fr-FR.json ├── pt-BR.json └── ru-RU.json ├── package-lock.json ├── package.json ├── src ├── assets │ ├── app │ │ ├── usaflag.gif │ │ └── usaflag.png │ ├── appx │ │ ├── SmallTile.png │ │ ├── Square150x150Logo.png │ │ ├── Square44x44Logo.png │ │ ├── Square44x44Logo.targetsize-44_altform-unplated.png │ │ ├── StoreLogo.png │ │ └── Wide310x150Logo.png │ ├── icons │ │ ├── darwin │ │ │ └── logo.png │ │ └── win32 │ │ │ ├── icon.ico │ │ │ └── logo.png │ ├── logo.png │ ├── statusBarIcon.png │ ├── statusBarIconError.png │ ├── tray │ │ ├── logo@18.png │ │ └── logo@32.png │ ├── trayLogo@18.png │ └── trayLogo@32.png ├── browser │ ├── css │ │ ├── style.css │ │ └── wakandaForever.css │ ├── index.html │ ├── preload.ts │ └── renderer │ │ ├── api.ts │ │ ├── eventSettings.ts │ │ ├── i18n.ts │ │ ├── index.ts │ │ ├── lastFM.ts │ │ ├── listeners.ts │ │ ├── modal.ts │ │ ├── tsconfig.json │ │ ├── updater.ts │ │ └── utils.ts ├── index.ts ├── managers │ ├── SongData.ts │ ├── bridge.ts │ ├── browser.ts │ ├── discord.ts │ ├── i18n.ts │ ├── ipc.ts │ ├── lastFM.ts │ ├── launch.ts │ ├── modal.ts │ ├── sentry.ts │ ├── store.ts │ ├── tray.ts │ ├── updater.ts │ └── watchdog.ts └── utils │ ├── advancedSongDataSearch.ts │ ├── apiRequest.ts │ ├── checkAppDependency.ts │ ├── checkSupporter.ts │ ├── crowdin.ts │ ├── execPromise.ts │ ├── functions.ts │ ├── getAppDataPath.ts │ ├── getLibrarySongArtwork.ts │ ├── json.ts │ ├── msStoreModal.ts │ ├── notifications.ts │ ├── protocol.ts │ ├── quitITunes.ts │ ├── replaceVariables.ts │ ├── theme.ts │ └── watchdog │ ├── details.ts │ ├── index.ts │ ├── installer.ts │ ├── state.ts │ └── uninstaller.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: zephra 2 | github: zephraOSS 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug/issue 3 | title: "BUG | short description of your bug" 4 | labels: [ bug ] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: "## Welcome! Is there an existing issue for this?" 9 | - type: markdown 10 | attributes: 11 | value: "### Please search to see if an issue already exists for the bug you encountered." 12 | - type: textarea 13 | attributes: 14 | label: Current Behavior 15 | description: A concise description of what you're experiencing. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Expected Behavior 21 | description: A concise description of what you expected to happen. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Steps To Reproduce 27 | description: Steps to reproduce the behavior. 28 | placeholder: | 29 | 1. In this environment... 30 | 2. With this config... 31 | 3. Run '...' 32 | 4. See error... 33 | validations: 34 | required: false 35 | - type: input 36 | attributes: 37 | label: OS 38 | description: What operating system are you using? 39 | placeholder: ex. Windows 10 40 | validations: 41 | required: true 42 | - type: input 43 | attributes: 44 | label: OS Version 45 | description: What version of the operating system are you using? 46 | placeholder: ex. 20H2 47 | validations: 48 | required: false 49 | - type: input 50 | attributes: 51 | label: AMRPC Version 52 | description: What version of AMRPC are you using? 53 | placeholder: ex. 4.0.0 54 | validations: 55 | required: true 56 | - type: dropdown 57 | attributes: 58 | label: AMRPC Mode 59 | options: 60 | - iTunes 61 | - Apple Music 62 | default: 1 63 | - type: textarea 64 | attributes: 65 | label: Anything else? 66 | description: | 67 | Links? References? Anything that will give us more context about the issue you are encountering! 68 | 69 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 70 | 71 | *[How do I find the log file?](https://github.com/ZephraCloud/Apple-Music-RPC/wiki/Creating-a-bug-report#how-do-i-find-the-log-file)* 72 | validations: 73 | required: false 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'FEATURE REQUEST | ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build-mac.yml: -------------------------------------------------------------------------------- 1 | name: Build (macOS) 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - ".github/workflows/build-mac.yml" 8 | - "src/**" 9 | - "package.json" 10 | 11 | jobs: 12 | release: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [macos-latest] 18 | 19 | steps: 20 | - name: Check out Git repository 21 | uses: actions/checkout@v1 22 | 23 | - name: Install Node.js, NPM and Yarn 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: 16 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Create i18n types 32 | run: npm run generate:i18n-types-ci 33 | 34 | - name: Build 35 | run: npm run build:macos 36 | env: 37 | GH_TOKEN: ${{ secrets.github_token }} 38 | ELECTRON: true 39 | USE_HARD_LINKS: false 40 | -------------------------------------------------------------------------------- /.github/workflows/build-win.yml: -------------------------------------------------------------------------------- 1 | name: Build (Windows) 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - ".github/workflows/build-win.yml" 8 | - "src/**" 9 | - "package.json" 10 | 11 | jobs: 12 | release: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [windows-latest] 18 | 19 | steps: 20 | - name: Check out Git repository 21 | uses: actions/checkout@v1 22 | 23 | - name: Install Node.js, NPM and Yarn 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: 16 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Create i18n types 32 | run: npm run generate:i18n-types-ci 33 | 34 | - name: Build 35 | run: npm run build:win 36 | env: 37 | GH_TOKEN: ${{ secrets.github_token }} 38 | ELECTRON: true 39 | USE_HARD_LINKS: false 40 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | paths: 18 | - ".github/workflows/codeql-analysis.yml" 19 | - "src/**" 20 | pull_request: 21 | # The branches below must be a subset of the branches above 22 | branches: ["main"] 23 | paths: 24 | - ".github/workflows/codeql-analysis.yml" 25 | - "src/**" 26 | schedule: 27 | - cron: "45 0 * * 0" 28 | 29 | jobs: 30 | analyze: 31 | name: Analyze 32 | runs-on: ubuntu-latest 33 | permissions: 34 | actions: read 35 | contents: read 36 | security-events: write 37 | 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | language: ["javascript"] 42 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 43 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 44 | 45 | steps: 46 | - name: Checkout repository 47 | uses: actions/checkout@v3 48 | 49 | # Initializes the CodeQL tools for scanning. 50 | - name: Initialize CodeQL 51 | uses: github/codeql-action/init@v2 52 | with: 53 | languages: ${{ matrix.language }} 54 | # If you wish to specify custom queries, you can do so here or in a config file. 55 | # By default, queries listed here will override any specified in a config file. 56 | # Prefix the list here with "+" to use these queries and those in the config file. 57 | 58 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 59 | # queries: security-extended,security-and-quality 60 | 61 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 62 | # If this step fails, then you should remove it and run the build manually (see below) 63 | - name: Autobuild 64 | uses: github/codeql-action/autobuild@v2 65 | 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 68 | 69 | # If the Autobuild fails above, remove it and uncomment the following three lines. 70 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 71 | 72 | # - run: | 73 | # echo "Run, Build Application using script" 74 | # ./location_of_script_within_repo/buildscript.sh 75 | 76 | - name: Perform CodeQL Analysis 77 | uses: github/codeql-action/analyze@v2 78 | with: 79 | category: "/language:${{matrix.language}}" 80 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Prettier 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | prettier: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | # Make sure the actual branch is checked out when running on pull requests 16 | ref: ${{ github.head_ref }} 17 | # This is important to fetch the changes to the previous commit 18 | fetch-depth: 0 19 | 20 | - name: Prettify code 21 | uses: creyD/prettier_action@v4.1.1 22 | with: 23 | only_changed: True 24 | -------------------------------------------------------------------------------- /.github/workflows/virustotal.yml: -------------------------------------------------------------------------------- 1 | name: VirusTotal - Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | virustotal: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: VirusTotal Scan 13 | uses: crazy-max/ghaction-virustotal@v2 14 | with: 15 | update_release_body: true 16 | vt_api_key: ${{ secrets.VT_API_KEY }} 17 | files: | 18 | .exe$ 19 | .dmg$ 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /app 3 | /dist 4 | /node_modules 5 | /src/language 6 | /@types/zephra/I18n.d.ts 7 | 8 | /builder/cert.pfx 9 | 10 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": false 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deepscan.enable": true 3 | } -------------------------------------------------------------------------------- /@types/zephra/index.d.ts: -------------------------------------------------------------------------------- 1 | interface currentTrack { 2 | name: string; 3 | artist: string; 4 | album: string; 5 | mediaKind: string; 6 | duration: number; 7 | endTime?: number; 8 | elapsedTime: number; 9 | remainingTime: number; 10 | url: string; 11 | genre: string; 12 | releaseYear: number; 13 | id: number; 14 | artwork: string; 15 | playerState?: "playing" | "paused" | "stopped"; 16 | } 17 | 18 | interface PresenceData { 19 | details?: string; 20 | state?: string; 21 | startTimestamp?: number; 22 | endTimestamp?: number; 23 | largeImageKey?: string; 24 | largeImageText?: string; 25 | smallImageKey?: string; 26 | smallImageText?: string; 27 | partyId?: string; 28 | partySize?: number; 29 | partyMax?: number; 30 | matchSecret?: string; 31 | joinSecret?: string; 32 | spectateSecret?: string; 33 | buttons?: [PresenceDataButton, PresenceDataButton?]; 34 | } 35 | 36 | interface PresenceDataButton { 37 | label: string; 38 | url: string; 39 | } 40 | 41 | interface ModalData { 42 | id?: string; 43 | title: string; 44 | description: string; 45 | priority?: boolean; 46 | i18n?: { 47 | [language: string]: ModalI18n; 48 | }; 49 | buttons?: ModalButton[]; 50 | } 51 | 52 | interface ModalButton { 53 | label: string; 54 | style: string; 55 | events?: ModalButtonEvent[]; 56 | } 57 | 58 | interface ModalButtonEvent { 59 | /** 60 | * The name of the event 61 | * @example "onclick" 62 | */ 63 | name: string; 64 | /** 65 | * The value of the event 66 | * @example "closeModal(this.parentElement.id)" 67 | */ 68 | value?: string; 69 | type?: "close" | "delete"; 70 | save?: string; 71 | /** 72 | * The action of the event 73 | */ 74 | action?: () => void; 75 | } 76 | 77 | interface ModalI18n { 78 | title: string; 79 | description: string; 80 | buttons?: { 81 | [label: string]: string; 82 | }; 83 | } 84 | 85 | interface APIUserRoles { 86 | id: string; 87 | name: string; 88 | } 89 | 90 | interface AppDependencies { 91 | music: boolean; 92 | iTunes: boolean; 93 | appleMusic: boolean; 94 | watchDog: boolean; 95 | discord: boolean; 96 | } 97 | 98 | interface SongDataT { 99 | url: string; 100 | collectionId: number | string; 101 | trackId: number | string; 102 | explicit: boolean; 103 | artwork: string; 104 | } 105 | 106 | interface ImgBBResponse { 107 | data: { 108 | id: string; 109 | title: string; 110 | url_viewer: string; 111 | url: string; 112 | display_url: string; 113 | width: number; 114 | height: number; 115 | size: number; 116 | time: string; 117 | expiration: string; 118 | image: { 119 | filename: string; 120 | name: string; 121 | mime: string; 122 | extension: string; 123 | url: string; 124 | }; 125 | thumb: { 126 | filename: string; 127 | name: string; 128 | mime: string; 129 | extension: string; 130 | url: string; 131 | }; 132 | medium: { 133 | filename: string; 134 | name: string; 135 | mime: string; 136 | extension: string; 137 | url: string; 138 | }; 139 | delete_url: string; 140 | }; 141 | success: boolean; 142 | status: number; 143 | } 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](https://user-images.githubusercontent.com/53608074/201397177-1d08c4ed-d595-41ab-86cb-3e8bd1da46dd.png) 2 | # Apple Music RPC for Discord 3 | 4 | ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/downloads-pre/zephraOSS/Apple-Music-RPC/total) ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/downloads/zephraOSS/Apple-Music-RPC/latest/total) 5 | 6 | ## Apple Music Preview 7 | [Apple Music Preview](https://apps.microsoft.com/store/detail/apple-music-preview/9PFHDD62MXS1) integration is currently in beta testing. [View Releases](https://github.com/zephraOSS/Apple-Music-RPC/releases) 8 | 9 | ## Installation 10 | * [Microsoft Store](https://docs.amrpc.zephra.cloud/installation/windows/microsoft-store) 11 | * [Windows](https://docs.amrpc.zephra.cloud/installation/windows) 12 | * [macOS](https://docs.amrpc.zephra.cloud/installation/macos) 13 | 14 | ## You need help? 15 | * Take a look at [the documentation](https://docs.amrpc.zephra.cloud) 16 | * Join [our Discord](https://discord.gg/APDghNfJhQ) 17 | 18 | ## Information 19 | This RPC is only for iTunes/Apple Music. If you use Apple Music in your browser (macOS, Windows, Linux), check out [Apple Music RPC for PreMiD](https://premid.app/store/presences/Apple%20Music)! 20 | 21 | ## Support 22 |
23 | 24 |
25 | -------------------------------------------------------------------------------- /builder/I18n.d.ts: -------------------------------------------------------------------------------- 1 | export interface I18n { 2 | [key: string | number | symbol]: any; 3 | } 4 | -------------------------------------------------------------------------------- /builder/stable-macos.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "com.zephra.amrpc", 3 | "productName": "AMRPC", 4 | "artifactName": "AMRPC.${ext}", 5 | "copyright": "Copyright © 2022 zephra", 6 | "protocols": [ 7 | { 8 | "name": "AMRPC", 9 | "schemes": ["amrpc"] 10 | } 11 | ], 12 | "asar": true, 13 | "asarUnpack": ["node_modules/apple-bridge/dist/darwin/osascript/"], 14 | "directories": { 15 | "app": "dist", 16 | "buildResources": "src/assets", 17 | "output": "app/macos" 18 | }, 19 | "dmg": { 20 | "window": { 21 | "width": 660, 22 | "height": 400 23 | }, 24 | "contents": [ 25 | { 26 | "x": 180, 27 | "y": 170 28 | }, 29 | { 30 | "x": 480, 31 | "y": 170, 32 | "type": "link", 33 | "path": "/Applications" 34 | } 35 | ], 36 | "title": "AMRPC", 37 | "writeUpdateInfo": false 38 | }, 39 | "mac": { 40 | "icon": "src/assets/icons/darwin/logo.png", 41 | "target": { 42 | "target": "default", 43 | "arch": ["x64", "arm64"] 44 | }, 45 | "extendInfo": { 46 | "LSUIElement": 1 47 | }, 48 | "category": "public.app-category.utilities", 49 | "artifactName": "AMRPC-Installer-macOS-${arch}.${ext}", 50 | "publish": { 51 | "provider": "github", 52 | "releaseType": "draft" 53 | }, 54 | "darkModeSupport": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /builder/stable-win-store.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "com.zephra.amrpc", 3 | "productName": "AMRPC", 4 | "artifactName": "AMRPC.${ext}", 5 | "copyright": "Copyright © 2022 zephra", 6 | "protocols": [ 7 | { 8 | "name": "AMRPC", 9 | "schemes": ["amrpc"] 10 | } 11 | ], 12 | "asar": true, 13 | "asarUnpack": ["node_modules/apple-bridge/dist/win32/wscript/"], 14 | "directories": { 15 | "app": "dist", 16 | "buildResources": "src/assets", 17 | "output": "app/win/store" 18 | }, 19 | "appx": { 20 | "applicationId": "zephra.AMRPC", 21 | "backgroundColor": "transparent", 22 | "displayName": "AMRPC", 23 | "identityName": "62976zephra.AMRPC", 24 | "publisher": "CN=11A3B5E5-0C40-4A2A-9E60-5A3AC8090080", 25 | "publisherDisplayName": "zephra", 26 | "languages": ["en-US", "de-DE"], 27 | "addAutoLaunchExtension": true 28 | }, 29 | "win": { 30 | "target": "appx", 31 | "icon": "src/assets/appx/StoreLogo.png" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /builder/stable.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId": "com.zephra.amrpc", 3 | "productName": "AMRPC", 4 | "artifactName": "AMRPC.${ext}", 5 | "copyright": "Copyright © 2022 zephra", 6 | "protocols": [ 7 | { 8 | "name": "AMRPC", 9 | "schemes": ["amrpc"] 10 | } 11 | ], 12 | "asar": true, 13 | "asarUnpack": ["node_modules/apple-bridge/dist/win32/wscript/"], 14 | "directories": { 15 | "app": "dist", 16 | "buildResources": "src/assets", 17 | "output": "app/win/normal" 18 | }, 19 | "nsis": { 20 | "createDesktopShortcut": "always" 21 | }, 22 | "win": { 23 | "target": "nsis", 24 | "icon": "src/assets/icons/win32/icon.ico", 25 | "artifactName": "AMRPC-Installer.exe", 26 | "verifyUpdateCodeSignature": false, 27 | "publisherName": "zephra", 28 | "publish": ["github"] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /i18n/en-US.json 3 | translation: /i18n/%locale%.json 4 | -------------------------------------------------------------------------------- /i18n/af-ZA.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "admin": "Moet asseblief nie AMRPC met administrateur regte laat loop nie!", 4 | "noTrack": "Geen snit bespeur nie", 5 | "jsFileExtension": "Daar is 'n fout met die JS-lêerassosiasiedata. Klik asseblief op die knoppie hieronder om meer te wete te kom.", 6 | "cmd": "Daar is 'n fout met opdragprompt. Klik asseblief op die knoppie hieronder om meer te wete te kom." 7 | }, 8 | "tray": { 9 | "restart": "Herbegin", 10 | "reportProblem": "Report a Problem", 11 | "quitITunes": { 12 | "info": "This takes about 3 seconds", 13 | "button": "Quit iTunes" 14 | }, 15 | "quit": "Hou op" 16 | }, 17 | "settings": { 18 | "config": { 19 | "autoLaunch": "Outomatiese bekendstelling", 20 | "autoUpdates": "Automatic Updates", 21 | "betaUpdates": "Beta Updates", 22 | "installUpdate": { 23 | "label": "Install Update", 24 | "button": "Install %version%", 25 | "buttonNA": "No update available" 26 | }, 27 | "show": "Wys teenwoordigheid", 28 | "hideOnPause": "Versteek teenwoordigheid op pouse", 29 | "artworkPrioLocal": "Prioritize Local Artwork", 30 | "showTimestamps": "Wys Tydstempels", 31 | "showAlbumArtwork": "Wys albumkunswerk", 32 | "performanceMode": "Prestasiemodus", 33 | "hardwareAcceleration": "Hardeware versnelling", 34 | "checkIfMusicInstalled": "Check if Music is installed", 35 | "colorTheme": { 36 | "label": "Voorkeurkleurtema", 37 | "light": "Lig", 38 | "dark": "Donker", 39 | "auto": "Donker van sonsondergang tot sonsopkoms", 40 | "os": "Stelsel verstek" 41 | }, 42 | "language": "Verkose Taal", 43 | "rpcLargeImageText": "Presence Large Image Text", 44 | "rpcDetails": "Teenwoordigheid Besonderhede", 45 | "rpcState": "Teenwoordigheid Staat", 46 | "enableLastFM": "Aktiveer Last.fm", 47 | "lastFMUser": { 48 | "label": "Last.fm-gebruiker", 49 | "connect": "Koppel", 50 | "reconnect": "Herkoppel", 51 | "connecting": "Koppel tans...", 52 | "cancel": "Kanselleer" 53 | }, 54 | "enableCache": "Aktiveer Cache", 55 | "resetCache": "Stel kas terug" 56 | }, 57 | "common": { 58 | "reset": "Terugstel" 59 | }, 60 | "category": { 61 | "general": "Algemeen", 62 | "softwareUpdate": "Software Update", 63 | "cache": "Cache Management", 64 | "advanced": "Advanced" 65 | }, 66 | "warn": { 67 | "music": { 68 | "title": "Aandag", 69 | "description": "Die Musiek-toepassing kon nie gevind word nie. Maak asseblief seker dat die Musiek-toepassing geïnstalleer is." 70 | }, 71 | "discord": { 72 | "title": "Aandag", 73 | "description": "Discord kon nie gevind word nie. Maak asseblief seker dat Discord geïnstalleer is." 74 | } 75 | }, 76 | "note": { 77 | "language": "As jou taal ontbreek, kontak ons.", 78 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.", 79 | "cache": { 80 | "size": "Huidige kasgrootte: %size%, %count% items", 81 | "warn": "WAARSKUWING As u die kas deaktiveer, sal alle gekasdata uitvee.", 82 | "info": "Die kas stoor alle albumkunswerk en ander data om die hoeveelheid versoeke aan Apple te verminder. Dit sal die laaityd van die albumkunswerk en skakel verbeter." 83 | }, 84 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed." 85 | }, 86 | "extra": { 87 | "github": "Kyk op GitHub", 88 | "koFi": "Skenk", 89 | "reloadPage": "Herlaai", 90 | "restartApp": "Herbegin vereis" 91 | }, 92 | "modal": { 93 | "newUpdate": { 94 | "title": "Sagteware opdatering", 95 | "description": "Weergawe %version% is beskikbaar vir jou rekenaar.", 96 | "installed": { 97 | "description": "Weergawe %version% is beskikbaar vir jou rekenaar en is gereed om te installeer.", 98 | "buttons": { 99 | "install": "Installeer nou", 100 | "later": "Later" 101 | } 102 | }, 103 | "buttons": { 104 | "downloadAndInstall": "Laai af en installeer", 105 | "download": "Laai af en installeer later" 106 | } 107 | }, 108 | "koFi": { 109 | "title": "Vereis Premium", 110 | "description": "Om hierdie kenmerk te gebruik, word 'n eenmalige skenking of 'n lidmaatskap vereis. Jy kan skenk hier. As jy reeds geskenk het, probeer om AMRPC te herbegin. Leer meer" 111 | }, 112 | "microsoftStore": { 113 | "description": "Het jy geweet dat jy AMRPC vanaf die Microsoft Store kan installeer? Maar maak seker dat u dit deaktiveer Outomatiese bekendstelling in die instellings voordat u dit vanaf die Microsoft Store installeer.", 114 | "buttons": { 115 | "download": "Laai nou af" 116 | } 117 | }, 118 | "buttons": { 119 | "no": "Geen", 120 | "later": "Nie nou nie", 121 | "yes": "Ja", 122 | "okay": "Goed", 123 | "learnMore": "Leer meer", 124 | "close": "Gesluit" 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /i18n/de-DE.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "admin": "Bitte starte AMRPC nicht mit Administrator-Rechten!", 4 | "noTrack": "Kein Lied erkannt", 5 | "jsFileExtension": "Es liegt ein Fehler bei den JS-Dateizuordnungsdaten vor. Klicke auf den Button unten, um mehr zu erfahren.", 6 | "cmd": "Es liegt ein Fehler mit der Eingabeaufforderung vor. Klicke auf den Button unten, um mehr zu erfahren." 7 | }, 8 | "tray": { 9 | "restart": "Neustart", 10 | "reportProblem": "Problem melden", 11 | "quitITunes": { 12 | "info": "Vorgang dauert ca. 3 Sekunden", 13 | "button": "iTunes beenden" 14 | }, 15 | "quit": "Beenden" 16 | }, 17 | "settings": { 18 | "config": { 19 | "autoLaunch": "Autostart", 20 | "autoUpdates": "Automatische Updates", 21 | "betaUpdates": "Beta Updates", 22 | "installUpdate": { 23 | "label": "Update installieren", 24 | "button": "%version% installieren", 25 | "buttonNA": "Kein Update verfügbar" 26 | }, 27 | "show": "Presence anzeigen", 28 | "hideOnPause": "Presence während Pause ausblenden", 29 | "artworkPrioLocal": "Lokales Artwork priorisieren", 30 | "showTimestamps": "Timestamps anzeigen", 31 | "showAlbumArtwork": "Album Artwork anzeigen", 32 | "performanceMode": "Performance Modus", 33 | "hardwareAcceleration": "Hardware-Beschleunigung", 34 | "checkIfMusicInstalled": "Music-Installation überprüfen", 35 | "colorTheme": { 36 | "label": "Bevorzugtes Farbschema", 37 | "light": "Hell", 38 | "dark": "Dunkel", 39 | "auto": "Dunkel von Sonnenuntergang bis -aufgang", 40 | "os": "System Farbschema" 41 | }, 42 | "language": "Bevorzugte Sprache", 43 | "rpcLargeImageText": "Presence Large Image Text", 44 | "rpcDetails": "Presence Details", 45 | "rpcState": "Presence State", 46 | "enableLastFM": "Last.fm aktivieren", 47 | "lastFMUser": { 48 | "label": "Last.fm Benutzer", 49 | "connect": "Verbinden", 50 | "reconnect": "Neu verbinden", 51 | "connecting": "Verbinden...", 52 | "cancel": "Abbrechen" 53 | }, 54 | "enableCache": "Cache aktivieren", 55 | "resetCache": "Cache löschen" 56 | }, 57 | "common": { 58 | "reset": "Löschen" 59 | }, 60 | "category": { 61 | "general": "Allgemein", 62 | "softwareUpdate": "Software Update", 63 | "cache": "Cache Management", 64 | "advanced": "Erweitert" 65 | }, 66 | "warn": { 67 | "music": { 68 | "title": "Achtung", 69 | "description": "Die Musik App konnte nicht gefunden werden. Bitte stelle sicher, dass die Musik App installiert ist." 70 | }, 71 | "discord": { 72 | "title": "Achtung", 73 | "description": "Discord konnte nicht gefunden werden. Bitte stelle sicher, dass Discord installiert ist." 74 | } 75 | }, 76 | "note": { 77 | "language": "Wenn deine Sprache fehlt, kontaktiere uns.", 78 | "artworkPrioLocal": "Wenn ein lokales Artwork verfügbar ist, wird es anstelle des Artworks der Apple Music API verwendet.", 79 | "cache": { 80 | "size": "Derzeitige Cache-Größe: %size%, %count% Einträge", 81 | "warn": "WARNUNG Wenn der Cache deaktiviert wird, werden alle Daten gelöscht.", 82 | "info": "Der Cache speichert alle Album-Cover und andere Daten, um die Anzahl der Anfragen an Apple zu reduzieren. Dadurch wird die Ladezeit des Album-Covers und des Links verbessert." 83 | }, 84 | "checkIfMusicInstalled": "Nur deaktivieren, falls Probleme mit den Music Checks auftreten und du sicher bist, dass Music installiert ist." 85 | }, 86 | "extra": { 87 | "github": "Auf GitHub ansehen", 88 | "koFi": "Spenden", 89 | "reloadPage": "Neu laden", 90 | "restartApp": "Neustart erforderlich" 91 | }, 92 | "modal": { 93 | "newUpdate": { 94 | "title": "Software Update", 95 | "description": "Die Version %version% ist für deinen PC verfügbar.", 96 | "installed": { 97 | "description": "Die Version %version% ist für deinen PC verfügbar und kann installiert werden.", 98 | "buttons": { 99 | "install": "Jetzt installieren", 100 | "later": "Später" 101 | } 102 | }, 103 | "buttons": { 104 | "downloadAndInstall": "Laden & installieren", 105 | "download": "Laden & später installieren" 106 | } 107 | }, 108 | "koFi": { 109 | "title": "Erfordert Premium", 110 | "description": "Um dieses Feature nutzen zu können, ist eine one-time Spende oder Mitgliedschaft erforderlich. Du kannst hier spenden. Solltest du bereits gespendet haben, starte einmal AMRPC neu. Mehr erfahren" 111 | }, 112 | "microsoftStore": { 113 | "description": "Wusstest du, dass du AMRPC auch im Microsoft Store herunterladen kannst? Aber stelle sicher, dass du Auto Launch in den Einstellungen deaktivierst, bevor du AMRPC im Microsoft Store installierst.", 114 | "buttons": { 115 | "download": "Jetzt herunterladen" 116 | } 117 | }, 118 | "buttons": { 119 | "no": "Nein", 120 | "later": "Nicht jetzt", 121 | "yes": "Ja", 122 | "okay": "Okay", 123 | "learnMore": "Mehr erfahren", 124 | "close": "Schließen" 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /i18n/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "buttons": { 4 | "openSettings": "Open Settings" 5 | } 6 | }, 7 | "error": { 8 | "admin": "Please do not run AMRPC with administrator privileges!", 9 | "noTrack": "No track detected", 10 | "jsFileExtension": "There is an error with the JS file association data. Please click on the button below to learn more.", 11 | "cmd": "There is an error with Command Prompt. Please click on the button below to learn more.", 12 | "iTunesInstalledCheck": "AMRPC couldn't check if iTunes is installed. If you're sure that iTunes is installed, go to Settings > Advanced and disable \"Check if Music is installed\".", 13 | "watchDog": { 14 | "title": "AMRPC WatchDog not installed", 15 | "description": "WatchDog is required for Apple Music (Preview). Do you want to install it?" 16 | } 17 | }, 18 | "tray": { 19 | "restart": "Restart", 20 | "reportProblem": "Report a Problem", 21 | "quitITunes": { 22 | "info": "Takes about 3 seconds", 23 | "button": "Quit iTunes" 24 | }, 25 | "quit": "Quit" 26 | }, 27 | "settings": { 28 | "config": { 29 | "autoLaunch": "Auto Launch", 30 | "autoUpdates": "Automatic Updates", 31 | "betaUpdates": "Beta Updates", 32 | "installUpdate": { 33 | "label": "Install Update", 34 | "button": "Install %version%", 35 | "buttonNA": "No update available" 36 | }, 37 | "show": "Show Presence", 38 | "hideOnPause": "Hide Presence on Pause", 39 | "artworkPrioLocal": "Prioritize Local Artwork", 40 | "showTimestamps": "Show Timestamps", 41 | "showAlbumArtwork": "Show Album Artwork", 42 | "performanceMode": "Performance Mode", 43 | "hardwareAcceleration": "Hardware Acceleration", 44 | "checkIfMusicInstalled": "Check if Music is installed", 45 | "colorTheme": { 46 | "label": "Preferred Color Theme", 47 | "light": "Light", 48 | "dark": "Dark", 49 | "auto": "Dark from Sunset to Sunrise", 50 | "os": "System Default" 51 | }, 52 | "language": "Preferred Language", 53 | "rpcLargeImageText": "Presence Large Image Text", 54 | "rpcDetails": "Presence Details", 55 | "rpcState": "Presence State", 56 | "enableLastFM": "Enable Last.fm", 57 | "lastFMUser": { 58 | "label": "Last.fm User", 59 | "connect": "Connect", 60 | "reconnect": "Reconnect", 61 | "connecting": "Connecting...", 62 | "cancel": "Cancel" 63 | }, 64 | "enableCache": "Enable Cache", 65 | "resetCache": "Reset Cache" 66 | }, 67 | "common": { 68 | "reset": "Reset" 69 | }, 70 | "category": { 71 | "general": "General", 72 | "softwareUpdate": "Software Update", 73 | "cache": "Cache Management", 74 | "advanced": "Advanced" 75 | }, 76 | "warn": { 77 | "music": { 78 | "title": "Attention", 79 | "description": "The Music app could not be found. Please make sure that the Music app is installed." 80 | }, 81 | "discord": { 82 | "title": "Attention", 83 | "description": "Discord could not be found. Please make sure that Discord is installed." 84 | } 85 | }, 86 | "note": { 87 | "language": "If your language is missing, contact us.", 88 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.", 89 | "cache": { 90 | "size": "Current cache size: %size%, %count% items", 91 | "warn": "WARNING Disabling the cache will delete all cached data.", 92 | "info": "The cache stores all album artwork and other data to reduce the amount of requests to Apple. This will improve the loading time of the album artwork and link." 93 | }, 94 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed." 95 | }, 96 | "extra": { 97 | "github": "View on GitHub", 98 | "koFi": "Donate", 99 | "reloadPage": "Reload", 100 | "restartApp": "Restart required" 101 | }, 102 | "modal": { 103 | "newUpdate": { 104 | "title": "Software Update", 105 | "description": "Version %version% is available for your PC.", 106 | "installed": { 107 | "description": "Version %version% is available for your PC and is ready to install.", 108 | "buttons": { 109 | "install": "Install now", 110 | "later": "Later" 111 | } 112 | }, 113 | "buttons": { 114 | "downloadAndInstall": "Download & install", 115 | "download": "Download & install later" 116 | } 117 | }, 118 | "koFi": { 119 | "title": "Requires Premium", 120 | "description": "To use this feature, a one-time donation or a membership is required. You can donate here. If you have already donated, try to restart AMRPC. Learn More" 121 | }, 122 | "microsoftStore": { 123 | "description": "Did you know that you can install AMRPC from the Microsoft Store? But make sure to disable Auto Launch in the settings before installing it from the Microsoft Store.", 124 | "buttons": { 125 | "download": "Download now" 126 | } 127 | }, 128 | "buttons": { 129 | "no": "No", 130 | "later": "Not now", 131 | "yes": "Yes", 132 | "okay": "Okay", 133 | "learnMore": "Learn more", 134 | "close": "Close" 135 | } 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /i18n/fr-FR.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "admin": "Merci de ne pas exécuter AMRPC avec les droits d'administrateur !", 4 | "noTrack": "Aucune piste détéctée", 5 | "jsFileExtension": "Il y a une erreur dans les données d'association du fichier JS. Veuillez cliquer sur le bouton ci-dessous pour en savoir plus.", 6 | "cmd": "Il y a une erreur avec le terminal de commande. Veuillez cliquer sur le bouton ci-dessous pour en savoir plus." 7 | }, 8 | "tray": { 9 | "restart": "Redémarrer", 10 | "reportProblem": "Signaler un problème", 11 | "quitITunes": { 12 | "info": "Cela prend 3 secondes", 13 | "button": "Quitter iTunes" 14 | }, 15 | "quit": "Quitter" 16 | }, 17 | "settings": { 18 | "config": { 19 | "autoLaunch": "Lancement Automatique", 20 | "autoUpdates": "Mises à jour automatiques", 21 | "betaUpdates": "Mises à jour bêta", 22 | "installUpdate": { 23 | "label": "Installer la mise à jour", 24 | "button": "Installer %version%", 25 | "buttonNA": "Vous êtes à jour" 26 | }, 27 | "show": "Afficher la Présence", 28 | "hideOnPause": "Cacher la Présence en Pause", 29 | "artworkPrioLocal": "Pochettes d’album en local", 30 | "showTimestamps": "Afficher le Timestamp", 31 | "showAlbumArtwork": "Afficher la Pochette de l'Album", 32 | "performanceMode": "Mode Performance", 33 | "hardwareAcceleration": "Accélération Hardware", 34 | "checkIfMusicInstalled": "Vérifier si Music est installé", 35 | "colorTheme": { 36 | "label": "Thème de Couleur préféré", 37 | "light": "Clair", 38 | "dark": "Sombre", 39 | "auto": "Sombre du coucher au lever du soleil", 40 | "os": "Valeur par défaut du système" 41 | }, 42 | "language": "Langue préférée", 43 | "rpcLargeImageText": "Texte sur l’image de l’activité", 44 | "rpcDetails": "Détails de la Présence", 45 | "rpcState": "État de la Présence", 46 | "enableLastFM": "Activer Last.fm", 47 | "lastFMUser": { 48 | "label": "Utilisateur Last.fm", 49 | "connect": "Connecter", 50 | "reconnect": "Reconnecter", 51 | "connecting": "Connexion...", 52 | "cancel": "Annuler" 53 | }, 54 | "enableCache": "Activer le Cache", 55 | "resetCache": "Réinitialiser le Cache" 56 | }, 57 | "common": { 58 | "reset": "Réinitialiser" 59 | }, 60 | "category": { 61 | "general": "Général", 62 | "softwareUpdate": "Mise à jour logicielle", 63 | "cache": "Gestion du cache", 64 | "advanced": "Paramètres avancés" 65 | }, 66 | "warn": { 67 | "music": { 68 | "title": "Attention", 69 | "description": "L'app Musique n'a pas pu être trouvé. Veuillez vérifier que l'app Musique est installé." 70 | }, 71 | "discord": { 72 | "title": "Attention", 73 | "description": "Discord n'a pas pu être trouvé. Veuillez vérifier que Discord est bien installé." 74 | } 75 | }, 76 | "note": { 77 | "language": "Si votre langue manque, contactez nous.", 78 | "artworkPrioLocal": "Si une pochette d'album en local est disponible, elle sera utilisée à la place de celle renvoyée par l'API d'Apple Music.", 79 | "cache": { 80 | "size": "Taille du cache actuelle : %size%, %count% éléments", 81 | "warn": "ATTENTION Désactiver le cache va supprimer toutes les données en cache.", 82 | "info": "Le cache sauvegarde toutes les couvertures d'album et autres données pour réduire le nombre de requêtes vers Apple. Cela améliore le temps de chargement des couvertures et des liens." 83 | }, 84 | "checkIfMusicInstalled": "Désactivez ceci seulement si vous rencontrez des problèmes avec ce que vous avez coché, et que vous êtes sûrs que l'application Music est bien installé." 85 | }, 86 | "extra": { 87 | "github": "Voir sur GitHub", 88 | "koFi": "Faire un Don", 89 | "reloadPage": "Recharger", 90 | "restartApp": "Rechargement requis" 91 | }, 92 | "modal": { 93 | "newUpdate": { 94 | "title": "Mise à jour du Logiciel", 95 | "description": "La version %version% est disponible pour votre ordinateur.", 96 | "installed": { 97 | "description": "La version %version% est disponible pour votre ordinateur et elle est prête à être installé.", 98 | "buttons": { 99 | "install": "Installer maintenant", 100 | "later": "Plus tard" 101 | } 102 | }, 103 | "buttons": { 104 | "downloadAndInstall": "Télécharger & installer", 105 | "download": "Télécharger & installer plus tard" 106 | } 107 | }, 108 | "koFi": { 109 | "title": "Nécessite Premium", 110 | "description": "Pour utiliser cette fonctionnalité, une donation-en-une-fois ou un abonnement membre est nécessaire. Vous pouvez faire un don ici. Si vous avez déjà fait un don, essayer de redémarrer AMRPC. En apprendre plus" 111 | }, 112 | "microsoftStore": { 113 | "description": "Saviez-vous que vous pouvez installer AMRPC depuis le Microsoft Store ? Mais désactivez Lancement Automatique dans les réglages avant de l'installer depuis le Microsoft Store.", 114 | "buttons": { 115 | "download": "Télécharger maintenant" 116 | } 117 | }, 118 | "buttons": { 119 | "no": "Non", 120 | "later": "Pas maintenant", 121 | "yes": "Oui", 122 | "okay": "Ok", 123 | "learnMore": "En apprendre plus", 124 | "close": "Fermer" 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /i18n/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "admin": "Não execute o AMRPC como administrador!", 4 | "noTrack": "Nenhuma música detectada", 5 | "jsFileExtension": "Há um erro com o arquivo JS de associação dos dados. Clique no botão abaixo para saber mais.", 6 | "cmd": "Há um erro com o prompt de comando. Clique no botão abaixo para saber mais." 7 | }, 8 | "tray": { 9 | "restart": "Reiniciar", 10 | "reportProblem": "Reportar um problema", 11 | "quitITunes": { 12 | "info": "Isso levará em torno de 3 segundos", 13 | "button": "Encerrar iTunes" 14 | }, 15 | "quit": "Encerrar" 16 | }, 17 | "settings": { 18 | "config": { 19 | "autoLaunch": "Iniciar automaticamente", 20 | "autoUpdates": "Automatic Updates", 21 | "betaUpdates": "Beta Updates", 22 | "installUpdate": { 23 | "label": "Install Update", 24 | "button": "Install %version%", 25 | "buttonNA": "No update available" 26 | }, 27 | "show": "Mostrar Presença", 28 | "hideOnPause": "Esconder Presença quando pausar", 29 | "artworkPrioLocal": "Prioritize Local Artwork", 30 | "showTimestamps": "Mostrar marcadores de tempo", 31 | "showAlbumArtwork": "Mostrar arte do álbum", 32 | "performanceMode": "Modo Desempenho", 33 | "hardwareAcceleration": "Aceleração de hardware", 34 | "checkIfMusicInstalled": "Check if Music is installed", 35 | "colorTheme": { 36 | "label": "Esquema de cores", 37 | "light": "Claro", 38 | "dark": "Escuro", 39 | "auto": "Escuro do pôr do sol até nascer do sol", 40 | "os": "Padrão do sistema" 41 | }, 42 | "language": "Idioma", 43 | "rpcLargeImageText": "Texto da presença na imagem", 44 | "rpcDetails": "Detalhes da presença", 45 | "rpcState": "Estado da presença", 46 | "enableLastFM": "Habilitar Last.fm", 47 | "lastFMUser": { 48 | "label": "Usuário do Last.fm", 49 | "connect": "Conectar", 50 | "reconnect": "Reconectar", 51 | "connecting": "Conectando...", 52 | "cancel": "Cancelar" 53 | }, 54 | "enableCache": "Habilitar cache", 55 | "resetCache": "Redefinir cache" 56 | }, 57 | "common": { 58 | "reset": "Redefinir" 59 | }, 60 | "category": { 61 | "general": "Geral", 62 | "softwareUpdate": "Software Update", 63 | "cache": "Gerência de cache", 64 | "advanced": "Advanced" 65 | }, 66 | "warn": { 67 | "music": { 68 | "title": "Atenção", 69 | "description": "O aplicativo música não pôde ser encontrado. Certifique-se de que o aplicativo música está instalado." 70 | }, 71 | "discord": { 72 | "title": "Atenção", 73 | "description": "O Discord não pôde ser encontrado. Certifique-se de que o Discord está instalado." 74 | } 75 | }, 76 | "note": { 77 | "language": "Se seu idioma não está disponível, entre em contato conosco.", 78 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.", 79 | "cache": { 80 | "size": "Tamanho atual do cache: %size%, %count% itens", 81 | "warn": "AVISO Desabilitar o cache causará a remoção de todos os dados do cache.", 82 | "info": "O cache guarda todas as artes de álbum e outros dados para reduzir o número de requisições feitos para Apple. Isso pode melhorar o tempo de carregamento da arte do álbum e do link." 83 | }, 84 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed." 85 | }, 86 | "extra": { 87 | "github": "Ver no GitHub", 88 | "koFi": "Doar", 89 | "reloadPage": "Recarregar", 90 | "restartApp": "Reinício necessário" 91 | }, 92 | "modal": { 93 | "newUpdate": { 94 | "title": "Atualização do programa", 95 | "description": "A Versão %version% está disponível para seu PC.", 96 | "installed": { 97 | "description": "A versão %version% está disponível para seu PC e pronta para ser instalada.", 98 | "buttons": { 99 | "install": "Instalar agora", 100 | "later": "Mais tarde" 101 | } 102 | }, 103 | "buttons": { 104 | "downloadAndInstall": "Baixar e instalar", 105 | "download": "Baixar e instalar mais tarde" 106 | } 107 | }, 108 | "koFi": { 109 | "title": "Requer Premium", 110 | "description": "Para usar essa funcionalidade é necessário fazer uma doação única ou possuir uma inscrição. Você pode doar aqui. Se você já fez uma doação tente reiniciar o AMRPC. Saiba mais" 111 | }, 112 | "microsoftStore": { 113 | "description": "Você sabia que pode instalar o AMRPC através da Loja Microsoft? Certifique-se de desabilitar a opção Iniciar automaticamente nas configurações antes de instalar a versão da Loja Microsoft.", 114 | "buttons": { 115 | "download": "Baixar agora" 116 | } 117 | }, 118 | "buttons": { 119 | "no": "Não", 120 | "later": "Agora não", 121 | "yes": "Sim", 122 | "okay": "Ok", 123 | "learnMore": "Saiba mais", 124 | "close": "Fechar" 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /i18n/ru-RU.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "admin": "Пожалуйста, не запускайте AMRPC с правами администратора!", 4 | "noTrack": "Песня не найдена", 5 | "jsFileExtension": "Возникла ошибка с JS файлом. Пожалуйста, нажмите на кнопку, чтобы узнать больше.", 6 | "cmd": "Возникла ошибка с Командной строкой. Пожалуйста, нажмите на кнопку ниже, чтобы узнать больше." 7 | }, 8 | "tray": { 9 | "restart": "Перезапустить", 10 | "reportProblem": "Report a Problem", 11 | "quitITunes": { 12 | "info": "This takes about 3 seconds", 13 | "button": "Quit iTunes" 14 | }, 15 | "quit": "Выйти" 16 | }, 17 | "settings": { 18 | "config": { 19 | "autoLaunch": "Автозапуск", 20 | "autoUpdates": "Automatic Updates", 21 | "betaUpdates": "Beta Updates", 22 | "installUpdate": { 23 | "label": "Install Update", 24 | "button": "Install %version%", 25 | "buttonNA": "No update available" 26 | }, 27 | "show": "Показывать активность", 28 | "hideOnPause": "Скрывать активность, когда песня на паузе", 29 | "artworkPrioLocal": "Prioritize Local Artwork", 30 | "showTimestamps": "Show Timestamps", 31 | "showAlbumArtwork": "Показывать обложку альбома", 32 | "performanceMode": "Режим производительности", 33 | "hardwareAcceleration": "Аппаратное ускорение", 34 | "checkIfMusicInstalled": "Check if Music is installed", 35 | "colorTheme": { 36 | "label": "Предпочтительная тема", 37 | "light": "Светлая", 38 | "dark": "Тёмная", 39 | "auto": "Тёмная только с вечера до утра", 40 | "os": "Системная" 41 | }, 42 | "language": "Язык", 43 | "rpcLargeImageText": "Presence Large Image Text", 44 | "rpcDetails": "Вторая строка в активности", 45 | "rpcState": "Третья строка в активности", 46 | "enableLastFM": "Использовать Last.fm", 47 | "lastFMUser": { 48 | "label": "Пользователь Last.fm", 49 | "connect": "Подключить", 50 | "reconnect": "Переподключение", 51 | "connecting": "Подключение...", 52 | "cancel": "Отмена" 53 | }, 54 | "enableCache": "Использовать кэш", 55 | "resetCache": "Сбросить кэш" 56 | }, 57 | "common": { 58 | "reset": "Сброс" 59 | }, 60 | "category": { 61 | "general": "Основные", 62 | "softwareUpdate": "Software Update", 63 | "cache": "Cache Management", 64 | "advanced": "Advanced" 65 | }, 66 | "warn": { 67 | "music": { 68 | "title": "Внимание", 69 | "description": "Музыкальное приложение не найдено. Пожалуйста, убедитесь, что музыкальное приложение установлено." 70 | }, 71 | "discord": { 72 | "title": "Внимание", 73 | "description": "Discord не найден. Пожалуйста, убедитесь, что Discord установлен." 74 | } 75 | }, 76 | "note": { 77 | "language": "Если Вы не нашли ваш язык, свяжитесь с нами.", 78 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.", 79 | "cache": { 80 | "size": "Размер кэша: %size%, %count% файлов", 81 | "warn": "ВНИМАНИЕ Отключение кэша удалит все кэшированные данные.", 82 | "info": "В кэш сохраняются все обложки альбомов и другие данные для уменьшения запросов к Apple. Это сократит время загрузки обложек и ссылок на песню." 83 | }, 84 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed." 85 | }, 86 | "extra": { 87 | "github": "Репозиторий на GitHub", 88 | "koFi": "Пожертвовать", 89 | "reloadPage": "Перезагрузить", 90 | "restartApp": "Требуется перезагрузка" 91 | }, 92 | "modal": { 93 | "newUpdate": { 94 | "title": "Обновление приложения", 95 | "description": "Версия %version% доступна для Вашего ПК.", 96 | "installed": { 97 | "description": "Версия %version% доступна для Вашего ПК и готова к установке.", 98 | "buttons": { 99 | "install": "Установить сейчас", 100 | "later": "Позже" 101 | } 102 | }, 103 | "buttons": { 104 | "downloadAndInstall": "Скачать и установить", 105 | "download": "Скачать и установить позже" 106 | } 107 | }, 108 | "koFi": { 109 | "title": "Требуется Премиум", 110 | "description": "Чтобы использовать эту функцию Вам нужно совершить одно пожертвование или быть платным подписчиком. Вы можете пожертвовать здесь. Если Вы уже совершили пожертвование, перезапустите AMRPC. Читать больше" 111 | }, 112 | "microsoftStore": { 113 | "description": "Вы знали, что Вы можете установить AMRPC в Microsoft Store? Но не забудьте отключить Автозапуск в настройках перед установкой.", 114 | "buttons": { 115 | "download": "Скачать сейчас" 116 | } 117 | }, 118 | "buttons": { 119 | "no": "Нет", 120 | "later": "Не сейчас", 121 | "yes": "Да", 122 | "okay": "Окей", 123 | "learnMore": "Читать больше", 124 | "close": "Закрыть" 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amrpc", 3 | "version": "4.3.0", 4 | "description": "Discord RPC for Apple Music", 5 | "scripts": { 6 | "start": "npm run copy && npm run generate:i18n-types && npm run build && electron ./dist/", 7 | "copy": "copyfiles -u 1 -a src/assets/** src/language/** src/browser/*.css src/browser/*.html src/browser/css/*.css dist/ && copyfiles package.json node_modules/** dist/", 8 | "build": "tsc && cd src/browser/renderer/ && tsc", 9 | "build:win": "npm run build && npm run copy && electron-builder --config builder/stable.json", 10 | "build:win-store": "npm run build && npm run copy && electron-builder --config builder/stable-win-store.json", 11 | "build:macos": "npm run build && npm run copy && electron-builder --config builder/stable-macos.json", 12 | "generate:i18n-types": "make_types -i @types/zephra/I18n.d.ts i18n/en-US.json I18n", 13 | "generate:i18n-types-ci": "copyfiles -u 1 -a builder/I18n.d.ts @types/zephra/" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/zephraOSS/Apple-Music-RPC.git" 18 | }, 19 | "keywords": [ 20 | "discord", 21 | "rpc", 22 | "iTunes", 23 | "Apple", 24 | "Music" 25 | ], 26 | "author": "zephra", 27 | "license": "GPL-3.0", 28 | "bugs": { 29 | "url": "https://github.com/zephraOSS/Apple-Music-RPC/issues" 30 | }, 31 | "homepage": "https://github.com/zephraOSS/Apple-Music-RPC#readme", 32 | "dependencies": { 33 | "@crowdin/ota-client": "^0.7.0", 34 | "@sentry/electron": "^4.2.0", 35 | "apple-bridge": "^1.4.1", 36 | "auto-launch": "^5.0.5", 37 | "cheerio": "^1.0.0-rc.12", 38 | "child-process-promise": "^2.2.1", 39 | "decompress": "^4.2.1", 40 | "discord-rpc": "^4.0.1", 41 | "electron-autotheme": "^1.3.2", 42 | "electron-log": "^4.4.4", 43 | "electron-store": "^8.0.1", 44 | "electron-updater": "^5.3.0", 45 | "fetch": "^1.1.0", 46 | "form-data": "^4.0.0", 47 | "fs": "0.0.1-security", 48 | "http": "^0.0.1-security", 49 | "lastfmapi": "^0.1.1", 50 | "url": "^0.11.0", 51 | "ws": "^8.6.0" 52 | }, 53 | "devDependencies": { 54 | "@types/auto-launch": "^5.0.2", 55 | "@types/cron": "^1.7.3", 56 | "@types/decompress": "^4.2.4", 57 | "@types/discord-rpc": "^4.0.2", 58 | "@types/ws": "^8.5.3", 59 | "copyfiles": "^2.4.1", 60 | "electron": "^22.3.5", 61 | "electron-builder": "^23.6.0", 62 | "maketypes": "^1.1.2", 63 | "typescript": "^4.6.3" 64 | }, 65 | "overrides": { 66 | "minimatch": ">=3.0.5" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/assets/app/usaflag.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/app/usaflag.gif -------------------------------------------------------------------------------- /src/assets/app/usaflag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/app/usaflag.png -------------------------------------------------------------------------------- /src/assets/appx/SmallTile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/SmallTile.png -------------------------------------------------------------------------------- /src/assets/appx/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Square150x150Logo.png -------------------------------------------------------------------------------- /src/assets/appx/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Square44x44Logo.png -------------------------------------------------------------------------------- /src/assets/appx/Square44x44Logo.targetsize-44_altform-unplated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Square44x44Logo.targetsize-44_altform-unplated.png -------------------------------------------------------------------------------- /src/assets/appx/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/StoreLogo.png -------------------------------------------------------------------------------- /src/assets/appx/Wide310x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Wide310x150Logo.png -------------------------------------------------------------------------------- /src/assets/icons/darwin/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/icons/darwin/logo.png -------------------------------------------------------------------------------- /src/assets/icons/win32/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/icons/win32/icon.ico -------------------------------------------------------------------------------- /src/assets/icons/win32/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/icons/win32/logo.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/statusBarIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/statusBarIcon.png -------------------------------------------------------------------------------- /src/assets/statusBarIconError.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/statusBarIconError.png -------------------------------------------------------------------------------- /src/assets/tray/logo@18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/tray/logo@18.png -------------------------------------------------------------------------------- /src/assets/tray/logo@32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/tray/logo@32.png -------------------------------------------------------------------------------- /src/assets/trayLogo@18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/trayLogo@18.png -------------------------------------------------------------------------------- /src/assets/trayLogo@32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/trayLogo@32.png -------------------------------------------------------------------------------- /src/browser/css/wakandaForever.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #0c0b13 !important; 3 | color: #ffffff8c !important; 4 | --border-color: #664eae !important; 5 | } 6 | 7 | div.control { 8 | background-color: #252046 !important; 9 | } 10 | 11 | div.content form .settings_setting { 12 | background-color: rgb(0 0 0 / 75%) !important; 13 | } 14 | 15 | .cfgSwitch i::before, 16 | .cfgSwitch i { 17 | background-color: #1a0554 !important; 18 | } 19 | 20 | .cfgSwitch i::after { 21 | background-color: #c4a8ff !important; 22 | } 23 | 24 | .cfgSwitch input:checked + i { 25 | background-color: #664eae !important; 26 | } 27 | -------------------------------------------------------------------------------- /src/browser/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | 3 | console.log("[BROWSER PRELOAD] Ready"); 4 | 5 | contextBridge.exposeInMainWorld("electron", { 6 | appVersion: async () => { 7 | return await ipcRenderer.invoke("appVersion"); 8 | }, 9 | getPlatform: async () => { 10 | return await ipcRenderer.invoke("getPlatform"); 11 | }, 12 | isWindowsStore: async () => { 13 | return await ipcRenderer.invoke("isWindowsStore"); 14 | }, 15 | isDeveloper: async () => { 16 | return await ipcRenderer.invoke("isDeveloper"); 17 | }, 18 | isSupporter: async () => { 19 | return await ipcRenderer.invoke("isSupporter"); 20 | }, 21 | getLanguages: async () => { 22 | return ipcRenderer.invoke("getLanguages"); 23 | }, 24 | getLangStrings: () => { 25 | return ipcRenderer.invoke("getLangStrings"); 26 | }, 27 | getSystemTheme: () => { 28 | return ipcRenderer.invoke("getSystemTheme", {}); 29 | }, 30 | getTheme: () => { 31 | return ipcRenderer.invoke("getTheme", {}); 32 | }, 33 | getCurrentTrack: () => { 34 | return ipcRenderer.invoke("getCurrentTrack", {}); 35 | }, 36 | checkAppDependencies: () => { 37 | return ipcRenderer.invoke("checkAppDependencies", {}); 38 | }, 39 | appData: { 40 | set: (k, v) => ipcRenderer.invoke("updateAppData", k, v), 41 | get: (k) => { 42 | return ipcRenderer.invoke("getAppData", k); 43 | } 44 | }, 45 | config: { 46 | set: (k, v) => ipcRenderer.invoke("updateConfig", k, v), 47 | get: (k) => { 48 | return ipcRenderer.invoke("getConfig", k); 49 | }, 50 | reset: (k) => { 51 | return ipcRenderer.invoke("resetConfig", k); 52 | } 53 | }, 54 | lastFM: { 55 | getUser: () => { 56 | return ipcRenderer.invoke("lastfm-getUser"); 57 | }, 58 | connect: (connect: boolean = true) => { 59 | return ipcRenderer.invoke("lastfm-connect", connect); 60 | } 61 | }, 62 | fetchChangelog: async () => { 63 | return await ipcRenderer.invoke("fetchChangelog"); 64 | }, 65 | openURL: (url: string) => { 66 | if ( 67 | !url.startsWith("https://") && 68 | !url.startsWith("http://") && 69 | !url.startsWith("mailto:") 70 | ) 71 | return; 72 | 73 | ipcRenderer.invoke("openURL", url); 74 | }, 75 | fetchCacheSize: () => { 76 | return ipcRenderer.invoke("fetchCacheSize"); 77 | }, 78 | songDataFeedback: (data) => ipcRenderer.invoke("songDataFeedback", data), 79 | resetCache: () => ipcRenderer.invoke("resetCache"), 80 | minimize: () => ipcRenderer.invoke("windowControl", "minimize"), 81 | maximize: () => ipcRenderer.invoke("windowControl", "maximize"), 82 | hide: () => ipcRenderer.invoke("windowControl", "hide"), 83 | reload: () => ipcRenderer.invoke("windowControl", "reload"), 84 | restart: () => ipcRenderer.invoke("appControl", "restart") 85 | }); 86 | 87 | contextBridge.exposeInMainWorld("api", { 88 | send: (channel, data) => { 89 | const validChannels = ["update-download", "update-install"]; 90 | 91 | if (validChannels.includes(channel)) ipcRenderer.send(channel, data); 92 | }, 93 | receive: (channel, func) => { 94 | const validChannels = [ 95 | "update-system-theme", 96 | "new-update-available", 97 | "update-download-progress-update", 98 | "update-downloaded", 99 | "get-current-track", 100 | "open-modal", 101 | "lastfm-connect", 102 | "url" 103 | ]; 104 | 105 | if (validChannels.includes(channel)) 106 | ipcRenderer.on(channel, (_event, ...args) => func(...args)); 107 | } 108 | }); 109 | 110 | ipcRenderer.invoke("isReady", true); 111 | -------------------------------------------------------------------------------- /src/browser/renderer/api.ts: -------------------------------------------------------------------------------- 1 | import { Modal } from "./modal.js"; 2 | import { updateTheme } from "./utils.js"; 3 | 4 | export function init() { 5 | window.api.receive("update-system-theme", (_e, theme) => { 6 | console.log(`[BROWSER RENDERER] Changed theme to ${theme}`); 7 | 8 | updateTheme(theme); 9 | }); 10 | 11 | window.api.receive("get-current-track", (data) => { 12 | if (data && data.artwork && data.playerState === "playing") { 13 | document.querySelector(".logo").src = 14 | data.artwork.replace("500x500bb", "40x40bb"); 15 | 16 | document 17 | .querySelectorAll("#thumbsUp, #thumbsDown") 18 | .forEach((ele: HTMLElement) => { 19 | ele.style.display = "unset"; 20 | }); 21 | } else { 22 | document.querySelector(".logo").src = 23 | "../assets/logo.png"; 24 | 25 | document 26 | .querySelectorAll("#thumbsUp, #thumbsDown") 27 | .forEach((ele: HTMLElement) => { 28 | ele.style.display = "none"; 29 | }); 30 | } 31 | }); 32 | 33 | window.api.receive("open-modal", async (data) => { 34 | if (data.i18n) { 35 | const lang = await window.electron.config.get("language"); 36 | 37 | if (data.i18n[lang]) { 38 | data.title = data.i18n[lang].title; 39 | data.description = data.i18n[lang].description; 40 | 41 | if (data.buttons.length > 0 && data.i18n[lang].buttons) { 42 | data.buttons.forEach((button) => { 43 | button.label = data.i18n[lang].buttons[button.label]; 44 | }); 45 | } 46 | } 47 | } 48 | 49 | new Modal(data.title, data.description, data.buttons); 50 | }); 51 | 52 | window.api.receive("url", async (url: string) => { 53 | console.log("[BROWSER][RENDERER]", "Received URL", url); 54 | 55 | document.getElementById(url).scrollIntoView({ 56 | behavior: "smooth", 57 | block: "center" 58 | }); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /src/browser/renderer/eventSettings.ts: -------------------------------------------------------------------------------- 1 | export function init() { 2 | const data = [ 3 | { 4 | selector: ".settings_setting:has(input#config_wakandaForeverMode)", 5 | dates: ["YYYY-08-28", "2022-11-11"], 6 | configKey: "wakandaForeverMode", 7 | disabledConfigValue: false 8 | } 9 | ]; 10 | 11 | data.forEach(({ selector, dates, configKey, disabledConfigValue }) => { 12 | const ele = document.querySelector(selector); 13 | 14 | if (ele) { 15 | const today = new Date(), 16 | todayString = `${today.getFullYear()}-${ 17 | today.getMonth() + 1 18 | }-${today.getDate()}`; 19 | 20 | let notTodayCount = 0; 21 | 22 | dates.forEach(async (date) => { 23 | date = date 24 | .replace("YYYY", today.getFullYear().toString()) 25 | .replace("MM", (today.getMonth() + 1).toString()) 26 | .replace("DD", today.getDate().toString()); 27 | 28 | if (date !== todayString) notTodayCount++; 29 | if (notTodayCount === dates.length) { 30 | ele.remove(); 31 | 32 | if ( 33 | (await window.electron.config.get(configKey)) !== 34 | disabledConfigValue 35 | ) { 36 | window.electron.config.set( 37 | configKey, 38 | disabledConfigValue 39 | ); 40 | } 41 | } 42 | }); 43 | } 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/browser/renderer/i18n.ts: -------------------------------------------------------------------------------- 1 | import { fetchCacheSize } from "./index.js"; 2 | 3 | export class i18n { 4 | public strings; 5 | 6 | constructor() { 7 | this.updateTranslation(); 8 | } 9 | 10 | public async updateTranslation() { 11 | this.strings = await window.electron.getLangStrings(); 12 | } 13 | 14 | public async updateLanguage() { 15 | await this.updateTranslation(); 16 | 17 | const languages: Array = await window.electron.getLanguages(), 18 | userLanguage = await window.electron.config.get("language"); 19 | 20 | document.querySelector("select#config_language").innerHTML = ""; 21 | 22 | languages.forEach((lang) => { 23 | const option = document.createElement("option"); 24 | 25 | option.value = lang; 26 | option.innerText = lang; 27 | 28 | document.querySelector("#config_language").appendChild(option); 29 | }); 30 | 31 | document.querySelector( 32 | "select#config_language" 33 | ).value = userLanguage; 34 | 35 | document 36 | .querySelectorAll(".settings_setting label") 37 | .forEach((ele: HTMLElement) => { 38 | const ls = 39 | this.strings.settings.config[ 40 | ele.dataset.for?.replace("config_", "") ?? 41 | ele.getAttribute("for")?.replace("config_", "") 42 | ]; 43 | 44 | if (ls) { 45 | if (typeof ls === "object") ele.innerHTML = ls["label"]; 46 | else ele.innerHTML = ls; 47 | } 48 | }); 49 | 50 | document 51 | .querySelectorAll( 52 | ".settings_setting select:not(#config_language) option" 53 | ) 54 | .forEach((ele) => { 55 | const ls = 56 | this.strings.settings.config[ 57 | ele.parentElement 58 | .getAttribute("id") 59 | .replace("config_", "") 60 | ]?.[ele.getAttribute("value")]; 61 | 62 | if (ls) ele.innerHTML = ls; 63 | }); 64 | 65 | document 66 | .querySelectorAll(".settings_setting select#config_language option") 67 | .forEach((ele: HTMLOptionElement) => { 68 | const optionLang = ele.value.replace("_", "-"), 69 | optionLangCountry = 70 | optionLang.split("-")[1] ?? optionLang.toUpperCase(), 71 | languageNames = new Intl.DisplayNames([optionLang], { 72 | type: "language", 73 | languageDisplay: "standard" 74 | }), 75 | languageNamesEnglish = new Intl.DisplayNames(["en"], { 76 | type: "language", 77 | languageDisplay: "standard" 78 | }); 79 | 80 | let nativeLang = languageNames 81 | .of(optionLang) 82 | .replace(/\((.*?)\)/, `(${optionLangCountry})`); 83 | 84 | const englishLang = languageNamesEnglish 85 | .of(optionLang) 86 | .replace(/\((.*?)\)/, `(${optionLangCountry})`); 87 | 88 | nativeLang = 89 | nativeLang.charAt(0).toUpperCase() + nativeLang.slice(1); 90 | 91 | ele.textContent = 92 | nativeLang === englishLang 93 | ? nativeLang 94 | : `${nativeLang} - ${englishLang}`; 95 | }); 96 | 97 | document.querySelectorAll(".extra span").forEach((ele) => { 98 | const ls = this.strings.settings.extra[ele.parentElement.id]; 99 | 100 | if (ls) ele.innerHTML = ls; 101 | }); 102 | 103 | document.querySelectorAll("[data-i18n]").forEach((ele: HTMLElement) => { 104 | const key = ele.dataset.i18n, 105 | vars = ele.dataset.i18nVars ?? ""; 106 | 107 | if (key) { 108 | const translation = this.getStringVar(key, vars); 109 | 110 | if (translation) ele.innerHTML = translation; 111 | else { 112 | console.error( 113 | `Element with data-i18n attribute has no translation for key "${key}"`, 114 | ele 115 | ); 116 | } 117 | } else { 118 | console.error( 119 | "Element with data-i18n attribute has no key", 120 | ele 121 | ); 122 | } 123 | }); 124 | 125 | fetchCacheSize(); 126 | } 127 | 128 | public getString(key: string) { 129 | if (!key) return; 130 | 131 | const keys = key.split("."); 132 | 133 | let value = this.strings; 134 | 135 | for (const k of keys) { 136 | value = value?.[k]; 137 | } 138 | 139 | return value; 140 | } 141 | 142 | public getStringVar(key: string, vars: { [key: string]: any } | string) { 143 | let string = this.getString(key); 144 | 145 | if (!string) return; 146 | 147 | if (typeof vars === "string") vars = this.varConvert(vars); 148 | 149 | for (const k in vars) { 150 | string = string.replace(`%{${k}}`, vars[k]); 151 | } 152 | 153 | return string; 154 | } 155 | 156 | public autoGetString(ele: HTMLElement, key?: string) { 157 | if (!key) { 158 | key = ( 159 | ele.dataset.for ?? 160 | ele.parentElement.getAttribute("id") ?? 161 | ele.getAttribute("for") 162 | )?.replace("config_", ""); 163 | } 164 | 165 | const string = this.getString(key); 166 | 167 | if (!string) return; 168 | 169 | if (typeof string === "object") { 170 | ele.innerHTML = string["label"]; 171 | } 172 | } 173 | 174 | /** 175 | * Convert String variables to Object 176 | * @param vars 177 | * @returns {Object} 178 | * @example varConvert("key1=value1,key2=value2") 179 | */ 180 | public varConvert(vars: string) { 181 | const result: { [key: string]: any } = {}; 182 | 183 | vars.split(",").forEach((v) => { 184 | const [key, value] = v.split("="); 185 | 186 | result[key] = value; 187 | }); 188 | 189 | return result; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/browser/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import { Modal } from "./modal.js"; 2 | import { i18n as i18nClass } from "./i18n.js"; 3 | 4 | import { updateTheme, openURL, newNote } from "./utils.js"; 5 | 6 | import { init as initAPI } from "./api.js"; 7 | import { init as initLastFM } from "./lastFM.js"; 8 | import { init as initUpdater } from "./updater.js"; 9 | import { init as initListeners } from "./listeners.js"; 10 | import { init as initEventSettings } from "./eventSettings.js"; 11 | 12 | initAPI(); 13 | 14 | declare global { 15 | interface Window { 16 | electron: any; 17 | api: any; 18 | } 19 | } 20 | 21 | export const restartRequiredMemory = {}, 22 | i18n = new i18nClass(); 23 | 24 | export let appVersion, 25 | platform, 26 | isSupporter: boolean = null, 27 | isDeveloper: boolean = null, 28 | appDependencies: { 29 | music: boolean; 30 | discord: boolean; 31 | } = null; 32 | 33 | console.log("[BROWSER][RENDERER] Loading..."); 34 | 35 | initLastFM(); 36 | initEventSettings(); 37 | initListeners(); 38 | initUpdater(); 39 | 40 | updateTheme(); 41 | i18n.updateLanguage(); 42 | 43 | (async () => { 44 | const seenChangelogs = await window.electron.appData.get("changelog"); 45 | 46 | appVersion = await window.electron.appVersion(); 47 | platform = await window.electron.getPlatform(); 48 | isSupporter = await window.electron.isSupporter(); 49 | isDeveloper = await window.electron.isDeveloper(); 50 | appDependencies = await window.electron.checkAppDependencies(); 51 | 52 | document.querySelector("span#extra_version").textContent = `${ 53 | isDeveloper ? "Developer" : "" 54 | } v${appVersion}`; 55 | 56 | document 57 | .querySelector("span#extra_version") 58 | .setAttribute( 59 | "onclick", 60 | `window.electron.openURL('https://github.com/ZephraCloud/Apple-Music-RPC/releases/tag/v${appVersion}')` 61 | ); 62 | 63 | if (isDeveloper) { 64 | document.querySelector( 65 | ".settings_setting input#config_autoLaunch" 66 | ).disabled = true; 67 | } 68 | 69 | document 70 | .querySelectorAll("input[os], option[os]") 71 | .forEach((ele: HTMLElement) => { 72 | if (ele.getAttribute("os") !== platform) 73 | (ele.parentNode)?.remove(); 74 | }); 75 | 76 | if (!appDependencies.music) { 77 | newNote( 78 | "warn", 79 | i18n.strings.settings.warn.music.title, 80 | i18n.strings.settings.warn.music.description 81 | ); 82 | } 83 | 84 | if (!appDependencies.discord) { 85 | newNote( 86 | "warn", 87 | i18n.strings.settings.warn.discord.title, 88 | i18n.strings.settings.warn.discord.description 89 | ); 90 | } 91 | 92 | if (isSupporter) { 93 | document 94 | .querySelectorAll(".settings_setting_premium") 95 | .forEach((ele) => { 96 | const formEle = ele.querySelector("input, select"); 97 | 98 | ele.classList.remove("settings_setting_premium"); 99 | 100 | if (formEle) formEle.removeAttribute("disabled"); 101 | }); 102 | } else { 103 | document 104 | .querySelectorAll(".settings_setting_premium") 105 | .forEach((ele) => { 106 | ele.addEventListener("click", () => { 107 | new Modal( 108 | i18n.strings.settings.modal.koFi.title, 109 | i18n.strings.settings.modal.koFi.description, 110 | [ 111 | { 112 | label: i18n.strings.settings.modal.buttons 113 | .later, 114 | style: "btn-grey", 115 | events: [ 116 | { 117 | name: "click", 118 | type: "delete" 119 | } 120 | ] 121 | }, 122 | { 123 | label: "Ko-fi", 124 | style: "btn-primary", 125 | events: [ 126 | { 127 | name: "click", 128 | action: () => { 129 | openURL("https://ko-fi.com/zephra"); 130 | } 131 | } 132 | ] 133 | } 134 | ] 135 | ); 136 | }); 137 | }); 138 | } 139 | 140 | window.electron.getCurrentTrack().then((data) => { 141 | if (data && data.artwork && data.playerState === "playing") { 142 | document.querySelector(".logo").src = 143 | data.artwork.replace("500x500bb", "40x40bb"); 144 | } else { 145 | document.querySelector(".logo").src = 146 | "../assets/logo.png"; 147 | } 148 | }); 149 | 150 | if (!seenChangelogs[appVersion]) { 151 | const changelog = await window.electron.fetchChangelog(); 152 | 153 | if (changelog && appVersion === changelog.tag_name) { 154 | new Modal( 155 | `Changelog ${changelog.name}`, 156 | // @ts-ignore 157 | marked.parse(changelog.body.replace("# Changelog:\r\n", "")), 158 | [ 159 | { 160 | label: i18n.strings.settings.modal.buttons.okay, 161 | style: "btn-grey", 162 | events: [ 163 | { 164 | name: "onclick", 165 | value: "updateDataChangelogJS(appVersion, true)", 166 | type: "delete" 167 | } 168 | ] 169 | } 170 | ] 171 | ); 172 | } 173 | } 174 | })(); 175 | 176 | export function fetchCacheSize() { 177 | window.electron.fetchCacheSize().then((stats) => { 178 | const ele = document.querySelector("#cacheNoteSize"), 179 | string: string = i18n.strings.settings.note.cache.size; 180 | 181 | ele.textContent = string 182 | .replace("%size%", `${stats.fileSize.toFixed(2)} MB`) 183 | .replace("%count%", stats.size); 184 | }); 185 | } 186 | 187 | setInterval(fetchCacheSize, 30000); 188 | 189 | console.log("[BROWSER][RENDERER] Loaded"); 190 | -------------------------------------------------------------------------------- /src/browser/renderer/lastFM.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from "./index.js"; 2 | 3 | export async function init() { 4 | const lastFMUser: { 5 | username: string; 6 | key: string; 7 | } = await window.electron.lastFM.getUser(); 8 | 9 | const userBtn = document.querySelector( 10 | ".cfgButton#connectLastFM" 11 | ), 12 | toggle = document.querySelector( 13 | "#config_enableLastFM" 14 | ); 15 | 16 | if (lastFMUser?.username && lastFMUser?.key) 17 | userBtn.textContent = lastFMUser.username; 18 | else if (lastFMUser?.username && !lastFMUser.key) 19 | userBtn.textContent = "Reconnect"; 20 | else userBtn.textContent = "Connect"; 21 | 22 | if (!(await window.electron.config.get("enableLastFM"))) 23 | userBtn.classList.add("disabled"); 24 | 25 | toggle.addEventListener("change", () => { 26 | userBtn.classList[toggle.checked ? "remove" : "add"]("disabled"); 27 | }); 28 | 29 | userBtn.addEventListener("click", () => { 30 | if (userBtn.classList.contains("disabled")) return; 31 | 32 | const strings = i18n.strings; 33 | 34 | switch (userBtn.textContent) { 35 | case strings.settings.config.lastFMUser.cancel: 36 | userBtn.textContent = lastFMUser.username; 37 | 38 | break; 39 | 40 | default: 41 | window.electron.lastFM.connect(); 42 | userBtn.textContent = 43 | strings.settings.config.lastFMUser.connecting; 44 | 45 | break; 46 | } 47 | }); 48 | 49 | userBtn.addEventListener("mouseover", async () => { 50 | if (userBtn.classList.contains("disabled")) return; 51 | 52 | const strings = await window.electron.getLangStrings(); 53 | 54 | switch (userBtn.textContent) { 55 | case strings.settings.config.lastFMUser.connecting: 56 | userBtn.textContent = strings.settings.config.lastFMUser.cancel; 57 | 58 | break; 59 | 60 | case lastFMUser.username: 61 | userBtn.textContent = 62 | strings.settings.config.lastFMUser.reconnect; 63 | 64 | break; 65 | } 66 | }); 67 | 68 | userBtn.addEventListener("mouseout", async () => { 69 | if (userBtn.classList.contains("disabled")) return; 70 | 71 | const strings = await window.electron.getLangStrings(); 72 | 73 | switch (userBtn.textContent) { 74 | case strings.settings.config.lastFMUser.cancel: 75 | userBtn.textContent = 76 | strings.settings.config.lastFMUser.connecting; 77 | 78 | break; 79 | 80 | case strings.settings.config.lastFMUser.reconnect: 81 | userBtn.textContent = lastFMUser.username; 82 | 83 | break; 84 | } 85 | }); 86 | 87 | window.api.receive( 88 | "lastfm-connect", 89 | (session: { username: string; key: string }) => { 90 | userBtn.textContent = session.username; 91 | 92 | lastFMUser.username = session.username; 93 | lastFMUser.key = session.key; 94 | } 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/browser/renderer/listeners.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fetchCacheSize, 3 | platform, 4 | restartRequiredMemory, 5 | i18n 6 | } from "./index.js"; 7 | import { updateTheme } from "./utils.js"; 8 | 9 | export function init() { 10 | window.addEventListener("offline", () => { 11 | document.body.classList.add("offline"); 12 | }); 13 | 14 | window.addEventListener("online", () => { 15 | document.body.classList.remove("offline"); 16 | }); 17 | 18 | document 19 | .querySelector("span.dot.minimize") 20 | ?.addEventListener("click", window.electron.minimize); 21 | 22 | document 23 | .querySelector("span.dot.maximize") 24 | ?.addEventListener("click", window.electron.maximize); 25 | 26 | document 27 | .querySelector("span.dot.close") 28 | ?.addEventListener("click", window.electron.hide); 29 | 30 | document.body.addEventListener("click", (e) => { 31 | if (e.target instanceof HTMLAnchorElement && e.target.href) { 32 | e.preventDefault(); 33 | window.electron.openURL(e.target.href); 34 | } 35 | }); 36 | 37 | document.querySelectorAll("#thumbsUp, #thumbsDown").forEach((ele) => { 38 | ele.addEventListener("click", (e) => { 39 | window.electron.songDataFeedback( 40 | (e.target).id === "thumbsUp" 41 | ); 42 | }); 43 | }); 44 | 45 | document 46 | .querySelectorAll(".settings_setting input, select") 47 | .forEach(async (ele: HTMLInputElement | HTMLSelectElement) => { 48 | const configKey = ele.id.replace("config_", ""), 49 | eleTag = ele.tagName.toLowerCase(), 50 | eleValue = (value?: string | boolean): string | boolean => { 51 | if (value) { 52 | if (eleTag === "input" && ele.type === "checkbox") { 53 | (ele as HTMLInputElement).checked = 54 | value as boolean; 55 | } else ele.value = value.toString(); 56 | 57 | return; 58 | } 59 | 60 | if (eleTag === "input" && ele.type === "checkbox") 61 | return (ele as HTMLInputElement).checked; 62 | else return ele.value; 63 | }; 64 | 65 | if (ele.type === "text") { 66 | let timeout; 67 | 68 | ele.addEventListener("keyup", () => { 69 | clearTimeout(timeout); 70 | 71 | timeout = setTimeout(() => { 72 | window.electron.config.set(configKey, eleValue()); 73 | }, 1500); 74 | }); 75 | } else { 76 | ele.addEventListener("change", async () => { 77 | const value = eleValue(), 78 | configKey = ele.id.replace("config_", ""); 79 | 80 | if (ele.dataset.restart === "true") 81 | checkRestartRequired(value, ele.id); 82 | 83 | window.electron.config.set(configKey, value); 84 | 85 | valueChangeEvents(ele); 86 | }); 87 | } 88 | 89 | const configValue = await window.electron.config.get(configKey); 90 | 91 | if (configValue?.toString()) { 92 | if (ele.id !== "config_language") eleValue(configValue); 93 | 94 | ele.classList.remove("cfg_loading"); 95 | ele.parentElement.classList.remove("cfg_loading"); 96 | 97 | if (ele.dataset.restart === "true") 98 | restartRequiredMemory[ele.id] = configValue.toString(); 99 | 100 | if (ele.id === "config_wakandaForeverMode" && configValue) { 101 | const ele: HTMLLinkElement = document.createElement("link"); 102 | 103 | ele.rel = "stylesheet"; 104 | ele.href = "css/wakandaForever.css"; 105 | 106 | document.head.appendChild(ele); 107 | } 108 | } 109 | }); 110 | 111 | document 112 | .querySelectorAll(".settings_setting button") 113 | .forEach(async (button: HTMLButtonElement) => { 114 | button.addEventListener("click", async (e) => { 115 | e.preventDefault(); 116 | 117 | if ( 118 | !button.dataset.action || 119 | button.classList.contains("disabled") 120 | ) 121 | return; 122 | 123 | const Await = button.dataset.await === "true", 124 | func = button.dataset.action 125 | .split(".") 126 | .reduce((o, i) => o[i], window.electron); 127 | 128 | if (!Await) func(); 129 | else { 130 | const innerText = button.innerText; 131 | 132 | button.innerHTML = ``; 133 | 134 | await window.electron[button.dataset.action](); 135 | 136 | button.innerText = innerText; 137 | } 138 | 139 | if (button.dataset.action === "resetCache") fetchCacheSize(); 140 | }); 141 | }); 142 | 143 | document 144 | .querySelectorAll(".settings_setting[data-enableReset='true']") 145 | .forEach((ele) => { 146 | const formField: HTMLInputElement | HTMLSelectElement = 147 | ele.querySelector("input, select"), 148 | label = ele.querySelector("label:first-of-type"); 149 | 150 | const resetButton = document.createElement("span"); 151 | 152 | resetButton.classList.add("resetButton"); 153 | resetButton.innerHTML = ``; 154 | 155 | resetButton.addEventListener("click", async () => { 156 | const configKey = label 157 | .getAttribute("for") 158 | .replace("config_", ""); 159 | 160 | formField.value = await window.electron.config.reset(configKey); 161 | }); 162 | 163 | ele.querySelector(".setting_main").appendChild(resetButton); 164 | }); 165 | 166 | document 167 | .querySelectorAll(".settings_category[data-restriction-os]") 168 | .forEach(async (ele: HTMLDivElement) => { 169 | const restrictOS = ele.dataset.restrictionOs.split(","), 170 | os = platform ?? (await window.electron.getPlatform()); 171 | 172 | console.log( 173 | `OS Restriction for category "${ 174 | ele.querySelector("label[for]")?.getAttribute("for") ?? 175 | ele.id ?? 176 | "Unknown" 177 | }": ${restrictOS.join(", ")} | Current OS: ${os}™` 178 | ); 179 | 180 | if (!restrictOS.includes(os)) ele.remove(); 181 | }); 182 | 183 | document 184 | .querySelectorAll(".settings_setting[data-restriction-os]") 185 | .forEach(async (ele: HTMLDivElement) => { 186 | const restrictOS = ele.dataset.restrictionOs.split(","), 187 | os = platform ?? (await window.electron.getPlatform()), 188 | removeEle = ele.hasAttribute("data-restriction-os-remove"); 189 | 190 | console.log( 191 | `OS Restriction for setting "${ 192 | ele.querySelector("label[for]")?.getAttribute("for") ?? 193 | ele.id ?? 194 | "Unknown" 195 | }": ${restrictOS.join(", ")} | Current OS: ${os}™` 196 | ); 197 | 198 | if (!restrictOS.includes(os)) { 199 | if (removeEle) ele.remove(); 200 | else { 201 | ele.querySelector( 202 | "label.cfgSwitch, select, input" 203 | ).classList.add("cfg_loading"); 204 | } 205 | } 206 | }); 207 | 208 | document 209 | .querySelectorAll(".settings_category[data-restriction-store]") 210 | .forEach(async (ele: HTMLDivElement) => { 211 | if (await window.electron.isWindowsStore()) ele.remove(); 212 | }); 213 | 214 | document 215 | .querySelectorAll( 216 | ".settings_category, .settings_setting input, .settings_setting select" 217 | ) 218 | .forEach(checkRestrictionSetting); 219 | } 220 | 221 | async function checkRestartRequired( 222 | value: string | boolean, 223 | id: string 224 | ): Promise { 225 | const restartAppSpan = 226 | document.querySelector("span#restartApp"), 227 | reloadAppSpan = 228 | document.querySelector("span#reloadPage"), 229 | isSame = value.toString() === restartRequiredMemory[id]; 230 | 231 | restartAppSpan.style["display"] = isSame ? "none" : "inline"; 232 | reloadAppSpan.style["display"] = isSame ? "inline" : "none"; 233 | } 234 | 235 | function valueChangeEvents(ele): void { 236 | if (ele.id === "config_colorTheme") updateTheme(); 237 | else if (ele.id === "config_language") i18n.updateLanguage(); 238 | else if (ele.id === "config_autoLaunch") 239 | window.api.send("autolaunch-change", {}); 240 | else if (ele.id === "config_wakandaForeverMode") { 241 | if (ele.checked) { 242 | const ele: HTMLLinkElement = document.createElement("link"); 243 | 244 | ele.rel = "stylesheet"; 245 | ele.href = "css/wakandaForever.css"; 246 | 247 | document.head.appendChild(ele); 248 | } else { 249 | document 250 | .querySelector( 251 | "link[href='css/wakandaForever.css']" 252 | ) 253 | ?.remove(); 254 | } 255 | } 256 | 257 | checkRestrictionSetting(ele); 258 | } 259 | 260 | function checkRestrictionSetting(ele: HTMLElement) { 261 | console.log(ele); 262 | 263 | document 264 | .querySelectorAll(".settings_setting[data-restriction-setting]") 265 | .forEach(async (eleQS: HTMLDivElement) => { 266 | const restrictSetting = eleQS.dataset.restrictionSetting, 267 | restrictValue = eleQS.dataset.restrictionSettingValue ?? "true"; 268 | 269 | if (!restrictSetting) return; 270 | 271 | if (ele.id === `config_${restrictSetting}`) { 272 | const configValue = await window.electron.config.get( 273 | restrictSetting 274 | ); 275 | 276 | eleQS.classList[ 277 | configValue !== restrictValue ? "add" : "remove" 278 | ]("cfg_loading"); 279 | } 280 | }); 281 | 282 | document 283 | .querySelectorAll(".settings_category[data-restriction-setting]") 284 | .forEach(async (eleQS: HTMLDivElement) => { 285 | const restrictSetting = eleQS.dataset.restrictionSetting; 286 | 287 | if (!restrictSetting) return; 288 | 289 | if (ele.id === `config_${restrictSetting}`) { 290 | const configValue = await window.electron.config.get( 291 | restrictSetting 292 | ); 293 | 294 | eleQS.style.display = configValue ? "block" : "none"; 295 | } 296 | }); 297 | } 298 | -------------------------------------------------------------------------------- /src/browser/renderer/modal.ts: -------------------------------------------------------------------------------- 1 | import { generateEleId, openURL } from "./utils.js"; 2 | 3 | export class Modal { 4 | private readonly title: string; 5 | private readonly description: string; 6 | // @ts-ignore 7 | private buttons: ModalButton[]; 8 | 9 | public modalId: string; 10 | 11 | // @ts-ignore 12 | constructor(title: string, description: string, buttons: ModalButton[]) { 13 | const ELE = { 14 | modal: document.createElement("div"), 15 | title: document.createElement("h1"), 16 | description: document.createElement("p"), 17 | body: document.body 18 | }; 19 | 20 | this.title = title; 21 | this.description = description; 22 | this.buttons = buttons || []; 23 | 24 | ELE.body.appendChild(ELE.modal); 25 | ELE.modal.appendChild(ELE.title); 26 | ELE.modal.appendChild(ELE.description); 27 | 28 | ELE.modal.classList.add("modal"); 29 | ELE.title.classList.add("title"); 30 | ELE.description.classList.add("description"); 31 | 32 | ELE.title.innerHTML = this.title; 33 | ELE.description.innerHTML = this.description; 34 | 35 | ELE.modal.id = this.modalId = generateEleId(); 36 | 37 | for (let i = 0; i < this.buttons.length; i++) { 38 | if (i > 2) return; 39 | const btn = buttons[i], 40 | ele = document.createElement("p"); 41 | 42 | ele.classList.add("btn"); 43 | ele.classList.add(btn.style); 44 | 45 | if (i === 2) ele.classList.add("btn-last"); 46 | 47 | ele.innerHTML = btn.label; 48 | 49 | if (btn.events) { 50 | for (let i2 = 0; i2 < buttons[i].events.length; i2++) { 51 | const event = buttons[i].events[i2]; 52 | 53 | if (event.value) ele.setAttribute(event.name, event.value); 54 | if (event.type === "close" || event.type === "delete") { 55 | ele.addEventListener(event.name, () => { 56 | this[event.type](); 57 | if (event.save) 58 | window.electron.appData.set(event.save, true); 59 | }); 60 | } else if (event.action) 61 | ele.addEventListener(event.name, () => event.action()); 62 | } 63 | } 64 | 65 | ELE.modal.appendChild(ele); 66 | } 67 | 68 | document 69 | .querySelectorAll(".modal a") 70 | .forEach((element: HTMLAnchorElement) => { 71 | element.addEventListener("click", function (e) { 72 | e.preventDefault(); 73 | openURL(element.href); 74 | 75 | return false; 76 | }); 77 | }); 78 | 79 | if (ELE.body.classList.contains("modalIsOpen")) { 80 | ELE.modal.style.display = "none"; 81 | ELE.modal.classList.add("awaiting"); 82 | } else ELE.body.classList.add("modalIsOpen"); 83 | } 84 | 85 | close() { 86 | Modal.close(this.modalId); 87 | } 88 | 89 | open() { 90 | Modal.open(this.modalId); 91 | } 92 | 93 | delete() { 94 | Modal.delete(this.modalId); 95 | } 96 | 97 | static close(id: string) { 98 | const ele = document.querySelector(`div.modal#${id}`); 99 | 100 | ele.style.display = "none"; 101 | 102 | document.body.classList.remove("modalIsOpen"); 103 | 104 | checkForAwaitingModal(); 105 | } 106 | 107 | static open(id: string) { 108 | const ele = document.querySelector(`div.modal#${id}`); 109 | 110 | ele.style.display = "block"; 111 | ele.classList.remove("awaiting"); 112 | 113 | document.body.classList.add("modalIsOpen"); 114 | } 115 | 116 | static delete(id: string) { 117 | const ele = document.querySelector(`div.modal#${id}`); 118 | 119 | ele.remove(); 120 | 121 | document.body.classList.remove("modalIsOpen"); 122 | 123 | checkForAwaitingModal(); 124 | } 125 | } 126 | 127 | function checkForAwaitingModal() { 128 | if (document.querySelector("div.modal.awaiting")) 129 | Modal.open(document.querySelector("div.modal.awaiting").id); 130 | } 131 | -------------------------------------------------------------------------------- /src/browser/renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "module": "es2015", 6 | "moduleResolution": "node" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/browser/renderer/updater.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from "./index.js"; 2 | import { Modal } from "./modal.js"; 3 | 4 | export function init() { 5 | const installUpdateBtn = 6 | document.querySelector("#installUpdate"); 7 | 8 | installUpdateBtn.addEventListener("click", () => { 9 | if (isButtonActive()) window.api.send("update-install"); 10 | }); 11 | 12 | window.api.receive("new-update-available", (data) => { 13 | console.log("[BROWSER RENDERER] New update available", data); 14 | 15 | new Modal( 16 | i18n.strings.settings.modal["newUpdate"].title, 17 | i18n.strings.settings.modal["newUpdate"].description.replace( 18 | "%version%", 19 | data.version 20 | ), 21 | [ 22 | { 23 | label: i18n.strings.settings.modal["newUpdate"].buttons 24 | .downloadAndInstall, 25 | style: "btn-grey", 26 | events: [ 27 | { 28 | name: "onclick", 29 | value: "window.api.send('update-download', true)" 30 | }, 31 | { 32 | name: "click", 33 | type: "delete" 34 | } 35 | ] 36 | }, 37 | { 38 | label: i18n.strings.settings.modal["newUpdate"].buttons 39 | .download, 40 | style: "btn-grey", 41 | events: [ 42 | { 43 | name: "onclick", 44 | value: "window.api.send('update-download', false)" 45 | }, 46 | { 47 | name: "click", 48 | type: "delete" 49 | } 50 | ] 51 | }, 52 | { 53 | label: i18n.strings.settings.modal.buttons.later, 54 | style: "btn-grey", 55 | events: [ 56 | { 57 | name: "click", 58 | type: "delete" 59 | } 60 | ] 61 | } 62 | ] 63 | ); 64 | }); 65 | 66 | window.api.receive("update-download-progress-update", (_e, data) => { 67 | document.querySelector( 68 | "span#download-progress" 69 | ).style.display = data.percent === 100 ? "none" : "inline-block"; 70 | 71 | installUpdateBtn.textContent = data.percent; 72 | }); 73 | 74 | window.api.receive("update-downloaded", (_e, data) => { 75 | installUpdateBtn.setAttribute( 76 | "data-i18n-vars", 77 | `version=${data.version}` 78 | ); 79 | installUpdateBtn.classList.remove("cfg_loading"); 80 | 81 | installUpdateBtn.textContent = i18n.getStringVar( 82 | "settings.config.installUpdate.button", 83 | installUpdateBtn.getAttribute("data-i18n-vars") 84 | ); 85 | 86 | new Modal( 87 | i18n.strings.settings.modal["newUpdate"].title, 88 | i18n.strings.settings.modal[ 89 | "newUpdate" 90 | ].installed.description.replace("%version%", data.version), 91 | [ 92 | { 93 | label: i18n.strings.settings.modal["newUpdate"].installed 94 | .buttons.install, 95 | style: "btn-grey", 96 | events: [ 97 | { 98 | name: "onclick", 99 | value: "window.api.send('update-install', {})" 100 | }, 101 | { 102 | name: "click", 103 | type: "delete" 104 | } 105 | ] 106 | }, 107 | { 108 | label: i18n.strings.settings.modal["newUpdate"].installed 109 | .buttons.later, 110 | style: "btn-grey", 111 | events: [ 112 | { 113 | name: "click", 114 | type: "delete" 115 | } 116 | ] 117 | } 118 | ] 119 | ); 120 | }); 121 | 122 | function isButtonActive() { 123 | return !installUpdateBtn.classList.contains("cfg_loading"); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/browser/renderer/utils.ts: -------------------------------------------------------------------------------- 1 | export function openURL(url) { 2 | if (url) window.electron.openURL(url); 3 | } 4 | 5 | export function generateEleId() { 6 | const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 7 | 8 | let result = ""; 9 | 10 | for (let i = 0; i < characters.length; i++) 11 | result += characters.charAt( 12 | Math.floor(Math.random() * characters.length) 13 | ); 14 | 15 | return result; 16 | } 17 | 18 | export async function updateTheme(theme?: string) { 19 | if (!theme) theme = await window.electron.getTheme(); 20 | 21 | document.querySelector("body").setAttribute("data-theme", theme); 22 | } 23 | 24 | export function newNote( 25 | type: string, 26 | titleText: string, 27 | descriptionText: string 28 | ) { 29 | const note = document.createElement("div"), 30 | title: HTMLHeadingElement = document.createElement("h3"), 31 | description: HTMLParagraphElement = document.createElement("p"); 32 | 33 | note.classList.add("note"); 34 | note.classList.add(`note-${type}`); 35 | title.classList.add("noteTitle"); 36 | description.classList.add("noteDescription"); 37 | 38 | title.innerText = titleText; 39 | description.innerText = descriptionText; 40 | 41 | note.appendChild(title); 42 | note.appendChild(description); 43 | document.querySelector(".notes").appendChild(note); 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { app, nativeTheme } from "electron"; 2 | 3 | import { appData, config } from "./managers/store"; 4 | 5 | import { TrayManager } from "./managers/tray"; 6 | import { ModalWatcher } from "./managers/modal"; 7 | import { Bridge } from "./managers/bridge"; 8 | import { WatchDog } from "./managers/watchdog"; 9 | import { Browser } from "./managers/browser"; 10 | import { LastFM } from "./managers/lastFM"; 11 | import { Updater } from "./managers/updater"; 12 | 13 | import { init as initSentry } from "./managers/sentry"; 14 | import { init as initAutoLaunch } from "./managers/launch"; 15 | import { init as initTheme } from "./utils/theme"; 16 | import { init as initMsStoreModal } from "./utils/msStoreModal"; 17 | import { init as initCrowdin } from "./utils/crowdin"; 18 | import { init as initProtocol } from "./utils/protocol"; 19 | 20 | import { checkAppDependency } from "./utils/checkAppDependency"; 21 | import { WatchDogInstaller } from "./utils/watchdog"; 22 | 23 | import * as log from "electron-log"; 24 | 25 | export const isBeta = app.getVersion().includes("beta"), 26 | isRC = app.getVersion().includes("rc"); 27 | 28 | export let trayManager: TrayManager; 29 | export let modalWatcher: ModalWatcher; 30 | export let appDependencies: AppDependencies; 31 | export let lastFM: LastFM; 32 | export let bridge: Bridge; 33 | export let watchDog: WatchDog; 34 | export let updater: Updater; 35 | 36 | Object.assign(console, log.functions); 37 | 38 | if (!app.isPackaged) log.transports.file.fileName = "development.log"; 39 | else if (isBeta) { 40 | log.transports.file.fileName = `beta-${app 41 | .getVersion() 42 | .replace(/\./g, "_")}.log`; 43 | } 44 | if (!app.requestSingleInstanceLock()) app.quit(); 45 | 46 | log.info( 47 | "------------------------------------", 48 | `STARTING - ${app.getVersion()}`, 49 | "------------------------------------" 50 | ); 51 | 52 | if (isBeta) { 53 | log.info("[STARTUP]", "Detected beta build. Enabling beta updates"); 54 | config.set("betaUpdates", true); 55 | } 56 | 57 | if (isRC) log.info("[STARTUP]", "Detected release candidate build"); 58 | if (process.windowsStore) log.info("[STARTUP]", "Detected Windows Store build"); 59 | 60 | initSentry(); 61 | initProtocol(); 62 | 63 | appData.set("installUpdate", false); 64 | 65 | if (process.platform !== "win32") { 66 | config.set("autoUpdates", false); 67 | config.set("betaUpdates", false); 68 | } 69 | 70 | app.on("ready", async () => { 71 | await initCrowdin().catch((err) => log.error("[READY][initCrowdin]", err)); 72 | 73 | trayManager = new TrayManager(); 74 | modalWatcher = new ModalWatcher(); 75 | updater = new Updater(); 76 | appDependencies = await checkAppDependency(); 77 | 78 | if ( 79 | config.get("enableLastFM") && 80 | config.get("lastFM.username") && 81 | config.get("lastFM.key") 82 | ) 83 | lastFM = new LastFM(); 84 | 85 | initTheme(); 86 | initAutoLaunch(); 87 | initMsStoreModal(); 88 | 89 | if (config.get("service") === "music") { 90 | watchDog = new WatchDog(); 91 | 92 | if (config.get("watchdog.autoUpdates")) WatchDogInstaller(true); 93 | 94 | // Every hour 95 | setInterval(() => { 96 | if (config.get("watchdog.autoUpdates")) WatchDogInstaller(true); 97 | }, 1000 * 60 * 60); 98 | } 99 | 100 | if (appDependencies.music && appDependencies.discord) bridge = new Bridge(); 101 | else Browser.windowAction("show"); 102 | 103 | nativeTheme.on("updated", () => { 104 | log.info( 105 | `[Backend] Theme changed to ${ 106 | nativeTheme.shouldUseDarkColors ? "dark" : "light" 107 | }` 108 | ); 109 | 110 | if (config.get("colorTheme") === "os") { 111 | Browser.send("update-system-theme", false, { 112 | theme: nativeTheme.shouldUseDarkColors ? "dark" : "light" 113 | }); 114 | } 115 | }); 116 | }); 117 | 118 | export function setLastFM(connect: boolean) { 119 | lastFM = new LastFM(connect); 120 | } 121 | -------------------------------------------------------------------------------- /src/managers/SongData.ts: -------------------------------------------------------------------------------- 1 | import { cache, config } from "./store"; 2 | import { apiRequest } from "../utils/apiRequest"; 3 | 4 | export class SongData { 5 | public history: SongDataT[] = []; 6 | 7 | public getSongData( 8 | title: string, 9 | album: string, 10 | artist: string 11 | ): Promise { 12 | return new Promise((resolve, reject) => { 13 | if (!title || !album || !artist || title.includes("Connecting…")) 14 | return resolve(null); 15 | 16 | const reqParam = encodeURIComponent(`${title} ${album} ${artist}`) 17 | .replace(/"/g, "%27") 18 | .replace(/"/g, "%22"), 19 | cacheItem = cache.get( 20 | `${title}_:_${album}_:_${artist}` 21 | ) as SongDataT; 22 | 23 | if (cacheItem) return resolve(cacheItem); 24 | 25 | apiRequest( 26 | `search?term=${reqParam}&entity=musicTrack`, 27 | "https://itunes.apple.com/" 28 | ).then((r) => { 29 | if (!r || !r.results?.[0]) return reject("not_found"); 30 | 31 | const res = r.results[0], 32 | data: SongDataT = { 33 | url: res.trackViewUrl, 34 | collectionId: res.collectionId, 35 | trackId: res.trackId, 36 | explicit: !res.notExplicit, 37 | artwork: res.artworkUrl100.replace( 38 | "100x100bb", 39 | "500x500bb" 40 | ) 41 | }; 42 | 43 | if (config.get("enableCache")) 44 | cache.set(`${title}_:_${album}_:_${artist}`, data); 45 | 46 | this.history.push(data); 47 | 48 | resolve(data); 49 | }); 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/managers/browser.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron"; 2 | import { getConfig, setConfig, config } from "./store"; 3 | import { init as initIPC } from "./ipc"; 4 | import { exec } from "child_process"; 5 | 6 | import * as path from "path"; 7 | import * as log from "electron-log"; 8 | 9 | export class Browser { 10 | private window: BrowserWindow; 11 | 12 | public isReady: boolean = false; 13 | 14 | static awaitsSend: { channel: string; args: any[] }[] = []; 15 | static instance: Browser; 16 | 17 | constructor(url: string = "") { 18 | if (Browser.instance) { 19 | Browser.windowAction("show"); 20 | return; 21 | } 22 | 23 | initIPC(); 24 | 25 | this.initWindow(undefined, url); 26 | 27 | Browser.instance = this; 28 | } 29 | 30 | initWindow(show: boolean = false, url: string = "") { 31 | const windowState = getConfig("windowState"); 32 | 33 | this.window = new BrowserWindow({ 34 | x: windowState?.x, 35 | y: windowState?.y, 36 | webPreferences: { 37 | preload: path.join(app.getAppPath(), "browser/preload.js") 38 | }, 39 | icon: path.join(app.getAppPath(), "assets/logo.png"), 40 | frame: false, 41 | resizable: false 42 | }); 43 | 44 | this.window.loadFile(path.join(app.getAppPath(), "browser/index.html")); 45 | 46 | ["moved", "close"].forEach((event: any) => { 47 | this.window.on(event, Browser.saveWindowState); 48 | }); 49 | 50 | this.window.on("closed", () => { 51 | this.window = null; 52 | 53 | if (process.platform === "darwin") app.dock.hide(); 54 | }); 55 | 56 | if (process.platform === "darwin") { 57 | this.window.on("hide", () => { 58 | const isVisible = 59 | process.platform === "darwin" 60 | ? this.window.isVisibleOnAllWorkspaces() 61 | : this.window.isVisible(); 62 | 63 | if ( 64 | !this.window.isMinimized() || 65 | (!isVisible && !this.window.isFocusable()) 66 | ) 67 | app.dock.hide(); 68 | }); 69 | 70 | this.window.on("show", app.dock.show); 71 | } 72 | 73 | if (show || url) this.window.show(); 74 | if (url) { 75 | this.window.webContents.on("did-finish-load", () => { 76 | this.window.webContents.send("url", url); 77 | }); 78 | } 79 | } 80 | 81 | async windowAction(action: string) { 82 | switch (action) { 83 | case "show": 84 | if (this.window) this.window.show(); 85 | else this.initWindow(true); 86 | 87 | this.checkAwaits(); 88 | 89 | break; 90 | case "hide": 91 | this.window?.hide(); 92 | 93 | break; 94 | case "close": 95 | this.window?.close(); 96 | 97 | break; 98 | case "minimize": 99 | this.window?.minimize(); 100 | 101 | break; 102 | case "maximize": 103 | this.window?.maximize(); 104 | 105 | break; 106 | case "restore": 107 | this.window?.restore(); 108 | 109 | break; 110 | case "reload": 111 | if (!app.isPackaged) await this.copyBrowserFiles(); 112 | this.window?.reload(); 113 | 114 | break; 115 | } 116 | } 117 | 118 | saveWindowState() { 119 | if (!this.window) return; 120 | 121 | setConfig("windowState", this.window.getBounds()); 122 | } 123 | 124 | send(channel: string, ...args: any[]) { 125 | if (!this.window || !this.window.webContents) return; 126 | 127 | setTimeout( 128 | () => this.window.webContents.send(channel, ...args), 129 | this.isReady ? 200 : 2500 130 | ); 131 | } 132 | 133 | checkAwaits() { 134 | if (Browser.awaitsSend.length > 0 && this.window?.isVisible()) { 135 | Browser.awaitsSend.forEach((data) => { 136 | this.send(data.channel, ...data.args); 137 | }); 138 | } 139 | } 140 | 141 | async copyBrowserFiles() { 142 | if (app.isPackaged) return; 143 | 144 | return new Promise(function (resolve) { 145 | const execute = exec( 146 | "npm run copy && cd src/browser/renderer/ && tsc", 147 | (error, _stdout, stderr) => { 148 | if (error) 149 | return log.error( 150 | "[Browser][copyBrowserFiles][exec] Error", 151 | error.message 152 | ); 153 | if (stderr) 154 | return log.error( 155 | "[Browser][copyBrowserFiles][exec] Stderr", 156 | stderr 157 | ); 158 | } 159 | ); 160 | 161 | execute.addListener("error", resolve); 162 | execute.addListener("exit", resolve); 163 | }); 164 | } 165 | 166 | static getInstance(): Browser { 167 | if (!Browser.instance) Browser.instance = new Browser(); 168 | 169 | return Browser.instance; 170 | } 171 | 172 | static windowAction(action: string) { 173 | Browser.getInstance().windowAction(action); 174 | } 175 | 176 | static saveWindowState() { 177 | Browser.getInstance().saveWindowState(); 178 | } 179 | 180 | static send(channel: string, create: boolean = false, ...args: any[]) { 181 | if (create) { 182 | Browser.awaitsSend.push({ channel, args }); 183 | 184 | Browser.getInstance(); 185 | } else if (Browser.instance && Browser.instance.isReady) { 186 | Browser.getInstance().send(channel, ...args); 187 | } else { 188 | Browser.awaitsSend.push({ channel, args }); 189 | } 190 | } 191 | 192 | static setTheme(theme: string) { 193 | if (config.get("colorTheme") === "auto") 194 | Browser.send("update-system-theme", false, theme); 195 | } 196 | 197 | static setReady(ready: boolean = true) { 198 | Browser.getInstance().isReady = ready; 199 | Browser.getInstance().checkAwaits(); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/managers/discord.ts: -------------------------------------------------------------------------------- 1 | import { Client, Presence, register, User } from "discord-rpc"; 2 | import { app } from "electron"; 3 | 4 | import { appDependencies, trayManager } from "../index"; 5 | 6 | import { config, getConfig, setConfig } from "./store"; 7 | import { Browser } from "./browser"; 8 | import { Bridge } from "./bridge"; 9 | import { SongData } from "./SongData"; 10 | 11 | import { checkSupporter } from "../utils/checkSupporter"; 12 | import { songDataSearchStation } from "../utils/advancedSongDataSearch"; 13 | import { getLibrarySongArtwork } from "../utils/getLibrarySongArtwork"; 14 | import { replaceVariables } from "../utils/replaceVariables"; 15 | 16 | import * as log from "electron-log"; 17 | 18 | export class Discord { 19 | private client: Client; 20 | private isReady: boolean = false; 21 | private startUp: boolean = true; 22 | private defaultLIT: string = `AMRPC - ${ 23 | app.isPackaged ? app.getVersion() : "Development" 24 | }`; 25 | private triggerAfterReady: (() => void)[] = []; 26 | 27 | public activity: Presence = {}; 28 | public isLive: boolean = false; 29 | public isConnected: boolean = false; 30 | public currentTrack: currentTrack; 31 | public isSupporter: boolean = null; 32 | public songData: SongData = new SongData(); 33 | 34 | static instance: Discord; 35 | 36 | constructor() { 37 | if (!appDependencies.discord) return; 38 | 39 | this.connect(); 40 | 41 | Discord.instance = this; 42 | 43 | [ 44 | "rpcDetails", 45 | "rpcState", 46 | "rpcLargeImageText", 47 | "show", 48 | "showAlbumArtwork", 49 | "showTimestamps" 50 | ].forEach((key) => { 51 | // @ts-ignore 52 | config.onDidChange(key, () => configChange(key)); 53 | }); 54 | 55 | async function configChange(type: string) { 56 | if ( 57 | Discord.instance.currentTrack && 58 | Object.keys(Discord.instance.currentTrack).length > 0 && 59 | !Discord.instance.isLive 60 | ) { 61 | if (type.startsWith("rpc")) { 62 | const discordType = type.replace("rpc", ""), 63 | varResult = new replaceVariables( 64 | Discord.instance.currentTrack 65 | ).getResult(discordType); 66 | 67 | Discord.instance.activity[ 68 | discordType.charAt(0).toLowerCase() + 69 | discordType.slice(1) 70 | ] = varResult; 71 | 72 | log.info( 73 | "[DISCORD][configChange]", 74 | `${discordType}: ${varResult}` 75 | ); 76 | 77 | Discord.setActivity(Discord.instance.activity); 78 | } else if (type === "show") { 79 | if ( 80 | config.get("show") && 81 | Discord.instance.currentTrack && 82 | Discord.instance.activity 83 | ) { 84 | Discord.setActivity(Discord.instance.activity); 85 | } else Discord.clearActivity(); 86 | } else if (type === "showAlbumArtwork") { 87 | Discord.instance.activity.largeImageKey = 88 | config.get("showAlbumArtwork") && 89 | Discord.instance.currentTrack?.artwork 90 | ? Discord.instance.currentTrack.artwork 91 | : config.get("artwork"); 92 | 93 | log.info( 94 | "[DISCORD][configChange]", 95 | `showAlbumArtwork: ${config.get("showAlbumArtwork")}` 96 | ); 97 | 98 | Discord.setActivity(Discord.instance.activity); 99 | } else if (type === "showTimestamps") { 100 | if (config.get("showTimestamps")) { 101 | const currentTrack = await Bridge.fetchMusic(); 102 | 103 | if ( 104 | !currentTrack || 105 | Object.keys(currentTrack).length === 0 106 | ) 107 | return; 108 | 109 | Discord.instance.activity.endTimestamp = 110 | Math.floor(Date.now() / 1000) - 111 | currentTrack.elapsedTime + 112 | currentTrack.duration; 113 | } else delete Discord.instance.activity.endTimestamp; 114 | 115 | log.info( 116 | "[DISCORD][configChange]", 117 | `showTimestamps: ${config.get("showTimestamps")}` 118 | ); 119 | 120 | Discord.setActivity(Discord.instance.activity); 121 | } 122 | } 123 | } 124 | } 125 | 126 | connect() { 127 | this.client = new Client({ 128 | transport: "ipc" 129 | }); 130 | 131 | this.client 132 | .login({ 133 | clientId: "842112189618978897" 134 | }) 135 | .then(async (client) => { 136 | log.info("[DISCORD]", `Client logged in ${client.user.id}`); 137 | 138 | try { 139 | this.isSupporter = await checkSupporter(client.user.id); 140 | } catch (err) { 141 | log.error("[DISCORD]", `Supporter check error: ${err}`); 142 | } 143 | 144 | if (!this.isSupporter) { 145 | this.activity.largeImageText = this.defaultLIT; 146 | 147 | setConfig("rpcLargeImageText", `AMRPC - %version%`); 148 | } 149 | 150 | this.isReady = true; 151 | this.isConnected = true; 152 | this.startUp = false; 153 | 154 | trayManager.discordConnectionUpdate(true); 155 | 156 | this.triggerAfterReady.forEach((func) => func()); 157 | this.triggerAfterReady = []; 158 | }) 159 | .catch((err) => { 160 | log.error("[DISCORD]", `Client login error: ${err}`); 161 | log.info("[DISCORD]", "Retrying in 5 seconds..."); 162 | 163 | // Could not connect: Discord (most likely) not running 164 | // RPC_CONNECTION_TIMEOUT: Discord (most likely) running and needs restart (most of the time) 165 | // -> retry in 5 seconds 166 | 167 | setTimeout(() => this.connect(), 5000); 168 | }); 169 | 170 | this.client.on("disconnected", () => { 171 | log.info("[DISCORD]", "Client disconnected"); 172 | 173 | this.isReady = false; 174 | this.isConnected = false; 175 | 176 | trayManager.discordConnectionUpdate(false); 177 | 178 | this.connect(); 179 | }); 180 | 181 | register("842112189618978897"); 182 | } 183 | 184 | setActivity(activity: Presence) { 185 | if (!this.isSupporter) activity.largeImageText = this.defaultLIT; 186 | if (!config.get("showTimestamps")) delete activity.endTimestamp; 187 | 188 | this.activity = activity; 189 | 190 | if (this.isReady) { 191 | const time = Date.now(); 192 | 193 | this.client 194 | .setActivity(activity) 195 | .then(() => { 196 | log.info( 197 | "[DISCORD][setActivity]", 198 | `Activity set (${Date.now() - time}ms)` 199 | ); 200 | }) 201 | .catch((err) => { 202 | log.error("[DISCORD][setActivity]", `Client error: ${err}`); 203 | }); 204 | } else { 205 | if (!this.startUp) this.connect(); 206 | 207 | this.triggerAfterReady.push(() => this.setActivity(activity)); 208 | } 209 | } 210 | 211 | clearActivity() { 212 | if (this.isReady) { 213 | const time = Date.now(); 214 | 215 | this.client 216 | .clearActivity() 217 | .then(() => { 218 | log.info( 219 | "[DISCORD][clearActivity]", 220 | `Activity cleared (${Date.now() - time}ms)` 221 | ); 222 | }) 223 | .catch((err) => { 224 | log.error( 225 | "[DISCORD][clearActivity]", 226 | `Client error: ${err}` 227 | ); 228 | }); 229 | } else { 230 | if (!this.startUp) this.connect(); 231 | 232 | this.triggerAfterReady.push(() => this.clearActivity()); 233 | } 234 | } 235 | 236 | async setCurrentTrack(currentTrack: currentTrack) { 237 | const thisCurrenTrack = this.currentTrack; 238 | 239 | if (thisCurrenTrack) delete thisCurrenTrack.url; 240 | if (thisCurrenTrack === currentTrack) return; 241 | 242 | const activity: Presence = {}, 243 | replacedVars = new replaceVariables(currentTrack); 244 | 245 | activity.largeImageText = replacedVars.getResult("largeImageText"); 246 | activity.largeImageKey = getConfig("artwork"); 247 | activity.details = replacedVars.getResult("details"); 248 | activity.state = replacedVars.getResult("state"); 249 | 250 | if (currentTrack.endTime) activity.endTimestamp = currentTrack.endTime; 251 | else if (currentTrack.duration > 0) { 252 | activity.endTimestamp = 253 | Math.floor(Date.now() / 1000) - 254 | currentTrack.elapsedTime + 255 | currentTrack.duration; 256 | 257 | this.isLive = false; 258 | } else if (!currentTrack.artist && !currentTrack.album) { 259 | if (activity.endTimestamp) delete activity.endTimestamp; 260 | 261 | activity.details = currentTrack.name.substring(0, 128); 262 | activity.state = "LIVE"; 263 | 264 | this.isLive = true; 265 | } 266 | 267 | this.currentTrack = currentTrack; 268 | 269 | this.setActivity(activity); 270 | 271 | if (this.isLive) { 272 | const songData = await songDataSearchStation(currentTrack.name); 273 | 274 | if (songData.artwork) activity.largeImageKey = songData.artwork; 275 | if (songData.url) 276 | activity.buttons = [ 277 | { 278 | label: "Play on Apple Music", 279 | url: songData.url 280 | } 281 | ]; 282 | 283 | this.setActivity(activity); 284 | } else { 285 | if (!config.get("artworkPrioLocal")) { 286 | this.getSongData(currentTrack).catch(async () => { 287 | this.setLocalArtwork( 288 | await Bridge.getCurrentTrackArtwork() 289 | ).catch(() => { 290 | this.activity.largeImageKey = getConfig("artwork"); 291 | this.setActivity(this.activity); 292 | }); 293 | }); 294 | } else { 295 | this.setLocalArtwork(await Bridge.getCurrentTrackArtwork()) 296 | .then(() => { 297 | this.getSongData( 298 | currentTrack, 299 | this.activity, 300 | true 301 | ).catch(() => {}); 302 | }) 303 | .catch(() => { 304 | this.getSongData(currentTrack).catch(() => { 305 | this.activity.largeImageKey = getConfig("artwork"); 306 | this.setActivity(this.activity); 307 | }); 308 | }); 309 | } 310 | } 311 | } 312 | 313 | setLocalArtwork(artwork: string) { 314 | return new Promise(async (resolve, reject) => { 315 | const artworkData = await getLibrarySongArtwork(artwork); 316 | 317 | if (artworkData && artworkData.url) { 318 | this.activity.largeImageKey = artworkData.url; 319 | 320 | Browser.send("get-current-track", false, { 321 | artwork: artworkData.url, 322 | playerState: "playing" 323 | }); 324 | 325 | this.setActivity(this.activity); 326 | resolve(null); 327 | } else reject("No artwork found"); 328 | }); 329 | } 330 | 331 | getSongData(currentTrack, activity = this.activity, noArtwork = false) { 332 | return new Promise((resolve, reject) => { 333 | this.songData 334 | .getSongData( 335 | currentTrack.name, 336 | currentTrack.album, 337 | currentTrack.artist 338 | ) 339 | .then((data) => { 340 | currentTrack.url = data.url; 341 | 342 | activity.buttons = [ 343 | { 344 | label: "Play on Apple Music", 345 | url: data.url 346 | } 347 | ]; 348 | 349 | if (!noArtwork) { 350 | if (!currentTrack.artwork) 351 | currentTrack.artwork = data.artwork; 352 | 353 | Browser.send("get-current-track", false, { 354 | artwork: data.artwork, 355 | playerState: currentTrack.playerState 356 | }); 357 | 358 | if (getConfig("showAlbumArtwork")) 359 | activity.largeImageKey = currentTrack.artwork; 360 | } 361 | 362 | this.setActivity(activity); 363 | resolve(null); 364 | }) 365 | .catch((err) => { 366 | log.error( 367 | "[DISCORD][setCurrentTrack][getSongData]", 368 | `Error: ${err}` 369 | ); 370 | 371 | delete activity.buttons; 372 | delete this.activity.buttons; 373 | 374 | reject(err); 375 | }); 376 | }); 377 | } 378 | 379 | static getInstance() { 380 | if (!Discord.instance) new Discord(); 381 | 382 | return Discord.instance; 383 | } 384 | 385 | static setActivity(activity: Presence) { 386 | Discord.getInstance().setActivity(activity); 387 | } 388 | 389 | static clearActivity() { 390 | Discord.getInstance().clearActivity(); 391 | } 392 | 393 | static setCurrentTrack(currentTrack: currentTrack) { 394 | Discord.getInstance().setCurrentTrack(currentTrack); 395 | } 396 | } 397 | 398 | export function getUserData(): Promise { 399 | return new Promise((resolve, reject) => { 400 | const client = new Client({ transport: "ipc" }); 401 | 402 | client 403 | .login({ 404 | clientId: "686635833226166279" 405 | }) 406 | .then(({ user }) => client.destroy().then(() => resolve(user))) 407 | .catch(reject); 408 | }); 409 | } 410 | -------------------------------------------------------------------------------- /src/managers/i18n.ts: -------------------------------------------------------------------------------- 1 | import { config } from "./store"; 2 | 3 | import * as fs from "fs"; 4 | import * as path from "path"; 5 | import * as log from "electron-log"; 6 | 7 | import { JSONParse } from "../utils/json"; 8 | import getAppDataPath from "../utils/getAppDataPath"; 9 | 10 | import type { I18n } from "../../@types/zephra/I18n"; 11 | 12 | export class i18n { 13 | public static appDataPath = path.join(getAppDataPath(), "i18n"); 14 | 15 | public static onLanguageUpdate(func: (lang: string) => void) { 16 | config.onDidChange("language", func); 17 | } 18 | 19 | public static getLangStrings(): I18n | Record { 20 | const filePath = path.join( 21 | this.appDataPath, 22 | `${config.get("language")}.json` 23 | ); 24 | 25 | if (!fs.existsSync(filePath)) { 26 | log.warn( 27 | "[i18n][getLangStrings]", 28 | `Translations file (${config.get( 29 | "language" 30 | )}) not found at ${filePath}` 31 | ); 32 | 33 | return {}; 34 | } 35 | 36 | return JSONParse(fs.readFileSync(filePath, "utf8")); 37 | } 38 | 39 | public static writeLangStrings(lang: string, strings: any) { 40 | if (!fs.existsSync(this.appDataPath)) 41 | fs.mkdirSync(this.appDataPath, { recursive: true }); 42 | 43 | try { 44 | fs.writeFileSync( 45 | path.join(this.appDataPath, `${lang}.json`), 46 | JSON.stringify(strings, null, 4) 47 | ); 48 | } catch (e) { 49 | log.error("[i18n][writeLangStrings]", e); 50 | } 51 | } 52 | 53 | public static deleteLangDir() { 54 | if (!fs.existsSync(this.appDataPath)) return; 55 | 56 | fs.rmSync(this.appDataPath, { 57 | recursive: true 58 | }); 59 | } 60 | 61 | public static getLanguages() { 62 | if (!fs.existsSync(this.appDataPath)) return []; 63 | 64 | return fs 65 | .readdirSync(this.appDataPath) 66 | .map((file) => file.replace(".json", "")); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/managers/ipc.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, nativeTheme, shell } from "electron"; 2 | import { appData, cache, config } from "./store"; 3 | import { Browser } from "./browser"; 4 | import { Discord } from "./discord"; 5 | import { i18n } from "./i18n"; 6 | 7 | import { useDarkMode } from "../utils/theme"; 8 | import { appDependencies, updater, setLastFM } from "../index"; 9 | 10 | import { init as initAutoLaunch } from "./launch"; 11 | 12 | import * as log from "electron-log"; 13 | import * as fs from "fs"; 14 | 15 | import fetch from "node-fetch"; 16 | 17 | export function init() { 18 | ipcMain.on("update-download", (_e, install) => { 19 | if (install) appData.set("installUpdate", true); 20 | 21 | updater.downloadUpdate(); 22 | }); 23 | 24 | ipcMain.on("update-install", () => { 25 | let intervalAttempts = 0; 26 | 27 | const updateInterval = setInterval(() => { 28 | if (updater) { 29 | updater.installUpdate(); 30 | clearInterval(updateInterval); 31 | } else if (intervalAttempts >= 10) { 32 | log.error( 33 | "[IPC][updateInstall]", 34 | "Failed to install update (10 attempts)" 35 | ); 36 | 37 | clearInterval(updateInterval); 38 | } else intervalAttempts++; 39 | }, 1000); 40 | }); 41 | 42 | ipcMain.handle("autolaunch-change", () => { 43 | initAutoLaunch(); 44 | }); 45 | 46 | ipcMain.handle("appVersion", () => { 47 | return app.getVersion(); 48 | }); 49 | 50 | ipcMain.handle("getPlatform", () => { 51 | return process.platform; 52 | }); 53 | 54 | ipcMain.handle("isWindowsStore", () => { 55 | return process.windowsStore; 56 | }); 57 | 58 | ipcMain.handle("isDeveloper", () => { 59 | return !app.isPackaged; 60 | }); 61 | 62 | ipcMain.handle("isSupporter", () => { 63 | return appDependencies?.discord 64 | ? Discord.instance?.isSupporter ?? false 65 | : false; 66 | }); 67 | 68 | ipcMain.handle("getLanguages", () => { 69 | return i18n.getLanguages(); 70 | }); 71 | 72 | ipcMain.handle("getLangStrings", () => { 73 | return i18n.getLangStrings(); 74 | }); 75 | 76 | ipcMain.handle("getSystemTheme", () => { 77 | return nativeTheme.shouldUseDarkColors ? "dark" : "light"; 78 | }); 79 | 80 | ipcMain.handle("getTheme", () => { 81 | const colorTheme = config.get("colorTheme"); 82 | 83 | if (colorTheme === "auto") return useDarkMode() ? "dark" : "light"; 84 | else if (colorTheme === "os") 85 | return nativeTheme.shouldUseDarkColors ? "dark" : "light"; 86 | else return colorTheme; 87 | }); 88 | 89 | ipcMain.handle("getCurrentTrack", () => { 90 | if ( 91 | !appDependencies.discord || 92 | !Discord.instance || 93 | !Discord.instance.currentTrack 94 | ) 95 | return { artwork: null, playerState: null }; 96 | 97 | return { 98 | artwork: Discord.instance.currentTrack.artwork ?? null, 99 | playerState: Discord.instance.currentTrack.playerState ?? "stopped" 100 | }; 101 | }); 102 | 103 | ipcMain.handle("getConfig", (_e, k: string) => { 104 | return config.get(k); 105 | }); 106 | 107 | ipcMain.handle("resetConfig", (_e, k: string) => { 108 | // @ts-ignore 109 | config.reset(k); 110 | 111 | return config.get(k); 112 | }); 113 | 114 | ipcMain.handle("getAppData", (_e, k: string) => { 115 | return appData.get(k); 116 | }); 117 | 118 | ipcMain.handle("updateConfig", (_e, k: string, v: any) => { 119 | if ( 120 | k === "rpcLargeImageText" && 121 | (!appDependencies.discord || 122 | !Discord.instance || 123 | (appDependencies.discord && !Discord.instance.isSupporter)) 124 | ) { 125 | return log.warn( 126 | "[IPC][UPDATE_CONFIG]", 127 | `User is not a supporter, cannot change large image text (isSupporter: ${Discord.instance?.isSupporter})` 128 | ); 129 | } 130 | 131 | if (k === "enableCache" && !v) cache.clear(); 132 | 133 | config.set(k, v); 134 | }); 135 | 136 | ipcMain.handle("updateAppData", (_e, k: string, v: any) => 137 | appData.set(k, v) 138 | ); 139 | 140 | ipcMain.handle("windowControl", (_e, action: string) => { 141 | Browser.windowAction(action); 142 | }); 143 | 144 | ipcMain.handle("appControl", (_e, action) => { 145 | if (action === "restart") { 146 | app.relaunch(); 147 | app.exit(); 148 | } 149 | }); 150 | 151 | ipcMain.handle("fetchChangelog", async () => { 152 | const res = await fetch( 153 | "https://api.github.com/repos/ZephraCloud/Apple-Music-RPC/releases/latest", 154 | { 155 | cache: "no-store" 156 | } 157 | ); 158 | 159 | return await res.json(); 160 | }); 161 | 162 | ipcMain.handle("openURL", (_e, url) => { 163 | if ( 164 | !url.startsWith("https://") && 165 | !url.startsWith("http://") && 166 | !url.startsWith("amrpc://") && 167 | !url.startsWith("mailto:") 168 | ) 169 | return; 170 | 171 | shell.openExternal(url); 172 | }); 173 | 174 | ipcMain.handle("fetchCacheSize", () => { 175 | if (!fs.existsSync(cache.path)) { 176 | log.info("[IPC][fetchCacheSize]", "Cache file does not exist"); 177 | 178 | return { size: 0, fileSize: 0 }; 179 | } 180 | 181 | return { 182 | size: cache.size, 183 | fileSize: fs.statSync(cache.path).size / (1024 * 1024) 184 | }; 185 | }); 186 | 187 | ipcMain.handle("songDataFeedback", (_e, _isPositive) => { 188 | if (!appDependencies.discord || !Discord.instance) return; 189 | 190 | // @ts-ignore 191 | const songDataHistory = Discord.instance.songData.history; 192 | 193 | // TODO: Get current song data and send feedback to the API :: Paused until new website is released 194 | }); 195 | 196 | ipcMain.handle("isReady", (_e, isReady: boolean) => { 197 | Browser.setReady(isReady); 198 | }); 199 | 200 | ipcMain.handle("checkAppDependencies", () => { 201 | return appDependencies; 202 | }); 203 | 204 | // Last.fm 205 | ipcMain.handle("lastfm-getUser", () => { 206 | return config.get("lastFM"); 207 | }); 208 | 209 | ipcMain.handle("lastfm-connect", (_e, connect: boolean = false) => { 210 | setLastFM(connect); 211 | }); 212 | 213 | // Button actions 214 | ipcMain.handle("resetCache", () => { 215 | cache.clear(); 216 | }); 217 | } 218 | -------------------------------------------------------------------------------- /src/managers/lastFM.ts: -------------------------------------------------------------------------------- 1 | import { shell } from "electron"; 2 | 3 | import { config } from "./store"; 4 | import { Browser } from "./browser"; 5 | 6 | import LastFMAPI from "lastfmapi"; 7 | 8 | import * as log from "electron-log"; 9 | import * as http from "http"; 10 | 11 | export class LastFM { 12 | private lastfm: any; 13 | private connect: boolean; 14 | 15 | constructor(connect: boolean = false) { 16 | if (!config.get("enableLastFM")) { 17 | log.info("[LastFM]", "LastFM is disabled"); 18 | return; 19 | } 20 | 21 | log.info("[LastFM]", "Initializing LastFM"); 22 | 23 | this.connect = connect; 24 | this.lastfm = new LastFMAPI({ 25 | api_key: "f8f2436148e4ee8f54ffb494ce03cfde", 26 | secret: "886a030b18072bb4f27cef686aa10d2c" 27 | }); 28 | 29 | this.authenticate(); 30 | } 31 | 32 | async authenticate() { 33 | const configData: { 34 | [key: string]: any; 35 | } = config.get("lastFM"); 36 | 37 | if (!this.connect && configData?.username && configData?.key) { 38 | log.info("[LastFM]", "Loading data from config"); 39 | 40 | this.lastfm.setSessionCredentials( 41 | configData.username, 42 | configData.key 43 | ); 44 | 45 | return; 46 | } 47 | 48 | log.info("[LastFM]", "Authenticating with LastFM"); 49 | 50 | const authURL = this.lastfm.getAuthenticationUrl({ 51 | cb: "http://localhost:9101/lastfm" 52 | }); 53 | 54 | shell.openExternal(authURL); 55 | 56 | AuthServer.destroy(); 57 | 58 | const token = await new AuthServer().create(); 59 | 60 | log.info("[LastFM]", "Token received:", token); 61 | 62 | await this.lastfm.authenticate(token, (err: any, session: any) => { 63 | if (err) return log.error("[LastFM]", err); 64 | 65 | log.info("[LastFM]", "Authenticated with LastFM"); 66 | 67 | config.set("lastFM", { 68 | username: session.username, 69 | key: session.key 70 | }); 71 | 72 | Browser.send("lastfm-connect", false, session); 73 | }); 74 | } 75 | 76 | public nowPlaying(data: { 77 | artist: string; 78 | track: string; 79 | album: string; 80 | duration: number; 81 | }) { 82 | if ( 83 | !this.lastfm.sessionCredentials?.username || 84 | !this.lastfm.sessionCredentials?.key 85 | ) 86 | return log.warn( 87 | "[LastFM][nowPlaying]", 88 | "No session credentials found" 89 | ); 90 | 91 | if (!data.artist || !data.track || !data.album) 92 | return log.warn("[LastFM][nowPlaying]", "Missing data"); 93 | 94 | this.lastfm.track.updateNowPlaying(data, (err: any) => { 95 | if (err) { 96 | log.error("[LastFM]", err); 97 | 98 | if ( 99 | err.message === 100 | "Invalid session key - Please re-authenticate" 101 | ) { 102 | this.connect = true; 103 | this.authenticate(); 104 | } 105 | } 106 | }); 107 | } 108 | 109 | public scrobble(data: { 110 | artist: string; 111 | track: string; 112 | album: string; 113 | duration: number; 114 | timestamp: number; 115 | }) { 116 | if ( 117 | !this.lastfm.sessionCredentials?.username || 118 | !this.lastfm.sessionCredentials?.key 119 | ) 120 | return log.warn( 121 | "[LastFM][scrobble]", 122 | "No session credentials found" 123 | ); 124 | 125 | if (!data.artist || !data.track || !data.album) 126 | return log.warn("[LastFM][scrobble]", "Missing data"); 127 | 128 | this.lastfm.track.scrobble(data, (err: any) => { 129 | if (err) { 130 | log.error("[LastFM]", err); 131 | 132 | if ( 133 | err.message === 134 | "Invalid session key - Please re-authenticate" 135 | ) { 136 | this.connect = true; 137 | this.authenticate(); 138 | } 139 | } 140 | }); 141 | } 142 | } 143 | 144 | class AuthServer { 145 | private server: any; 146 | 147 | static instance: AuthServer; 148 | 149 | public create(): Promise { 150 | AuthServer.instance = this; 151 | 152 | return new Promise((resolve, reject) => { 153 | this.server = http.createServer(); 154 | 155 | this.server.listen(9101); 156 | this.server.on("request", (req: any, res: any) => { 157 | const url = new URL(req.url, "http://localhost:9101"); 158 | 159 | if (url.pathname === "/lastfm") { 160 | const query = url.searchParams, 161 | token = query.get("token"); 162 | 163 | if (token) { 164 | log.info("[LastFM]", "Received token from LastFM"); 165 | 166 | resolve(token); 167 | } else reject("No token provided"); 168 | 169 | this.server.close(); 170 | } 171 | 172 | res.end( 173 | `

Authentication successful

You can close this tab now

` 174 | ); 175 | }); 176 | }); 177 | } 178 | 179 | static destroy() { 180 | AuthServer.instance?.server.close(); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/managers/launch.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { getConfig } from "./store"; 3 | 4 | import AutoLaunch from "auto-launch"; 5 | 6 | import * as log from "electron-log"; 7 | import * as path from "path"; 8 | 9 | export function init() { 10 | if (!app.isPackaged) return; 11 | 12 | const logNote = process.windowsStore 13 | ? "[AutoLaunch][Windows-Store]" 14 | : "[AutoLaunch]"; 15 | 16 | const paths = { 17 | explorer: path.join("C:", "Windows", "explorer.exe"), 18 | app: path.join( 19 | "shell:AppsFolder", 20 | "62976zephra.AMRPC_xe0z77jsegffp!62976zephra.AMRPC" 21 | ) 22 | }; 23 | 24 | const autoLaunch = new AutoLaunch({ 25 | name: "AMRPC", 26 | path: process.windowsStore 27 | ? `${paths.explorer} ${paths.app}` 28 | : app.getPath("exe") 29 | }); 30 | 31 | autoLaunch[getConfig("autoLaunch") ? "enable" : "disable"]().catch( 32 | (err) => { 33 | log.warn(`${logNote}[Error]`, err); 34 | } 35 | ); 36 | 37 | log.info(logNote, "AutoLaunch initialized"); 38 | } 39 | -------------------------------------------------------------------------------- /src/managers/modal.ts: -------------------------------------------------------------------------------- 1 | import { Browser } from "./browser"; 2 | import { appData } from "./store"; 3 | import { apiRequest } from "../utils/apiRequest"; 4 | import { CronJob } from "cron"; 5 | 6 | import * as log from "electron-log"; 7 | 8 | // https://docs.amrpc.zephra.cloud/developer-resources/modals for more info 9 | export class ModalWatcher { 10 | constructor() { 11 | this.checkForModals(); 12 | // :00, :15, :30, :45 13 | try { 14 | new CronJob("*/15 * * * *", this.checkForModals).start(); 15 | } catch (e) { 16 | log.error("[ModalWatcher]", e); 17 | } 18 | } 19 | 20 | private checkForModals() { 21 | log.info("[ModalWatcher][checkForModals] Checking for modals"); 22 | 23 | apiRequest("modals.json", "https://api.zephra.cloud/amrpc/").then( 24 | (data: ModalData[]) => { 25 | if (!data || Object.keys(data).length === 0) return; 26 | 27 | data.forEach((modal: ModalData) => { 28 | if (appData.get("modals")[modal.id]) return; 29 | 30 | this.openModal(modal); 31 | }); 32 | } 33 | ); 34 | } 35 | 36 | private openModal(data: ModalData) { 37 | if (!data || Object.keys(data).length === 0) return; 38 | 39 | log.info("[ModalWatcher][openModal] Opening modal", data.id); 40 | 41 | Browser.send( 42 | "open-modal", 43 | data.priority?.toString() ? data.priority : false, 44 | data 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/managers/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/electron"; 2 | import * as log from "electron-log"; 3 | 4 | import { app } from "electron"; 5 | 6 | export function init() { 7 | if (!app.isPackaged) return log.info("[SENTRY]", "Sentry is disabled"); 8 | 9 | Sentry.init({ 10 | dsn: "https://5da6c0e155b9475299808dd3daa0cf93@o1209127.ingest.sentry.io/6402650", 11 | environment: /-[a-z]*/g.test(app.getVersion()) 12 | ? app.getVersion().replace(/^(\d+)\.(\d+)\.(\d+)-/g, "") 13 | : "production" 14 | }); 15 | 16 | log.info("[SENTRY]", "Sentry initialized"); 17 | } 18 | -------------------------------------------------------------------------------- /src/managers/store.ts: -------------------------------------------------------------------------------- 1 | import Store from "electron-store"; 2 | import * as log from "electron-log"; 3 | 4 | export const config = new Store({ 5 | defaults: { 6 | autoLaunch: true, 7 | autoUpdates: process.platform === "win32", 8 | betaUpdates: false, 9 | show: true, 10 | hideOnPause: true, 11 | artworkPrioLocal: false, 12 | showAlbumArtwork: true, 13 | showTimestamps: true, 14 | hardwareAcceleration: true, 15 | enableCache: true, 16 | enableLastFM: false, 17 | checkIfMusicInstalled: true, 18 | service: "amp", 19 | colorTheme: "light", 20 | language: "en-US", 21 | artwork: "applemusic-logo", 22 | rpcLargeImageText: "AMRPC - %version%", 23 | rpcDetails: "%title% - %album%", 24 | rpcState: "%artist%", 25 | lastFM: { 26 | username: "", 27 | key: "" 28 | }, 29 | watchdog: { 30 | autoUpdates: true, 31 | mirrorAppState: true 32 | } 33 | } 34 | }), 35 | appData = new Store({ 36 | name: "data", 37 | defaults: { 38 | modals: { 39 | nineEleven: false, 40 | appleEvent: false 41 | }, 42 | nineElevenCovers: false, 43 | changelog: {}, 44 | zephra: { 45 | userId: false, 46 | userAuth: false, 47 | lastAuth: false 48 | } 49 | } 50 | }), 51 | // Key format: "songName_:_albumName_:_artistName" 52 | cache = new Store({ 53 | name: "cache", 54 | accessPropertiesByDotNotation: false 55 | }); 56 | 57 | // Change old language config to new language config 58 | if (config.get("language").includes("_")) config.reset("language"); 59 | 60 | export function getConfig(key: string): any { 61 | return config.get(key); 62 | } 63 | 64 | export function getAppData(key: string): any { 65 | return appData.get(key); 66 | } 67 | 68 | export function setConfig(key: string, value: any) { 69 | config.set(key, value); 70 | 71 | log.info("[STORE][CONFIG]", `Set ${key} to ${JSON.stringify(value)}`); 72 | } 73 | 74 | export function setAppData(key: string, value: any) { 75 | appData.set(key, value); 76 | 77 | log.info("[STORE][APPDATA]", `Set ${key} to ${JSON.stringify(value)}`); 78 | } 79 | -------------------------------------------------------------------------------- /src/managers/tray.ts: -------------------------------------------------------------------------------- 1 | import { Tray, Menu, app, shell } from "electron"; 2 | 3 | import { WatchDogState } from "../utils/watchdog"; 4 | import { quitITunes } from "../utils/quitITunes"; 5 | import { Browser } from "./browser"; 6 | import { i18n } from "./i18n"; 7 | import { config } from "./store"; 8 | 9 | import * as path from "path"; 10 | import * as log from "electron-log"; 11 | 12 | export class TrayManager { 13 | private tray: Tray; 14 | private i18n = i18n.getLangStrings(); 15 | 16 | private isDiscordConnected = false; 17 | 18 | constructor() { 19 | this.tray = new Tray( 20 | path.join( 21 | app.getAppPath(), 22 | process.platform === "darwin" 23 | ? "assets/statusBarIcon.png" 24 | : "assets/trayLogo@32.png" 25 | ) 26 | ); 27 | 28 | this.tray.setToolTip("AMRPC"); 29 | this.tray.setContextMenu(this.createContextMenu()); 30 | this.tray.on("click", () => { 31 | if (process.platform === "win32") new Browser(); 32 | else this.tray.popUpContextMenu(); 33 | }); 34 | 35 | ["service", "watchdog.mirrorAppState"].forEach((key: any) => { 36 | config.onDidChange(key, this.update.bind(this)); 37 | }); 38 | 39 | i18n.onLanguageUpdate(() => { 40 | this.i18n = i18n.getLangStrings(); 41 | this.update(); 42 | }); 43 | } 44 | 45 | private createContextMenu(): Electron.Menu { 46 | const items = [ 47 | { 48 | label: `${ 49 | app.isPackaged ? "AMRPC" : "AMRPC - DEV" 50 | } v${app.getVersion()}`, 51 | icon: path.join(app.getAppPath(), "assets/trayLogo@18.png"), 52 | enabled: false 53 | }, 54 | { 55 | label: 56 | (process.platform === "darwin" && 57 | parseFloat(process.release.toString()) <= 10.15) || 58 | process.platform === "win32" 59 | ? config.get("service") === "itunes" 60 | ? "iTunes" 61 | : "Apple Music (Preview)" 62 | : "Apple Music", 63 | enabled: false 64 | }, 65 | { 66 | label: `Discord${ 67 | this.isDiscordConnected ? " " : " not " 68 | }connected`, 69 | enabled: false 70 | }, 71 | { type: "separator" }, 72 | { 73 | label: this.i18n?.tray?.reportProblem ?? "Report a Problem", 74 | click() { 75 | shell.openExternal("https://discord.gg/APDghNfJhQ"); 76 | } 77 | }, 78 | { type: "separator" }, 79 | { 80 | label: "Settings", 81 | click() { 82 | new Browser(); 83 | } 84 | }, 85 | { type: "separator" }, 86 | { 87 | label: 88 | this.i18n?.tray?.quitITunes?.info ?? 89 | "This takes about 3 seconds", 90 | enabled: false, 91 | visible: 92 | process.platform === "win32" && 93 | config.get("service") === "itunes" 94 | }, 95 | { 96 | label: this.i18n?.tray?.quitITunes?.button ?? "Quit iTunes", 97 | visible: 98 | process.platform === "win32" && 99 | config.get("service") === "itunes", 100 | click() { 101 | quitITunes(); 102 | } 103 | }, 104 | { 105 | type: "separator", 106 | visible: 107 | process.platform === "win32" && 108 | config.get("service") === "itunes" 109 | }, 110 | { 111 | label: this.i18n?.tray?.restart ?? "Restart", 112 | click() { 113 | app.relaunch(); 114 | app.exit(); 115 | } 116 | }, 117 | { 118 | label: this.i18n?.tray?.quit ?? "Quit", 119 | click() { 120 | if ( 121 | process.platform === "win32" && 122 | config.get("service") === "music" && 123 | config.get("watchdog.mirrorAppState") 124 | ) { 125 | WatchDogState(false) 126 | .then(app.quit) 127 | .catch(() => { 128 | log.error( 129 | "[READY][Quit]", 130 | "Failed to stop WatchDog. Quitting app." 131 | ); 132 | app.quit(); 133 | }); 134 | } else app.quit(); 135 | } 136 | } 137 | ]; 138 | 139 | return Menu.buildFromTemplate( 140 | // @ts-ignore 141 | items.filter((item) => item.visible !== false) 142 | ); 143 | } 144 | 145 | update() { 146 | log.info("[TrayManager]", "Updating tray"); 147 | 148 | this.tray.setContextMenu(this.createContextMenu()); 149 | } 150 | 151 | public discordConnectionUpdate(isConnected: boolean) { 152 | this.isDiscordConnected = isConnected; 153 | 154 | if (process.platform === "darwin") { 155 | this.tray.setImage( 156 | path.join( 157 | app.getAppPath(), 158 | `assets/statusBarIcon${isConnected ? "" : "Error"}.png` 159 | ) 160 | ); 161 | } 162 | 163 | this.update(); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/managers/updater.ts: -------------------------------------------------------------------------------- 1 | import { autoUpdater } from "electron-updater"; 2 | 3 | import { Browser } from "./browser"; 4 | import { appData, config } from "./store"; 5 | 6 | import * as log from "electron-log"; 7 | 8 | export class Updater { 9 | private userNotified: boolean | string = false; 10 | 11 | constructor() { 12 | log.info("[UPDATER]", "Updater initialized"); 13 | 14 | autoUpdater.allowPrerelease = config.get("betaUpdates"); 15 | autoUpdater.autoDownload = false; 16 | 17 | autoUpdater.on("update-available", (info) => { 18 | log.info( 19 | "[UPDATER]", 20 | `Update available (${info.version})`, 21 | `Auto Update: ${config.get("autoUpdate")}`, 22 | `Beta Updates: ${config.get("betaUpdates")}` 23 | ); 24 | 25 | if (config.get("autoUpdate") && process.platform === "win32") 26 | this.downloadUpdate(); 27 | else if (!this.userNotified) { 28 | Browser.send("new-update-available", true, { 29 | version: info.version 30 | }); 31 | 32 | this.userNotified = true; 33 | } 34 | }); 35 | 36 | autoUpdater.on("error", (err) => log.error("[UPDATER]", err)); 37 | 38 | autoUpdater.on("download-progress", (progressObj) => { 39 | if ( 40 | progressObj.percent === 25 || 41 | progressObj.percent === 50 || 42 | progressObj.percent === 75 || 43 | progressObj.percent === 100 44 | ) { 45 | log.info( 46 | "[UPDATER]", 47 | `Downloading update... (${progressObj.percent}%)` 48 | ); 49 | } 50 | 51 | Browser.send("update-download-progress-update", true, { 52 | percent: progressObj.percent, 53 | transferred: progressObj.transferred, 54 | total: progressObj.total, 55 | speed: progressObj.bytesPerSecond 56 | }); 57 | }); 58 | 59 | autoUpdater.on("update-downloaded", (info) => { 60 | log.info("[UPDATER]", `Update downloaded (${info.version})`); 61 | 62 | if (appData.get("installUpdate")) this.installUpdate(); 63 | else Browser.send("update-downloaded", true, {}); 64 | }); 65 | 66 | setInterval(() => { 67 | this.checkForUpdates(); 68 | }, 1.8e6); 69 | 70 | this.checkForUpdates(); 71 | } 72 | 73 | public checkForUpdates() { 74 | log.info("[UPDATER]", "Checking for Updates..."); 75 | 76 | autoUpdater[ 77 | this.userNotified ? "checkForUpdates" : "checkForUpdatesAndNotify" 78 | ]().then(() => log.info("[UPDATER]", "Update check completed")); 79 | } 80 | 81 | public downloadUpdate() { 82 | log.info("[UPDATER]", "Update download initiated"); 83 | 84 | autoUpdater 85 | .downloadUpdate() 86 | .then((r) => { 87 | log.info("[UPDATER]", "Downloading update...", r); 88 | }) 89 | .catch((err) => { 90 | log.error("[UPDATER]", "Error downloading update", err); 91 | }); 92 | } 93 | 94 | public installUpdate() { 95 | log.info("[UPDATER]", "Installing update... (Quiting)"); 96 | 97 | autoUpdater.quitAndInstall(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/managers/watchdog.ts: -------------------------------------------------------------------------------- 1 | import { dialog, shell } from "electron"; 2 | 3 | import { watchDog } from "../index"; 4 | import { config } from "./store"; 5 | import { i18n } from "./i18n"; 6 | import { JSONParse } from "../utils/json"; 7 | import { 8 | WatchDogDetails, 9 | WatchDogInstaller, 10 | WatchDogState 11 | } from "../utils/watchdog"; 12 | 13 | import WebSocket from "ws"; 14 | import EventEmitter from "events"; 15 | 16 | import * as log from "electron-log"; 17 | 18 | interface WatchDogData { 19 | type: "res" | "event"; 20 | title: string; 21 | artist: string; 22 | album: string; 23 | duration: number; 24 | endTime: number; // Timestamp 25 | thumbnailPath: string; 26 | playerState: "playing" | "paused" | "not_started"; 27 | } 28 | 29 | export class WatchDog { 30 | private socket: WebSocket; 31 | private emitter = new EventEmitter(); 32 | 33 | public watchdogUpdating = false; 34 | 35 | constructor() { 36 | this.init(); 37 | } 38 | 39 | async init() { 40 | if (this.watchdogUpdating) { 41 | log.info("[WatchDog]", "WatchDog is updating"); 42 | 43 | setTimeout(this.init, 2500); 44 | } else { 45 | if (WatchDogDetails("status")) { 46 | log.info("[WatchDog]", "WatchDog is installed"); 47 | 48 | if (await WatchDogDetails("running")) watchDog.connect(); 49 | else { 50 | WatchDogState(true); 51 | 52 | setTimeout(async () => { 53 | if (await WatchDogDetails("running")) 54 | watchDog.connect(); 55 | else watchDog.init(); 56 | }, 2500); 57 | } 58 | } else { 59 | log.info("[WatchDog]", "WatchDog is not installed"); 60 | 61 | if (config.get("watchdog.autoUpdates")) { 62 | setTimeout(watchDog.init, 3000); 63 | 64 | return; 65 | } 66 | 67 | const strings = i18n.getLangStrings(), 68 | msgBox = dialog.showMessageBoxSync({ 69 | type: "error", 70 | // @ts-ignore 71 | title: strings.error.watchDog.title, 72 | // @ts-ignore 73 | message: strings.error.watchDog.description, 74 | buttons: [ 75 | strings.settings.modal.buttons.yes, 76 | strings.settings.modal.buttons.learnMore 77 | ] 78 | }); 79 | 80 | switch (msgBox) { 81 | case 0: 82 | WatchDogInstaller(true); 83 | break; 84 | 85 | case 1: 86 | shell.openExternal( 87 | "https://docs.amrpc.zephra.cloud/articles/watchdog" 88 | ); 89 | break; 90 | 91 | default: 92 | break; 93 | } 94 | } 95 | } 96 | } 97 | 98 | public connect(): void { 99 | let closeByError = false; 100 | 101 | this.socket = new WebSocket("ws://localhost:9632/watchdog"); 102 | 103 | this.socket.addEventListener("open", () => { 104 | log.info("[WatchDog]", "Connected to WebSocket"); 105 | 106 | // TEMP 107 | this.socket.send("getCurrentTrack"); 108 | }); 109 | 110 | this.socket.addEventListener("close", () => { 111 | log.info("[WatchDog]", "Disconnected from WebSocket"); 112 | 113 | if (!closeByError) { 114 | log.info("[WatchDog]", "Retrying in 5 seconds..."); 115 | setTimeout(this.init, 5000); 116 | } else closeByError = false; 117 | }); 118 | 119 | this.socket.addEventListener("error", (e) => { 120 | closeByError = true; 121 | 122 | log.error("[WatchDog]", "Error connecting to WebSocket", e); 123 | log.info("[WatchDog]", "Retrying in 5 seconds..."); 124 | 125 | setTimeout(this.init, 5000); 126 | }); 127 | 128 | this.socket.addEventListener("message", (e) => { 129 | const data: WatchDogData = JSONParse(e.data as string); 130 | 131 | if (!data || Object.keys(data).length === 0 || !data.playerState) 132 | return; 133 | if (data.type === "res") return; 134 | 135 | if (data.playerState === "playing") { 136 | const { elapsedTime, remainingTime } = this.getTimeData( 137 | data.duration, 138 | data.endTime 139 | ); 140 | 141 | this.emitter.emit("playing", { 142 | name: data.title || "", 143 | artist: data.artist || "", 144 | album: data.album || "", 145 | duration: data.duration || 0, 146 | elapsedTime, 147 | remainingTime, 148 | endTime: data.endTime, 149 | playerState: data.playerState 150 | }); 151 | } else { 152 | this.emitter.emit( 153 | data.playerState === "not_started" 154 | ? "stopped" 155 | : data.playerState 156 | ); 157 | } 158 | }); 159 | } 160 | 161 | public close(): void { 162 | this.socket.close(); 163 | } 164 | 165 | public reconnect(): void { 166 | this.close(); 167 | this.init(); 168 | } 169 | 170 | public isConnected(): boolean { 171 | return this.socket && this.socket.readyState === WebSocket.OPEN; 172 | } 173 | 174 | public send(message: string): void { 175 | this.socket.send(message); 176 | } 177 | 178 | public getCurrentTrack(): Promise { 179 | return new Promise((resolve, reject) => { 180 | if (!this.isConnected()) return resolve({} as currentTrack); 181 | 182 | const gThis = this; 183 | 184 | let failCount = 0; 185 | 186 | this.socket.addEventListener("message", onMessage); 187 | this.socket.send("getCurrentTrack"); 188 | 189 | function onMessage(e: WebSocket.MessageEvent) { 190 | const data: WatchDogData = JSONParse(e.data as string); 191 | 192 | if (!data || Object.keys(data).length === 0) return reject(); 193 | 194 | if (data.type === "event") { 195 | failCount++; 196 | 197 | if (failCount > 4) reject(); 198 | 199 | return; 200 | } 201 | 202 | const { elapsedTime, remainingTime } = gThis.getTimeData( 203 | data.duration, 204 | data.endTime 205 | ); 206 | 207 | resolve({ 208 | name: data.title || "", 209 | artist: data.artist || "", 210 | album: data.album || "", 211 | duration: data.duration || 0, 212 | elapsedTime, 213 | remainingTime, 214 | endTime: data.endTime, 215 | playerState: data.playerState 216 | } as currentTrack); 217 | gThis.socket.removeEventListener("message", onMessage); 218 | } 219 | }); 220 | } 221 | 222 | private getTimeData(duration: number, endTime: number) { 223 | const durationMS = duration * 1000, 224 | elapsedTime = (Date.now() - (endTime - durationMS)) / 1000, 225 | remainingTime = (durationMS - elapsedTime * 1000) / 1000; 226 | 227 | return { 228 | elapsedTime, 229 | remainingTime 230 | }; 231 | } 232 | 233 | public on( 234 | event: currentTrack["playerState"], 235 | listener: (currentTrack: currentTrack) => void 236 | ) { 237 | this.emitter.on(event, listener); 238 | } 239 | 240 | public emit( 241 | event: currentTrack["playerState"], 242 | currentTrack: currentTrack 243 | ) { 244 | this.emitter.emit(event, currentTrack); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/utils/advancedSongDataSearch.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | import * as cheerio from "cheerio"; 4 | import * as log from "electron-log"; 5 | 6 | export async function songDataSearchStation(title: string) { 7 | log.info("[songDataSearchStation]", "Searching for", title); 8 | 9 | const res = await fetch( 10 | `https://music.apple.com/us/search?term=${encodeURIComponent(title)}`, 11 | { 12 | headers: { 13 | "User-Agent": "AMRPC" 14 | }, 15 | cache: "no-store" 16 | } 17 | ); 18 | 19 | const html = await res.text(); 20 | 21 | const $ = cheerio.load(html); 22 | 23 | const results = $( 24 | 'div[aria-label="Top Results"] > .section-content > ul.grid' 25 | ).toArray(); 26 | 27 | let songData: { 28 | url?: string; 29 | artwork?: string; 30 | } = {}; 31 | 32 | results.forEach((result) => { 33 | if ( 34 | Object.keys(songData).length > 0 || 35 | !$(result) 36 | .find("li.top-search-lockup__secondary") 37 | .text() 38 | .includes("Radio Station") 39 | ) 40 | return; 41 | 42 | const srcSet = $(result) 43 | .find("div.top-search-lockup__artwork picture source") 44 | .attr("srcset"); 45 | 46 | const artwork = srcSet.split(",")[0].split(" ")[0]; 47 | 48 | songData = { 49 | url: `https://music.apple.com/us/search?term=${encodeURIComponent( 50 | title 51 | )}`, 52 | artwork: artwork.replace("110x110", "512x512") 53 | }; 54 | }); 55 | 56 | return songData; 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/apiRequest.ts: -------------------------------------------------------------------------------- 1 | import { JSONParse } from "./json"; 2 | 3 | import fetch from "node-fetch"; 4 | 5 | import * as log from "electron-log"; 6 | 7 | /** 8 | * @param path the path to the file 9 | * @param host host url is https://www.zephra.cloud/zaphy/api/ 10 | */ 11 | export async function apiRequest( 12 | path: string, 13 | host: string = "https://www.zephra.cloud/zaphy/api/" 14 | ) { 15 | try { 16 | const res = await fetch(host + path, { 17 | headers: { 18 | "User-Agent": "AMRPC" 19 | }, 20 | cache: "no-store" 21 | }); 22 | 23 | return JSONParse(await res.text()); 24 | } catch (e) { 25 | log.error("[apiRequest]", "Error:", e); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/checkAppDependency.ts: -------------------------------------------------------------------------------- 1 | import { dialog, shell } from "electron"; 2 | import { exec } from "child_process"; 3 | 4 | import { i18n } from "../managers/i18n"; 5 | import { config } from "../managers/store"; 6 | 7 | import * as log from "electron-log"; 8 | import execPromise from "./execPromise"; 9 | import { Browser } from "../managers/browser"; 10 | 11 | export async function checkAppDependency(): Promise { 12 | const iTunes = 13 | process.platform === "win32" 14 | ? await checkIfAppIsInstalled("iTunes") 15 | : true; 16 | const appleMusic = 17 | process.platform === "win32" 18 | ? await checkIfAppIsInstalled("Apple Music") 19 | : null; 20 | 21 | // TODO: check if WatchDog is installed 22 | 23 | if (iTunes || appleMusic) { 24 | log["info"]( 25 | `[checkAppDependency][Music] ${ 26 | iTunes ? "iTunes Found" : "AppleMusicPreview found" 27 | }` 28 | ); 29 | } else { 30 | log["warn"]("[checkAppDependency][Music] Not found"); 31 | } 32 | 33 | return { 34 | music: iTunes || appleMusic, 35 | iTunes: iTunes, 36 | appleMusic, 37 | watchDog: true, 38 | discord: true 39 | }; 40 | } 41 | 42 | export async function checkIfAppIsInstalled(appName: string): Promise { 43 | if (appName === "iTunes" && !config.get("checkIfMusicInstalled")) { 44 | log.info( 45 | "[checkAppDependency][checkIfAppIsInstalled]", 46 | "Skipping iTunes" 47 | ); 48 | 49 | return true; 50 | } 51 | 52 | if (appName === "Apple Music") { 53 | const stdout = await execPromise( 54 | `Get-AppxPackage -Name "AppleInc.AppleMusicWin"`, 55 | { shell: "powershell.exe" } 56 | ); 57 | 58 | return stdout.includes("AppleMusicWin"); 59 | } 60 | 61 | try { 62 | exec(`where ${appName}`, (err, stdout) => { 63 | if (err) { 64 | log.error("[checkAppDependency][checkIfAppIsInstalled]", err); 65 | 66 | if (err.toString().includes("Command failed: where iTunes")) { 67 | const strings = i18n.getLangStrings(); 68 | 69 | if (!strings || Object.keys(strings).length === 0) { 70 | log.error( 71 | "[checkAppDependency][checkIfAppIsInstalled]", 72 | "i18n strings not found" 73 | ); 74 | 75 | return false; 76 | } 77 | 78 | if ( 79 | dialog.showMessageBoxSync({ 80 | type: "info", 81 | title: "AMRPC", 82 | message: strings.error.iTunesInstalledCheck, 83 | buttons: [strings.general.buttons.openSettings] 84 | }) === 0 85 | ) 86 | Browser.windowAction("show"); 87 | } 88 | 89 | return false; 90 | } else { 91 | return stdout.includes(appName); 92 | } 93 | }); 94 | } catch (e) { 95 | log.info( 96 | "[checkAppDependency][checkIfAppIsInstalled]", 97 | "Check the documentation for more information:", 98 | "https://docs.amrpc.zephra.cloud/articles/command-prompt-error" 99 | ); 100 | log.error( 101 | "[checkAppDependency][checkIfAppIsInstalled]", 102 | "Check if AMRPC has permission to access Command Prompt" 103 | ); 104 | log.error("[checkAppDependency][checkIfAppIsInstalled]", e); 105 | 106 | const strings = i18n.getLangStrings(); 107 | 108 | if (!strings || Object.keys(strings).length === 0) { 109 | log.error( 110 | "[checkAppDependency][checkIfAppIsInstalled]", 111 | "i18n strings not found" 112 | ); 113 | 114 | return false; 115 | } 116 | 117 | if ( 118 | dialog.showMessageBoxSync({ 119 | type: "error", 120 | title: "AMRPC - Apple Bridge Error", 121 | message: strings.error.cmd, 122 | buttons: [strings.settings.modal.buttons.learnMore] 123 | }) === 0 124 | ) { 125 | shell.openExternal( 126 | "https://docs.amrpc.zephra.cloud/articles/command-prompt-error" 127 | ); 128 | } 129 | 130 | return false; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/utils/checkSupporter.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./apiRequest"; 2 | 3 | export async function checkSupporter(userId: string) { 4 | const res: APIUserRoles[] = await apiRequest(`user/${userId}/roles`); 5 | 6 | if (!res) return false; 7 | 8 | return res.some( 9 | (role) => 10 | role.id === "1034108574537883708" /* Features+ */ || 11 | role.id === "810577364051951648" /* Staff */ 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/crowdin.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { i18n } from "../managers/i18n"; 3 | 4 | import otaClient, { Translations } from "@crowdin/ota-client"; 5 | 6 | import * as log from "electron-log"; 7 | 8 | /* 9 | OTA Client info: 10 | https://www.npmjs.com/package/@crowdin/ota-client#quick-start 11 | Language codes: 12 | https://developer.crowdin.com/language-codes/ 13 | */ 14 | 15 | /** 16 | * @description Downloads all translations from Crowdin. Use at app start. 17 | */ 18 | export async function init() { 19 | if (!app.isPackaged) { 20 | return log.warn( 21 | "[Crowdin]", 22 | "Not downloading translations in dev mode" 23 | ); 24 | } 25 | 26 | log.info("[Crowdin]", "Initializing Crowdin OTA Client"); 27 | 28 | const client = new otaClient("4e8945ce96a5a9adcee3308fjap"); 29 | 30 | return new Promise(async (resolve, reject) => { 31 | log.info("[Crowdin]", "Downloading translations"); 32 | 33 | const translations: Translations = await client.getTranslations(); 34 | 35 | if (!translations) { 36 | reject(); 37 | return log.warn("[Crowdin]", "No translations available"); 38 | } 39 | 40 | log.info("[Crowdin]", "Deleting old translations if exist"); 41 | 42 | i18n.deleteLangDir(); 43 | 44 | for (const locale of Object.keys(translations)) { 45 | for (const translation of translations[locale]) { 46 | i18n.writeLangStrings(locale, translation.content); 47 | } 48 | } 49 | 50 | resolve(); 51 | 52 | log.info("[Crowdin]", "Finished"); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/execPromise.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | export default function execPromise( 4 | command: string, 5 | options?: any 6 | ): Promise { 7 | return new Promise(function (resolve, reject) { 8 | exec(command, options, (error, stdout) => { 9 | if (error) { 10 | reject(error); 11 | return; 12 | } 13 | resolve(stdout.toString()); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/functions.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | 3 | export function bounce(type: "informational" | "critical") { 4 | if (process.platform !== "darwin") return false; 5 | 6 | if (!app.dock.isVisible()) app.dock.show(); 7 | 8 | return app.dock.bounce(type); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/getAppDataPath.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | 3 | import path from "path"; 4 | import fs from "fs"; 5 | 6 | export default function getAppDataPath() { 7 | const appPaths = ["AMRPC", "amrpc", "apple-music-rpc"]; 8 | 9 | for (const appPath of appPaths) { 10 | const appDataPath = path.join(app.getPath("appData"), appPath); 11 | 12 | if (fs.existsSync(appDataPath)) return appDataPath; 13 | } 14 | 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/getLibrarySongArtwork.ts: -------------------------------------------------------------------------------- 1 | import { Bridge } from "../managers/bridge"; 2 | import { JSONParse } from "./json"; 3 | 4 | import fetch from "node-fetch"; 5 | import FormData from "form-data"; 6 | 7 | import * as fs from "fs"; 8 | import * as log from "electron-log"; 9 | 10 | export async function getLibrarySongArtwork( 11 | artwork 12 | ): Promise { 13 | if (!artwork) artwork = await Bridge.getCurrentTrackArtwork(); 14 | 15 | return new Promise(async (resolve, reject) => { 16 | if (!artwork) return resolve(null); 17 | 18 | const form = new FormData(); 19 | 20 | form.append("image", fs.readFileSync(artwork).toString("base64")); 21 | form.append( 22 | "name", 23 | `${Date.now()}-${Math.random().toString().replace(".", "")}` 24 | ); 25 | 26 | try { 27 | const res = await fetch( 28 | "https://www.zephra.cloud/api/amrpc/image-upload", 29 | { 30 | method: "POST", 31 | headers: { 32 | "mime-type": "multipart/form-data" 33 | }, 34 | body: form 35 | } 36 | ), 37 | json = JSONParse(await res.text()); 38 | 39 | resolve(json?.data); 40 | } catch (err) { 41 | log.error("[getLibrarySongArtwork]", err); 42 | 43 | reject(); 44 | } 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/json.ts: -------------------------------------------------------------------------------- 1 | export function JSONParse(str: string) { 2 | try { 3 | return JSON.parse(str); 4 | } catch (e) { 5 | return null; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/msStoreModal.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { Browser } from "../managers/browser"; 3 | import { appData } from "../managers/store"; 4 | import { i18n } from "../managers/i18n"; 5 | 6 | import * as log from "electron-log"; 7 | 8 | export function init() { 9 | if ( 10 | process.platform !== "win32" || 11 | process.windowsStore || 12 | !app.isPackaged || 13 | appData.get("modals.msStoreModal") 14 | ) 15 | return; 16 | 17 | const string = i18n.getLangStrings(); 18 | 19 | if (!string || Object.keys(string).length === 0) { 20 | log.warn("[msStoreModal]", "Canceled due to missing language file"); 21 | return; 22 | } 23 | 24 | const modal: ModalData = { 25 | title: "Microsoft Store", 26 | description: string.settings.modal.microsoftStore.description, 27 | buttons: [ 28 | { 29 | label: string.settings.modal.microsoftStore.buttons.download, 30 | style: "btn-primary", 31 | events: [ 32 | { 33 | name: "onclick", 34 | value: "window.electron.openURL('https://amrpc.zephra.cloud#download')" 35 | }, 36 | { 37 | name: "click", 38 | type: "delete", 39 | save: "modals.msStoreModal" 40 | } 41 | ] 42 | }, 43 | { 44 | label: string.settings.modal.buttons.close, 45 | style: "btn-grey", 46 | events: [ 47 | { 48 | name: "click", 49 | type: "delete" 50 | } 51 | ] 52 | }, 53 | { 54 | label: string.settings.modal.buttons.no, 55 | style: "btn-red", 56 | events: [ 57 | { 58 | name: "click", 59 | type: "delete", 60 | save: "modals.msStoreModal" 61 | } 62 | ] 63 | } 64 | ] 65 | }; 66 | 67 | log.info("[msStoreModal]", "Initializing Microsoft Store modal"); 68 | 69 | Browser.send("open-modal", true, modal); 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/notifications.ts: -------------------------------------------------------------------------------- 1 | import { app, Notification } from "electron"; 2 | 3 | import path from "path"; 4 | 5 | export function createNotification( 6 | title, 7 | description, 8 | show?: boolean 9 | ): Notification { 10 | const notification = new Notification({ 11 | title: title, 12 | body: description, 13 | icon: path.join(app.getAppPath(), "assets/logo.png") 14 | }); 15 | 16 | if (show) notification.show(); 17 | 18 | return notification; 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/protocol.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | 3 | import * as path from "path"; 4 | import { Browser } from "../managers/browser"; 5 | 6 | export function init() { 7 | if (process.defaultApp) { 8 | if (process.argv.length >= 2) { 9 | app.setAsDefaultProtocolClient("amrpc", process.execPath, [ 10 | path.resolve(process.argv[1]) 11 | ]); 12 | } 13 | } else { 14 | app.setAsDefaultProtocolClient("amrpc"); 15 | } 16 | 17 | app.on("second-instance", (_e, commandLine) => { 18 | let url = commandLine.pop()?.replace("amrpc://", ""); 19 | 20 | if (url && url.endsWith("/")) url = url.slice(0, -1); 21 | 22 | if (url === "--allow-file-access-from-files") new Browser(); 23 | else { 24 | new Browser(url.replace("settings/", "")); 25 | } 26 | }); 27 | 28 | app.on("open-url", (_e, url) => { 29 | if (!url) return; 30 | 31 | url = url.replace("amrpc://", ""); 32 | 33 | if (url && url.endsWith("/")) url = url.slice(0, -1); 34 | 35 | if (url === "--allow-file-access-from-files") new Browser(); 36 | else { 37 | new Browser(url.replace("settings/", "")); 38 | } 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/quitITunes.ts: -------------------------------------------------------------------------------- 1 | import { quitITunes as quitAppleBridgeITunes } from "apple-bridge"; 2 | 3 | import { bridge } from "../index"; 4 | 5 | export function quitITunes() { 6 | if (bridge && bridge.scrobbleTimeout) clearTimeout(bridge.scrobbleTimeout); 7 | 8 | quitAppleBridgeITunes(); 9 | 10 | if (bridge) bridge.bridge.emit("stopped", "music"); 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/replaceVariables.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from "../managers/store"; 2 | import { app } from "electron"; 3 | 4 | export class replaceVariables { 5 | private currentTrack: currentTrack; 6 | 7 | constructor(currentTrack: currentTrack) { 8 | this.currentTrack = currentTrack; 9 | } 10 | 11 | getResult(config: string) { 12 | return this.testVars( 13 | getConfig(`rpc${config.charAt(0).toUpperCase() + config.slice(1)}`) 14 | ); 15 | } 16 | 17 | testVars(config: string) { 18 | if (!config) return undefined; 19 | 20 | // e.g. "%title% - %album%" => "-" 21 | const separator = config 22 | .replace(/\([^)]*\)/g, "") // Remove all brackets w/ content 23 | ?.split(/%title%|%album%|%artist%|%year%|%version%/g) 24 | ?.find((e) => e.length > 0) 25 | ?.trim() 26 | ?.replace(/[a-zA-Z0-9 ]/g, ""); 27 | 28 | let returnStr = ""; 29 | 30 | // e.g. "%title% - %album%" => 31 | // "TITLE_IS_AV - ALBUM_IS_AV" or 32 | // " - ALBUM_IS_AV" 33 | const testStr = config 34 | .replace( 35 | /%title%/g, 36 | this.currentTrack.name ? "_TITLE_IS_AV_" : "_TITLE_NOT_AV_" 37 | ) 38 | .replace( 39 | /%album%/g, 40 | this.currentTrack.album ? "_ALBUM_IS_AV_" : "_ALBUM_NOT_AV_" 41 | ) 42 | .replace( 43 | /%artist%/g, 44 | this.currentTrack.artist ? "_ARTIST_IS_AV_" : "_ARTIST_NOT_AV_" 45 | ) 46 | .replace( 47 | /%year%/g, 48 | this.currentTrack.releaseYear ? "_YEAR_IS_AV_" : "_YEAR_NOT_AV_" 49 | ) 50 | .replace( 51 | /%version%/g, 52 | app.getVersion() ? "_VERSION_IS_AV_" : "_VERSION_NOT_AV_" 53 | ); 54 | 55 | testStr 56 | .replace(/\([^)]*_NOT_AV_[^)]*\)/g, "") // Remove all brackets w/ content if variable is not available 57 | .split(/_[a-zA-Z]*_NOT_AV_|-/g) 58 | .filter((e) => { 59 | return e.trim() !== undefined && e.trim() !== ""; 60 | }) 61 | .forEach((e) => { 62 | e = e.trim(); 63 | 64 | const regexBracketsMatch = e.match(/\([^)]+\)/g); 65 | 66 | regexBracketsMatch?.forEach((bracketE) => { 67 | const regexMatch = 68 | bracketE.match(/_[a-zA-Z]*_IS_AV_|-/g)?.[0] ?? "", 69 | cfgElement = regexMatch 70 | .replace("_IS_AV_", "") 71 | .slice(1) 72 | .toLowerCase(), 73 | cfgValue = this.getValue(cfgElement); 74 | 75 | let tempE = e; 76 | 77 | if (bracketE.includes("_IS_AV_")) 78 | tempE = tempE.replace(regexMatch, cfgValue); 79 | else tempE = tempE.replace(bracketE, ""); 80 | 81 | e = tempE; 82 | }); 83 | 84 | // e.g. "_TITLE_IS_AV" => "title" 85 | const regexMatch = e.match(/_[a-zA-Z]*_IS_AV_|-/g)?.[0] ?? "", 86 | cfgElement = regexMatch 87 | .replace("_IS_AV_", "") 88 | .slice(1) 89 | .toLowerCase(), 90 | cfgValue = this.getValue(cfgElement), 91 | regex = new RegExp(`%[a-z]*%|${separator}`, "g"); 92 | 93 | if ( 94 | regexMatch !== "" && 95 | (!cfgElement || 96 | (!cfgValue && e !== config.replace(regex, "").trim())) 97 | ) 98 | return; 99 | 100 | if (returnStr) returnStr += ` ${separator} `; 101 | 102 | returnStr += 103 | cfgElement && cfgValue 104 | ? e.replace(regexMatch, cfgValue) 105 | : e; 106 | }); 107 | 108 | return returnStr ? returnStr.substring(0, 128) : undefined; 109 | } 110 | 111 | private getValue(variable: string) { 112 | switch (variable) { 113 | case "title": 114 | return this.currentTrack.name; 115 | case "album": 116 | return this.currentTrack.album; 117 | case "artist": 118 | return this.currentTrack.artist; 119 | case "year": 120 | return this.currentTrack.releaseYear?.toString(); 121 | case "version": 122 | return app.isPackaged ? app.getVersion() : "Development"; 123 | default: 124 | return undefined; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import { Browser } from "../managers/browser"; 2 | import { appData } from "../managers/store"; 3 | import { AutoTheme } from "electron-autotheme"; 4 | 5 | export let autoTheme: AutoTheme; 6 | 7 | export function init() { 8 | autoTheme = new AutoTheme((useDark: boolean) => { 9 | Browser.setTheme(useDark ? "dark" : "light"); 10 | }, appData); 11 | } 12 | 13 | export function useDarkMode() { 14 | return autoTheme.useDarkMode(); 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/watchdog/details.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import getAppDataPath from "../getAppDataPath"; 5 | import execPromise from "../execPromise"; 6 | 7 | export function WatchDogDetails( 8 | type: "version" | "status" | "running" 9 | ): string | boolean | Promise | null { 10 | const appData = getAppDataPath(), 11 | appDataWatchDog = path.join(appData, "/watchdog/"); 12 | 13 | if (type === "version") { 14 | if ( 15 | !appData || 16 | !fs.existsSync(path.join(appDataWatchDog, "version.txt")) 17 | ) 18 | return null; 19 | 20 | return fs.readFileSync( 21 | path.join(appDataWatchDog, "version.txt"), 22 | "utf-8" 23 | ); 24 | } else if (type === "status") { 25 | if (!appData) return null; 26 | 27 | return ( 28 | fs.existsSync(appDataWatchDog) && 29 | fs.existsSync(path.join(appDataWatchDog, "watchdog.exe")) && 30 | fs.existsSync(path.join(appDataWatchDog, "Newtonsoft.Json.dll")) && 31 | fs.existsSync(path.join(appDataWatchDog, "websocket-sharp.dll")) 32 | ); 33 | } else if (type === "running") { 34 | if (!appData) return null; 35 | 36 | return new Promise((resolve) => { 37 | execPromise("tasklist").then((res) => { 38 | resolve(res.includes("watchdog.exe")); 39 | }); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/watchdog/index.ts: -------------------------------------------------------------------------------- 1 | import { WatchDogDetails } from "./details"; 2 | import { WatchDogInstaller } from "./installer"; 3 | import { WatchDogState } from "./state"; 4 | import { WatchDogUninstaller } from "./uninstaller"; 5 | 6 | export { 7 | WatchDogDetails, 8 | WatchDogInstaller, 9 | WatchDogState, 10 | WatchDogUninstaller 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/watchdog/installer.ts: -------------------------------------------------------------------------------- 1 | import { WatchDogDetails, WatchDogState } from "./"; 2 | import { watchDog } from "../../index"; 3 | 4 | import fetch from "node-fetch"; 5 | import fs from "fs"; 6 | import path from "path"; 7 | import decompress from "decompress"; 8 | 9 | import getAppDataPath from "../getAppDataPath"; 10 | 11 | import * as log from "electron-log"; 12 | 13 | // AppData structure: 14 | // ../watchdog/ 15 | // - watchdog.exe 16 | // - [...] 17 | // - version.txt 18 | 19 | export async function WatchDogInstaller(initWatchDog: boolean = false) { 20 | if (watchDog?.watchdogUpdating) return; 21 | 22 | const start = Date.now(); 23 | 24 | log.info("[WatchDogInstaller]", "Checking for WatchDog update"); 25 | 26 | // Get latest release 27 | const latestRelease = await ( 28 | await fetch( 29 | "https://api.github.com/repos/zephraOSS/AMRPC-WatchDog/releases/latest" 30 | ) 31 | )?.json(), 32 | currentVersion = WatchDogDetails("version"), 33 | appData = getAppDataPath(), 34 | watchdogPath = path.join(appData, "/watchdog/"); 35 | 36 | if (!latestRelease || latestRelease.tag_name === currentVersion) return; 37 | if (!fs.existsSync(watchdogPath)) fs.mkdirSync(watchdogPath); 38 | 39 | watchDog.watchdogUpdating = true; 40 | 41 | WatchDogState(false); 42 | 43 | // Download latest release 44 | const downloadURL = latestRelease.assets?.find( 45 | (asset) => asset.name === "watchdog.zip" 46 | )?.browser_download_url, 47 | downloadPath = path.join(watchdogPath, "watchdog.zip"); 48 | 49 | if (!downloadURL) return log.info("[WatchDogInstaller]", "No download URL"); 50 | 51 | log.info("[WatchDogInstaller]", "Downloading WatchDog update"); 52 | 53 | await fetch(downloadURL).then((res) => { 54 | const dest = fs.createWriteStream(downloadPath); 55 | 56 | res.body.pipe(dest); 57 | log.info("[WatchDogInstaller]", "Downloaded WatchDog update"); 58 | }); 59 | 60 | log.info("[WatchDogInstaller]", "Deleting old WatchDog files"); 61 | 62 | fs.readdirSync(watchdogPath).forEach((file) => { 63 | if (file === "version.txt") return; 64 | 65 | fs.unlinkSync(path.join(watchdogPath, file)); 66 | }); 67 | 68 | log.info("[WatchDogInstaller]", "Awaiting WatchDog update extraction"); 69 | 70 | let extractAttempts = 0; 71 | 72 | const extractInterval = setInterval(async () => { 73 | if (extractAttempts > 30) { 74 | clearInterval(extractInterval); 75 | log.error( 76 | "[WatchDogInstaller]", 77 | "Failed to extract WatchDog update" 78 | ); 79 | 80 | return; 81 | } else if (!fs.existsSync(downloadPath)) return extractAttempts++; 82 | 83 | clearInterval(extractInterval); 84 | 85 | log.info("[WatchDogInstaller]", "Extracting WatchDog update"); 86 | 87 | await decompress(downloadPath, path.join(watchdogPath)); 88 | 89 | fs.writeFileSync( 90 | path.join(watchdogPath, "version.txt"), 91 | latestRelease.tag_name 92 | ); 93 | 94 | fs.unlinkSync(downloadPath); 95 | 96 | log.info( 97 | "[WatchDogInstaller]", 98 | `Finished in ${(Date.now() - start) / 1000}s` 99 | ); 100 | 101 | watchDog.watchdogUpdating = false; 102 | 103 | if (initWatchDog) watchDog.init(); 104 | }, 1000); 105 | } 106 | -------------------------------------------------------------------------------- /src/utils/watchdog/state.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | import path from "path"; 4 | 5 | import getAppDataPath from "../getAppDataPath"; 6 | 7 | import * as log from "electron-log"; 8 | 9 | export function WatchDogState(start: boolean = false) { 10 | const appName = "watchdog.exe"; 11 | 12 | return new Promise((resolve, reject) => { 13 | if (start) { 14 | exec( 15 | `start ${path.join(getAppDataPath(), "watchdog", appName)}`, 16 | (error) => { 17 | if (error) { 18 | log.error( 19 | "[WatchDog][State]", 20 | "Error starting WatchDog:", 21 | error.message 22 | ); 23 | reject(error); 24 | } else { 25 | log.info("[WatchDog][State]", "Started WatchDog"); 26 | resolve(); 27 | } 28 | } 29 | ); 30 | } else { 31 | log.info("[WatchDog][State]", "Stopping WatchDog"); 32 | 33 | exec(`tasklist /FI "IMAGENAME eq ${appName}"`, (error, stdout) => { 34 | if (error) { 35 | log.error( 36 | "[WatchDog][State]", 37 | `Error finding process: ${error.message}` 38 | ); 39 | reject(error); 40 | 41 | return; 42 | } 43 | 44 | const lines = stdout.split("\n"); 45 | 46 | for (const line of lines) { 47 | if (line.includes(appName)) { 48 | const parts = line.split(/\s+/), 49 | pid = parseInt(parts[1], 10); 50 | 51 | exec(`taskkill /F /PID ${pid}`, (killError) => { 52 | if (killError) { 53 | log.error( 54 | "[WatchDog][State]", 55 | `Error killing process: ${killError.message}` 56 | ); 57 | reject(killError); 58 | } else { 59 | log.info( 60 | "[WatchDog][State]", 61 | `Successfully killed WatchDog process with PID ${pid}` 62 | ); 63 | resolve(); 64 | } 65 | }); 66 | } 67 | } 68 | }); 69 | } 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/watchdog/uninstaller.ts: -------------------------------------------------------------------------------- 1 | import { WatchDogDetails } from "./details"; 2 | 3 | import fs from "fs"; 4 | import path from "path"; 5 | 6 | import getAppDataPath from "../getAppDataPath"; 7 | 8 | import * as log from "electron-log"; 9 | 10 | export function WatchDogUninstaller() { 11 | if (!WatchDogDetails("status")) return; 12 | 13 | const appData = path.join(getAppDataPath(), "/watchdog/"); 14 | 15 | if (!fs.existsSync(appData)) return; 16 | 17 | log.info("[WatchDogUninstaller]", "Deleting WatchDog files"); 18 | 19 | fs.readdirSync(appData).forEach((file) => { 20 | fs.unlinkSync(path.join(appData, file)); 21 | }); 22 | 23 | fs.rmdirSync(appData); 24 | 25 | log.info("[WatchDogUninstaller]", "Deleted WatchDog files"); 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "moduleResolution": "node", 6 | "inlineSourceMap": true, 7 | "outDir": "dist/", 8 | "rootDir": "src/", 9 | "removeComments": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "noUnusedParameters": true, 13 | "noUnusedLocals": true 14 | }, 15 | "exclude": ["**/node_modules/"] 16 | } 17 | --------------------------------------------------------------------------------