mirror of
https://github.com/nixietab/picodulce.git
synced 2025-10-18 16:25:11 +01:00
Compare commits
53 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2ca02b378c | ||
![]() |
3c9634bd24 | ||
![]() |
13a167f7fa | ||
![]() |
42dfaf8904 | ||
![]() |
078518e5ad | ||
![]() |
7f0108221b | ||
![]() |
16936e3a0d | ||
![]() |
8f24903fb3 | ||
![]() |
408afef6f1 | ||
![]() |
2b950c3c2d | ||
![]() |
12b8b67bbd | ||
![]() |
aceb899ea6 | ||
![]() |
5e27a78ded | ||
![]() |
aaf5ccbc90 | ||
![]() |
aaf73533c1 | ||
![]() |
8a6246cbbb | ||
![]() |
723cd1dd56 | ||
![]() |
1e30d2a164 | ||
![]() |
9523f1ab0c | ||
![]() |
103927328a | ||
![]() |
faefc09aad | ||
![]() |
dfc2dac3f5 | ||
![]() |
7942c09082 | ||
![]() |
d85cfe2de6 | ||
![]() |
d145fb2df1 | ||
![]() |
dedc59d09c | ||
![]() |
32e4783218 | ||
![]() |
502e64df83 | ||
![]() |
1cfb6ffcb6 | ||
![]() |
77291ad89e | ||
![]() |
3baf6e0b1d | ||
![]() |
0768897706 | ||
![]() |
ba8072c669 | ||
![]() |
785e9be9f9 | ||
![]() |
0cbd000be4 | ||
![]() |
52b635285e | ||
![]() |
67a16c008a | ||
![]() |
a4bd707461 | ||
![]() |
1b27fffc96 | ||
![]() |
fade5f86b7 | ||
![]() |
823b438840 | ||
![]() |
9a8c3f44d0 | ||
![]() |
6b65fb0d1e | ||
![]() |
8247009d60 | ||
![]() |
e5c395d031 | ||
![]() |
263e6eae07 | ||
![]() |
ec99488326 | ||
![]() |
61cd427beb | ||
![]() |
cb2f5b52b3 | ||
![]() |
ba40354a5d | ||
![]() |
0c151b058e | ||
![]() |
fc7f47d273 | ||
![]() |
4f4ff35ee5 |
36
.github/workflows/Bleeding-Job.yaml
vendored
36
.github/workflows/Bleeding-Job.yaml
vendored
@ -1,36 +0,0 @@
|
|||||||
name: Bleeding Update version
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out the repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.x'
|
|
||||||
|
|
||||||
- name: Update version.json
|
|
||||||
run: |
|
|
||||||
git fetch --prune --unshallow
|
|
||||||
commit_count=$(git rev-list --count HEAD)
|
|
||||||
version=$(jq -r '.version' version.json)
|
|
||||||
jq --arg versionBleeding "$version-$commit_count" '. + {versionBleeding: $versionBleeding}' version.json > version.tmp && mv version.tmp version.json
|
|
||||||
|
|
||||||
- name: Commit and push changes
|
|
||||||
run: |
|
|
||||||
git config --global user.name 'github-actions[bot]'
|
|
||||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
|
||||||
git add version.json
|
|
||||||
git commit -m "Update version.json with commit count"
|
|
||||||
git push
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
6
PKGBUILD
6
PKGBUILD
@ -1,11 +1,11 @@
|
|||||||
pkgname=picodulce
|
pkgname=picodulce
|
||||||
pkgver=0.11.7
|
pkgver=0.13.5
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Launcher for Minecraft based on the picomc library"
|
pkgdesc="Launcher for Minecraft based on the zucaro library"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
OPTIONS=(!strip !docs libtool emptydirs)
|
OPTIONS=(!strip !docs libtool emptydirs)
|
||||||
url="https://github.com/nixietab/picodulce"
|
url="https://github.com/nixietab/picodulce"
|
||||||
license=('MIT') # Replace with your project's license
|
license=('MIT')
|
||||||
depends=('python' 'python-virtualenv' 'xdg-utils')
|
depends=('python' 'python-virtualenv' 'xdg-utils')
|
||||||
makedepends=('git')
|
makedepends=('git')
|
||||||
source=("git+https://github.com/nixietab/picodulce.git")
|
source=("git+https://github.com/nixietab/picodulce.git")
|
||||||
|
15
README.md
15
README.md
@ -27,7 +27,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
Picodulce is a feature-rich launcher for Minecraft, developed using Qt5. It serves as a graphical user interface (GUI) for the picomc project, providing users with a seamless experience in managing and launching game versions.
|
Picodulce is a feature-rich launcher for Minecraft, developed using Qt5. It serves as a graphical user interface (GUI) for the [zucaro backend](https://github.com/nixietab/zucaro), providing users with a seamless experience in managing and launching game versions.
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
@ -44,12 +44,19 @@
|
|||||||
## Windows
|
## Windows
|
||||||
For Windows systems using the [installer](https://github.com/nixietab/picodulce/releases/latest) is recommended
|
For Windows systems using the [installer](https://github.com/nixietab/picodulce/releases/latest) is recommended
|
||||||
|
|
||||||
|
# Linux (Generic)
|
||||||
|
We have a install script, to use it run:
|
||||||
|
|
||||||
|
~~~
|
||||||
|
curl -sSL https://raw.githubusercontent.com/nixietab/picodulce/refs/heads/main/install-universal.sh | bash
|
||||||
|
~~~
|
||||||
|
|
||||||
## Arch Linux
|
## Arch Linux
|
||||||
The package is available in the [AUR](https://aur.archlinux.org/packages/picodulce) as ```picodulce```
|
The package is available in the [AUR](https://aur.archlinux.org/packages/picodulce) as ```picodulce```
|
||||||
|
|
||||||
For installing on Arch without using an AUR helper a PKGBUILD is provided
|
For installing on Arch without using an AUR helper a PKGBUILD is provided
|
||||||
```
|
```
|
||||||
git clone https://aur.archlinux.org/picodulce.git
|
git clone https://github.com/nixietab/picodulce.git
|
||||||
cd picodulce
|
cd picodulce
|
||||||
makepkg -si
|
makepkg -si
|
||||||
```
|
```
|
||||||
@ -61,7 +68,7 @@ makepkg -si
|
|||||||
``` git clone https://github.com/nixietab/picodulce ```
|
``` git clone https://github.com/nixietab/picodulce ```
|
||||||
|
|
||||||
### 2. (Optional) Set Up a Virtual Environment
|
### 2. (Optional) Set Up a Virtual Environment
|
||||||
Setting up a virtual environment is recommended to avoid dependency conflicts. Picodulce relies on the path of the `picomc` project, and using a virtual environment helps prevent errors.
|
Setting up a virtual environment is recommended to avoid dependency conflicts. Picodulce relies on the path of the `zucaro` project, and using a virtual environment helps prevent errors.
|
||||||
|
|
||||||
Create the virtual environment:
|
Create the virtual environment:
|
||||||
|
|
||||||
@ -86,7 +93,7 @@ On the venv run it as a normal python script
|
|||||||
|
|
||||||
```python picodulce.py```
|
```python picodulce.py```
|
||||||
|
|
||||||
Just make sure you have Java installed for running the actual game
|
Just make sure you have Java installed for running the actual game, or check the "manage java" option inside the launcher settings
|
||||||
|
|
||||||
### About the name
|
### About the name
|
||||||
The name "Picodulce" comes from a popular Argentinian candy. This reflects the enjoyable and user-friendly experience that the launcher aims to provide, making game management straightforward and pleasant.
|
The name "Picodulce" comes from a popular Argentinian candy. This reflects the enjoyable and user-friendly experience that the launcher aims to provide, making game management straightforward and pleasant.
|
||||||
|
344
authser.py
344
authser.py
@ -1,43 +1,29 @@
|
|||||||
import sys
|
import sys
|
||||||
import subprocess
|
import json
|
||||||
import re
|
import os
|
||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from PyQt5.QtWidgets import (QApplication, QDialog, QLabel, QVBoxLayout,
|
from PyQt5.QtWidgets import (QApplication, QDialog, QLabel, QVBoxLayout,
|
||||||
QPushButton, QLineEdit, QMessageBox)
|
QPushButton, QLineEdit, QMessageBox)
|
||||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QUrl, QObject
|
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QUrl, QObject
|
||||||
from PyQt5.QtGui import QDesktopServices
|
from PyQt5.QtGui import QDesktopServices
|
||||||
|
from zucaro.logging import logger
|
||||||
|
from zucaro.launcher import get_default_root, Launcher
|
||||||
|
|
||||||
class AuthenticationParser:
|
# Constants for Microsoft Authentication
|
||||||
@staticmethod
|
URL_DEVICE_AUTH = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
|
||||||
def clean_ansi(text):
|
URL_TOKEN = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||||
ansi_clean = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
URL_XBL = "https://user.auth.xboxlive.com/user/authenticate"
|
||||||
printable_clean = re.compile(r'[^\x20-\x7E\n]')
|
URL_XSTS = "https://xsts.auth.xboxlive.com/xsts/authorize"
|
||||||
text = ansi_clean.sub('', text)
|
URL_MC = "https://api.minecraftservices.com/authentication/login_with_xbox"
|
||||||
text = printable_clean.sub('', text)
|
URL_PROFILE = "https://api.minecraftservices.com/minecraft/profile"
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
@staticmethod
|
CLIENT_ID = "c52aed44-3b4d-4215-99c5-824033d2bc0f"
|
||||||
def is_auth_error(output):
|
SCOPE = "XboxLive.signin offline_access"
|
||||||
cleaned_output = AuthenticationParser.clean_ansi(output)
|
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||||
return "AADSTS70016" in cleaned_output and "not yet been authorized" in cleaned_output
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_auth_output(output):
|
|
||||||
cleaned_output = AuthenticationParser.clean_ansi(output)
|
|
||||||
if AuthenticationParser.is_auth_error(cleaned_output):
|
|
||||||
return None
|
|
||||||
|
|
||||||
pattern = r"https://[^\s]+"
|
|
||||||
code_pattern = r"code\s+([A-Z0-9]+)"
|
|
||||||
|
|
||||||
url_match = re.search(pattern, cleaned_output)
|
|
||||||
code_match = re.search(code_pattern, cleaned_output, re.IGNORECASE)
|
|
||||||
|
|
||||||
if url_match and code_match:
|
|
||||||
return {
|
|
||||||
'url': url_match.group(0),
|
|
||||||
'code': code_match.group(1)
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
class AuthDialog(QDialog):
|
class AuthDialog(QDialog):
|
||||||
def __init__(self, url, code, parent=None, error_mode=False):
|
def __init__(self, url, code, parent=None, error_mode=False):
|
||||||
@ -106,117 +92,254 @@ class AuthenticationThread(QThread):
|
|||||||
error_occurred = pyqtSignal(str)
|
error_occurred = pyqtSignal(str)
|
||||||
auth_error_detected = pyqtSignal(str)
|
auth_error_detected = pyqtSignal(str)
|
||||||
finished = pyqtSignal()
|
finished = pyqtSignal()
|
||||||
|
access_token_received = pyqtSignal(dict)
|
||||||
|
|
||||||
def __init__(self, account):
|
def __init__(self, username):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.account = account
|
self.username = username
|
||||||
self.process = None
|
self.device_code = None
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
self.current_output = ""
|
|
||||||
self.waiting_for_auth = False
|
|
||||||
|
|
||||||
def run(self):
|
async def _ms_oauth(self):
|
||||||
|
data = {"client_id": CLIENT_ID, "scope": SCOPE}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(URL_DEVICE_AUTH, data=data) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise Exception(f"Failed to get device code: {await resp.text()}")
|
||||||
|
j = await resp.json()
|
||||||
|
self.device_code = j["device_code"]
|
||||||
|
self.auth_data_received.emit({
|
||||||
|
'url': j["verification_uri"],
|
||||||
|
'code': j["user_code"]
|
||||||
|
})
|
||||||
|
|
||||||
|
while self.is_running:
|
||||||
|
data = {
|
||||||
|
"grant_type": GRANT_TYPE,
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"device_code": self.device_code
|
||||||
|
}
|
||||||
|
|
||||||
|
async with session.post(URL_TOKEN, data=data) as resp:
|
||||||
|
j = await resp.json()
|
||||||
|
if resp.status == 400:
|
||||||
|
if j["error"] == "authorization_pending":
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise Exception(j["error_description"])
|
||||||
|
elif resp.status != 200:
|
||||||
|
raise Exception(f"Token request failed: {j}")
|
||||||
|
|
||||||
|
return j["access_token"], j["refresh_token"]
|
||||||
|
|
||||||
|
async def _xbl_auth(self, access_token):
|
||||||
|
data = {
|
||||||
|
"Properties": {
|
||||||
|
"AuthMethod": "RPS",
|
||||||
|
"SiteName": "user.auth.xboxlive.com",
|
||||||
|
"RpsTicket": f"d={access_token}"
|
||||||
|
},
|
||||||
|
"RelyingParty": "http://auth.xboxlive.com",
|
||||||
|
"TokenType": "JWT"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(URL_XBL, json=data) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise Exception(f"XBL auth failed: {await resp.text()}")
|
||||||
|
j = await resp.json()
|
||||||
|
return j["Token"], j["DisplayClaims"]["xui"][0]["uhs"]
|
||||||
|
|
||||||
|
async def _xsts_auth(self, xbl_token):
|
||||||
|
data = {
|
||||||
|
"Properties": {
|
||||||
|
"SandboxId": "RETAIL",
|
||||||
|
"UserTokens": [xbl_token]
|
||||||
|
},
|
||||||
|
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||||
|
"TokenType": "JWT"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(URL_XSTS, json=data) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise Exception(f"XSTS auth failed: {await resp.text()}")
|
||||||
|
j = await resp.json()
|
||||||
|
return j["Token"]
|
||||||
|
|
||||||
|
async def _mc_auth(self, uhs, xsts_token):
|
||||||
|
data = {
|
||||||
|
"identityToken": f"XBL3.0 x={uhs};{xsts_token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(URL_MC, json=data) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise Exception(f"MC auth failed: {await resp.text()}")
|
||||||
|
j = await resp.json()
|
||||||
|
return j["access_token"]
|
||||||
|
|
||||||
|
async def _get_profile(self, mc_token):
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {mc_token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(URL_PROFILE, headers=headers) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise Exception(f"Profile request failed: {await resp.text()}")
|
||||||
|
return await resp.json()
|
||||||
|
|
||||||
|
async def _auth_flow(self):
|
||||||
try:
|
try:
|
||||||
command = f'picomc account authenticate {self.account}'
|
ms_access_token, refresh_token = await self._ms_oauth()
|
||||||
|
xbl_token, uhs = await self._xbl_auth(ms_access_token)
|
||||||
|
xsts_token = await self._xsts_auth(xbl_token)
|
||||||
|
mc_token = await self._mc_auth(uhs, xsts_token)
|
||||||
|
profile = await self._get_profile(mc_token)
|
||||||
|
|
||||||
self.process = subprocess.Popen(
|
self.access_token_received.emit({
|
||||||
command,
|
'access_token': mc_token,
|
||||||
shell=True,
|
'refresh_token': refresh_token,
|
||||||
stdout=subprocess.PIPE,
|
'profile': profile
|
||||||
stderr=subprocess.STDOUT,
|
})
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
universal_newlines=True
|
|
||||||
)
|
|
||||||
|
|
||||||
self.current_output = ""
|
|
||||||
while self.is_running and self.process.poll() is None:
|
|
||||||
line = self.process.stdout.readline()
|
|
||||||
if line:
|
|
||||||
self.current_output += line
|
|
||||||
|
|
||||||
if not self.waiting_for_auth:
|
|
||||||
parsed_data = AuthenticationParser.parse_auth_output(self.current_output)
|
|
||||||
if parsed_data:
|
|
||||||
self.auth_data_received.emit(parsed_data)
|
|
||||||
self.waiting_for_auth = True
|
|
||||||
self.current_output = ""
|
|
||||||
elif AuthenticationParser.is_auth_error(self.current_output):
|
|
||||||
self.auth_error_detected.emit(self.current_output)
|
|
||||||
self.waiting_for_auth = False
|
|
||||||
self.current_output = ""
|
|
||||||
|
|
||||||
self.process.wait()
|
|
||||||
self.finished.emit()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error_occurred.emit(str(e))
|
self.error_occurred.emit(str(e))
|
||||||
self.finished.emit()
|
|
||||||
|
|
||||||
def send_enter(self):
|
def run(self):
|
||||||
if self.process and self.process.poll() is None:
|
try:
|
||||||
self.process.stdin.write("\n")
|
loop = asyncio.new_event_loop()
|
||||||
self.process.stdin.flush()
|
asyncio.set_event_loop(loop)
|
||||||
|
loop.run_until_complete(self._auth_flow())
|
||||||
|
except Exception as e:
|
||||||
|
self.error_occurred.emit(str(e))
|
||||||
|
finally:
|
||||||
|
self.finished.emit()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
if self.process:
|
|
||||||
self.process.terminate()
|
|
||||||
|
|
||||||
class MinecraftAuthenticator(QObject): # Changed to inherit from QObject
|
class MinecraftAuthenticator(QObject):
|
||||||
auth_finished = pyqtSignal(bool) # Add signal for completion
|
auth_finished = pyqtSignal(bool)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.auth_thread = None
|
self.auth_thread = None
|
||||||
self.current_auth_data = None
|
|
||||||
self.auth_dialog = None
|
self.auth_dialog = None
|
||||||
self.success = False
|
self.success = False
|
||||||
|
self.username = None
|
||||||
|
|
||||||
|
# Initialize the launcher to get the correct config path
|
||||||
|
with Launcher.new() as launcher:
|
||||||
|
self.config_path = launcher.root
|
||||||
|
|
||||||
def authenticate(self, username):
|
def authenticate(self, username):
|
||||||
"""
|
self.username = username
|
||||||
Start the authentication process for the given username
|
|
||||||
Returns immediately, authentication result will be emitted via auth_finished signal
|
|
||||||
"""
|
|
||||||
self.success = False
|
self.success = False
|
||||||
|
|
||||||
|
# Create accounts.json if it doesn't exist
|
||||||
|
if not self.save_to_accounts_json():
|
||||||
|
return
|
||||||
|
|
||||||
self.auth_thread = AuthenticationThread(username)
|
self.auth_thread = AuthenticationThread(username)
|
||||||
self.auth_thread.auth_data_received.connect(self.show_auth_dialog)
|
self.auth_thread.auth_data_received.connect(self.show_auth_dialog)
|
||||||
self.auth_thread.auth_error_detected.connect(self.handle_auth_error)
|
|
||||||
self.auth_thread.error_occurred.connect(self.show_error)
|
self.auth_thread.error_occurred.connect(self.show_error)
|
||||||
|
self.auth_thread.access_token_received.connect(self.on_access_token_received)
|
||||||
self.auth_thread.finished.connect(self.on_authentication_finished)
|
self.auth_thread.finished.connect(self.on_authentication_finished)
|
||||||
self.auth_thread.start()
|
self.auth_thread.start()
|
||||||
|
|
||||||
def show_auth_dialog(self, auth_data):
|
def show_auth_dialog(self, auth_data):
|
||||||
self.current_auth_data = auth_data
|
|
||||||
|
|
||||||
if self.auth_dialog is not None:
|
if self.auth_dialog is not None:
|
||||||
self.auth_dialog.close()
|
self.auth_dialog.close()
|
||||||
self.auth_dialog = None
|
|
||||||
|
|
||||||
self.auth_dialog = AuthDialog(auth_data['url'], auth_data['code'])
|
self.auth_dialog = AuthDialog(auth_data['url'], auth_data['code'])
|
||||||
if self.auth_dialog.exec_() == QDialog.Accepted:
|
|
||||||
self.auth_thread.send_enter()
|
|
||||||
|
|
||||||
def handle_auth_error(self, output):
|
result = self.auth_dialog.exec_()
|
||||||
if self.current_auth_data:
|
|
||||||
if self.auth_dialog is not None:
|
|
||||||
self.auth_dialog.close()
|
|
||||||
self.auth_dialog = None
|
|
||||||
|
|
||||||
self.auth_dialog = AuthDialog(
|
if result != QDialog.Accepted:
|
||||||
self.current_auth_data['url'],
|
self.auth_thread.stop()
|
||||||
self.current_auth_data['code'],
|
|
||||||
error_mode=True
|
|
||||||
)
|
|
||||||
if self.auth_dialog.exec_() == QDialog.Accepted:
|
|
||||||
self.auth_thread.send_enter()
|
|
||||||
|
|
||||||
def show_error(self, error_message):
|
def show_error(self, error_msg):
|
||||||
QMessageBox.critical(None, "Error", f"Authentication error: {error_message}")
|
QMessageBox.critical(None, "Error", error_msg)
|
||||||
self.success = False
|
self.success = False
|
||||||
self.auth_finished.emit(False)
|
self.auth_finished.emit(False)
|
||||||
|
|
||||||
|
def save_to_accounts_json(self):
|
||||||
|
try:
|
||||||
|
accounts_file = Path(self.config_path) / "accounts.json"
|
||||||
|
|
||||||
|
if accounts_file.exists():
|
||||||
|
with open(accounts_file) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
else:
|
||||||
|
config = {
|
||||||
|
"default": None,
|
||||||
|
"accounts": {},
|
||||||
|
"client_token": str(uuid.uuid4())
|
||||||
|
}
|
||||||
|
accounts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Only create/update if account doesn't exist
|
||||||
|
if self.username not in config["accounts"]:
|
||||||
|
config["accounts"][self.username] = {
|
||||||
|
"uuid": "-",
|
||||||
|
"online": True,
|
||||||
|
"microsoft": True,
|
||||||
|
"gname": "-",
|
||||||
|
"access_token": "-",
|
||||||
|
"refresh_token": "-",
|
||||||
|
"is_authenticated": False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set as default if no default exists
|
||||||
|
if config["default"] is None:
|
||||||
|
config["default"] = self.username
|
||||||
|
|
||||||
|
with open(accounts_file, 'w') as f:
|
||||||
|
json.dump(config, f, indent=4)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize account data: {str(e)}")
|
||||||
|
QMessageBox.critical(None, "Error", f"Failed to initialize account data: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def on_access_token_received(self, data):
|
||||||
|
try:
|
||||||
|
accounts_file = Path(self.config_path) / "accounts.json"
|
||||||
|
|
||||||
|
with open(accounts_file) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
if self.username in config["accounts"]:
|
||||||
|
config["accounts"][self.username].update({
|
||||||
|
"access_token": data['access_token'],
|
||||||
|
"refresh_token": data['refresh_token'],
|
||||||
|
"uuid": data['profile']['id'],
|
||||||
|
"gname": data['profile']['name'],
|
||||||
|
"is_authenticated": True
|
||||||
|
})
|
||||||
|
|
||||||
|
with open(accounts_file, 'w') as f:
|
||||||
|
json.dump(config, f, indent=4)
|
||||||
|
|
||||||
|
self.success = True
|
||||||
|
QMessageBox.information(None, "Success",
|
||||||
|
f"Successfully authenticated account: {self.username}")
|
||||||
|
else:
|
||||||
|
raise Exception("Account not found in configuration")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update account data: {str(e)}")
|
||||||
|
QMessageBox.critical(None, "Error", f"Failed to update account data: {str(e)}")
|
||||||
|
self.success = False
|
||||||
|
|
||||||
|
self.auth_finished.emit(self.success)
|
||||||
|
|
||||||
def on_authentication_finished(self):
|
def on_authentication_finished(self):
|
||||||
if self.auth_dialog is not None:
|
if self.auth_dialog is not None:
|
||||||
self.auth_dialog.close()
|
self.auth_dialog.close()
|
||||||
@ -226,8 +349,8 @@ class MinecraftAuthenticator(QObject): # Changed to inherit from QObject
|
|||||||
self.auth_thread.stop()
|
self.auth_thread.stop()
|
||||||
self.auth_thread = None
|
self.auth_thread = None
|
||||||
|
|
||||||
self.success = True
|
if not self.success:
|
||||||
self.auth_finished.emit(True)
|
self.auth_finished.emit(False)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
if self.auth_dialog is not None:
|
if self.auth_dialog is not None:
|
||||||
@ -238,7 +361,6 @@ class MinecraftAuthenticator(QObject): # Changed to inherit from QObject
|
|||||||
self.auth_thread.stop()
|
self.auth_thread.stop()
|
||||||
self.auth_thread.wait()
|
self.auth_thread.wait()
|
||||||
|
|
||||||
# Example usage
|
def create_authenticator():
|
||||||
if __name__ == '__main__':
|
"""Factory function to create a new MinecraftAuthenticator instance"""
|
||||||
authenticator = MinecraftAuthenticator()
|
return MinecraftAuthenticator()
|
||||||
authenticator.authenticate("TestUser")
|
|
||||||
|
231
healthcheck.py
Normal file
231
healthcheck.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import modulecli
|
||||||
|
from PyQt5.QtWidgets import QApplication, QDialog, QVBoxLayout, QLabel, QProgressBar
|
||||||
|
from PyQt5.QtCore import Qt, QThread, pyqtSignal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
class CopyThread(QThread):
|
||||||
|
progress_changed = pyqtSignal(int)
|
||||||
|
finished = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, src_dir, dst_dir):
|
||||||
|
super().__init__()
|
||||||
|
self.src_dir = src_dir
|
||||||
|
self.dst_dir = dst_dir
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# Gather all files recursively
|
||||||
|
files_to_copy = []
|
||||||
|
for root, dirs, files in os.walk(self.src_dir):
|
||||||
|
for f in files:
|
||||||
|
full_path = os.path.join(root, f)
|
||||||
|
relative_path = os.path.relpath(full_path, self.src_dir)
|
||||||
|
files_to_copy.append(relative_path)
|
||||||
|
|
||||||
|
total_files = len(files_to_copy)
|
||||||
|
copied_files = 0
|
||||||
|
|
||||||
|
for relative_path in files_to_copy:
|
||||||
|
src_path = os.path.join(self.src_dir, relative_path)
|
||||||
|
dst_path = os.path.join(self.dst_dir, relative_path)
|
||||||
|
dst_folder = os.path.dirname(dst_path)
|
||||||
|
|
||||||
|
if not os.path.exists(dst_folder):
|
||||||
|
try:
|
||||||
|
os.makedirs(dst_folder)
|
||||||
|
except PermissionError:
|
||||||
|
print(f"Skipping folder {dst_folder} (permission denied)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy2(src_path, dst_path)
|
||||||
|
except PermissionError:
|
||||||
|
print(f"Skipping file {dst_path} (permission denied)")
|
||||||
|
|
||||||
|
copied_files += 1
|
||||||
|
progress_percent = int((copied_files / total_files) * 100)
|
||||||
|
self.progress_changed.emit(progress_percent)
|
||||||
|
|
||||||
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheck:
|
||||||
|
def __init__(self):
|
||||||
|
self.config = None
|
||||||
|
|
||||||
|
def check_config_file(self):
|
||||||
|
config_path = "config.json"
|
||||||
|
default_config = {
|
||||||
|
"IsRCPenabled": False,
|
||||||
|
"CheckUpdate": False,
|
||||||
|
"IsBleeding": False,
|
||||||
|
"LastPlayed": "",
|
||||||
|
"TotalPlaytime": 0,
|
||||||
|
"IsFirstLaunch": True,
|
||||||
|
"Instance": "default",
|
||||||
|
"Theme": "Dark.json",
|
||||||
|
"ThemeBackground": True,
|
||||||
|
"ThemeRepository": "https://raw.githubusercontent.com/nixietab/picodulce-themes/main/repo.json",
|
||||||
|
"Locale": "en",
|
||||||
|
"ManageJava": False,
|
||||||
|
"MaxRAM": "2G",
|
||||||
|
"JavaPath": "",
|
||||||
|
"ZucaroCheck": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
with open(config_path, "w") as config_file:
|
||||||
|
json.dump(default_config, config_file, indent=4)
|
||||||
|
self.config = default_config
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "r") as config_file:
|
||||||
|
self.config = json.load(config_file)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
with open(config_path, "w") as config_file:
|
||||||
|
json.dump(default_config, config_file, indent=4)
|
||||||
|
self.config = default_config
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
for key, value in default_config.items():
|
||||||
|
if key not in self.config:
|
||||||
|
self.config[key] = value
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
with open(config_path, "w") as config_file:
|
||||||
|
json.dump(self.config, config_file, indent=4)
|
||||||
|
|
||||||
|
def get_folder_size(self, folder_path):
|
||||||
|
total_size = 0
|
||||||
|
for dirpath, dirnames, filenames in os.walk(folder_path):
|
||||||
|
for f in filenames:
|
||||||
|
fp = os.path.join(dirpath, f)
|
||||||
|
if os.path.isfile(fp):
|
||||||
|
total_size += os.path.getsize(fp)
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
def zucaro_health_check(self):
|
||||||
|
if self.config.get("ZucaroCheck"):
|
||||||
|
return
|
||||||
|
|
||||||
|
output = modulecli.run_command("instance dir").strip()
|
||||||
|
instance_dir = os.path.abspath(output)
|
||||||
|
base_dir = os.path.abspath(os.path.join(instance_dir, "..", ".."))
|
||||||
|
|
||||||
|
possible_zucaro = [os.path.join(base_dir, "zucaro"), os.path.join(base_dir, ".zucaro")]
|
||||||
|
possible_picomc = [os.path.join(base_dir, "picomc"), os.path.join(base_dir, ".picomc")]
|
||||||
|
|
||||||
|
zucaro_dir = next((d for d in possible_zucaro if os.path.exists(d)), None)
|
||||||
|
picomc_dir = next((d for d in possible_picomc if os.path.exists(d)), None)
|
||||||
|
|
||||||
|
if picomc_dir is None or zucaro_dir is None:
|
||||||
|
print("Required directories not found. Skipping copy.")
|
||||||
|
# Mark the check as done so it wont run again
|
||||||
|
self.config["ZucaroCheck"] = True
|
||||||
|
with open("config.json", "w") as f:
|
||||||
|
json.dump(self.config, f, indent=4)
|
||||||
|
return
|
||||||
|
|
||||||
|
picomc_size = self.get_folder_size(picomc_dir)
|
||||||
|
zucaro_size = self.get_folder_size(zucaro_dir)
|
||||||
|
|
||||||
|
if picomc_size <= zucaro_size:
|
||||||
|
print("No action needed. Zucaro folder is not smaller than Picomc.")
|
||||||
|
# Update config so the check is considered done
|
||||||
|
self.config["ZucaroCheck"] = True
|
||||||
|
with open("config.json", "w") as f:
|
||||||
|
json.dump(self.config, f, indent=4)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Copying Picomc ({picomc_size} bytes) to Zucaro ({zucaro_size} bytes)...")
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
dialog = QDialog()
|
||||||
|
dialog.setWindowTitle("Working...")
|
||||||
|
dialog.setWindowModality(Qt.ApplicationModal)
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
label = QLabel("Working on stuff, please wait...")
|
||||||
|
progress = QProgressBar()
|
||||||
|
progress.setValue(0)
|
||||||
|
layout.addWidget(label)
|
||||||
|
layout.addWidget(progress)
|
||||||
|
dialog.setLayout(layout)
|
||||||
|
|
||||||
|
# Setup copy thread
|
||||||
|
thread = CopyThread(picomc_dir, zucaro_dir)
|
||||||
|
thread.progress_changed.connect(progress.setValue)
|
||||||
|
thread.finished.connect(dialog.accept)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
dialog.exec_() # Runs the modal event loop
|
||||||
|
|
||||||
|
# Mark as done
|
||||||
|
self.config["ZucaroCheck"] = True
|
||||||
|
with open("config.json", "w") as f:
|
||||||
|
json.dump(self.config, f, indent=4)
|
||||||
|
|
||||||
|
print("Copy completed.")
|
||||||
|
|
||||||
|
def themes_integrity(self):
|
||||||
|
themes_folder = "themes"
|
||||||
|
dark_theme_file = os.path.join(themes_folder, "Dark.json")
|
||||||
|
native_theme_file = os.path.join(themes_folder, "Native.json")
|
||||||
|
|
||||||
|
dark_theme_content = {
|
||||||
|
"manifest": {
|
||||||
|
"name": "Dark",
|
||||||
|
"description": "The default picodulce launcher theme",
|
||||||
|
"author": "Nixietab",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"palette": {
|
||||||
|
"Window": "#353535",
|
||||||
|
"WindowText": "#ffffff",
|
||||||
|
"Base": "#191919",
|
||||||
|
"AlternateBase": "#353535",
|
||||||
|
"ToolTipBase": "#ffffff",
|
||||||
|
"ToolTipText": "#ffffff",
|
||||||
|
"Text": "#ffffff",
|
||||||
|
"Button": "#353535",
|
||||||
|
"ButtonText": "#ffffff",
|
||||||
|
"BrightText": "#ff0000",
|
||||||
|
"Link": "#2a82da",
|
||||||
|
"Highlight": "#4bb679",
|
||||||
|
"HighlightedText": "#ffffff"
|
||||||
|
},
|
||||||
|
"background_image_base64": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
native_theme_content = {
|
||||||
|
"manifest": {
|
||||||
|
"name": "Native",
|
||||||
|
"description": "The native looks of your OS",
|
||||||
|
"author": "Your Qt Style",
|
||||||
|
"license": "Any"
|
||||||
|
},
|
||||||
|
"palette": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if not os.path.exists(themes_folder):
|
||||||
|
print(f"Creating folder: {themes_folder}")
|
||||||
|
os.makedirs(themes_folder)
|
||||||
|
|
||||||
|
if not os.path.isfile(dark_theme_file):
|
||||||
|
print(f"Creating file: {dark_theme_file}")
|
||||||
|
with open(dark_theme_file, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(dark_theme_content, file, indent=2)
|
||||||
|
print("Dark.json has been created successfully.")
|
||||||
|
|
||||||
|
if not os.path.isfile(native_theme_file):
|
||||||
|
print(f"Creating file: {native_theme_file}")
|
||||||
|
with open(native_theme_file, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(native_theme_content, file, indent=2)
|
||||||
|
print("Native.json has been created successfully.")
|
||||||
|
|
||||||
|
if os.path.isfile(dark_theme_file) and os.path.isfile(native_theme_file):
|
||||||
|
print("Theme Integrity OK")
|
110
install-universal.sh
Normal file
110
install-universal.sh
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PICODULCE_DIR="$HOME/.picodulce"
|
||||||
|
GIT_URL="https://github.com/nixietab/picodulce.git"
|
||||||
|
DESKTOP_FILE="$HOME/.local/share/applications/picodulce.desktop"
|
||||||
|
BIN_FILE="/usr/bin/picodulce"
|
||||||
|
|
||||||
|
# --- Helper functions ---
|
||||||
|
msg() {
|
||||||
|
echo -e "\033[1;32m$1\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
err() {
|
||||||
|
echo -e "\033[1;31m$1\033[0m" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
read -rp "Press Enter to continue..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Check dependencies ---
|
||||||
|
msg "Checking Python3..."
|
||||||
|
if ! command -v python3 >/dev/null; then
|
||||||
|
err "Python3 is not installed. Please install it first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
msg "Checking venv module..."
|
||||||
|
if ! python3 -m venv --help >/dev/null 2>&1; then
|
||||||
|
err "python3-venv is not available. Please install it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Clone repo ---
|
||||||
|
msg "Cloning Picodulce repo..."
|
||||||
|
rm -rf "$PICODULCE_DIR"
|
||||||
|
git clone "$GIT_URL" "$PICODULCE_DIR"
|
||||||
|
|
||||||
|
# --- Create virtual environment ---
|
||||||
|
cd "$PICODULCE_DIR"
|
||||||
|
msg "Creating virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# --- Create run.sh ---
|
||||||
|
msg "Creating run.sh..."
|
||||||
|
cat > "$PICODULCE_DIR/run.sh" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "venv folder does not exist. Creating virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
echo "Installing required packages..."
|
||||||
|
pip install -r requirements.txt
|
||||||
|
else
|
||||||
|
source venv/bin/activate
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec python picodulce.py
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x "$PICODULCE_DIR/run.sh"
|
||||||
|
|
||||||
|
# --- Create .desktop entry ---
|
||||||
|
msg "Creating .desktop entry..."
|
||||||
|
mkdir -p "$(dirname "$DESKTOP_FILE")"
|
||||||
|
|
||||||
|
cat > "$DESKTOP_FILE" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=Picodulce
|
||||||
|
Exec=$PICODULCE_DIR/run.sh
|
||||||
|
Icon=$PICODULCE_DIR/launcher_icon.ico
|
||||||
|
Terminal=true
|
||||||
|
Type=Application
|
||||||
|
Comment=Picodulce Launcher
|
||||||
|
Categories=Game;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# --- Ask if install in /usr/bin ---
|
||||||
|
echo
|
||||||
|
read -rp "Do you want to install the "picodulce" command? it requires sudo. (y/n) " choice
|
||||||
|
if [[ "$choice" =~ ^[Yy]$ ]]; then
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Root permissions required to install into /usr/bin"
|
||||||
|
sudo bash -c "cat > $BIN_FILE" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
cd $PICODULCE_DIR
|
||||||
|
exec ./run.sh
|
||||||
|
EOF
|
||||||
|
sudo chmod +x "$BIN_FILE"
|
||||||
|
else
|
||||||
|
cat > "$BIN_FILE" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
cd $PICODULCE_DIR
|
||||||
|
exec ./run.sh
|
||||||
|
EOF
|
||||||
|
chmod +x "$BIN_FILE"
|
||||||
|
fi
|
||||||
|
msg "Installed 'picodulce' command in /usr/bin"
|
||||||
|
fi
|
||||||
|
|
||||||
|
msg "Installation complete!"
|
||||||
|
echo "You can run Picodulce with:"
|
||||||
|
echo " $PICODULCE_DIR/run.sh"
|
||||||
|
echo "Or from your applications menu."
|
46
modulecli.py
Normal file
46
modulecli.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from io import StringIO
|
||||||
|
import sys
|
||||||
|
import shlex
|
||||||
|
import gc
|
||||||
|
|
||||||
|
def run_command(command="zucaro"):
|
||||||
|
# Remove all zucaro-related modules from sys.modules BEFORE import
|
||||||
|
modules_to_remove = [mod for mod in sys.modules if mod.startswith('zucaro')]
|
||||||
|
for mod in modules_to_remove:
|
||||||
|
del sys.modules[mod]
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# Import zucaro_cli dynamically
|
||||||
|
from zucaro.cli.main import zucaro_cli
|
||||||
|
|
||||||
|
# Redirect stdout and stderr to capture the command output
|
||||||
|
old_stdout, old_stderr = sys.stdout, sys.stderr
|
||||||
|
sys.stdout = mystdout = StringIO()
|
||||||
|
sys.stderr = mystderr = StringIO()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use shlex.split to properly parse the command string
|
||||||
|
# This will call Click's CLI as if from command line, using args
|
||||||
|
zucaro_cli.main(args=shlex.split(command))
|
||||||
|
except SystemExit as e:
|
||||||
|
if e.code != 0:
|
||||||
|
print(f"Command exited with code {e.code}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}", file=sys.stderr)
|
||||||
|
finally:
|
||||||
|
# Restore stdout and stderr
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
sys.stderr = old_stderr
|
||||||
|
|
||||||
|
output = mystdout.getvalue().strip()
|
||||||
|
error = mystderr.getvalue().strip()
|
||||||
|
|
||||||
|
# Cleanup: remove zucaro-related modules from sys.modules and force garbage collection
|
||||||
|
modules_to_remove = [mod for mod in sys.modules if mod.startswith('zucaro')]
|
||||||
|
for mod in modules_to_remove:
|
||||||
|
del sys.modules[mod]
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
return f"Error: No output from command. Stderr: {error}"
|
||||||
|
return output
|
687
picodulce.py
687
picodulce.py
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
picomc
|
zucaro
|
||||||
PyQt5
|
PyQt5
|
||||||
requests
|
requests
|
||||||
|
aiohttp
|
||||||
pypresence
|
pypresence
|
||||||
tqdm
|
tqdm
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "0.12",
|
"version": "0.13.6",
|
||||||
"links": [
|
"links": [
|
||||||
"https://raw.githubusercontent.com/nixietab/picodulce/main/version.json",
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/version.json",
|
||||||
"https://raw.githubusercontent.com/nixietab/picodulce/main/picodulce.py",
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/picodulce.py",
|
||||||
@ -7,7 +7,9 @@
|
|||||||
"https://raw.githubusercontent.com/nixietab/picodulce/main/drums.gif",
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/drums.gif",
|
||||||
"https://raw.githubusercontent.com/nixietab/picodulce/main/marroc.py",
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/marroc.py",
|
||||||
"https://raw.githubusercontent.com/nixietab/picodulce/main/holiday.ico",
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/holiday.ico",
|
||||||
"https://raw.githubusercontent.com/nixietab/picodulce/main/authser.py"
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/authser.py",
|
||||||
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/healthcheck.py",
|
||||||
|
"https://raw.githubusercontent.com/nixietab/picodulce/main/modulecli.py"
|
||||||
],
|
],
|
||||||
"versionBleeding": "0.11.9.1-166"
|
"versionBleeding": "0.13.3-212"
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user