From fa7640adae021d84a64b058aaa311ce165e95057 Mon Sep 17 00:00:00 2001 From: febrezo Date: Sun, 10 May 2026 04:48:14 +0200 Subject: [PATCH 1/2] Simplify InfiniSim launching --- .github/workflows/appimage.yml | 47 +++ .gitignore | 2 + CMakeLists.txt | 2 + README.md | 88 +++++ packaging/appimage/AppRun | 5 + packaging/appimage/build-appimage.sh | 88 +++++ packaging/appimage/infinisim.desktop | 8 + packaging/appimage/infinisim.svg | 7 + scripts/infinisim-launcher.sh | 470 +++++++++++++++++++++++++++ 9 files changed, 717 insertions(+) create mode 100644 .github/workflows/appimage.yml create mode 100755 packaging/appimage/AppRun create mode 100755 packaging/appimage/build-appimage.sh create mode 100644 packaging/appimage/infinisim.desktop create mode 100644 packaging/appimage/infinisim.svg create mode 100755 scripts/infinisim-launcher.sh diff --git a/.github/workflows/appimage.yml b/.github/workflows/appimage.yml new file mode 100644 index 0000000..13a2555 --- /dev/null +++ b/.github/workflows/appimage.yml @@ -0,0 +1,47 @@ +name: Build AppImage + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +jobs: + appimage: + runs-on: ubuntu-22.04 + + steps: + - name: Install build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y \ + cmake \ + curl \ + g++ \ + git \ + libfuse2 \ + libpng-dev \ + libsdl2-dev \ + ninja-build \ + npm \ + python3-pil \ + rsync + sudo npm install -g lv_font_conv@1.5.2 + + - name: Checkout source files + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build AppImage + run: | + chmod +x ./packaging/appimage/build-appimage.sh + ./packaging/appimage/build-appimage.sh + + - name: Upload AppImage + uses: actions/upload-artifact@v4 + with: + name: InfiniSim-AppImage + path: ./*.AppImage + if-no-files-found: error diff --git a/.gitignore b/.gitignore index b16195e..de99470 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ tools !bootloader/bootloader-5.0.4.bin *.map *.out +*.AppImage +*.raw pinetime*.cbp # InfiniTime's files diff --git a/CMakeLists.txt b/CMakeLists.txt index 316090b..74c211e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -186,6 +186,7 @@ target_sources(infinisim PUBLIC ${InfiniTime_DIR}/src/displayapp/Colors.cpp ${InfiniTime_DIR}/src/displayapp/DisplayApp.h ${InfiniTime_DIR}/src/displayapp/DisplayApp.cpp + ${InfiniTime_DIR}/src/displayapp/localization/Localization.cpp ${InfiniTime_DIR}/src/buttonhandler/ButtonHandler.h ${InfiniTime_DIR}/src/buttonhandler/ButtonHandler.cpp ${InfiniTime_DIR}/src/components/stopwatch/StopWatchController.h @@ -353,6 +354,7 @@ add_subdirectory(img) add_dependencies(infinisim infinisim_img_background) install(TARGETS infinisim DESTINATION bin) +install(PROGRAMS scripts/infinisim-launcher.sh DESTINATION bin RENAME infinisim-launcher) # helper library to manipulate littlefs raw image add_executable(littlefs-do diff --git a/README.md b/README.md index 8cd216e..e5ec10e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,94 @@ Or use it to develop new Watchfaces, new Screens, or quickly iterate on the user For a history on how this simulator started and the challenges on its way visit the [original PR](https://github.com/InfiniTimeOrg/InfiniTime/pull/743). +## Quick start + +The recommended way to distribute InfiniSim is the AppImage built by GitHub Actions. +It contains the launcher, InfiniSim sources, and a prebuilt simulator for the bundled official InfiniTime checkout. +That means the default official path does not require users to install CMake, SDL2 development headers, Node.js, or +Python packages locally. + +After downloading the AppImage: + +```sh +chmod +x InfiniSim-*.AppImage +./InfiniSim-*.AppImage +``` + +On first start it shows 3 simple actions: + +- start a simulator that is already compiled (the bundled official binary is preferred when present) +- choose a local InfiniTime source directory and compile it +- clone/update the official InfiniTime repository and compile it + +When cloning from the launcher, InfiniSim prefers the InfiniTime revision that was validated for that AppImage build. +This keeps clone-and-build behavior reproducible and avoids breakage from upstream API drift. + +Build output and cloned sources are kept outside the AppImage: + +- official InfiniTime clone: `~/.local/share/infinisim/InfiniTime` +- build cache for custom local sources: `~/.cache/infinisim` +- last selected source path and executable path: `~/.config/infinisim/launcher.conf` + +InfiniTime sources are used at compile time. Because of that, choosing a custom local InfiniTime checkout requires a +local rebuild. The AppImage keeps a zero-build path with the bundled binary, and only asks for build tools when you +choose one of the compile actions or set `INFINISIM_FORCE_REBUILD=1`. + +Advanced launcher overrides: + +- `INFINISIM_BINARY=/path/to/infinisim` starts a precompiled simulator directly +- `INFINITIME_DIR=/path/to/InfiniTime` skips the chooser and compiles that source tree + +For development from a source checkout, run the same launcher directly: + +```sh +./scripts/infinisim-launcher.sh +``` + +When a rebuild is needed, these native build tools are required: + +- CMake +- Git +- SDL2 development files +- a C++ compiler (`g++` or `clang++`) +- Node.js/npm +- Python 3 + +On Ubuntu/Debian: + +```sh +sudo apt install -y cmake git libsdl2-dev g++ npm python3 python3-venv +``` + +On Fedora: + +```sh +sudo dnf install cmake git SDL2-devel gcc-c++ npm python3 python3-virtualenv +``` + +The launcher installs `lv_font_conv` and `Pillow` in its own cache when they are not available globally. + +### Build an AppImage + +An AppImage wrapper can be generated with: + +```sh +./packaging/appimage/build-appimage.sh +``` + +This helper builds the official simulator binary, places it inside the AppImage, packages the launcher and source tree, +and uses `linuxdeploy` to bundle runtime libraries. + +Prerequisites for generating the AppImage locally: + +- clone with submodules (`git clone --recursive ...`), or run `git submodule update --init --recursive` +- host build tools: `cmake`, `git`, `g++`/`clang++`, `libsdl2-dev`, `npm`, Python 3 + Pillow +- packaging helpers: `curl`, `rsync`, and FUSE runtime support for AppImage tooling + +The resulting file is generated at the repository root as `InfiniSim-.AppImage`. + +GitHub Actions builds the same artifact via `.github/workflows/appimage.yml`. + ## Get the Sources Clone this repository and tell `git` to recursively download the submodules as well diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun new file mode 100755 index 0000000..b2813ee --- /dev/null +++ b/packaging/appimage/AppRun @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${HERE}/usr/bin/infinisim-launcher" "$@" diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh new file mode 100755 index 0000000..5fe2ff2 --- /dev/null +++ b/packaging/appimage/build-appimage.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +APPDIR="${ROOT_DIR}/build/AppDir" +LINUXDEPLOY="${ROOT_DIR}/build/linuxdeploy-x86_64.AppImage" +OFFICIAL_BUILD_DIR="${ROOT_DIR}/build/appimage-official" + +rm -rf "${APPDIR}" + +mkdir -p "${APPDIR}/usr/bin" \ + "${APPDIR}/usr/share/applications" \ + "${APPDIR}/usr/share/icons/hicolor/scalable/apps" \ + "${APPDIR}/usr/share/infinisim/resources" + +if [[ -d "${ROOT_DIR}/InfiniTime/src/libs/lvgl/src" ]]; then + official_ref="$(git -C "${ROOT_DIR}/InfiniTime" rev-parse HEAD)" + + cmake -S "${ROOT_DIR}" -B "${OFFICIAL_BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_PNG=OFF + cmake --build "${OFFICIAL_BUILD_DIR}" --parallel "$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '2')" + + install -m 0755 "${OFFICIAL_BUILD_DIR}/infinisim" "${APPDIR}/usr/bin/infinisim-official" + install -m 0755 "${OFFICIAL_BUILD_DIR}/littlefs-do" "${APPDIR}/usr/bin/littlefs-do" + if [[ -f "${OFFICIAL_BUILD_DIR}/resources/resource.zip" ]]; then + install -m 0644 "${OFFICIAL_BUILD_DIR}/resources/resource.zip" "${APPDIR}/usr/share/infinisim/resources/resource.zip" + fi + printf '%s\n' "${official_ref}" > "${APPDIR}/usr/share/infinisim/resources/infinitime-ref.txt" +else + printf '%s\n' "InfiniTime submodule is missing; AppImage will not include the official prebuilt binary." >&2 +fi + +install -m 0755 "${ROOT_DIR}/scripts/infinisim-launcher.sh" "${APPDIR}/usr/bin/infinisim-launcher" +install -m 0755 "${ROOT_DIR}/packaging/appimage/AppRun" "${APPDIR}/AppRun" +install -m 0644 "${ROOT_DIR}/packaging/appimage/infinisim.desktop" "${APPDIR}/usr/share/applications/infinisim.desktop" +install -m 0644 "${ROOT_DIR}/packaging/appimage/infinisim.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/infinisim.svg" + +if command -v git >/dev/null 2>&1; then + install -m 0755 "$(command -v git)" "${APPDIR}/usr/bin/git" + if [[ -d /usr/lib/git-core ]]; then + mkdir -p "${APPDIR}/usr/lib" + rsync -a /usr/lib/git-core "${APPDIR}/usr/lib/" + fi + if [[ -d /usr/share/git-core ]]; then + mkdir -p "${APPDIR}/usr/share" + rsync -a /usr/share/git-core "${APPDIR}/usr/share/" + fi +fi + +rsync -a --delete \ + --exclude '/.git' \ + --exclude '/build' \ + --exclude '/InfiniTime' \ + --exclude '/node_modules' \ + --exclude '/.venv' \ + "${ROOT_DIR}/" "${APPDIR}/usr/share/infinisim/source/" + +if [[ ! -x "${LINUXDEPLOY}" ]]; then + curl -L \ + https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage \ + -o "${LINUXDEPLOY}" + chmod +x "${LINUXDEPLOY}" +fi + +linuxdeploy_args=( + --appdir "${APPDIR}" + --desktop-file "${APPDIR}/usr/share/applications/infinisim.desktop" + --icon-file "${APPDIR}/usr/share/icons/hicolor/scalable/apps/infinisim.svg" + --executable "${APPDIR}/usr/bin/infinisim-launcher" +) + +if [[ -x "${APPDIR}/usr/bin/infinisim-official" ]]; then + linuxdeploy_args+=(--executable "${APPDIR}/usr/bin/infinisim-official") +fi + +if [[ -x "${APPDIR}/usr/bin/littlefs-do" ]]; then + linuxdeploy_args+=(--executable "${APPDIR}/usr/bin/littlefs-do") +fi + +if [[ -x "${APPDIR}/usr/bin/git" ]]; then + linuxdeploy_args+=(--executable "${APPDIR}/usr/bin/git") + while IFS= read -r helper; do + linuxdeploy_args+=(--executable "${helper}") + done < <(find "${APPDIR}/usr/lib/git-core" -maxdepth 1 -type f -executable 2>/dev/null) +fi + +ARCH=x86_64 "${LINUXDEPLOY}" "${linuxdeploy_args[@]}" --output appimage diff --git a/packaging/appimage/infinisim.desktop b/packaging/appimage/infinisim.desktop new file mode 100644 index 0000000..65dc35d --- /dev/null +++ b/packaging/appimage/infinisim.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=InfiniSim +Comment=InfiniTime simulator launcher +Exec=infinisim-launcher +Icon=infinisim +Categories=Development;Emulator; +Terminal=false diff --git a/packaging/appimage/infinisim.svg b/packaging/appimage/infinisim.svg new file mode 100644 index 0000000..404ee87 --- /dev/null +++ b/packaging/appimage/infinisim.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/infinisim-launcher.sh b/scripts/infinisim-launcher.sh new file mode 100755 index 0000000..ed5a287 --- /dev/null +++ b/scripts/infinisim-launcher.sh @@ -0,0 +1,470 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="InfiniSim" +INFINITIME_REPO="https://github.com/InfiniTimeOrg/InfiniTime.git" + +if [[ -n "${APPDIR:-}" && -d "${APPDIR}/usr/share/infinisim/source" ]]; then + export PATH="${APPDIR}/usr/bin:${PATH}" + if [[ -d "${APPDIR}/usr/lib/git-core" ]]; then + export GIT_EXEC_PATH="${APPDIR}/usr/lib/git-core" + fi + if [[ -d "${APPDIR}/usr/share/git-core/templates" ]]; then + export GIT_TEMPLATE_DIR="${APPDIR}/usr/share/git-core/templates" + fi + PACKAGED_INFINISIM_SOURCE_DIR="${APPDIR}/usr/share/infinisim/source" + INFINISIM_SOURCE_DIR="${PACKAGED_INFINISIM_SOURCE_DIR}" +else + PACKAGED_INFINISIM_SOURCE_DIR="" + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + INFINISIM_SOURCE_DIR="$(cd "${script_dir}/.." && pwd)" +fi + +DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/infinisim" +CACHE_HOME="${XDG_CACHE_HOME:-${HOME}/.cache}/infinisim" +CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME}/.config}/infinisim" +CONFIG_FILE="${CONFIG_HOME}/launcher.conf" +BUILD_ROOT="${CACHE_HOME}/build" +DEPS_ROOT="${CACHE_HOME}/deps" +DEFAULT_INFINITIME_DIR="${DATA_HOME}/InfiniTime" +BUNDLED_INFINISIM="${APPDIR:-}/usr/bin/infinisim-official" +BUNDLED_LITTLEFS_DO="${APPDIR:-}/usr/bin/littlefs-do" +BUNDLED_RESOURCE_ZIP="${APPDIR:-}/usr/share/infinisim/resources/resource.zip" +BUNDLED_INFINITIME_REF_FILE="${APPDIR:-}/usr/share/infinisim/resources/infinitime-ref.txt" + +mkdir -p "${DATA_HOME}" "${CACHE_HOME}" "${CONFIG_HOME}" "${BUILD_ROOT}" "${DEPS_ROOT}" + +prepare_stable_appimage_source() { + if [[ -z "${PACKAGED_INFINISIM_SOURCE_DIR}" ]]; then + return + fi + + local stable_source_dir="${CACHE_HOME}/source" + local stamp_file="${stable_source_dir}/.infinisim-appimage-stamp" + local appimage_stamp + + if [[ -n "${APPIMAGE:-}" && -f "${APPIMAGE}" ]]; then + appimage_stamp="$(stat -c '%n:%Y:%s' "${APPIMAGE}")" + else + appimage_stamp="$(stat -c '%n:%Y:%s' "${APPDIR}/AppRun")" + fi + + if [[ ! -f "${stable_source_dir}/CMakeLists.txt" ]] || [[ ! -f "${stamp_file}" ]] || [[ "$(cat "${stamp_file}")" != "${appimage_stamp}" ]]; then + rm -rf "${stable_source_dir}" + mkdir -p "${stable_source_dir}" + cp -a "${PACKAGED_INFINISIM_SOURCE_DIR}/." "${stable_source_dir}/" + printf '%s\n' "${appimage_stamp}" > "${stamp_file}" + fi + + INFINISIM_SOURCE_DIR="${stable_source_dir}" +} + +message() { + local text="$1" + if command -v zenity >/dev/null 2>&1; then + zenity --info --title="${APP_NAME}" --text="${text}" || true + elif command -v kdialog >/dev/null 2>&1; then + kdialog --title "${APP_NAME}" --msgbox "${text}" || true + else + printf '%s\n' "${text}" >&2 + fi +} + +load_config() { + if [[ -f "${CONFIG_FILE}" ]]; then + # shellcheck disable=SC1090 + source "${CONFIG_FILE}" + fi +} + +error() { + local text="$1" + if command -v zenity >/dev/null 2>&1; then + zenity --error --title="${APP_NAME}" --text="${text}" || true + elif command -v kdialog >/dev/null 2>&1; then + kdialog --title "${APP_NAME}" --error "${text}" || true + else + printf 'Error: %s\n' "${text}" >&2 + fi +} + +choose_action() { + if command -v zenity >/dev/null 2>&1; then + zenity --list --title="${APP_NAME}" \ + --width=760 --height=320 \ + --text="Elige como iniciar InfiniSim." \ + --column="accion" --column="opcion" --hide-column=1 \ + "run-compiled" "Iniciar un simulador ya compilado" \ + "build-local" "Compilar desde una carpeta local de InfiniTime" \ + "build-clone" "Descargar InfiniTime oficial y compilar" 2>/dev/null || true + elif command -v kdialog >/dev/null 2>&1; then + kdialog --title "${APP_NAME}" --menu "Elige como iniciar InfiniSim." \ + "run-compiled" "Iniciar un simulador ya compilado" \ + "build-local" "Compilar desde una carpeta local de InfiniTime" \ + "build-clone" "Descargar InfiniTime oficial y compilar" 2>/dev/null || true + else + printf '%s\n' "Selecciona una accion:" >&2 + printf '%s\n' "1) Iniciar simulador ya compilado" >&2 + printf '%s\n' "2) Compilar desde carpeta local de InfiniTime" >&2 + printf '%s\n' "3) Descargar InfiniTime oficial y compilar" >&2 + printf '> ' >&2 + read -r reply + case "${reply}" in + 1) printf '%s\n' "run-compiled" ;; + 2) printf '%s\n' "build-local" ;; + 3) printf '%s\n' "build-clone" ;; + *) return 1 ;; + esac + fi +} + +choose_directory() { + local initial_dir="${1:-${HOME}}" + if command -v zenity >/dev/null 2>&1; then + zenity --file-selection --directory --filename="${initial_dir}/" --title="Selecciona la carpeta de InfiniTime" 2>/dev/null || true + elif command -v kdialog >/dev/null 2>&1; then + kdialog --getexistingdirectory "${initial_dir}" "Selecciona la carpeta de InfiniTime" 2>/dev/null || true + else + printf '%s' "Ruta de InfiniTime: " >&2 + read -r reply + printf '%s\n' "${reply}" + fi +} + +choose_binary_file() { + local initial_file="${1:-}" + if command -v zenity >/dev/null 2>&1; then + if [[ -n "${initial_file}" ]]; then + zenity --file-selection --filename="${initial_file}" --title="Selecciona un ejecutable de InfiniSim" 2>/dev/null || true + else + zenity --file-selection --title="Selecciona un ejecutable de InfiniSim" 2>/dev/null || true + fi + elif command -v kdialog >/dev/null 2>&1; then + if [[ -n "${initial_file}" ]]; then + kdialog --getopenfilename "${initial_file}" "*" "Selecciona un ejecutable de InfiniSim" 2>/dev/null || true + else + kdialog --getopenfilename "${HOME}" "*" "Selecciona un ejecutable de InfiniSim" 2>/dev/null || true + fi + else + printf '%s' "Ruta del ejecutable de InfiniSim: " >&2 + read -r reply + printf '%s\n' "${reply}" + fi +} + +load_last_dir() { + load_config + printf '%s\n' "${INFINITIME_DIR:-}" +} + +load_last_binary() { + load_config + printf '%s\n' "${INFINISIM_BINARY:-}" +} + +save_last_dir() { + local dir="$1" + local last_binary + last_binary="$(load_last_binary)" + { + printf 'INFINITIME_DIR=%q\n' "${dir}" + printf 'INFINISIM_BINARY=%q\n' "${last_binary}" + } > "${CONFIG_FILE}" +} + +save_last_binary() { + local binary="$1" + local last_dir + last_dir="$(load_last_dir)" + { + printf 'INFINITIME_DIR=%q\n' "${last_dir}" + printf 'INFINISIM_BINARY=%q\n' "${binary}" + } > "${CONFIG_FILE}" +} + +ensure_command() { + local cmd="$1" + local hint="$2" + if ! command -v "${cmd}" >/dev/null 2>&1; then + error "Falta '${cmd}'. ${hint}" + exit 1 + fi +} + +missing_build_requirements() { + local missing=() + + if ! command -v cmake >/dev/null 2>&1; then + missing+=("cmake") + fi + + if ! command -v git >/dev/null 2>&1; then + missing+=("git") + fi + + if ! command -v c++ >/dev/null 2>&1 && ! command -v g++ >/dev/null 2>&1 && ! command -v clang++ >/dev/null 2>&1; then + missing+=("c++") + fi + + if command -v pkg-config >/dev/null 2>&1; then + if ! pkg-config --exists sdl2; then + missing+=("sdl2-dev") + fi + elif ! command -v sdl2-config >/dev/null 2>&1; then + missing+=("sdl2-dev") + fi + + printf '%s\n' "${missing[@]:-}" +} + +build_requirements_hint() { + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + source /etc/os-release + case "${ID:-}" in + fedora) + printf '%s\n' "sudo dnf install cmake git SDL2-devel gcc-c++ npm python3 python3-virtualenv" + return + ;; + ubuntu|debian|linuxmint|pop) + printf '%s\n' "sudo apt install -y cmake git libsdl2-dev g++ npm python3 python3-venv" + return + ;; + arch|manjaro) + printf '%s\n' "sudo pacman -S cmake git sdl2 gcc npm python python-virtualenv" + return + ;; + opensuse*|sles) + printf '%s\n' "sudo zypper install cmake git libSDL2-devel gcc-c++ npm python3 python3-virtualenv" + return + ;; + esac + fi + + printf '%s\n' "Instala: cmake, git, SDL2 devel, compilador C++, npm y python3" +} + +ensure_build_tools() { + local missing + missing="$(missing_build_requirements)" + if [[ -n "${missing}" ]]; then + error "Faltan dependencias de compilacion:\n${missing}\n\nInstalacion sugerida:\n$(build_requirements_hint)" + exit 1 + fi +} + +run_bundled_official() { + local infinitime_dir="$1" + shift + + if [[ "${INFINISIM_FORCE_REBUILD:-0}" == "1" || ! -x "${BUNDLED_INFINISIM}" ]]; then + return 1 + fi + + if [[ -d "${APPDIR:-}" && "${infinitime_dir}" == "${DEFAULT_INFINITIME_DIR}" ]]; then + if [[ -f "${BUNDLED_RESOURCE_ZIP}" && -x "${BUNDLED_LITTLEFS_DO}" ]]; then + "${BUNDLED_LITTLEFS_DO}" res load "${BUNDLED_RESOURCE_ZIP}" || true + fi + exec "${BUNDLED_INFINISIM}" "$@" + fi + + return 1 +} + +ensure_infinisim_submodules() { + if [[ ! -e "${INFINISIM_SOURCE_DIR}/lv_drivers/CMakeLists.txt" && -d "${INFINISIM_SOURCE_DIR}/.git" ]]; then + message "Preparando submodulos de InfiniSim..." + git -C "${INFINISIM_SOURCE_DIR}" submodule update --init --recursive lv_drivers + fi +} + +ensure_infinitime_tree() { + local dir="$1" + if [[ ! -f "${dir}/CMakeLists.txt" || ! -d "${dir}/src" ]]; then + error "'${dir}' no parece una carpeta valida de InfiniTime." + exit 1 + fi + + if [[ ! -d "${dir}/src/libs/lvgl/src" && -d "${dir}/.git" ]]; then + message "Preparando submodulos de InfiniTime..." + git -C "${dir}" submodule update --init --recursive + fi + + if [[ ! -d "${dir}/src/libs/lvgl/src" ]]; then + error "InfiniTime necesita el submodulo src/libs/lvgl. Ejecuta: git -C '${dir}' submodule update --init --recursive" + exit 1 + fi +} + +ensure_node_tools() { + if command -v lv_font_conv >/dev/null 2>&1; then + return + fi + ensure_command npm "Instala Node.js/npm o instala lv_font_conv manualmente." + if [[ ! -x "${DEPS_ROOT}/node_modules/.bin/lv_font_conv" ]]; then + message "Instalando lv_font_conv en la cache local de InfiniSim..." + npm --prefix "${DEPS_ROOT}" install lv_font_conv@1.5.2 + fi + export PATH="${DEPS_ROOT}/node_modules/.bin:${PATH}" +} + +ensure_python_tools() { + ensure_command python3 "Instala Python 3 para generar recursos." + if python3 - <<'PY' >/dev/null 2>&1 +import PIL +PY + then + return + fi + if [[ ! -x "${DEPS_ROOT}/venv/bin/python" ]]; then + message "Instalando Pillow en la cache local de InfiniSim..." + python3 -m venv "${DEPS_ROOT}/venv" + "${DEPS_ROOT}/venv/bin/python" -m pip install --upgrade pip wheel Pillow + fi + export PATH="${DEPS_ROOT}/venv/bin:${PATH}" +} + +clone_official_infinitime() { + local desired_ref="" + + if [[ -f "${BUNDLED_INFINITIME_REF_FILE}" ]]; then + desired_ref="$(head -n 1 "${BUNDLED_INFINITIME_REF_FILE}")" + elif [[ -d "${INFINISIM_SOURCE_DIR}/InfiniTime/.git" ]]; then + desired_ref="$(git -C "${INFINISIM_SOURCE_DIR}/InfiniTime" rev-parse HEAD 2>/dev/null || true)" + fi + + if [[ -d "${DEFAULT_INFINITIME_DIR}/.git" ]]; then + message "Actualizando InfiniTime oficial en ${DEFAULT_INFINITIME_DIR}..." + git -C "${DEFAULT_INFINITIME_DIR}" fetch --all --tags --prune >&2 + else + message "Clonando InfiniTime oficial en ${DEFAULT_INFINITIME_DIR}..." + rm -rf "${DEFAULT_INFINITIME_DIR}" + git clone --recursive "${INFINITIME_REPO}" "${DEFAULT_INFINITIME_DIR}" >&2 + fi + + if [[ -n "${desired_ref}" ]]; then + message "Usando revision oficial probada con esta version de InfiniSim..." + git -C "${DEFAULT_INFINITIME_DIR}" checkout "${desired_ref}" >&2 + else + git -C "${DEFAULT_INFINITIME_DIR}" pull --ff-only >&2 + fi + + git -C "${DEFAULT_INFINITIME_DIR}" submodule update --init --recursive >&2 + printf '%s\n' "${DEFAULT_INFINITIME_DIR}" +} + +resolve_compiled_binary() { + local action="$1" + local binary_path="" + local last_binary + last_binary="$(load_last_binary)" + + if [[ -n "${INFINISIM_BINARY:-}" ]]; then + binary_path="${INFINISIM_BINARY}" + elif [[ -x "${BUNDLED_INFINISIM}" ]]; then + binary_path="${BUNDLED_INFINISIM}" + else + binary_path="$(choose_binary_file "${last_binary}")" + fi + + if [[ -z "${binary_path}" ]]; then + exit 0 + fi + + if [[ ! -f "${binary_path}" ]]; then + error "No se encontro el archivo seleccionado." + exit 1 + fi + + if [[ ! -x "${binary_path}" ]]; then + error "El archivo seleccionado no es ejecutable." + exit 1 + fi + + save_last_binary "${binary_path}" + + if [[ "${action}" == "run-compiled" && "${binary_path}" == "${BUNDLED_INFINISIM}" ]] && [[ -f "${BUNDLED_RESOURCE_ZIP}" && -x "${BUNDLED_LITTLEFS_DO}" ]]; then + "${BUNDLED_LITTLEFS_DO}" res load "${BUNDLED_RESOURCE_ZIP}" || true + fi + + printf '%s\n' "${binary_path}" +} + +build_and_run() { + local infinitime_dir="$1" + local source_key build_dir jobs + shift + + source_key="$(printf '%s\n%s' "${INFINISIM_SOURCE_DIR}" "${infinitime_dir}" | cksum | awk '{print $1}')" + build_dir="${BUILD_ROOT}/${source_key}" + jobs="${INFINISIM_BUILD_JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '2')}" + + cmake -S "${INFINISIM_SOURCE_DIR}" -B "${build_dir}" \ + -DInfiniTime_DIR="${infinitime_dir}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_PNG=OFF + cmake --build "${build_dir}" --parallel "${jobs}" + + if [[ -f "${build_dir}/resources/resource.zip" && -x "${build_dir}/littlefs-do" ]]; then + "${build_dir}/littlefs-do" res load "${build_dir}/resources/resource.zip" || true + fi + + exec "${build_dir}/infinisim" "$@" +} + +main() { + local action infinitime_dir binary_path last_dir + prepare_stable_appimage_source + + if [[ -n "${INFINITIME_DIR:-}" ]]; then + action="build-local" + elif [[ -n "${INFINISIM_BINARY:-}" ]]; then + action="run-compiled" + else + action="$(choose_action)" + fi + + case "${action}" in + run-compiled) + binary_path="$(resolve_compiled_binary "${action}")" + exec "${binary_path}" "$@" + ;; + build-local) + last_dir="$(load_last_dir)" + if [[ -n "${INFINITIME_DIR:-}" ]]; then + infinitime_dir="${INFINITIME_DIR}" + else + infinitime_dir="$(choose_directory "${last_dir:-$HOME}")" + fi + ;; + build-clone) + infinitime_dir="$(clone_official_infinitime)" + ;; + "") + exit 0 + ;; + *) + error "Opcion no reconocida." + exit 1 + ;; + esac + + if [[ -z "${infinitime_dir:-}" ]]; then + exit 0 + fi + + infinitime_dir="$(cd "${infinitime_dir}" && pwd)" + ensure_infinitime_tree "${infinitime_dir}" + save_last_dir "${infinitime_dir}" + + run_bundled_official "${infinitime_dir}" "$@" || true + + ensure_build_tools + ensure_infinisim_submodules + ensure_node_tools + ensure_python_tools + + build_and_run "${infinitime_dir}" "$@" +} + +main "$@" From b2b3ecb7a1704aeb4b446fe1f1e39d2363a7b32f Mon Sep 17 00:00:00 2001 From: febrezo Date: Sun, 10 May 2026 15:36:44 +0200 Subject: [PATCH 2/2] feat: launcher addon, UI polish and i18n - Add the GTK launcher UI addon with fallback behavior - Harden SDL window positioning and keep the progress dialog polished - Add gettext-based English/Spanish localization with proper accents - Include translation files and AppImage packaging support - Update CI/AppImage release flow for distribution --- .github/workflows/appimage.yml | 11 + .gitignore | 2 + CMakeLists.txt | 7 +- README.md | 12 + main.cpp | 64 +- packaging/appimage/build-appimage.sh | 15 +- scripts/infinisim-launcher-en.mo | Bin 0 -> 4120 bytes scripts/infinisim-launcher-en.po | 188 ++++++ scripts/infinisim-launcher-es.mo | Bin 0 -> 4229 bytes scripts/infinisim-launcher-es.po | 188 ++++++ scripts/infinisim-launcher-ui.py | 847 +++++++++++++++++++++++++++ scripts/infinisim-launcher.pot | 188 ++++++ scripts/infinisim-launcher.sh | 192 +++++- scripts/infinisim_launcher_i18n.py | 76 +++ 14 files changed, 1761 insertions(+), 29 deletions(-) create mode 100644 scripts/infinisim-launcher-en.mo create mode 100644 scripts/infinisim-launcher-en.po create mode 100644 scripts/infinisim-launcher-es.mo create mode 100644 scripts/infinisim-launcher-es.po create mode 100644 scripts/infinisim-launcher-ui.py create mode 100644 scripts/infinisim-launcher.pot create mode 100644 scripts/infinisim_launcher_i18n.py diff --git a/.github/workflows/appimage.yml b/.github/workflows/appimage.yml index 13a2555..2a23c75 100644 --- a/.github/workflows/appimage.yml +++ b/.github/workflows/appimage.yml @@ -5,8 +5,13 @@ on: branches: [ main, develop ] pull_request: branches: [ main, develop ] + release: + types: [ published ] workflow_dispatch: +permissions: + contents: write + jobs: appimage: runs-on: ubuntu-22.04 @@ -45,3 +50,9 @@ jobs: name: InfiniSim-AppImage path: ./*.AppImage if-no-files-found: error + + - name: Attach AppImage to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: ./*.AppImage diff --git a/.gitignore b/.gitignore index de99470..536b060 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .idea/ # Python virtual environment for DFU images .venv/ +__pycache__/ +scripts/infinisim-launcher-ui.py.bak # CMake cmake-build-* diff --git a/CMakeLists.txt b/CMakeLists.txt index 74c211e..c6afa0f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -186,7 +186,6 @@ target_sources(infinisim PUBLIC ${InfiniTime_DIR}/src/displayapp/Colors.cpp ${InfiniTime_DIR}/src/displayapp/DisplayApp.h ${InfiniTime_DIR}/src/displayapp/DisplayApp.cpp - ${InfiniTime_DIR}/src/displayapp/localization/Localization.cpp ${InfiniTime_DIR}/src/buttonhandler/ButtonHandler.h ${InfiniTime_DIR}/src/buttonhandler/ButtonHandler.cpp ${InfiniTime_DIR}/src/components/stopwatch/StopWatchController.h @@ -355,6 +354,12 @@ add_dependencies(infinisim infinisim_img_background) install(TARGETS infinisim DESTINATION bin) install(PROGRAMS scripts/infinisim-launcher.sh DESTINATION bin RENAME infinisim-launcher) +install(PROGRAMS scripts/infinisim-launcher-ui.py DESTINATION bin RENAME infinisim-launcher-ui.py) +install(PROGRAMS scripts/infinisim_launcher_i18n.py DESTINATION bin) + +# Install i18n translation files +install(FILES scripts/infinisim-launcher-en.mo DESTINATION share/locale/en/LC_MESSAGES RENAME infinisim-launcher.mo) +install(FILES scripts/infinisim-launcher-es.mo DESTINATION share/locale/es/LC_MESSAGES RENAME infinisim-launcher.mo) # helper library to manipulate littlefs raw image add_executable(littlefs-do diff --git a/README.md b/README.md index e5ec10e..fe7cdd3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ On first start it shows 3 simple actions: - choose a local InfiniTime source directory and compile it - clone/update the official InfiniTime repository and compile it +The launcher now uses a GTK interface (GNOME-style) when available: + +- each action is shown as a card with icon, title, and short description +- after selecting an action, a loading spinner is displayed +- execution logs are available in an expandable details panel at the bottom + +If GTK Python bindings are not available, the launcher falls back to the previous text/dialog flow. + When cloning from the launcher, InfiniSim prefers the InfiniTime revision that was validated for that AppImage build. This keeps clone-and-build behavior reproducible and avoids breakage from upstream API drift. @@ -93,6 +101,10 @@ Prerequisites for generating the AppImage locally: - host build tools: `cmake`, `git`, `g++`/`clang++`, `libsdl2-dev`, `npm`, Python 3 + Pillow - packaging helpers: `curl`, `rsync`, and FUSE runtime support for AppImage tooling +Optional (recommended for the rich launcher UI): + +- `python3-gi` and GTK 3 runtime libraries + The resulting file is generated at the repository root as `InfiniSim-.AppImage`. GitHub Actions builds the same artifact via `.github/workflows/appimage.yml`. diff --git a/main.cpp b/main.cpp index 2db675f..c2ba86c 100644 --- a/main.cpp +++ b/main.cpp @@ -429,10 +429,66 @@ class Framework { // SDL_Init(SDL_INIT_VIDEO); // Initializing SDL as Video SDL_CreateWindowAndRenderer(width, height, 0, &window, &renderer); SDL_SetWindowTitle(window, "LV Simulator Status"); - { // move window a bit to the right, to not be over the PineTime Screen - int x, y; - SDL_GetWindowPosition(window, &x, &y); - SDL_SetWindowPosition(window, x + LV_HOR_RES_MAX, y); + { // keep both simulator windows aligned vertically and separated horizontally + SDL_Window* tftWindow = nullptr; + const Uint32 statusId = SDL_GetWindowID(window); + if (statusId > 1) { + SDL_Window* candidate = SDL_GetWindowFromID(statusId - 1); + if (candidate != nullptr && candidate != window) { + tftWindow = candidate; + } + } + + if (tftWindow == nullptr) { + constexpr Uint32 maxWindowSearch = 256; + for (Uint32 offset = 1; offset <= maxWindowSearch; ++offset) { + if (statusId > offset) { + SDL_Window* previous = SDL_GetWindowFromID(statusId - offset); + if (previous != nullptr && previous != window) { + tftWindow = previous; + break; + } + } + + SDL_Window* next = SDL_GetWindowFromID(statusId + offset); + if (next != nullptr && next != window) { + tftWindow = next; + break; + } + } + } + + // Get display bounds to center windows on screen + SDL_Rect displayBounds; + if (SDL_GetDisplayBounds(0, &displayBounds) != 0) { + displayBounds = {0, 0, 1280, 720}; + } + + int screenCenterY = displayBounds.y + (displayBounds.h / 2); + + if (tftWindow != nullptr) { + int tftX = 0; + int tftW = 0; + int tftH = 0; + int statusH = 0; + + SDL_GetWindowPosition(tftWindow, &tftX, nullptr); + SDL_GetWindowSize(tftWindow, &tftW, &tftH); + SDL_GetWindowSize(window, nullptr, &statusH); + + // Calculate Y position to center vertically on screen + int centeredY = screenCenterY - (std::max(tftH, statusH) / 2); + + // Keep the TFT window untouched and place the status window to its right + SDL_SetWindowPosition(window, tftX + tftW + 28, centeredY); + } else { + int statusH = 0; + SDL_GetWindowSize(window, nullptr, &statusH); + + // Center this window vertically on screen + int centeredY = screenCenterY - (statusH / 2); + SDL_SetWindowPosition(window, displayBounds.x + 100, centeredY); + } } SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0); // setting draw color SDL_RenderClear(renderer); // Clear the newly created window diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh index 5fe2ff2..14a2bf1 100755 --- a/packaging/appimage/build-appimage.sh +++ b/packaging/appimage/build-appimage.sh @@ -32,6 +32,14 @@ else fi install -m 0755 "${ROOT_DIR}/scripts/infinisim-launcher.sh" "${APPDIR}/usr/bin/infinisim-launcher" +install -m 0755 "${ROOT_DIR}/scripts/infinisim-launcher-ui.py" "${APPDIR}/usr/bin/infinisim-launcher-ui.py" +install -m 0755 "${ROOT_DIR}/scripts/infinisim_launcher_i18n.py" "${APPDIR}/usr/bin/infinisim_launcher_i18n.py" + +# Install i18n translation files +mkdir -p "${APPDIR}/usr/share/locale/en/LC_MESSAGES" \ + "${APPDIR}/usr/share/locale/es/LC_MESSAGES" +install -m 0644 "${ROOT_DIR}/scripts/infinisim-launcher-en.mo" "${APPDIR}/usr/share/locale/en/LC_MESSAGES/infinisim-launcher.mo" +install -m 0644 "${ROOT_DIR}/scripts/infinisim-launcher-es.mo" "${APPDIR}/usr/share/locale/es/LC_MESSAGES/infinisim-launcher.mo" install -m 0755 "${ROOT_DIR}/packaging/appimage/AppRun" "${APPDIR}/AppRun" install -m 0644 "${ROOT_DIR}/packaging/appimage/infinisim.desktop" "${APPDIR}/usr/share/applications/infinisim.desktop" install -m 0644 "${ROOT_DIR}/packaging/appimage/infinisim.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/infinisim.svg" @@ -67,7 +75,6 @@ linuxdeploy_args=( --appdir "${APPDIR}" --desktop-file "${APPDIR}/usr/share/applications/infinisim.desktop" --icon-file "${APPDIR}/usr/share/icons/hicolor/scalable/apps/infinisim.svg" - --executable "${APPDIR}/usr/bin/infinisim-launcher" ) if [[ -x "${APPDIR}/usr/bin/infinisim-official" ]]; then @@ -81,8 +88,10 @@ fi if [[ -x "${APPDIR}/usr/bin/git" ]]; then linuxdeploy_args+=(--executable "${APPDIR}/usr/bin/git") while IFS= read -r helper; do - linuxdeploy_args+=(--executable "${helper}") + if readelf -h "${helper}" >/dev/null 2>&1; then + linuxdeploy_args+=(--executable "${helper}") + fi done < <(find "${APPDIR}/usr/lib/git-core" -maxdepth 1 -type f -executable 2>/dev/null) fi -ARCH=x86_64 "${LINUXDEPLOY}" "${linuxdeploy_args[@]}" --output appimage +NO_STRIP=1 ARCH=x86_64 "${LINUXDEPLOY}" "${linuxdeploy_args[@]}" --output appimage diff --git a/scripts/infinisim-launcher-en.mo b/scripts/infinisim-launcher-en.mo new file mode 100644 index 0000000000000000000000000000000000000000..3214b1a9528b85d86af1e3faa7446c7e0f082586 GIT binary patch literal 4120 zcmeH|Ply~v6vitt(PZPlL}Q{xEj7_-(v#Up;yNKQ>+Tp9vSi6l0v<$ax@%_2?Wt<3 zs&{sxf(YV86qOtdBHonTauB?D5Jc32C-EdAD=0`%@T!R4tL~YdWaHh#7F+Y%s;+wV z-uGU0|F~_#d4{$heHZ#m>lix%{;(cDXkT5&*a`4!a1Xe317p{NkAvI6W8i&Y1Kb9B z@B#33a36RP+yQ>yTGM#{4R(;_91?bfggeQg1>;|=f)cf|Mr69-*J%QnJdQ`SjG4i zko36(-UfaP9t4-cC&2BS7@Gu7ffL}{Af5LOI1X;!T=;baB!AC>o1k?Hp1?RCEA;&g zB!7PfY29xi`NOspx^R&Eehegi&w`}yt04J*0VKUHf~4OU;9hVUBz?C)B+_>hq~{Hg z)?1MDeFG$YKLAPJ%OLr&?xsTD9U$quyBt3WlD{WGigz9)ecu7e&r2Zr_Z>+7T?Q$h zzsvEin+tvSgQV}XAnAJ!Bz@lmN#D;w()TZr^gRk=blxIJ`hEhEU%!In@A_NeGe~V; z0k$6G<^OaRwY$;DpGVLiMJElYJ&X=hK|7?FA&ns($jEl14;uNpA02M7`^!m`Cd2We z9`682A8JVDu#x6_&~dGTUMPc*kB7hq(W&8zgj`V`sF9A;AYF)&;-$8eekL`S)*8!A z9Ou@`g!eismE3t@z0z&&JCb|h7AkzIBjbfImP<0uy)v37(hHTk%4$2K>xZkRgpOs( z3Xt{KbTgF-vQ^F&18(IDxpW@>H2XYv(pK2i%39vSsWQ28N@?Xf*x>Rwmd>^EH0=*| zN!XzC`X;wLHL*zfjBa5^LvhcGdx{nm=$s%MmRZ8!(>v2 zf*6K4n)TRdXHZIoO^9q)%4J3?kNDL&IyAF}5{4rdF-0mMHSOFMWign|gzvDEBG++8 z+F*^uaVi9DESrOt!opsqQepElm9a0u1*gs28y-A{BSqbzvPVsDrLnXnd#HYyp{NTh zWxW(dLPpBXgU4j!KT3*0FUkppj=-q0iV9kpC*}Ji>%#L@RXk7Ekq>vVFur;ff5zlg zxjasdqu7+=J*5-VLnwp$+{{&)90;f6S-9B_4^5VhWP%iIAgWbYgeOM25QfMyDJ-ec zlsu%Z*ivePYpb+CRrf@(zGZUMHQb#0YIPvvbFHB60<&7F!)%SA<7qiMV$YW!gJ;Nhrx zC>pQw@oKGF-B(2$J1Lxx8dhi*YIgLPh7?^q*X>-iWkjPIuj_V-SMG4S#-$#cotdq# z_(@Jp8SSNp;eLjby1z68 zKlqRk0&xU{A_M{npZK_;TzuoO5(0!k;=l<-$OjOYB;piti1@v#?w%g+rnEEvt?sH< z@BQDu-kX;mxc3Fcc$Cja_xPbhU6yarwbe+bHXe*({gCr<{yE`j3jbKnWM zPQi8N^@>uDfWHITqW%pEZ?87bPrOa3_c0%V4};HwLofp+-k*cw*KJVz{4*$V{0BS( zo`jswf)~JdgELTg*atQEV^Hkh25*3W1%>a^?;uCu6W}|+O;FZ75AvsW`Qixm9Z=%` z0Vr|&4g3`NKT!6cVzco3B=~V~1C%^FQ1bq5P}biDg`Yd%S@1RR^WZ5quYxfsar_#T z=P!Yu0Usi$4};f1@h=0R*AKBHLnN)`ABJ!wL-q(ya`vbBNX#-M226YINU_g}=tW{Z z#it#@`J;Sr)5j&xk*4Pu3cW8#%@0!94bk*zK3prGhm)Y_U2^+LJ~Fr>o+Ht@4AFxO zDD@mk-em~C>T;%SQMf|8D5{IX#(HljNv$p`Q&dSd)76gEl__ry^_3kP-Sl(2Z=$T%=)!)rwq=DslLK9swiv1_i?w>n zsWx7Ilw?V{!-lesB3qWzI!zDST^1X1-hrzNow~@RdOe%6V>_8!J#}duPN>sZXRgwb zcXX`3Zug^aa!mW&({lH`F}0Pao* zS=`WJ7Yk+o(&GLyZR*O+A&+n*)U|mHd$t#uTJyQ>k~uNaO`FASTxEE>3G1h>M&B&=Col6JcFqm- z2X}iC!@A_m#;vkug~@l4s6yU+xuA{B!hHoX57y(sbG9fcmE$He=s|Zk;{0&5a-DKgRD+Fjmc)ZE)U$H1?Z$d# z^V;CL-A!ccV3i6n))z(>9v_Sz8=N2M^P}--baup8xoXO4uw9s}^qTD-vu(|s_t~sA zGdqykSa0P+6BNe3) z$;Mb%D`ww@ik%#e?+UI_vsdo5MnV5P*?rlobsVPZrsGh+M z9LLFULE7!1rG@RZBdLh8J#w_-s4{bSLu8F6Fh`Scx{_O7@Twdr4eII-2;VR&9xm842iGSwfj@+S@@JZqY3iT4d?b zHKCmy-jaUS1nxUpFSe$i1viC?;~H9Elg=b9fG#4*FQu`VFoL}W`HX$!vI8O|@HeLsRNm zad?XxXh>_%Vc#Bo=BO~(@m-Pj>i;i8Qw%-vi}i%p^3zdx3^huKz`yA5#W`M-MBIdkfGqcnd{iW%&s(O7fTpZDjQU3??LF|eE literal 0 HcmV?d00001 diff --git a/scripts/infinisim-launcher-es.po b/scripts/infinisim-launcher-es.po new file mode 100644 index 0000000..a9c4708 --- /dev/null +++ b/scripts/infinisim-launcher-es.po @@ -0,0 +1,188 @@ +# Spanish translation for InfiniSim Launcher UI +# Copyright (C) 2026 +# This file is distributed under the same license as the InfiniSim package. +# +msgid "" +msgstr "" +"Project-Id-Version: InfiniSim Launcher 1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-10 10:00+0000\n" +"PO-Revision-Date: 2026-05-10 10:00+0000\n" +"Last-Translator: InfiniSim Team\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: infinisim-launcher-ui.py +msgid "Preparing..." +msgstr "Preparando..." + +#: infinisim-launcher-ui.py +msgid "Initializing process" +msgstr "Iniciando proceso" + +#: infinisim-launcher-ui.py +msgid "Cancel" +msgstr "Cancelar" + +#: infinisim-launcher-ui.py +msgid "Start Simulator" +msgstr "Arrancar simulador" + +#: infinisim-launcher-ui.py +msgid "Execution Details" +msgstr "Detalles de ejecución" + +#: infinisim-launcher-ui.py +msgid "Launcher for the simulator" +msgstr "Lanzador del simulador" + +#: infinisim-launcher-ui.py +msgid "Emulate InfiniTime" +msgstr "Emula InfiniTime" + +#: infinisim-launcher-ui.py +msgid "Select a path to open or compile the simulator in a few steps." +msgstr "Elige una ruta para abrir o compilar el simulador en pocos pasos." + +#: infinisim-launcher-ui.py +msgid "Select local folder to compile" +msgstr "Seleccionar carpeta local para compilar" + +#: infinisim-launcher-ui.py +msgid "Compile using your local InfiniTime folder." +msgstr "Compila usando tu carpeta local de InfiniTime." + +#: infinisim-launcher-ui.py +msgid "Download official repository and compile" +msgstr "Descargar repositorio oficial y compilar" + +#: infinisim-launcher-ui.py +msgid "Download the official repo and compile automatically." +msgstr "Descarga el repo oficial y compila automáticamente." + +#: infinisim-launcher-ui.py +msgid "Select the local InfiniTime folder" +msgstr "Selecciona la carpeta local de InfiniTime" + +#: infinisim-launcher-ui.py +msgid "Select" +msgstr "Seleccionar" + +#: infinisim-launcher-ui.py +msgid "⚙️ Preparing simulator" +msgstr "⚙️ Preparando simulador" + +#: infinisim-launcher-ui.py +msgid "Starting build/startup tasks" +msgstr "Iniciando tareas de compilación/arranque" + +#: infinisim-launcher-ui.py +msgid "Error starting backend: {exc}" +msgstr "Error iniciando backend: {exc}" + +#: infinisim-launcher-ui.py +msgid "Could not start the assistant" +msgstr "No se pudo iniciar el asistente" + +#: infinisim-launcher-ui.py +msgid "An error occurred while starting the task. Check the execution details." +msgstr "Ocurrió un error al iniciar la tarea. Revisa los detalles de ejecución." + +#: infinisim-launcher-ui.py +msgid "Cancellation requested by user." +msgstr "Cancelación solicitada por el usuario." + +#: infinisim-launcher-ui.py +msgid "Could not cancel: {exc}" +msgstr "No se pudo cancelar: {exc}" + +#: infinisim-launcher-ui.py +msgid "✅ Compilation ready" +msgstr "✅ Compilación lista" + +#: infinisim-launcher-ui.py +msgid "You can start the simulator or close this window." +msgstr "Puedes arrancar el simulador o cerrar esta ventana." + +#: infinisim-launcher-ui.py +msgid "Compilation finished successfully." +msgstr "Compilación finalizada correctamente." + +#: infinisim-launcher-ui.py +msgid "Simulator ready" +msgstr "Simulador listo" + +#: infinisim-launcher-ui.py +msgid "Started successfully. You can close this window." +msgstr "Se inició correctamente. Puedes cerrar esta ventana." + +#: infinisim-launcher-ui.py +msgid "Simulator started successfully." +msgstr "Simulador iniciado correctamente." + +#: infinisim-launcher-ui.py +msgid "Invalid path" +msgstr "Ruta inválida" + +#: infinisim-launcher-ui.py +msgid "The selected folder does not exist or is not accessible." +msgstr "La carpeta seleccionada no existe o no es accesible." + +#: infinisim-launcher-ui.py +msgid "Simulator did not start" +msgstr "No arrancó el simulador" + +#: infinisim-launcher-ui.py +msgid "The main window could not be opened. Check graphics dependencies." +msgstr "La ventana principal no pudo abrirse. Revisa dependencias gráficas." + +#: infinisim-launcher-ui.py +msgid "Invalid configuration" +msgstr "Configuración inválida" + +#: infinisim-launcher-ui.py +msgid "The InfiniTime folder does not appear to be valid." +msgstr "La carpeta de InfiniTime no parece válida." + +#: infinisim-launcher-ui.py +msgid "Compilation failed" +msgstr "Falló la compilación" + +#: infinisim-launcher-ui.py +msgid "There were errors compiling the code. Check the details above." +msgstr "Hubo errores compilando el código. Revisa los detalles arriba." + +#: infinisim-launcher-ui.py +msgid "Could not start InfiniSim" +msgstr "No se pudo iniciar InfiniSim" + +#: infinisim-launcher-ui.py +msgid "An error occurred. Check the details." +msgstr "Ocurrió un error. Revisa los detalles." + +#: infinisim-launcher-ui.py +msgid "Canceling compilation" +msgstr "Cancelando compilación" + +#: infinisim-launcher-ui.py +msgid "Stopping process..." +msgstr "Deteniendo proceso..." + +#: infinisim-launcher-ui.py +msgid "Could not start" +msgstr "No se pudo arrancar" + +#: infinisim-launcher-ui.py +msgid "The compiled binary was not found to start the simulator." +msgstr "No se encontró el binario compilado para iniciar el simulador." + +#: infinisim-launcher-ui.py +msgid "Exception reading output:" +msgstr "Excepción en lectura de salida:" + +#: infinisim-launcher-ui.py +msgid "InfiniSim could not open the assistant. Check the console output for details." +msgstr "InfiniSim no pudo abrir el asistente. Revisa la salida en consola para más detalles." diff --git a/scripts/infinisim-launcher-ui.py b/scripts/infinisim-launcher-ui.py new file mode 100644 index 0000000..06273f4 --- /dev/null +++ b/scripts/infinisim-launcher-ui.py @@ -0,0 +1,847 @@ +#!/usr/bin/env python3 +import argparse +import os +import shlex +import subprocess +import sys +import threading +import traceback + +# Setup i18n first, before importing Gtk +try: + from infinisim_launcher_i18n import CURRENT_LANG +except ImportError: + CURRENT_LANG = "es" + +try: + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import GLib, Gtk, Gdk + import cairo + import math +except Exception: + # 90 means: launcher should fall back to text/zenity mode. + sys.exit(90) + + +class ActionRow(Gtk.ListBoxRow): + def __init__(self, action, icon_name, title, description): + super().__init__() + self.action = action + self.get_style_context().add_class("action-row") + + outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + outer.set_margin_top(12) + outer.set_margin_bottom(12) + outer.set_margin_start(14) + outer.set_margin_end(14) + + icon_halo = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + icon_halo.get_style_context().add_class("action-icon-halo") + icon_halo.set_size_request(44, 44) + + icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) + icon.set_pixel_size(22) + icon.set_valign(Gtk.Align.CENTER) + icon_halo.pack_start(icon, True, True, 0) + + text_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) + text_col.set_hexpand(True) + + title_label = Gtk.Label(label=title) + title_label.set_xalign(0) + title_label.get_style_context().add_class("action-title") + title_label.set_line_wrap(True) + + description_label = Gtk.Label(label=description) + description_label.set_xalign(0) + description_label.set_line_wrap(True) + description_label.set_max_width_chars(62) + description_label.get_style_context().add_class("action-subtitle") + + chevron = Gtk.Image.new_from_icon_name( + "go-next-symbolic", Gtk.IconSize.MENU + ) + chevron.get_style_context().add_class("action-chevron") + chevron.set_halign(Gtk.Align.END) + chevron.set_valign(Gtk.Align.CENTER) + + text_col.pack_start(title_label, False, False, 0) + text_col.pack_start(description_label, False, False, 0) + + outer.pack_start(icon_halo, False, False, 0) + outer.pack_start(text_col, True, True, 0) + outer.pack_start(chevron, False, False, 0) + self.add(outer) + + +class ProgressIndicator(Gtk.Box): + """Visual progress indicator with elegant gradient circles. Current step is slightly larger.""" + + def __init__(self, total_steps): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + self.set_halign(Gtk.Align.CENTER) + self.set_valign(Gtk.Align.CENTER) + + self.total_steps = total_steps + self.current_step = 0 + self.circles = [] + + # Darker, more elegant colors + self.color_active = (0.18, 0.40, 0.75) # #2d66be - Dark blue + self.color_inactive = (0.75, 0.75, 0.75) # #bfbfbf - Light gray + + for i in range(total_steps): + circle = Gtk.DrawingArea() + circle.set_size_request(20, 20) # Smaller, more elegant + circle.connect("draw", self._on_draw_circle, i) + self.circles.append(circle) + self.pack_start(circle, False, False, 0) + + def set_current_step(self, step): + """Update which step is active (0-indexed).""" + if 0 <= step < self.total_steps: + self.current_step = step + for circle in self.circles: + circle.queue_draw() + + def _on_draw_circle(self, widget, cr, step_index): + """Draw an elegant gradient circle with radial fade.""" + width = widget.get_allocated_width() + height = widget.get_allocated_height() + center_x = width / 2.0 + center_y = height / 2.0 + + if step_index == self.current_step: + # Current step: slightly larger, with radial gradient + radius = 6.5 + color = self.color_active + else: + # Other steps: smaller, subtle + radius = 5 + color = self.color_inactive + + # Create radial gradient for soft, elegant fade + gradient = cairo.RadialGradient(center_x, center_y, 0, center_x, center_y, radius) + + # Gradient: center bright, fade to transparent at edges + gradient.add_color_stop_rgba(0, color[0], color[1], color[2], 0.9) # Center: opaque + gradient.add_color_stop_rgba(1, color[0], color[1], color[2], 0.2) # Edge: transparent + + # Draw the gradient circle + cr.set_source(gradient) + cr.arc(center_x, center_y, radius, 0, 2 * math.pi) + cr.fill() + + # Subtle outer ring for definition + cr.arc(center_x, center_y, radius, 0, 2 * math.pi) + cr.set_source_rgba(color[0] * 0.6, color[1] * 0.6, color[2] * 0.6, 0.3) + cr.set_line_width(0.5) + cr.stroke() + + return False + + +class ProgressDialog(Gtk.Dialog): + """Floating modal dialog for build progress, styled like Elementary OS assistant.""" + + def __init__(self, parent, launcher_window): + super().__init__(type=Gtk.WindowType.TOPLEVEL) + self.launcher_window = launcher_window + self.set_transient_for(parent) + self.set_modal(True) + self.set_decorated(False) + self.set_keep_above(True) + self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) + + # Style class for modal + self.get_style_context().add_class("progress-modal") + + # Get content area but don't use default buttons + content = self.get_content_area() + content.set_spacing(0) + content.set_margin_top(0) + content.set_margin_bottom(0) + content.set_margin_start(0) + content.set_margin_end(0) + + # Main vertical container + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + main_box.get_style_context().add_class("app-shell") + + # Content area with padding + content_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + content_area.set_margin_top(24) + content_area.set_margin_bottom(24) + content_area.set_margin_start(22) + content_area.set_margin_end(22) + + # Status header (spinner + title + subtitle) + top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14) + top.set_hexpand(True) + + self.spinner = Gtk.Spinner() + self.spinner.set_size_request(36, 36) + + text_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + self.status_title = Gtk.Label(label=_("Preparing...")) + self.status_title.set_xalign(0) + self.status_title.get_style_context().add_class("inline-heading") + + self.status_subtitle = Gtk.Label(label=_("Initializing process")) + self.status_subtitle.set_xalign(0) + self.status_subtitle.get_style_context().add_class("inline-subtitle") + + text_col.pack_start(self.status_title, False, False, 0) + text_col.pack_start(self.status_subtitle, False, False, 0) + + top.pack_start(self.spinner, False, False, 0) + top.pack_start(text_col, True, True, 0) + + content_area.pack_start(top, False, False, 0) + + # Log details expander + details_frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + details_frame.get_style_context().add_class("details-box") + + self.details_expander = Gtk.Expander.new(_("Execution Details")) + self.details_expander.set_expanded(False) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_size_request(-1, 180) + scrolled.get_style_context().add_class("log-surface") + + self.log_view = Gtk.TextView() + self.log_view.set_editable(False) + self.log_view.set_monospace(True) + self.log_view.set_cursor_visible(False) + self.log_view.get_style_context().add_class("log-text") + + scrolled.add(self.log_view) + self.details_expander.add(scrolled) + details_frame.pack_start(self.details_expander, False, False, 0) + + content_area.pack_start(details_frame, True, True, 4) + + # Breadcrumbs + Action buttons row + action_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + action_row.set_hexpand(True) + + self.cancel_button = Gtk.Button.new_with_label(_("Cancel")) + self.cancel_button.get_style_context().add_class("danger-button") + self.cancel_button.get_style_context().add_class("destructive-action") + self.cancel_button.set_size_request(160, -1) + + # Progress indicator with 3 steps: Select -> Build -> Ready + self.progress_indicator = ProgressIndicator(3) + self.progress_indicator.set_current_step(0) # Start at "Seleccionar" + + self.start_button = Gtk.Button.new_with_label(_("Start Simulator")) + self.start_button.get_style_context().add_class("primary-button") + self.start_button.get_style_context().add_class("suggested-action") + self.start_button.set_size_request(160, -1) + + action_row.pack_start(self.cancel_button, False, False, 0) + action_row.pack_start(Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL), True, True, 0) + action_row.pack_start(self.progress_indicator, False, False, 0) + action_row.pack_start(Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL), True, True, 0) + action_row.pack_end(self.start_button, False, False, 0) + + content_area.pack_start(action_row, False, False, 8) + + main_box.pack_start(content_area, True, True, 0) + content.pack_start(main_box, True, True, 0) + + # Set size to content + some padding + self.set_size_request(760, 420) + + self.show_all() + + +class LauncherWindow(Gtk.ApplicationWindow): + def __init__(self, app, backend_path, simulator_args): + super().__init__(application=app) + self.backend_path = backend_path + self.simulator_args = simulator_args + self.process = None + self.is_running = False + self.cancel_requested = False + self.last_error_line = "" + self.error_type = "" + self.current_action = "" + self.ready_binary_path = "" + self.progress_dialog = None + + self.set_title("InfiniSim") + self.set_default_size(920, 620) + self.set_position(Gtk.WindowPosition.CENTER) + + self.connect("key-press-event", self._on_key_press) + self._setup_css() + + root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + root.get_style_context().add_class("window-root") + self.add(root) + + header = Gtk.HeaderBar() + header.set_show_close_button(True) + header.set_title("InfiniSim") + header.set_subtitle(_("Launcher for the simulator")) + self.set_titlebar(header) + + self.choice_page = self._build_choice_page() + root.pack_start(self.choice_page, True, True, 0) + + def _setup_css(self): + provider = Gtk.CssProvider() + provider.load_from_data( + b""" + .window-root { + background: linear-gradient(180deg, #f7f9fc 0%, #edf1f6 100%); + } + + .eyebrow { + color: #5f6b7d; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.4px; + } + + .hero-title { + color: #1f2735; + font-size: 38px; + font-weight: 800; + } + + .hero-subtitle { + color: #4f5b6e; + font-size: 17px; + font-weight: 700; + } + + .logo-wrap { + background: #ffffff; + border-radius: 999px; + border: 1px solid #d8e0ea; + box-shadow: 0 6px 20px rgba(25, 35, 56, 0.12); + } + + .choice-card { + background: rgba(255, 255, 255, 0.78); + border-radius: 12px; + border: 1px solid #d7dee8; + } + + .app-shell { + background: rgba(255, 255, 255, 0.95); + border-radius: 12px; + border: 1px solid #d7dee8; + } + + .progress-modal { + background: #ffffff; + } + + .inline-heading { + font-size: 24px; + font-weight: 700; + color: #1f2933; + } + + .inline-subtitle { + color: #556070; + font-size: 14px; + } + + .action-row { + background: transparent; + border-left: 4px solid transparent; + border-radius: 10px; + } + + .action-row:selected { + background: #edf3ff; + border-left: 4px solid #3584e4; + } + + .action-row image, + .action-row:selected image { + color: #2f435f; + -gtk-icon-effect: none; + } + + .action-icon-halo { + background: #f2f5fa; + border-radius: 999px; + border: 1px solid #dde4ee; + } + + .action-row:selected .action-icon-halo { + background: #dfe9ff; + border: 1px solid #b8ccf4; + } + + .action-title { + color: #1f2933; + font-size: 21px; + font-weight: 700; + } + + .action-subtitle { + color: #5b6473; + font-size: 13px; + } + + .action-chevron { + color: #7f8ba0; + } + + .primary-button { + border-radius: 10px; + padding: 10px 16px; + font-weight: 600; + } + + .danger-button { + border-radius: 10px; + padding: 10px 16px; + font-weight: 600; + } + + .details-box { + border-top: 1px solid #d8dfe8; + padding-top: 8px; + } + + .log-surface { + border: 1px solid #282c34; + border-radius: 10px; + background: #1c1f26; + } + + .log-text { + color: #a1efe4; + background: #1c1f26; + } + """ + ) + Gtk.StyleContext.add_provider_for_screen( + self.get_screen(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def _build_choice_page(self): + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + outer.set_margin_top(34) + outer.set_margin_bottom(34) + outer.set_margin_start(34) + outer.set_margin_end(34) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + content.set_halign(Gtk.Align.CENTER) + content.set_valign(Gtk.Align.CENTER) + content.set_vexpand(True) + content.set_hexpand(True) + content.set_size_request(760, -1) + + logo_wrap = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + logo_wrap.set_size_request(84, 84) + logo_wrap.set_halign(Gtk.Align.CENTER) + logo_wrap.get_style_context().add_class("logo-wrap") + + logo = Gtk.Image() + logo_path = self._resolve_logo_path() + if logo_path: + logo.set_from_file(logo_path) + else: + logo.set_from_icon_name("applications-development", Gtk.IconSize.DIALOG) + logo.set_pixel_size(44) + logo_wrap.pack_start(logo, True, True, 0) + + heading = Gtk.Label(label=_("Emulate InfiniTime")) + heading.set_xalign(0.5) + heading.get_style_context().add_class("hero-title") + + subtitle = Gtk.Label( + label=_("Select a path to open or compile the simulator in a few steps.") + ) + subtitle.set_xalign(0.5) + subtitle.set_justify(Gtk.Justification.CENTER) + subtitle.set_line_wrap(True) + subtitle.set_max_width_chars(56) + subtitle.get_style_context().add_class("hero-subtitle") + + card = Gtk.Frame() + card.get_style_context().add_class("choice-card") + card.set_size_request(720, -1) + + self.listbox = Gtk.ListBox() + self.listbox.set_activate_on_single_click(True) + self.listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.listbox.connect("row-activated", self._on_action_activated) + + self.listbox.add( + ActionRow( + "build-local", + "folder-open-symbolic", + _("Select local folder to compile"), + _("Compile using your local InfiniTime folder."), + ) + ) + self.listbox.add( + ActionRow( + "build-clone", + "folder-publicshare-symbolic", + _("Download official repository and compile"), + _("Download the official repo and compile automatically."), + ) + ) + + card.add(self.listbox) + + content.pack_start(logo_wrap, False, False, 0) + content.pack_start(heading, False, False, 0) + content.pack_start(subtitle, False, False, 0) + content.pack_start(card, True, True, 4) + outer.pack_start(content, True, True, 0) + return outer + + def _resolve_logo_path(self): + appdir = os.environ.get("APPDIR", "") + candidates = [] + + if appdir: + candidates.append( + os.path.join( + appdir, + "usr", + "share", + "icons", + "hicolor", + "scalable", + "apps", + "infinisim.svg", + ) + ) + + script_dir = os.path.dirname(os.path.abspath(__file__)) + candidates.append( + os.path.join(script_dir, "..", "packaging", "appimage", "infinisim.svg") + ) + + for path in candidates: + normalized = os.path.abspath(path) + if os.path.isfile(normalized): + return normalized + + return None + + def _on_action_activated(self, _listbox, row): + action = row.action + infinitime_dir = None + + if action == "build-local": + infinitime_dir = self._choose_directory( + _("Select the local InfiniTime folder") + ) + if not infinitime_dir: + return + + # Open progress dialog + self.progress_dialog = ProgressDialog(self, self) + self.progress_dialog.cancel_button.connect("clicked", self._on_cancel_clicked) + self.progress_dialog.start_button.connect("clicked", self._on_start_clicked) + + self._start_backend(action, infinitime_dir=infinitime_dir) + + def _choose_directory(self, title): + dialog = Gtk.FileChooserDialog( + title=title, + parent=self, + action=Gtk.FileChooserAction.SELECT_FOLDER, + ) + dialog.add_buttons( + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + _("Select"), + Gtk.ResponseType.OK, + ) + + result = None + response = dialog.run() + if response == Gtk.ResponseType.OK: + result = dialog.get_filename() + dialog.destroy() + return result + + def _append_log(self, line): + if not self.progress_dialog: + return + + marker_prefix = "[INFINISIM_READY_BINARY:" + if line.startswith(marker_prefix) and line.rstrip().endswith("]"): + self.ready_binary_path = line.strip()[len(marker_prefix):-1] + return + + lower_line = line.lower() + if "error:" in lower_line and "info:" not in lower_line: + self.last_error_line = line.strip() + if "[infinisim_error_type:" in lower_line: + start = lower_line.find("[infinisim_error_type:") + end = lower_line.find("]", start) + if end > start: + self.error_type = lower_line[start + 21:end] + + # Mirror launcher output to stderr so terminal users can inspect logs. + try: + sys.stderr.write(line) + if not line.endswith("\n"): + sys.stderr.write("\n") + sys.stderr.flush() + except Exception: + pass + + buffer_obj = self.progress_dialog.log_view.get_buffer() + end_iter = buffer_obj.get_end_iter() + buffer_obj.insert(end_iter, line) + mark = buffer_obj.create_mark(None, buffer_obj.get_end_iter(), False) + self.progress_dialog.log_view.scroll_to_mark(mark, 0.0, True, 0.0, 1.0) + + def _start_backend(self, action, binary=None, infinitime_dir=None): + if not self.progress_dialog: + return + + self.is_running = True + self.cancel_requested = False + self.last_error_line = "" + self.error_type = "" + self.current_action = action + self.ready_binary_path = "" + self.process = None + + self.progress_dialog.log_view.get_buffer().set_text("") + self.progress_dialog.status_title.set_text(_("⚙️ Preparing simulator")) + self.progress_dialog.status_subtitle.set_text(_("Starting build/startup tasks")) + self.progress_dialog.progress_indicator.set_current_step(1) # Compilando + self.progress_dialog.details_expander.set_expanded(False) + self.progress_dialog.cancel_button.set_sensitive(True) + self.progress_dialog.cancel_button.show() + self.progress_dialog.start_button.set_sensitive(False) + self.progress_dialog.spinner.start() + + command = [ + self.backend_path, + "--launcher-no-gui", + "--launcher-detach", + "--launcher-action", + action, + ] + + if binary: + command.extend(["--launcher-binary", binary]) + if infinitime_dir: + command.extend(["--launcher-infinitime-dir", infinitime_dir]) + + runtime_args = list(self.simulator_args) + command.append("--") + command.extend(runtime_args) + + self._append_log("$ " + " ".join(shlex.quote(part) for part in command) + "\n") + + env = os.environ.copy() + env["INFINISIM_DETACH"] = "1" + env["INFINISIM_EMBEDDED_UI"] = "1" + if action in ("build-local", "build-clone"): + env["INFINISIM_BUILD_ONLY"] = "1" + + try: + self.process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + except Exception as exc: + self.progress_dialog.spinner.stop() + self.is_running = False + self.progress_dialog.cancel_button.set_sensitive(False) + self._append_log(_("Error starting backend: {exc}").format(exc=exc) + "\n") + self.progress_dialog.details_expander.set_expanded(True) + self._show_error( + _("Could not start the assistant"), + _("An error occurred while starting the task. Check the execution details."), + ) + return + + def read_output(): + try: + assert self.process is not None + for line in self.process.stdout: + GLib.idle_add(self._append_log, line) + exit_code = self.process.wait() + GLib.idle_add(self._on_process_done, exit_code) + except Exception: + GLib.idle_add( + self._append_log, + _("Exception reading output:") + "\n" + traceback.format_exc() + "\n", + ) + GLib.idle_add(self._on_process_done, 1) + + threading.Thread(target=read_output, daemon=True).start() + + def _on_process_done(self, exit_code): + if not self.progress_dialog: + return + + self.progress_dialog.spinner.stop() + self.progress_dialog.cancel_button.set_sensitive(False) + self.is_running = False + + if self.cancel_requested: + self.cancel_requested = False + self._close_progress_dialog() + return False + + if exit_code == 0: + if self.current_action in ("build-local", "build-clone") and self.ready_binary_path: + self.progress_dialog.status_title.set_text(_("✅ Compilation ready")) + self.progress_dialog.status_subtitle.set_text( + _("You can start the simulator or close this window.") + ) + self.progress_dialog.progress_indicator.set_current_step(2) # Listo + self.progress_dialog.cancel_button.set_sensitive(False) + self.progress_dialog.start_button.set_sensitive(True) + self._append_log(_("Compilation finished successfully.") + "\n") + else: + self.progress_dialog.status_title.set_text(_("Simulator ready")) + self.progress_dialog.status_subtitle.set_text( + _("Started successfully. You can close this window.") + ) + self._append_log(_("Simulator started successfully.") + "\n") + GLib.timeout_add(2000, self._close_progress_dialog) + return False + + # Error handling + error_messages = { + "invalid_path": (_("Invalid path"), _("The selected folder does not exist or is not accessible.")), + "sim_launch": (_("Simulator did not start"), _("The main window could not be opened. Check graphics dependencies.")), + "cmake_config": (_("Invalid configuration"), _("The InfiniTime folder does not appear to be valid.")), + "cmake_build": (_("Compilation failed"), _("There were errors compiling the code. Check the details above.")), + } + + title, message = error_messages.get( + self.error_type, + (_("Could not start InfiniSim"), _("An error occurred. Check the details.")) + ) + + self._show_error(title, message) + return False + + def _show_error(self, title, message): + if not self.progress_dialog: + return + + self._append_log(f"ERROR: {title}\n{message}\n") + self.is_running = False + self.progress_dialog.spinner.stop() + self.progress_dialog.cancel_button.set_sensitive(True) + self.progress_dialog.cancel_button.set_label(_("Cancel")) + self.progress_dialog.start_button.set_sensitive(False) + self.progress_dialog.status_title.set_text("❌ " + title) + self.progress_dialog.status_subtitle.set_text(message) + self.progress_dialog.details_expander.set_expanded(True) + + def _on_cancel_clicked(self, button): + if self.progress_dialog.cancel_button.get_label() == "Cerrar": + self._close_progress_dialog() + return + + if not self.is_running: + self._close_progress_dialog() + return + + self.cancel_requested = True + self.progress_dialog.cancel_button.set_sensitive(False) + self.progress_dialog.status_title.set_text(_("Canceling compilation")) + self.progress_dialog.status_subtitle.set_text(_("Stopping process...")) + self._append_log(_("Cancellation requested by user.") + "\n") + + try: + if self.process and self.process.poll() is None: + self.process.terminate() + except Exception as exc: + self._append_log(_("Could not cancel: {exc}").format(exc=exc) + "\n") + + def _on_start_clicked(self, _button): + if not self.ready_binary_path: + self._show_error( + _("Could not start"), + _("The compiled binary was not found to start the simulator."), + ) + return + self._start_backend("run-compiled", binary=self.ready_binary_path) + + def _close_progress_dialog(self): + """Close the progress dialog and return to main window.""" + if self.progress_dialog: + self.progress_dialog.destroy() + self.progress_dialog = None + # Reset state + self.is_running = False + self.cancel_requested = False + self.ready_binary_path = "" + + def _on_key_press(self, widget, event): + """Handle Escape key to close the window.""" + if event.keyval == Gdk.KEY_Escape: + self.close() + return True + return False + + +class LauncherApplication(Gtk.Application): + def __init__(self, backend_path, simulator_args): + super().__init__(application_id="org.infinitime.infinisim.launcher") + self.backend_path = backend_path + self.simulator_args = simulator_args + + def do_activate(self): + try: + window = LauncherWindow(self, self.backend_path, self.simulator_args) + window.show_all() + except Exception: + details = traceback.format_exc() + print(details, file=sys.stderr) + print( + _("InfiniSim could not open the assistant. Check the console output for details."), + file=sys.stderr, + ) + self.quit() + + +def parse_args(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--backend", required=True) + parser.add_argument("sim_args", nargs=argparse.REMAINDER) + args = parser.parse_args() + + sim_args = list(args.sim_args) + if sim_args and sim_args[0] == "--": + sim_args = sim_args[1:] + + return args.backend, sim_args + + +def main(): + backend_path, sim_args = parse_args() + app = LauncherApplication(backend_path, sim_args) + # Gtk.Application parses argv again; pass only program name to avoid + # treating our custom --backend flag as an unknown GTK option. + return app.run([sys.argv[0]]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/infinisim-launcher.pot b/scripts/infinisim-launcher.pot new file mode 100644 index 0000000..0eba9ab --- /dev/null +++ b/scripts/infinisim-launcher.pot @@ -0,0 +1,188 @@ +# Translation file for InfiniSim Launcher UI +# Copyright (C) 2026 +# This file is distributed under the same license as the InfiniSim package. +# +msgid "" +msgstr "" +"Project-Id-Version: InfiniSim Launcher 1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-10 10:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: infinisim-launcher-ui.py +msgid "Preparing..." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Initializing process" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Cancel" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Start Simulator" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Execution Details" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Launcher for the simulator" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Emulate InfiniTime" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Select a path to open or compile the simulator in a few steps." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Select local folder to compile" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Compile using your local InfiniTime folder." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Download official repository and compile" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Download the official repo and compile automatically." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Select the local InfiniTime folder" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Select" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "⚙️ Preparing simulator" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Starting build/startup tasks" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Error starting backend: {exc}" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Could not start the assistant" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "An error occurred while starting the task. Check the execution details." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Cancellation requested by user." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Could not cancel: {exc}" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "✅ Compilation ready" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "You can start the simulator or close this window." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Compilation finished successfully." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Simulator ready" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Started successfully. You can close this window." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Simulator started successfully." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Invalid path" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "The selected folder does not exist or is not accessible." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Simulator did not start" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "The main window could not be opened. Check graphics dependencies." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Invalid configuration" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "The InfiniTime folder does not appear to be valid." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Compilation failed" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "There were errors compiling the code. Check the details above." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Could not start InfiniSim" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "An error occurred. Check the details." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Canceling compilation" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Stopping process..." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Could not start" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "The compiled binary was not found to start the simulator." +msgstr "" + +#: infinisim-launcher-ui.py +msgid "Exception reading output:" +msgstr "" + +#: infinisim-launcher-ui.py +msgid "InfiniSim could not open the assistant. Check the console output for details." +msgstr "" diff --git a/scripts/infinisim-launcher.sh b/scripts/infinisim-launcher.sh index ed5a287..348d03d 100755 --- a/scripts/infinisim-launcher.sh +++ b/scripts/infinisim-launcher.sh @@ -3,6 +3,8 @@ set -euo pipefail APP_NAME="InfiniSim" INFINITIME_REPO="https://github.com/InfiniTimeOrg/InfiniTime.git" +LAUNCHER_ACTION_OVERRIDE="" +SIM_ARGS=() if [[ -n "${APPDIR:-}" && -d "${APPDIR}/usr/share/infinisim/source" ]]; then export PATH="${APPDIR}/usr/bin:${PATH}" @@ -14,10 +16,14 @@ if [[ -n "${APPDIR:-}" && -d "${APPDIR}/usr/share/infinisim/source" ]]; then fi PACKAGED_INFINISIM_SOURCE_DIR="${APPDIR}/usr/share/infinisim/source" INFINISIM_SOURCE_DIR="${PACKAGED_INFINISIM_SOURCE_DIR}" + LAUNCHER_SCRIPT="${APPDIR}/usr/bin/infinisim-launcher" + LAUNCHER_UI_SCRIPT="${APPDIR}/usr/bin/infinisim-launcher-ui.py" else PACKAGED_INFINISIM_SOURCE_DIR="" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INFINISIM_SOURCE_DIR="$(cd "${script_dir}/.." && pwd)" + LAUNCHER_SCRIPT="${script_dir}/infinisim-launcher.sh" + LAUNCHER_UI_SCRIPT="${script_dir}/infinisim-launcher-ui.py" fi DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/infinisim" @@ -34,6 +40,89 @@ BUNDLED_INFINITIME_REF_FILE="${APPDIR:-}/usr/share/infinisim/resources/infinitim mkdir -p "${DATA_HOME}" "${CACHE_HOME}" "${CONFIG_HOME}" "${BUILD_ROOT}" "${DEPS_ROOT}" +parse_launcher_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --launcher-action) + if [[ $# -lt 2 ]]; then + error "Falta valor para --launcher-action" + exit 2 + fi + LAUNCHER_ACTION_OVERRIDE="$2" + shift 2 + ;; + --launcher-infinitime-dir) + if [[ $# -lt 2 ]]; then + error "Falta valor para --launcher-infinitime-dir" + exit 2 + fi + export INFINITIME_DIR="$2" + shift 2 + ;; + --launcher-binary) + if [[ $# -lt 2 ]]; then + error "Falta valor para --launcher-binary" + exit 2 + fi + export INFINISIM_BINARY="$2" + shift 2 + ;; + --launcher-no-gui) + export INFINISIM_DISABLE_GUI=1 + shift + ;; + --launcher-detach) + export INFINISIM_DETACH=1 + shift + ;; + --) + shift + while [[ $# -gt 0 ]]; do + SIM_ARGS+=("$1") + shift + done + ;; + *) + SIM_ARGS+=("$1") + shift + ;; + esac + done +} + +maybe_run_graphical_ui() { + if [[ "${INFINISIM_DISABLE_GUI:-0}" == "1" ]]; then + return + fi + + if [[ -n "${LAUNCHER_ACTION_OVERRIDE}" || -n "${INFINISIM_BINARY:-}" || -n "${INFINITIME_DIR:-}" ]]; then + return + fi + + if [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then + return + fi + + if ! command -v python3 >/dev/null 2>&1; then + return + fi + + if [[ ! -f "${LAUNCHER_UI_SCRIPT}" ]]; then + return + fi + + set +e + python3 "${LAUNCHER_UI_SCRIPT}" --backend "${LAUNCHER_SCRIPT}" -- "${SIM_ARGS[@]}" + local ui_exit_code=$? + set -e + + if [[ ${ui_exit_code} -eq 90 ]]; then + return + fi + + exit ${ui_exit_code} +} + prepare_stable_appimage_source() { if [[ -z "${PACKAGED_INFINISIM_SOURCE_DIR}" ]]; then return @@ -61,6 +150,11 @@ prepare_stable_appimage_source() { message() { local text="$1" + if [[ "${INFINISIM_EMBEDDED_UI:-0}" == "1" ]]; then + printf 'Info: %s\n' "${text}" >&2 + return + fi + if command -v zenity >/dev/null 2>&1; then zenity --info --title="${APP_NAME}" --text="${text}" || true elif command -v kdialog >/dev/null 2>&1; then @@ -79,6 +173,11 @@ load_config() { error() { local text="$1" + if [[ "${INFINISIM_EMBEDDED_UI:-0}" == "1" ]]; then + printf 'Error: %s\n' "${text}" >&2 + return + fi + if command -v zenity >/dev/null 2>&1; then zenity --error --title="${APP_NAME}" --text="${text}" || true elif command -v kdialog >/dev/null 2>&1; then @@ -94,25 +193,21 @@ choose_action() { --width=760 --height=320 \ --text="Elige como iniciar InfiniSim." \ --column="accion" --column="opcion" --hide-column=1 \ - "run-compiled" "Iniciar un simulador ya compilado" \ - "build-local" "Compilar desde una carpeta local de InfiniTime" \ - "build-clone" "Descargar InfiniTime oficial y compilar" 2>/dev/null || true + "build-local" "Seleccionar carpeta local para compilar" \ + "build-clone" "Descargar repositorio oficial y compilar" 2>/dev/null || true elif command -v kdialog >/dev/null 2>&1; then kdialog --title "${APP_NAME}" --menu "Elige como iniciar InfiniSim." \ - "run-compiled" "Iniciar un simulador ya compilado" \ - "build-local" "Compilar desde una carpeta local de InfiniTime" \ - "build-clone" "Descargar InfiniTime oficial y compilar" 2>/dev/null || true + "build-local" "Seleccionar carpeta local para compilar" \ + "build-clone" "Descargar repositorio oficial y compilar" 2>/dev/null || true else printf '%s\n' "Selecciona una accion:" >&2 - printf '%s\n' "1) Iniciar simulador ya compilado" >&2 - printf '%s\n' "2) Compilar desde carpeta local de InfiniTime" >&2 - printf '%s\n' "3) Descargar InfiniTime oficial y compilar" >&2 + printf '%s\n' "1) Seleccionar carpeta local para compilar" >&2 + printf '%s\n' "2) Descargar repositorio oficial y compilar" >&2 printf '> ' >&2 read -r reply case "${reply}" in - 1) printf '%s\n' "run-compiled" ;; - 2) printf '%s\n' "build-local" ;; - 3) printf '%s\n' "build-clone" ;; + 1) printf '%s\n' "build-local" ;; + 2) printf '%s\n' "build-clone" ;; *) return 1 ;; esac fi @@ -253,6 +348,35 @@ ensure_build_tools() { fi } +launch_simulator() { + if [[ "${INFINISIM_DETACH:-0}" == "1" ]]; then + local launch_pid launch_log rc + launch_log="${CACHE_HOME}/last-simulator-launch.log" + + "$@" >"${launch_log}" 2>&1 & + launch_pid="$!" + + # Detect fast failures (missing libs, invalid executable, etc.). + sleep 0.25 + if kill -0 "${launch_pid}" 2>/dev/null; then + return 0 + fi + + if wait "${launch_pid}"; then + rc=0 + else + rc="$?" + fi + error "[INFINISIM_ERROR_TYPE:sim_launch] No se pudo abrir la ventana principal del simulador." + if [[ -s "${launch_log}" ]]; then + printf 'Detalle de arranque: %s\n' "$(tail -n 3 "${launch_log}" | tr '\n' ' ')" >&2 + fi + return "${rc}" + fi + + exec "$@" +} + run_bundled_official() { local infinitime_dir="$1" shift @@ -265,7 +389,8 @@ run_bundled_official() { if [[ -f "${BUNDLED_RESOURCE_ZIP}" && -x "${BUNDLED_LITTLEFS_DO}" ]]; then "${BUNDLED_LITTLEFS_DO}" res load "${BUNDLED_RESOURCE_ZIP}" || true fi - exec "${BUNDLED_INFINISIM}" "$@" + launch_simulator "${BUNDLED_INFINISIM}" "$@" + return 0 fi return 1 @@ -399,24 +524,42 @@ build_and_run() { build_dir="${BUILD_ROOT}/${source_key}" jobs="${INFINISIM_BUILD_JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '2')}" + message "Configurando compilacion..." cmake -S "${INFINISIM_SOURCE_DIR}" -B "${build_dir}" \ -DInfiniTime_DIR="${infinitime_dir}" \ -DCMAKE_BUILD_TYPE=Release \ - -DWITH_PNG=OFF - cmake --build "${build_dir}" --parallel "${jobs}" + -DWITH_PNG=OFF || { + error "[INFINISIM_ERROR_TYPE:cmake_config] Fallo la configuracion de compilacion con CMake" + return 1 + } + + message "Compilando..." + cmake --build "${build_dir}" --parallel "${jobs}" || { + error "[INFINISIM_ERROR_TYPE:cmake_build] Fallo la compilacion. Revisa los errores arriba." + return 1 + } if [[ -f "${build_dir}/resources/resource.zip" && -x "${build_dir}/littlefs-do" ]]; then "${build_dir}/littlefs-do" res load "${build_dir}/resources/resource.zip" || true fi - exec "${build_dir}/infinisim" "$@" + if [[ "${INFINISIM_BUILD_ONLY:-0}" == "1" ]]; then + printf '[INFINISIM_READY_BINARY:%s]\n' "${build_dir}/infinisim" + return 0 + fi + + launch_simulator "${build_dir}/infinisim" "$@" } main() { local action infinitime_dir binary_path last_dir + parse_launcher_args "$@" prepare_stable_appimage_source + maybe_run_graphical_ui - if [[ -n "${INFINITIME_DIR:-}" ]]; then + if [[ -n "${LAUNCHER_ACTION_OVERRIDE}" ]]; then + action="${LAUNCHER_ACTION_OVERRIDE}" + elif [[ -n "${INFINITIME_DIR:-}" ]]; then action="build-local" elif [[ -n "${INFINISIM_BINARY:-}" ]]; then action="run-compiled" @@ -427,7 +570,8 @@ main() { case "${action}" in run-compiled) binary_path="$(resolve_compiled_binary "${action}")" - exec "${binary_path}" "$@" + launch_simulator "${binary_path}" "${SIM_ARGS[@]}" + return 0 ;; build-local) last_dir="$(load_last_dir)" @@ -453,18 +597,24 @@ main() { exit 0 fi - infinitime_dir="$(cd "${infinitime_dir}" && pwd)" + if ! infinitime_dir="$(cd "${infinitime_dir}" && pwd)"; then + error "[INFINISIM_ERROR_TYPE:invalid_path] La ruta proporcionada no existe." + return 1 + fi + ensure_infinitime_tree "${infinitime_dir}" save_last_dir "${infinitime_dir}" - run_bundled_official "${infinitime_dir}" "$@" || true + if [[ "${INFINISIM_BUILD_ONLY:-0}" != "1" ]] && run_bundled_official "${infinitime_dir}" "${SIM_ARGS[@]}"; then + return 0 + fi ensure_build_tools ensure_infinisim_submodules ensure_node_tools ensure_python_tools - build_and_run "${infinitime_dir}" "$@" + build_and_run "${infinitime_dir}" "${SIM_ARGS[@]}" } main "$@" diff --git a/scripts/infinisim_launcher_i18n.py b/scripts/infinisim_launcher_i18n.py new file mode 100644 index 0000000..07cd46b --- /dev/null +++ b/scripts/infinisim_launcher_i18n.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +i18n module for InfiniSim Launcher UI +Provides gettext-based localization support +""" +import gettext +import locale +import os +import sys + +def setup_i18n(): + """Initialize gettext for the launcher UI.""" + # Determine the locale directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + appdir = os.environ.get("APPDIR", "") + + locale_dirs = [] + + # Prefer APPDIR location (AppImage) + if appdir: + locale_dirs.append(os.path.join(appdir, "usr", "share", "locale")) + + # Fallback to script directory + ../share/locale + locale_dirs.append(os.path.join(script_dir, "..", "share", "locale")) + + # System locale directory + locale_dirs.append("/usr/share/locale") + + # Find the first valid locale directory + locale_dir = None + for path in locale_dirs: + if os.path.isdir(path): + locale_dir = path + break + + # Get system language, default to Spanish if not supported + try: + lang, _ = locale.getdefaultlocale() + if lang: + lang = lang.split("_")[0] # Extract language code (e.g., "es" from "es_ES") + else: + lang = "es" + except Exception: + lang = "es" + + # Ensure we have a supported language fallback + supported_langs = ["en", "es"] + if lang not in supported_langs: + lang = "es" # Fallback to Spanish + + try: + if locale_dir: + translation = gettext.translation( + "infinisim-launcher", + localedir=locale_dir, + languages=[lang], + fallback=True + ) + else: + # Fallback if no locale directory found + translation = gettext.translation( + "infinisim-launcher", + languages=[lang], + fallback=True + ) + + translation.install() + return lang + except Exception as e: + print(f"Warning: Could not load translations: {e}", file=sys.stderr) + # Install null translation if all else fails + gettext.install("infinisim-launcher") + return lang + +# Initialize on import +CURRENT_LANG = setup_i18n()