Commit dfa036f8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

AL-Arcade MC Launcher: cross-platform shell, new design, Windows backend

One Qt/QML launcher for macOS and Windows with a pluggable game backend:
- android (macOS): upstream's Google Play sign-in, version checks and the
  mcpelauncher engine, kept intact behind a new shell.
- windows: finds Minecraft for Windows / Minecraft Preview (GDK and Store
  builds), starts it through its minecraft: URI and tracks when it runs.

Shared features, identical on both platforms:
- Worlds: list with game mode, size and last played (from level.dat),
  automatic backups after each play session, manual backups, restore as a
  new world, .mcworld import and export, move to Trash.
- Packs: resource, behavior and skin packs; .mcpack/.mcaddon import that
  updates packs in place by uuid.
- New design system in the AL-Arcade brand: theme tokens, restyled
  controls, Lucide icons, Inter + IBM Plex Sans Arabic, voxel logo, icon
  and hero art generated from code (tools/brand), RTL-aware layout.

macOS:
- Native WebKit Google sign-in sheet and Xbox/error helpers in Swift,
  replacing QtWebEngine (424 MB of the upstream app).
- The credentials key moves from the settings file to the Keychain.
- scripts/build-macos.sh builds engine + launcher + helpers into a signed
  .app/.dmg and records the real minimum macOS of the bundled libraries.

Windows:
- Credential Manager secret store, crash handler, icon/version resources,
  manifest; cross-compiles on macOS with llvm-mingw and Qt 6.11.2
  (scripts/fetch-windows-toolchain.sh, cmake/toolchains).

Upstream fixes: the Google Play ToS approval window was never opened; the
token cache is now keyed on startup so cached tokens survive restarts.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent d3a0f12f
cmake_minimum_required(VERSION 3.21)
# Builds AL-Arcade MC Launcher and the libraries it links against.
#
# Game backends:
# android - macOS/Linux. Downloads the Android build from Google Play and runs it
# with the mcpelauncher engine (engine/, a separate CMake project that
# scripts/build-macos.sh builds alongside this one).
# windows - Windows. Starts the official Minecraft for Windows; no engine needed.
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" ALARCADE_VERSION LIMIT_COUNT 1)
project(alarcade-mc-launcher-suite VERSION ${ALARCADE_VERSION} LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Several pinned upstream libraries still declare very old minimum versions.
set(CMAKE_POLICY_VERSION_MINIMUM 3.5 CACHE STRING "" FORCE)
if(APPLE)
enable_language(OBJCXX)
endif()
if(WIN32)
set(ALARCADE_BACKEND_DEFAULT windows)
else()
set(ALARCADE_BACKEND_DEFAULT android)
endif()
set(ALARCADE_BACKEND ${ALARCADE_BACKEND_DEFAULT} CACHE STRING "Game backend: android or windows")
set_property(CACHE ALARCADE_BACKEND PROPERTY STRINGS android windows)
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/launcher/LAUNCHER_VERSION_COMPAT" LAUNCHER_VERSION_COMPATS)
list(GET LAUNCHER_VERSION_COMPATS 0 LAUNCHER_VERSION_COMPAT)
set(LAUNCHER_VERSION_COMPAT ${LAUNCHER_VERSION_COMPAT} CACHE STRING "" FORCE)
if(ALARCADE_BACKEND STREQUAL "android")
add_subdirectory(deps/file-util)
add_subdirectory(deps/axml-parser)
add_subdirectory(deps/mcpelauncher-apkinfo)
add_subdirectory(deps/mcpelauncher-common)
add_subdirectory(deps/mcpelauncher-extract)
add_subdirectory(deps/google-play-api)
if(NOT APPLE)
# macOS signs in through the native WebKit sheet in launcher/macos instead.
add_subdirectory(deps/playdl-signin-ui-qt)
endif()
endif()
add_subdirectory(launcher)
if(ALARCADE_BACKEND STREQUAL "android")
set(GAME_LAUNCHER_PATH "." CACHE STRING "Paths, relative to the application directory, searched for mcpelauncher-client.")
if(GAME_LAUNCHER_PATH)
target_compile_definitions(alarcade-mc-launcher PRIVATE -DGAME_LAUNCHER_PATH="${GAME_LAUNCHER_PATH}")
endif()
endif()
2026-10-05 06:50:01,146 - aqt.main - INFO - installer 8471887744 aqtinstall(aqt) v3.3.0 on Python 3.14.7 [CPython Clang 21.0.0 (clang-2100.1.1.101)]
2026-10-05 06:50:01,147 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:50:01,740 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:50:02,159 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:50:02,542 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:50:02,975 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:50:03,408 - aqt.archives - WARNING - archives 8471887744 Failed to download checksum for the file 'online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml'. This may happen on unofficial mirrors.
2026-10-05 06:50:03,828 - aqt.main - ERROR - installer 8471887744 Failed to locate XML data for Qt version '6.11.2'.
==============================Suggested follow-up:==============================
* Please use 'aqt list-qt windows desktop' to show versions available.
2026-10-05 06:51:28,664 - aqt.main - INFO - installer 8471887744 aqtinstall(aqt) v3.3.0 on Python 3.14.7 [CPython Clang 21.0.0 (clang-2100.1.1.101)]
2026-10-05 06:51:29,946 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:51:30,766 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:51:31,643 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:51:32,636 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:51:33,909 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:51:35,056 - aqt.main - ERROR - installer 8471887744 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io']
==============================Suggested follow-up:==============================
* Check your internet connection
* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini
* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration)
* Please use 'aqt list-qt windows desktop' to show versions of Qt available.
2026-10-05 06:51:35,257 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6111/qt6_6111/Updates.xml.sha256
2026-10-05 06:51:36,334 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6111/qt6_6111/Updates.xml.sha256
2026-10-05 06:51:37,471 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6111/qt6_6111/Updates.xml.sha256
2026-10-05 06:51:38,432 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6111/qt6_6111/Updates.xml.sha256
2026-10-05 06:51:39,525 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6111/qt6_6111/Updates.xml.sha256
2026-10-05 06:51:40,736 - aqt.main - ERROR - installer 8471887744 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io']
==============================Suggested follow-up:==============================
* Check your internet connection
* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini
* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration)
* Please use 'aqt list-qt windows desktop' to show versions of Qt available.
2026-10-05 06:51:40,939 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256
2026-10-05 06:51:42,121 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256
2026-10-05 06:51:43,352 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256
2026-10-05 06:51:44,304 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256
2026-10-05 06:51:45,443 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256
2026-10-05 06:51:46,645 - aqt.main - ERROR - installer 8471887744 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io']
==============================Suggested follow-up:==============================
* Check your internet connection
* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini
* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration)
* Please use 'aqt list-qt windows desktop' to show versions of Qt available.
2026-10-05 06:52:03,219 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:04,357 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:05,406 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:06,555 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:07,486 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:08,561 - aqt.main - ERROR - installer 8471887744 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io']
==============================Suggested follow-up:==============================
* Check your internet connection
* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini
* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration)
* Please use 'aqt list-qt windows desktop' to show versions of Qt available.
2026-10-05 06:52:08,764 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:09,865 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:11,016 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:12,077 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:13,231 - aqt.helper - DEBUG - helper 8471887744 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6112/qt6_6112/Updates.xml.sha256
2026-10-05 06:52:14,313 - aqt.main - ERROR - installer 8471887744 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io']
==============================Suggested follow-up:==============================
* Check your internet connection
* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini
* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration)
* Please use 'aqt list-qt windows desktop' to show versions of Qt available.
* Please use 'aqt list-qt windows desktop --arch <QT_VERSION>' to list valid architectures.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 130" shape-rendering="crispEdges">
<rect x="61" y="1" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="11" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="11" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="11" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="21" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="21" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="21" width="8" height="8" fill="#D9B8FF"/>
<rect x="41" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="81" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="41" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="81" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="31" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="41" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="51" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="61" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="71" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="81" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="91" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="21" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="31" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="41" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="51" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="61" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="71" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="81" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="91" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="101" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="21" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="31" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="41" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="51" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="61" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="71" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="81" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="91" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="101" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="11" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="21" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="31" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="41" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="51" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="71" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="81" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="91" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="101" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="111" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="1" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="11" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="21" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="31" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="41" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="81" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="91" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="101" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="111" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="121" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="1" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="11" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="21" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="31" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="91" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="101" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="111" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="121" y="121" width="8" height="8" fill="#8A3CE6"/>
</svg>
# Cross-compiling the Windows (x86_64) launcher with llvm-mingw.
# LLVM_MINGW_ROOT: the unpacked llvm-mingw release (scripts/fetch-windows-toolchain.sh).
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
if(NOT LLVM_MINGW_ROOT)
set(LLVM_MINGW_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.cache/llvm-mingw")
endif()
get_filename_component(LLVM_MINGW_ROOT "${LLVM_MINGW_ROOT}" ABSOLUTE)
set(CMAKE_C_COMPILER "${LLVM_MINGW_ROOT}/bin/x86_64-w64-mingw32-clang")
set(CMAKE_CXX_COMPILER "${LLVM_MINGW_ROOT}/bin/x86_64-w64-mingw32-clang++")
set(CMAKE_RC_COMPILER "${LLVM_MINGW_ROOT}/bin/x86_64-w64-mingw32-windres")
set(CMAKE_AR "${LLVM_MINGW_ROOT}/bin/llvm-ar")
set(CMAKE_RANLIB "${LLVM_MINGW_ROOT}/bin/llvm-ranlib")
set(CMAKE_FIND_ROOT_PATH "${LLVM_MINGW_ROOT}/x86_64-w64-mingw32")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
This diff is collapsed.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 130" shape-rendering="crispEdges">
<rect x="61" y="1" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="11" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="11" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="11" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="21" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="21" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="21" width="8" height="8" fill="#D9B8FF"/>
<rect x="41" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="81" y="31" width="8" height="8" fill="#D9B8FF"/>
<rect x="41" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="51" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="61" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="71" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="81" y="41" width="8" height="8" fill="#D9B8FF"/>
<rect x="31" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="41" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="51" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="61" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="71" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="81" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="91" y="61" width="8" height="8" fill="#B26BFF"/>
<rect x="21" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="31" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="41" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="51" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="61" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="71" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="81" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="91" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="101" y="71" width="8" height="8" fill="#B26BFF"/>
<rect x="21" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="31" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="41" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="51" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="61" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="71" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="81" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="91" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="101" y="81" width="8" height="8" fill="#B26BFF"/>
<rect x="11" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="21" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="31" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="41" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="51" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="71" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="81" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="91" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="101" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="111" y="101" width="8" height="8" fill="#8A3CE6"/>
<rect x="1" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="11" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="21" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="31" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="41" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="81" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="91" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="101" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="111" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="121" y="111" width="8" height="8" fill="#8A3CE6"/>
<rect x="1" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="11" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="21" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="31" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="91" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="101" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="111" y="121" width="8" height="8" fill="#8A3CE6"/>
<rect x="121" y="121" width="8" height="8" fill="#8A3CE6"/>
</svg>
#ifndef BRANDING_H
#define BRANDING_H
#include <QString>
// Product identity in one place. The game data root stays "mcpelauncher" so the
// engine, worlds and downloaded versions are shared with upstream builds.
namespace Branding {
inline QString appName() { return QStringLiteral("AL-Arcade MC Launcher"); }
inline QString organizationName() { return QStringLiteral("AL-Arcade"); }
inline QString organizationDomain() { return QStringLiteral("al-arcade.com"); }
inline QString bundleIdentifier() { return QStringLiteral("com.alarcade.mclauncher"); }
inline QString keychainService() { return QStringLiteral("com.alarcade.mclauncher"); }
inline QString websiteUrl() { return QStringLiteral("https://al-arcade.com"); }
inline QString sourceUrl() { return QStringLiteral("https://gitlab.caprover.al-arcade.com/root/al-arcade-mc-launcher"); }
inline QString upstreamUrl() { return QStringLiteral("https://github.com/minecraft-linux/mcpelauncher-manifest"); }
inline QString disclaimer() {
return QStringLiteral("NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.");
}
}
#endif // BRANDING_H
#include "jobqueue.h"
#include <QFutureWatcher>
#include <QPointer>
#include <QtConcurrent>
void JobQueue::enqueue(const QString &label, Work work, Done done) {
m_jobs.enqueue({label, std::move(work), std::move(done)});
if (!m_busy)
startNext();
}
void JobQueue::startNext() {
if (m_jobs.isEmpty()) {
m_busy = false;
m_label.clear();
emit busyChanged();
return;
}
Job job = m_jobs.dequeue();
m_busy = true;
m_label = job.label;
m_progress = 0;
emit busyChanged();
emit progressChanged();
QPointer<JobQueue> self(this);
ProgressFn report = [self](qint64 done, qint64 total) {
const qreal value = total > 0 ? static_cast<qreal>(done) / static_cast<qreal>(total) : 0;
QMetaObject::invokeMethod(self, [self, value]() {
if (self) {
self->m_progress = value;
emit self->progressChanged();
}
}, Qt::QueuedConnection);
};
auto *watcher = new QFutureWatcher<QString>(this);
connect(watcher, &QFutureWatcher<QString>::finished, this, [this, watcher, done = job.done]() {
const QString error = watcher->result();
watcher->deleteLater();
if (done)
done(error);
startNext();
});
watcher->setFuture(QtConcurrent::run([work = job.work, report]() { return work(report); }));
}
#ifndef JOBQUEUE_H
#define JOBQUEUE_H
#include <QObject>
#include <QQueue>
#include <QString>
#include <functional>
// Runs file jobs (zipping, extracting, copying) one at a time on a worker
// thread and reports progress on the GUI thread.
class JobQueue : public QObject {
Q_OBJECT
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString label READ label NOTIFY busyChanged)
Q_PROPERTY(qreal progress READ progress NOTIFY progressChanged)
public:
using ProgressFn = std::function<void(qint64 done, qint64 total)>;
// Returns an error message, or an empty string on success. Runs off the GUI thread.
using Work = std::function<QString(const ProgressFn &progress)>;
// Runs on the GUI thread with the work's result.
using Done = std::function<void(const QString &error)>;
explicit JobQueue(QObject *parent = nullptr) : QObject(parent) {}
bool busy() const { return m_busy; }
QString label() const { return m_label; }
qreal progress() const { return m_progress; }
void enqueue(const QString &label, Work work, Done done);
signals:
void busyChanged();
void progressChanged();
private:
struct Job {
QString label;
Work work;
Done done;
};
void startNext();
QQueue<Job> m_jobs;
bool m_busy = false;
QString m_label;
qreal m_progress = 0;
};
#endif // JOBQUEUE_H
#include "leveldat.h"
#include <QCoreApplication>
#include <QFile>
#include <QSaveFile>
#include <QtEndian>
namespace {
enum TagType {
TagEnd = 0, TagByte = 1, TagShort = 2, TagInt = 3, TagLong = 4, TagFloat = 5, TagDouble = 6,
TagByteArray = 7, TagString = 8, TagList = 9, TagCompound = 10, TagIntArray = 11, TagLongArray = 12
};
constexpr int kHeaderSize = 8;
constexpr int kMaxDepth = 64;
class Reader {
public:
explicit Reader(const QByteArray &data) : m_data(data) {}
int pos = kHeaderSize;
bool ok = true;
bool need(qint64 n) {
if (!ok || n < 0 || pos + n > m_data.size())
ok = false;
return ok;
}
quint8 u8() { return need(1) ? static_cast<quint8>(m_data[pos++]) : 0; }
qint16 i16() {
if (!need(2)) return 0;
qint16 v = qFromLittleEndian<qint16>(m_data.constData() + pos);
pos += 2;
return v;
}
qint32 i32() {
if (!need(4)) return 0;
qint32 v = qFromLittleEndian<qint32>(m_data.constData() + pos);
pos += 4;
return v;
}
qint64 i64() {
if (!need(8)) return 0;
qint64 v = qFromLittleEndian<qint64>(m_data.constData() + pos);
pos += 8;
return v;
}
void skip(qint64 n) {
if (need(n))
pos += static_cast<int>(n);
}
QString string() {
int length = static_cast<quint16>(i16());
if (!need(length))
return QString();
QString value = QString::fromUtf8(m_data.constData() + pos, length);
pos += length;
return value;
}
void skipPayload(int type, int depth) {
if (depth > kMaxDepth) {
ok = false;
return;
}
switch (type) {
case TagByte: skip(1); break;
case TagShort: skip(2); break;
case TagInt: case TagFloat: skip(4); break;
case TagLong: case TagDouble: skip(8); break;
case TagByteArray: skip(i32()); break;
case TagString: skip(static_cast<quint16>(i16())); break;
case TagIntArray: skip(static_cast<qint64>(i32()) * 4); break;
case TagLongArray: skip(static_cast<qint64>(i32()) * 8); break;
case TagList: {
int elementType = u8();
qint32 count = i32();
for (qint32 i = 0; i < count && ok; i++)
skipPayload(elementType, depth + 1);
break;
}
case TagCompound:
while (ok) {
int child = u8();
if (child == TagEnd)
break;
skip(static_cast<quint16>(i16()));
skipPayload(child, depth + 1);
}
break;
default:
ok = false;
}
}
private:
const QByteArray &m_data;
};
struct LevelNameLocation {
int lengthOffset = -1; // offset of the 2-byte string length
int length = 0;
};
// Walks the root compound once, collecting the fields we care about.
LevelDat::Info parse(const QByteArray &data, LevelNameLocation *nameLocation) {
LevelDat::Info info;
if (data.size() < kHeaderSize + 3)
return info;
Reader r(data);
if (r.u8() != TagCompound)
return info;
r.skip(static_cast<quint16>(r.i16())); // root name
while (r.ok) {
int type = r.u8();
if (type == TagEnd)
break;
QString name = r.string();
if (type == TagString && name == QLatin1String("LevelName")) {
const int start = r.pos;
info.levelName = r.string();
if (r.ok && nameLocation) {
nameLocation->lengthOffset = start;
nameLocation->length = r.pos - start - 2;
}
} else if (type == TagInt && name == QLatin1String("GameType")) {
info.gameType = r.i32();
} else if (type == TagLong && name == QLatin1String("LastPlayed")) {
info.lastPlayed = r.i64();
} else if (type == TagByte && name == QLatin1String("IsHardcore")) {
info.hardcore = r.u8() != 0;
} else {
r.skipPayload(type, 1);
}
}
info.valid = r.ok;
return info;
}
}
namespace LevelDat {
Info read(const QString &levelDatPath) {
QFile file(levelDatPath);
if (!file.open(QIODevice::ReadOnly) || file.size() > 16 * 1024 * 1024)
return Info();
return parse(file.readAll(), nullptr);
}
bool setLevelName(const QString &levelDatPath, const QString &name) {
QFile file(levelDatPath);
if (!file.open(QIODevice::ReadOnly) || file.size() > 16 * 1024 * 1024)
return false;
QByteArray data = file.readAll();
file.close();
LevelNameLocation location;
Info info = parse(data, &location);
if (!info.valid || location.lengthOffset < 0)
return false;
QByteArray utf8 = name.toUtf8().left(32767);
QByteArray replacement(2, '\0');
qToLittleEndian<qint16>(static_cast<qint16>(utf8.size()), replacement.data());
replacement += utf8;
data.replace(location.lengthOffset, 2 + location.length, replacement);
qToLittleEndian<qint32>(static_cast<qint32>(data.size() - kHeaderSize), data.data() + 4);
QSaveFile out(levelDatPath);
if (!out.open(QIODevice::WriteOnly))
return false;
out.write(data);
return out.commit();
}
QString gameModeName(int gameType) {
switch (gameType) {
case 0: return QCoreApplication::translate("LevelDat", "Survival");
case 1: return QCoreApplication::translate("LevelDat", "Creative");
case 2: return QCoreApplication::translate("LevelDat", "Adventure");
case 6: return QCoreApplication::translate("LevelDat", "Spectator");
default: return QString();
}
}
}
#ifndef LEVELDAT_H
#define LEVELDAT_H
#include <QString>
// Minimal reader/writer for Bedrock's level.dat: an 8-byte header (storage
// version, payload length) followed by little-endian NBT. Only the top-level
// fields the launcher shows are read; everything is bounds-checked.
namespace LevelDat {
struct Info {
bool valid = false;
QString levelName;
int gameType = -1; // 0 survival, 1 creative, 2 adventure, 6 spectator
qint64 lastPlayed = 0; // seconds since the epoch
bool hardcore = false;
};
Info read(const QString &levelDatPath);
// Replaces the LevelName value, keeping every other byte. Returns false if the
// file has no LevelName or cannot be rewritten.
bool setLevelName(const QString &levelDatPath, const QString &name);
QString gameModeName(int gameType);
}
#endif // LEVELDAT_H
This diff is collapsed.
#ifndef PACKMANAGER_H
#define PACKMANAGER_H
#include <QAbstractListModel>
#include <QFutureWatcher>
#include <QStringList>
#include <QUrl>
#include "jobqueue.h"
// Resource, behavior and skin packs installed under one or more com.mojang
// folders. Imports .mcpack / .mcaddon / .zip files into the right place.
class PackManager : public QAbstractListModel {
Q_OBJECT
Q_PROPERTY(QStringList roots READ roots WRITE setRoots NOTIFY rootsChanged)
// "resources", "behavior", "skins" or "" for all
Q_PROPERTY(QString kind READ kind WRITE setKind NOTIFY kindChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(bool scanning READ scanning NOTIFY scanningChanged)
Q_PROPERTY(JobQueue *jobs READ jobs CONSTANT)
public:
enum Roles {
NameRole = Qt::UserRole + 1,
DescriptionRole,
VersionRole,
UuidRole,
KindRole,
IconUrlRole,
PathRole,
SizeRole,
DevelopmentRole,
};
struct Pack {
QString name;
QString description;
QString version;
QString uuid;
QString kind;
QString iconPath;
QString path;
qint64 sizeBytes = 0;
bool development = false;
};
explicit PackManager(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
QStringList roots() const { return m_roots; }
void setRoots(const QStringList &roots);
QString kind() const { return m_kind; }
void setKind(const QString &kind);
int count() const { return m_visible.size(); }
bool scanning() const { return m_scanning; }
JobQueue *jobs() { return &m_jobs; }
Q_INVOKABLE void refresh();
Q_INVOKABLE void importFile(const QUrl &file);
Q_INVOKABLE bool moveToTrash(const QString &packPath);
Q_INVOKABLE int countOf(const QString &kind) const;
// Parses a pack folder; returns an invalid Pack (empty uuid) if it is not one.
static Pack readPack(const QString &dir, const QString &kind, bool development);
signals:
void rootsChanged();
void kindChanged();
void countChanged();
void scanningChanged();
void operationFinished(bool ok, const QString &message);
private:
static QVector<Pack> scan(const QStringList &roots);
void applyFilter();
QString defaultRoot() const;
QStringList m_roots;
QString m_kind;
QVector<Pack> m_all;
QVector<Pack> m_visible;
QFutureWatcher<QVector<Pack>> m_scanWatcher;
JobQueue m_jobs;
bool m_scanning = false;
bool m_rescan = false;
};
#endif // PACKMANAGER_H
#include "platform.h"
#include <QCoreApplication>
#include <QDesktopServices>
#include <QDir>
#include <QFileInfo>
#include <QLocale>
#include <QProcess>
#include <QSysInfo>
bool Platform::isMac() const {
#ifdef Q_OS_MACOS
return true;
#else
return false;
#endif
}
bool Platform::isWindows() const {
#ifdef Q_OS_WIN
return true;
#else
return false;
#endif
}
QString Platform::osName() const {
return QSysInfo::prettyProductName();
}
QString Platform::fileManagerName() const {
#if defined(Q_OS_MACOS)
return tr("Finder");
#elif defined(Q_OS_WIN)
return tr("File Explorer");
#else
return tr("Files");
#endif
}
QString Platform::architecture() const {
return QSysInfo::currentCpuArchitecture();
}
void Platform::revealInFileManager(const QString &path) const {
const QFileInfo info(path);
#if defined(Q_OS_MACOS)
QProcess::startDetached(QStringLiteral("/usr/bin/open"), {QStringLiteral("-R"), info.absoluteFilePath()});
#elif defined(Q_OS_WIN)
QProcess::startDetached(QStringLiteral("explorer.exe"), {QStringLiteral("/select,") + QDir::toNativeSeparators(info.absoluteFilePath())});
#else
QDesktopServices::openUrl(QUrl::fromLocalFile(info.isDir() ? info.absoluteFilePath() : info.absolutePath()));
#endif
}
void Platform::openFolder(const QString &path) const {
QDir().mkpath(path);
QDesktopServices::openUrl(QUrl::fromLocalFile(path));
}
QString Platform::formatBytes(qint64 bytes) const {
return QLocale().formattedDataSize(bytes, 1, QLocale::DataSizeTraditionalFormat);
}
bool Platform::exists(const QString &path) const {
return QFileInfo::exists(path);
}
#ifndef PLATFORM_H
#define PLATFORM_H
#include <QObject>
#include <QUrl>
class QQmlEngine;
class QJSEngine;
// Operating-system helpers exposed to QML as the `Platform` singleton.
class Platform : public QObject {
Q_OBJECT
Q_PROPERTY(bool isMac READ isMac CONSTANT)
Q_PROPERTY(bool isWindows READ isWindows CONSTANT)
Q_PROPERTY(QString osName READ osName CONSTANT)
Q_PROPERTY(QString fileManagerName READ fileManagerName CONSTANT)
Q_PROPERTY(QString architecture READ architecture CONSTANT)
public:
static QObject *create(QQmlEngine *, QJSEngine *) { return new Platform; }
bool isMac() const;
bool isWindows() const;
QString osName() const;
QString fileManagerName() const;
QString architecture() const;
// Highlights the file or folder in Finder / File Explorer.
Q_INVOKABLE void revealInFileManager(const QString &path) const;
Q_INVOKABLE void openFolder(const QString &path) const;
Q_INVOKABLE QString formatBytes(qint64 bytes) const;
Q_INVOKABLE QString localPath(const QUrl &url) const { return url.toLocalFile(); }
Q_INVOKABLE QUrl fileUrl(const QString &path) const { return QUrl::fromLocalFile(path); }
Q_INVOKABLE bool exists(const QString &path) const;
};
#endif // PLATFORM_H
This diff is collapsed.
#ifndef WORLDMANAGER_H
#define WORLDMANAGER_H
#include <QAbstractListModel>
#include <QFutureWatcher>
#include <QSettings>
#include <QStringList>
#include <QUrl>
#include <QVariantList>
#include "jobqueue.h"
// The worlds under one or more com.mojang folders, with backups.
//
// Backups are .mcworld files in <backupsDir>/<world folder>/<timestamp>.mcworld
// with a .json sidecar holding the world name, so a backup can be restored even
// after the world itself was deleted. Restoring always creates a new world.
class WorldManager : public QAbstractListModel {
Q_OBJECT
Q_PROPERTY(QStringList roots READ roots WRITE setRoots NOTIFY rootsChanged)
Q_PROPERTY(QString backupsDir READ backupsDir WRITE setBackupsDir NOTIFY settingsChanged)
Q_PROPERTY(int keepBackups READ keepBackups WRITE setKeepBackups NOTIFY settingsChanged)
Q_PROPERTY(bool autoBackup READ autoBackup WRITE setAutoBackup NOTIFY settingsChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(qint64 totalSize READ totalSize NOTIFY countChanged)
Q_PROPERTY(bool scanning READ scanning NOTIFY scanningChanged)
Q_PROPERTY(JobQueue *jobs READ jobs CONSTANT)
public:
enum Roles {
WorldIdRole = Qt::UserRole + 1,
NameRole,
PathRole,
RootRole,
IconUrlRole,
SizeRole,
LastPlayedRole,
GameModeRole,
HardcoreRole,
BackupCountRole,
LastBackupRole,
ChangedSinceBackupRole,
};
struct World {
QString id;
QString name;
QString path;
QString root;
QString iconPath;
QString gameMode;
bool hardcore = false;
qint64 sizeBytes = 0;
qint64 lastPlayedMs = 0;
qint64 lastChangeMs = 0;
int backupCount = 0;
qint64 lastBackupMs = 0;
};
explicit WorldManager(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
QStringList roots() const { return m_roots; }
void setRoots(const QStringList &roots);
QString backupsDir() const;
void setBackupsDir(const QString &dir);
int keepBackups() const;
void setKeepBackups(int count);
bool autoBackup() const;
void setAutoBackup(bool enabled);
int count() const { return m_worlds.size(); }
qint64 totalSize() const;
bool scanning() const { return m_scanning; }
JobQueue *jobs() { return &m_jobs; }
Q_INVOKABLE void refresh();
Q_INVOKABLE QVariantMap get(int row) const;
Q_INVOKABLE void backupWorld(const QString &worldPath);
// Backs up every world changed since its newest backup (used after a game session).
Q_INVOKABLE void backupChangedWorlds();
Q_INVOKABLE void exportWorld(const QString &worldPath, const QUrl &destination);
Q_INVOKABLE void importWorld(const QUrl &file);
Q_INVOKABLE void restoreBackup(const QString &backupFile);
Q_INVOKABLE bool moveWorldToTrash(const QString &worldPath);
Q_INVOKABLE bool deleteBackup(const QString &backupFile);
Q_INVOKABLE QVariantList backupsFor(const QString &worldId) const;
Q_INVOKABLE QVariantList allBackups() const;
signals:
void rootsChanged();
void settingsChanged();
void countChanged();
void scanningChanged();
void backupsChanged();
void operationFinished(bool ok, const QString &message);
private:
static QVector<World> scan(const QStringList &roots, const QString &backupsDir);
void applyScan();
void enqueueBackup(const World &world);
void prune(const QString &worldId) const;
QString defaultImportRoot() const;
const World *findByPath(const QString &path) const;
QStringList m_roots;
QVector<World> m_worlds;
QSettings m_settings;
QFutureWatcher<QVector<World>> m_scanWatcher;
JobQueue m_jobs;
bool m_scanning = false;
bool m_rescan = false;
};
#endif // WORLDMANAGER_H
#include "ziputil.h"
#include "../third_party/miniz/miniz.h"
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
namespace {
size_t readFromFile(void *opaque, mz_uint64 offset, void *buffer, size_t size) {
auto *file = static_cast<QFile *>(opaque);
if (static_cast<qint64>(offset) != file->pos() && !file->seek(static_cast<qint64>(offset)))
return 0;
qint64 read = file->read(static_cast<char *>(buffer), static_cast<qint64>(size));
return read < 0 ? 0 : static_cast<size_t>(read);
}
size_t writeToFile(void *opaque, mz_uint64 offset, const void *buffer, size_t size) {
auto *file = static_cast<QFile *>(opaque);
if (static_cast<qint64>(offset) != file->pos() && !file->seek(static_cast<qint64>(offset)))
return 0;
qint64 written = file->write(static_cast<const char *>(buffer), static_cast<qint64>(size));
return written < 0 ? 0 : static_cast<size_t>(written);
}
QString zipError(mz_zip_archive *zip) {
return QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(zip)));
}
// Opens a zip for reading. The caller owns `file` for the lifetime of `zip`.
bool openReader(const QString &zipPath, QFile &file, mz_zip_archive &zip, QString *error) {
if (!file.open(QIODevice::ReadOnly)) {
if (error)
*error = QObject::tr("Cannot open %1: %2").arg(zipPath, file.errorString());
return false;
}
mz_zip_zero_struct(&zip);
zip.m_pRead = readFromFile;
zip.m_pIO_opaque = &file;
if (!mz_zip_reader_init(&zip, static_cast<mz_uint64>(file.size()), 0)) {
if (error)
*error = QObject::tr("%1 is not a valid zip archive (%2)").arg(QFileInfo(zipPath).fileName(), zipError(&zip));
return false;
}
return true;
}
}
namespace ZipUtil {
QString zipDirectory(const QString &sourceDir, const QString &zipPath, int level, const Progress &progress) {
QDir root(sourceDir);
if (!root.exists())
return QObject::tr("Folder not found: %1").arg(sourceDir);
QStringList files;
qint64 total = 0;
QDirIterator it(sourceDir, QDir::Files | QDir::Hidden | QDir::NoSymLinks, QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
files.append(it.filePath());
total += it.fileInfo().size();
}
files.sort();
QDir().mkpath(QFileInfo(zipPath).absolutePath());
const QString partPath = zipPath + ".part";
QFile out(partPath);
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate))
return QObject::tr("Cannot write %1: %2").arg(partPath, out.errorString());
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
zip.m_pWrite = writeToFile;
zip.m_pIO_opaque = &out;
if (!mz_zip_writer_init_v2(&zip, 0, 0)) {
out.close();
QFile::remove(partPath);
return zipError(&zip);
}
QString error;
qint64 done = 0;
for (const QString &path : files) {
QFile in(path);
if (!in.open(QIODevice::ReadOnly)) {
error = QObject::tr("Cannot read %1: %2").arg(path, in.errorString());
break;
}
const QByteArray name = root.relativeFilePath(path).toUtf8();
MZ_TIME_T modified = static_cast<MZ_TIME_T>(QFileInfo(path).lastModified().toSecsSinceEpoch());
if (!mz_zip_writer_add_read_buf_callback(&zip, name.constData(), readFromFile, &in, static_cast<mz_uint64>(in.size()),
&modified, nullptr, 0, static_cast<mz_uint>(level),
nullptr, 0, nullptr, 0)) {
error = QObject::tr("Cannot add %1: %2").arg(QString::fromUtf8(name), zipError(&zip));
break;
}
done += in.size();
if (progress)
progress(done, total);
}
if (error.isEmpty() && !mz_zip_writer_finalize_archive(&zip))
error = zipError(&zip);
mz_zip_writer_end(&zip);
out.close();
if (!error.isEmpty() || out.error() != QFileDevice::NoError) {
QFile::remove(partPath);
return error.isEmpty() ? out.errorString() : error;
}
QFile::remove(zipPath);
if (!QFile::rename(partPath, zipPath)) {
QFile::remove(partPath);
return QObject::tr("Cannot create %1").arg(zipPath);
}
return QString();
}
QString extractAll(const QString &zipPath, const QString &destDir, const Progress &progress) {
QFile file(zipPath);
mz_zip_archive zip;
QString error;
if (!openReader(zipPath, file, zip, &error))
return error;
QDir dest(destDir);
dest.mkpath(".");
const QString destRoot = QDir::cleanPath(dest.absolutePath()) + "/";
const mz_uint count = mz_zip_reader_get_num_files(&zip);
qint64 total = 0;
for (mz_uint i = 0; i < count; i++) {
mz_zip_archive_file_stat stat;
if (mz_zip_reader_file_stat(&zip, i, &stat))
total += static_cast<qint64>(stat.m_uncomp_size);
}
qint64 done = 0;
for (mz_uint i = 0; i < count && error.isEmpty(); i++) {
mz_zip_archive_file_stat stat;
if (!mz_zip_reader_file_stat(&zip, i, &stat)) {
error = zipError(&zip);
break;
}
QString name = QString::fromUtf8(stat.m_filename).replace('\\', '/');
if (name.isEmpty())
continue;
const QString target = QDir::cleanPath(dest.absoluteFilePath(name));
if (QDir::isAbsolutePath(name) || name.contains(':') || !(target + "/").startsWith(destRoot)) {
error = QObject::tr("The archive contains an unsafe path: %1").arg(name);
break;
}
if (mz_zip_reader_is_file_a_directory(&zip, i)) {
QDir().mkpath(target);
continue;
}
QDir().mkpath(QFileInfo(target).absolutePath());
QFile out(target);
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
error = QObject::tr("Cannot write %1: %2").arg(target, out.errorString());
break;
}
if (!mz_zip_reader_extract_to_callback(&zip, i, writeToFile, &out, 0))
error = QObject::tr("Cannot extract %1: %2").arg(name, zipError(&zip));
out.close();
done += static_cast<qint64>(stat.m_uncomp_size);
if (progress)
progress(done, total);
}
mz_zip_reader_end(&zip);
return error;
}
QStringList entryNames(const QString &zipPath, QString *error) {
QStringList names;
QFile file(zipPath);
mz_zip_archive zip;
if (!openReader(zipPath, file, zip, error))
return names;
const mz_uint count = mz_zip_reader_get_num_files(&zip);
for (mz_uint i = 0; i < count; i++) {
mz_zip_archive_file_stat stat;
if (mz_zip_reader_file_stat(&zip, i, &stat))
names.append(QString::fromUtf8(stat.m_filename).replace('\\', '/'));
}
mz_zip_reader_end(&zip);
return names;
}
QByteArray readEntry(const QString &zipPath, const QString &entryName, QString *error) {
QFile file(zipPath);
mz_zip_archive zip;
if (!openReader(zipPath, file, zip, error))
return QByteArray();
QByteArray data;
const int index = mz_zip_reader_locate_file(&zip, entryName.toUtf8().constData(), nullptr, 0);
if (index >= 0) {
size_t size = 0;
if (void *buffer = mz_zip_reader_extract_to_heap(&zip, static_cast<mz_uint>(index), &size, 0)) {
data = QByteArray(static_cast<const char *>(buffer), static_cast<qsizetype>(size));
mz_free(buffer);
}
} else if (error) {
*error = QObject::tr("%1 not found in archive").arg(entryName);
}
mz_zip_reader_end(&zip);
return data;
}
}
#ifndef ZIPUTIL_H
#define ZIPUTIL_H
#include <QString>
#include <QStringList>
#include <functional>
// Zip reading and writing on top of miniz. All file access goes through QFile,
// so non-ASCII paths (for example Arabic Windows user names) work everywhere.
namespace ZipUtil {
using Progress = std::function<void(qint64 done, qint64 total)>;
// Packs every file under `sourceDir` into `zipPath`, with paths relative to
// `sourceDir`. Written to a temporary file and moved into place on success.
// Returns an error message, or an empty string on success.
QString zipDirectory(const QString &sourceDir, const QString &zipPath, int level = 1, const Progress &progress = {});
// Unpacks `zipPath` into `destDir`. Entries that would escape `destDir`
// (absolute paths, "..") abort the extraction.
QString extractAll(const QString &zipPath, const QString &destDir, const Progress &progress = {});
// Names of the entries in the archive.
QStringList entryNames(const QString &zipPath, QString *error = nullptr);
// Reads one entry into memory, for small files such as manifest.json.
QByteArray readEntry(const QString &zipPath, const QString &entryName, QString *error = nullptr);
}
#endif // ZIPUTIL_H
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
#include <thread> #include <thread>
#include <dlfcn.h> #include <dlfcn.h>
#include <unistd.h> #include <unistd.h>
#include <EnvPathUtil.h>
bool CrashHandler::hasCrashed = false; bool CrashHandler::hasCrashed = false;
int CrashHandler::argc = 0; int CrashHandler::argc = 0;
...@@ -18,12 +17,11 @@ char** CrashHandler::argv = nullptr; ...@@ -18,12 +17,11 @@ char** CrashHandler::argv = nullptr;
#define __ASYNC_SAFE_LOG(msg) write(STDERR_FILENO, msg "\n", sizeof(msg "\n")); #define __ASYNC_SAFE_LOG(msg) write(STDERR_FILENO, msg "\n", sizeof(msg "\n"));
void CrashHandler::handleSignal(int signal, void *aptr) { void CrashHandler::handleSignal(int signal, void *aptr) {
__ASYNC_SAFE_LOG("Please try to restart this application using one of the following methods:"); __ASYNC_SAFE_LOG("The launcher crashed. Restart it in safe mode with:");
__ASYNC_SAFE_LOG("1. SAFE_MODE=1 USE_WEBENGINE=0 mcpelauncher-ui-qt");
#ifdef __APPLE__ #ifdef __APPLE__
__ASYNC_SAFE_LOG("2. SAFE_MODE=1 USE_WEBENGINE=0 open -a /Applications/Minecraft\\ Bedrock\\ Launcher.app"); __ASYNC_SAFE_LOG("SAFE_MODE=1 open -a \"/Applications/AL-Arcade MC Launcher.app\"");
#else #else
__ASYNC_SAFE_LOG("2. flatpak run --env=SAFE_MODE=1 --env=USE_WEBENGINE=0 io.mrarm.mcpelauncher"); __ASYNC_SAFE_LOG("SAFE_MODE=1 alarcade-mc-launcher");
#endif #endif
printf("Signal %i received\n", signal); printf("Signal %i received\n", signal);
...@@ -50,8 +48,7 @@ void CrashHandler::handleSignal(int signal, void *aptr) { ...@@ -50,8 +48,7 @@ void CrashHandler::handleSignal(int signal, void *aptr) {
printf("Signal Number %d\n", signal); printf("Signal Number %d\n", signal);
fflush(stdout); fflush(stdout);
if(getenv("SAFE_MODE") == nullptr) { if(getenv("SAFE_MODE") == nullptr) {
putenv("USE_WEBENGINE=0"); putenv((char *)"SAFE_MODE=1");
putenv("SAFE_MODE=1");
execv(argv[0], argv); execv(argv[0], argv);
} }
_Exit(signal); _Exit(signal);
...@@ -94,8 +91,7 @@ void CrashHandler::handleSignal(int signal, void *aptr) { ...@@ -94,8 +91,7 @@ void CrashHandler::handleSignal(int signal, void *aptr) {
printf("program failed with unix signal number: %d\n", signal); printf("program failed with unix signal number: %d\n", signal);
fflush(stdout); fflush(stdout);
if(getenv("SAFE_MODE") == nullptr) { if(getenv("SAFE_MODE") == nullptr) {
putenv("USE_WEBENGINE=0"); putenv((char *)"SAFE_MODE=1");
putenv("SAFE_MODE=1");
execv(argv[0], argv); execv(argv[0], argv);
} }
_exit(signal); _exit(signal);
......
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
#include "googleloginhelper.h" #include "googleloginhelper.h"
#ifdef __APPLE__
#include "macos/googlesigninwindow.h"
#else
#include <googleloginwindow.h> #include <googleloginwindow.h>
#endif
#include <QDialog>
#include <QStandardPaths> #include <QStandardPaths>
#include <QDir> #include <QDir>
#include <QWindow> #include <QWindow>
...@@ -8,13 +13,18 @@ ...@@ -8,13 +13,18 @@
#include "supportedandroidabis.h" #include "supportedandroidabis.h"
#include "launchersettings.h" #include "launchersettings.h"
#include "encryption.h" #include "encryption.h"
#include "secretstore.h"
static const QString kCredentialsKeyName = QStringLiteral("google-credentials-key");
std::string GoogleLoginHelper::getTokenCachePath() { std::string GoogleLoginHelper::getTokenCachePath() {
return QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)).filePath("playapi_token_cache.conf").toStdString(); QDir cacheDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation));
cacheDir.mkpath(".");
return cacheDir.filePath("playapi_token_cache.conf").toStdString();
} }
GoogleLoginHelper::GoogleLoginHelper() : loginCache(getTokenCachePath()), login(device, loginCache) { GoogleLoginHelper::GoogleLoginHelper() : loginCache(getTokenCachePath()), login(device, loginCache) {
unlockkey = settings.value("key").toString(); unlockkey = loadStoredKey();
loadAccount(); loadAccount();
} }
...@@ -22,6 +32,32 @@ GoogleLoginHelper::~GoogleLoginHelper() { ...@@ -22,6 +32,32 @@ GoogleLoginHelper::~GoogleLoginHelper() {
delete window; delete window;
} }
QString GoogleLoginHelper::loadStoredKey() {
if (!SecretStore::isSecure())
return settings.value("key").toString();
bool found = false;
QString key = SecretStore::get(kCredentialsKeyName, &found);
if (found)
return key;
// Move a key written by an older build out of the plain settings file.
QString legacy = settings.value("key").toString();
if (!legacy.isEmpty() && SecretStore::set(kCredentialsKeyName, legacy))
settings.remove("key");
return legacy;
}
void GoogleLoginHelper::storeKey(const QString& key) {
if (SecretStore::isSecure() && SecretStore::set(kCredentialsKeyName, key)) {
settings.remove("key");
return;
}
settings.setValue("key", key);
}
bool GoogleLoginHelper::credentialsInKeychain() const {
return SecretStore::isSecure() && !settings.contains("key");
}
void GoogleLoginHelper::loadAccount() { void GoogleLoginHelper::loadAccount() {
if (hasAccount) { if (hasAccount) {
return; return;
...@@ -45,6 +81,13 @@ void GoogleLoginHelper::loadAccount() { ...@@ -45,6 +81,13 @@ void GoogleLoginHelper::loadAccount() {
hasAccount = currentAccount.isValid(); hasAccount = currentAccount.isValid();
if (hasAccount) { if (hasAccount) {
// Reuse cached auth tokens across launches; the cache is encrypted
// with the same key, which upstream only applied during sign-in.
try {
loginCache.setKey(unlockkey.toStdString());
} catch (const std::exception&) {
loginCache.clear();
}
login.set_token(currentAccount.accountIdentifier().toStdString(), currentAccount.accountToken().toStdString()); login.set_token(currentAccount.accountIdentifier().toStdString(), currentAccount.accountToken().toStdString());
} else { } else {
settings.endGroup(); settings.endGroup();
...@@ -85,6 +128,13 @@ void GoogleLoginHelper::saveDeviceState() { ...@@ -85,6 +128,13 @@ void GoogleLoginHelper::saveDeviceState() {
void GoogleLoginHelper::acquireAccount(QWindow *parent) { void GoogleLoginHelper::acquireAccount(QWindow *parent) {
if (window) if (window)
return; return;
#ifdef __APPLE__
window = new GoogleSignInWindow();
connect(window, &GoogleSignInWindow::finished, this, [this](bool accepted) {
onLoginFinished(accepted ? QDialog::Accepted : QDialog::Rejected);
});
window->show(parent);
#else
window = new GoogleLoginWindow(); window = new GoogleLoginWindow();
window->setAttribute(Qt::WA_DeleteOnClose); window->setAttribute(Qt::WA_DeleteOnClose);
window->winId(); window->winId();
...@@ -92,16 +142,23 @@ void GoogleLoginHelper::acquireAccount(QWindow *parent) { ...@@ -92,16 +142,23 @@ void GoogleLoginHelper::acquireAccount(QWindow *parent) {
window->move(parent->x() + parent->width() / 2 - window->width() / 2, parent->y() + parent->height() / 2 - window->height() / 2); window->move(parent->x() + parent->width() / 2 - window->width() / 2, parent->y() + parent->height() / 2 - window->height() / 2);
window->show(); window->show();
connect(window, &QDialog::finished, this, &GoogleLoginHelper::onLoginFinished); connect(window, &QDialog::finished, this, &GoogleLoginHelper::onLoginFinished);
#endif
} }
void GoogleLoginHelper::onLoginFinished(int code) { void GoogleLoginHelper::onLoginFinished(int code) {
if (code == QDialog::Accepted) { if (code == QDialog::Accepted) {
try { try {
#ifdef __APPLE__
// The native sign-in keeps the generated key in the Keychain instead
// of asking for a password on every start.
unlockkey = QString();
#else
unlockkey = window->encryptionToken(); unlockkey = window->encryptionToken();
#endif
Encryption enc; Encryption enc;
if(unlockkey.isEmpty()) { if(unlockkey.isEmpty()) {
unlockkey = QString::fromStdString(enc.RandomKey()); unlockkey = QString::fromStdString(enc.RandomKey());
settings.setValue("key", unlockkey); storeKey(unlockkey);
} }
loginCache.clear(); loginCache.clear();
loginCache.setKey(unlockkey.toStdString()); loginCache.setKey(unlockkey.toStdString());
...@@ -131,6 +188,9 @@ void GoogleLoginHelper::onLoginFinished(int code) { ...@@ -131,6 +188,9 @@ void GoogleLoginHelper::onLoginFinished(int code) {
accountAcquireFinished(nullptr); accountAcquireFinished(nullptr);
} }
emit accountInfoChanged(); emit accountInfoChanged();
#ifdef __APPLE__
window->deleteLater();
#endif
window = nullptr; window = nullptr;
} }
...@@ -169,6 +229,9 @@ void GoogleLoginHelper::signOut() { ...@@ -169,6 +229,9 @@ void GoogleLoginHelper::signOut() {
currentAccount.setAccountToken(""); currentAccount.setAccountToken("");
settings.remove("googlelogin"); settings.remove("googlelogin");
settings.remove("device_state"); settings.remove("device_state");
settings.remove("key");
SecretStore::remove(kCredentialsKeyName);
unlockkey = QString();
loginCache.clear(); loginCache.clear();
hasEncryptedCredentials = false; hasEncryptedCredentials = false;
emit accountInfoChanged(); emit accountInfoChanged();
......
...@@ -9,7 +9,13 @@ ...@@ -9,7 +9,13 @@
#include "googleaccount.h" #include "googleaccount.h"
class QWindow; class QWindow;
#ifdef __APPLE__
class GoogleSignInWindow;
using SignInWindow = GoogleSignInWindow;
#else
class GoogleLoginWindow; class GoogleLoginWindow;
using SignInWindow = GoogleLoginWindow;
#endif
class GoogleLoginHelper : public QObject { class GoogleLoginHelper : public QObject {
Q_OBJECT Q_OBJECT
...@@ -23,7 +29,7 @@ class GoogleLoginHelper : public QObject { ...@@ -23,7 +29,7 @@ class GoogleLoginHelper : public QObject {
private: private:
QSettings settings; QSettings settings;
GoogleLoginWindow* window = nullptr; SignInWindow* window = nullptr;
GoogleAccount currentAccount; GoogleAccount currentAccount;
playapi::device_info device; playapi::device_info device;
playapi::encrypted_file_login_cache loginCache; playapi::encrypted_file_login_cache loginCache;
...@@ -37,6 +43,11 @@ private: ...@@ -37,6 +43,11 @@ private:
static std::string getTokenCachePath(); static std::string getTokenCachePath();
// The key that encrypts the stored credentials. Kept in the Keychain on macOS
// instead of next to the encrypted values in the settings file.
QString loadStoredKey();
void storeKey(const QString& key);
void loadDeviceState(); void loadDeviceState();
void saveDeviceState(); void saveDeviceState();
...@@ -82,6 +93,7 @@ public: ...@@ -82,6 +93,7 @@ public:
bool getHasEncryptedCredentials() { bool getHasEncryptedCredentials() {
return hasEncryptedCredentials; return hasEncryptedCredentials;
} }
Q_INVOKABLE bool credentialsInKeychain() const;
void setChromeOS(bool isChromeOS) { void setChromeOS(bool isChromeOS) {
auto shouldUpdateDevice = chromeOS != isChromeOS; auto shouldUpdateDevice = chromeOS != isChromeOS;
chromeOS = isChromeOS; chromeOS = isChromeOS;
......
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-archive-restore"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h2" />
<path d="M20 8v11a2 2 0 0 1-2 2h-2" />
<path d="m9 15 3-3 3 3" />
<path d="M12 12v9" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-archive"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
<path d="M10 12h4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-box"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-boxes"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" />
<path d="m7 16.5-4.74-2.85" />
<path d="m7 16.5 5-3" />
<path d="M7 16.5v5.17" />
<path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" />
<path d="m17 16.5-5-3" />
<path d="m17 16.5 4.74-2.85" />
<path d="M17 16.5v5.17" />
<path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" />
<path d="M12 8 7.26 5.15" />
<path d="m12 8 4.74-2.85" />
<path d="M12 13.5V8" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-bug"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-chevron-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-chevron-left"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-chevron-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-chevron-up"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m18 15-6-6-6 6" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-circle-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m16 9-5.5 5.5L8 12" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-circle-help"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-circle-x"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-clock"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-copy"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-cpu"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 20v2" />
<path d="M12 2v2" />
<path d="M17 20v2" />
<path d="M17 2v2" />
<path d="M2 12h2" />
<path d="M2 17h2" />
<path d="M2 7h2" />
<path d="M20 12h2" />
<path d="M20 17h2" />
<path d="M20 7h2" />
<path d="M7 20v2" />
<path d="M7 2v2" />
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="8" y="8" width="8" height="8" rx="1" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-download"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 15V3" />
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-ellipsis"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-external-link"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-eye-off"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-eye"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-file-down"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
<path d="M12 18v-6" />
<path d="m9 15 3 3 3-3" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-folder-open"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-gamepad-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="6" x2="10" y1="11" y2="11" />
<line x1="8" x2="8" y1="9" y2="13" />
<line x1="15" x2="15.01" y1="12" y2="12" />
<line x1="18" x2="18.01" y1="10" y2="10" />
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-gauge"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 14 4-4" />
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-globe"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-hard-drive"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 16h.01" />
<path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
<path d="M21.946 12.013H2.054" />
<path d="M6 16h.01" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-house"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
<path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-image"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-info"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-key-round"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-keyboard"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 8h.01" />
<path d="M12 12h.01" />
<path d="M14 8h.01" />
<path d="M16 12h.01" />
<path d="M18 8h.01" />
<path d="M6 8h.01" />
<path d="M7 16h10" />
<path d="M8 12h.01" />
<rect width="20" height="16" x="2" y="4" rx="2" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-languages"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m5 8 6 6" />
<path d="m4 14 6-6 2-3" />
<path d="M2 5h12" />
<path d="M7 2h1" />
<path d="m22 22-5-10-5 10" />
<path d="M14 18h6" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-layers"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-life-buoy"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m4.93 4.93 4.24 4.24" />
<path d="m14.83 9.17 4.24-4.24" />
<path d="m14.83 14.83 4.24 4.24" />
<path d="m9.17 14.83-4.24 4.24" />
<circle cx="12" cy="12" r="4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-loader-circle"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-lock"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-log-in"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m10 17 5-5-5-5" />
<path d="M15 12H3" />
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-log-out"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-map"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" />
<path d="M15 5.764v15" />
<path d="M9 3.236v15" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-monitor"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-mouse"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="5" y="2" width="14" height="20" rx="7" />
<path d="M12 6v4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-newspaper"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 18h-5" />
<path d="M18 14h-8" />
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2" />
<rect width="8" height="4" x="10" y="6" rx="1" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-package"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
<path d="M12 22V12" />
<polyline points="3.29 7 12 12 20.71 7" />
<path d="m7.5 4.27 9 5.15" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-pencil"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-play"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-plus"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-power"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v10" />
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-puzzle"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-refresh-cw"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-rotate-ccw"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-scroll-text"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 12h-5" />
<path d="M15 8h-5" />
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
<path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-search"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-settings"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-shield-check"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-sliders-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 5H3" />
<path d="M12 19H3" />
<path d="M14 3v4" />
<path d="M16 17v4" />
<path d="M21 12h-9" />
<path d="M21 19h-5" />
<path d="M21 5h-7" />
<path d="M8 10v4" />
<path d="M8 12H3" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-sparkles"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" />
<path d="M20 2v4" />
<path d="M22 4h-4" />
<circle cx="4" cy="20" r="2" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-square-terminal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 11 2-2-2-2" />
<path d="M11 13h4" />
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-store"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5" />
<path d="M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244" />
<path d="M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-swords"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m13 19 6-6" />
<path d="M14.5 17.5 3.586 6.586A2 2 0 013 5.172V3h2.172a2 2 0 011.414.586L17.5 14.5" />
<path d="m14.828 6.172 2.586-2.586A2 2 0 0118.828 3H21v2.172a2 2 0 01-.586 1.414l-2.586 2.586" />
<path d="m16 16 4 4" />
<path d="m19 21 2-2" />
<path d="m5 14 4 4" />
<path d="m5 21-2-2" />
<path d="M7.5 16.5 4 20" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-trash-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<!-- @license lucide-static v1.52.0 - ISC -->
<svg
class="lucide lucide-triangle-alert"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment