diff --git a/.github/renovate.json b/.github/renovate.json index fdbe3734d..45c62042c 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -44,22 +44,42 @@ ] } }, - "lockFileMaintenance": { - "vulnerabilityAlerts": { - "enabled": true, - "addLabels": ["security", "vulnerability"], - "assigneesFromCodeOwners": true, - "commitMessageSuffix": " [SECURITY]" + "vulnerabilityAlerts": { + "enabled": true, + "addLabels": ["security", "vulnerability"], + "assigneesFromCodeOwners": true, + "commitMessageSuffix": " [SECURITY]" + }, + "packageRules": [ + { + "description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs β€” group them and require manual approval from the Dependency Dashboard", + "matchPackageNames": [ + "expo", + "react", + "react-dom", + "react-native", + "react-native-web", + "expo-*", + "@expo/*" + ], + "groupName": "Expo SDK", + "dependencyDashboardApproval": true }, - "packageRules": [ - { - "description": "Group minor and patch GitHub Action updates into a single PR", - "matchManagers": ["github-actions"], - "groupName": "CI dependencies", - "groupSlug": "ci-deps", - "matchUpdateTypes": ["minor", "patch", "digest", "pin"], - "automerge": true - } - ] - } + { + "description": "Group minor and patch GitHub Action updates into a single PR", + "matchManagers": ["github-actions"], + "groupName": "CI dependencies", + "groupSlug": "ci-deps", + "matchUpdateTypes": ["minor", "patch", "digest", "pin"], + "automerge": true + }, + { + "description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)", + "matchDatasources": ["maven"], + "registryUrls": [ + "https://dl.google.com/dl/android/maven2/", + "https://repo.maven.apache.org/maven2/" + ] + } + ] } diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index fd68e23a1..69a115072 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -11,6 +11,12 @@ on: push: branches: [develop, master] +# Exposed to `expo prebuild` (app.config.js β†’ extra.build) so Settings can show the +# branch + commit a CI build was made from. EAS cloud builds use EAS_BUILD_* instead. +env: + EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} + EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }} + jobs: build-android-phone: if: (!contains(github.event.head_commit.message, '[skip ci]')) @@ -33,7 +39,7 @@ jobs: swap-storage: false - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -116,7 +122,7 @@ jobs: swap-storage: false - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -187,7 +193,7 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -219,10 +225,10 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.4" + xcode-version: "26.5" - name: πŸ—οΈ Setup EAS - uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} @@ -252,7 +258,7 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -284,7 +290,7 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.4" + xcode-version: "26.5" - name: πŸš€ Build iOS app env: @@ -312,7 +318,7 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -344,10 +350,10 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.4" + xcode-version: "26.5" - name: πŸ—οΈ Setup EAS - uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} @@ -380,7 +386,7 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -412,7 +418,7 @@ jobs: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 with: # renovate: datasource=custom.xcode depName=xcode versioning=loose - xcode-version: "26.4" + xcode-version: "26.5" - name: πŸš€ Build iOS app env: diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml index ae4c0fe02..0cb8afc3a 100644 --- a/.github/workflows/check-lockfile.yml +++ b/.github/workflows/check-lockfile.yml @@ -19,7 +19,7 @@ jobs: steps: - name: πŸ“₯ Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} show-progress: false diff --git a/.github/workflows/ci-codeql.yml b/.github/workflows/ci-codeql.yml index ba1c08dc8..f79cf58a4 100644 --- a/.github/workflows/ci-codeql.yml +++ b/.github/workflows/ci-codeql.yml @@ -24,16 +24,16 @@ jobs: steps: - name: πŸ“₯ Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: 🏁 Initialize CodeQL - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} queries: +security-extended,security-and-quality - name: πŸ› οΈ Autobuild - uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - name: πŸ§ͺ Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index feb9a00fe..b0ea48a25 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -23,7 +23,7 @@ jobs: steps: - name: πŸ“₯ Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml index 09aa23565..265f9efe9 100644 --- a/.github/workflows/detect-duplicate.yml +++ b/.github/workflows/detect-duplicate.yml @@ -21,7 +21,7 @@ jobs: contents: read steps: - name: πŸ“₯ Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: 🍞 Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 50013ba2b..8edb89169 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -51,7 +51,7 @@ jobs: contents: read steps: - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -69,7 +69,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: πŸ›’ Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: recursive @@ -97,10 +97,11 @@ jobs: - "check" - "format" - "typecheck" + - "i18n:check" steps: - name: "πŸ“₯ Checkout PR code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: recursive diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06dba5e37..c06e8b348 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 submodules: recursive @@ -88,7 +88,7 @@ jobs: bun run submodule-reload - name: πŸ—οΈ Setup EAS - uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main with: eas-version: latest token: ${{ secrets.EXPO_TOKEN }} @@ -182,7 +182,7 @@ jobs: actions: read # required for `gh run download` to list/fetch this run's artifacts steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 show-progress: false diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index 9eea1fbc1..4972e14fc 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -27,7 +27,7 @@ jobs: security-events: write # upload SARIF to code scanning steps: - name: πŸ“₯ Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week # instead of a fresh immutable entry per run, still refreshing the DB every week. @@ -54,7 +54,7 @@ jobs: output: trivy-results.sarif - name: πŸ“€ Upload results to code scanning - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: trivy-results.sarif category: trivy-fs diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml index 7cc321977..a23ecdf2c 100644 --- a/.github/workflows/update-issue-form.yml +++ b/.github/workflows/update-issue-form.yml @@ -18,7 +18,7 @@ jobs: steps: - name: πŸ“₯ Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: "🟒 Setup Node.js" uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/app.config.js b/app.config.js index 96bbd8ea0..970567360 100644 --- a/app.config.js +++ b/app.config.js @@ -1,3 +1,41 @@ +const { execFileSync } = require("node:child_process"); + +// Build metadata, injected into `extra.build` and read at runtime via +// expo-constants (see utils/version.ts). Sources in priority order: +// EAS cloud build β†’ GitHub Actions β†’ explicit EXPO_PUBLIC_* β†’ local git β†’ null. +const git = (args) => { + try { + return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return null; + } +}; + +const buildMeta = { + commit: + ( + process.env.EAS_BUILD_GIT_COMMIT_HASH || + process.env.GITHUB_SHA || + process.env.EXPO_PUBLIC_GIT_COMMIT || + git(["rev-parse", "HEAD"]) || + "" + ).slice(0, 7) || null, + branch: + process.env.EAS_BUILD_GIT_BRANCH || + process.env.GITHUB_HEAD_REF || + process.env.GITHUB_REF_NAME || + process.env.EXPO_PUBLIC_GIT_BRANCH || + git(["rev-parse", "--abbrev-ref", "HEAD"]) || + null, + profile: + process.env.EAS_BUILD_PROFILE || + process.env.EXPO_PUBLIC_BUILD_PROFILE || + null, + builtAt: new Date().toISOString(), +}; + module.exports = ({ config }) => { if (process.env.EXPO_TV !== "1") { config.plugins.push("expo-background-task"); @@ -22,6 +60,8 @@ module.exports = ({ config }) => { androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; } + config.extra = { ...config.extra, build: buildMeta }; + return { ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...config, diff --git a/bun.lock b/bun.lock index cf5844776..97ba4fa23 100644 --- a/bun.lock +++ b/bun.lock @@ -108,7 +108,7 @@ "@types/react": "~19.2.10", "@types/react-test-renderer": "19.1.0", "cross-env": "10.1.0", - "expo-doctor": "1.19.7", + "expo-doctor": "1.19.9", "husky": "9.1.7", "lint-staged": "17.0.5", "react-test-renderer": "19.2.3", @@ -969,7 +969,7 @@ "expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="], - "expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="], + "expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="], "expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="], @@ -1599,7 +1599,7 @@ "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], - "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"], + "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], diff --git a/components/SaveAccountModal.tsx b/components/SaveAccountModal.tsx index 42615c7d1..81508adef 100644 --- a/components/SaveAccountModal.tsx +++ b/components/SaveAccountModal.tsx @@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC = ({ [isAndroid], ); + const isPresentedRef = useRef(false); + useEffect(() => { if (visible) { bottomSheetModalRef.current?.present(); - } else { + } else if (isPresentedRef.current) { bottomSheetModalRef.current?.dismiss(); + isPresentedRef.current = false; } }, [visible]); const handleSheetChanges = useCallback( (index: number) => { - if (index === -1) { + if (index >= 0) { + isPresentedRef.current = true; + } else if (index === -1 && isPresentedRef.current) { + isPresentedRef.current = false; resetState(); onClose(); } diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx index 596bb610a..7db1671e9 100644 --- a/components/login/TVPasswordEntryModal.tsx +++ b/components/login/TVPasswordEntryModal.tsx @@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC = ({ diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx index 56b6413d2..6711d6554 100644 --- a/components/settings/UserInfo.tsx +++ b/components/settings/UserInfo.tsx @@ -1,8 +1,8 @@ -import * as Application from "expo-application"; import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; import { View, type ViewProps } from "react-native"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getVersionInfo } from "@/utils/version"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -13,10 +13,9 @@ export const UserInfo: React.FC = ({ ...props }) => { const [user] = useAtom(userAtom); const { t } = useTranslation(); - const version = - Application?.nativeApplicationVersion || - Application?.nativeBuildVersion || - "N/A"; + // Graduated build identifier β€” see utils/version.ts: + // dev β†’ "0.54.1 Β· branch Β· commit", develop/CI β†’ "0.54.1 Β· commit", production β†’ "0.54.1 (42)". + const { display: version } = getVersionInfo(); return ( diff --git a/package.json b/package.json index ccabd2f47..588ada9ae 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "lint": "biome check --write --unsafe --max-diagnostics 1000", "format": "biome format --write .", "doctor": "expo-doctor", - "test": "bun run typecheck && bun run lint && bun run format && bun run doctor", + "i18n:check": "bun scripts/check-i18n-keys.mjs", + "i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", + "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor", "postinstall": "patch-package" }, "dependencies": { @@ -129,7 +131,7 @@ "@types/react": "~19.2.10", "@types/react-test-renderer": "19.1.0", "cross-env": "10.1.0", - "expo-doctor": "1.19.7", + "expo-doctor": "1.19.9", "husky": "9.1.7", "lint-staged": "17.0.5", "react-test-renderer": "19.2.3", diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 185c306c7..52647bde7 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -40,6 +40,7 @@ import { } from "@/utils/secureCredentials"; import { store } from "@/utils/store"; import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync"; +import { APP_VERSION } from "@/utils/version"; interface Server { address: string; @@ -53,7 +54,7 @@ const initialApi = (() => { const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); const jellyfinInstance = new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.54.1" }, + clientInfo: { name: "Streamyfin", version: APP_VERSION }, deviceInfo: { name: deviceName, id, @@ -135,7 +136,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); return new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.54.1" }, + clientInfo: { name: "Streamyfin", version: APP_VERSION }, deviceInfo: { name: deviceName, id, @@ -169,7 +170,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.54.1"`, + }, DeviceId="${deviceId}", Version="${APP_VERSION}"`, }; }, [deviceId]); diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.mjs new file mode 100644 index 000000000..0e20e462e --- /dev/null +++ b/scripts/check-i18n-keys.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env bun +/** + * i18n key checker for Streamyfin. + * + * Detects: + * - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code + * that does not exist in the source locale (translations/en.json). These are bugs β€” + * the app renders the raw key. Always fails CI. + * - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the + * code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`). + * These are dead weight that also clutter every locale on Crowdin. + * + * Dynamic usage is handled conservatively: + * - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used. + * - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to + * whitelist keys (in Streamyfin these are user-defined section + * titles, not translation keys). + * - Edge cases the static scan cannot see can be allow-listed in the config file. + * + * Usage: + * bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused + * bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning + * bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely + * bun scripts/check-i18n-keys.mjs --json # machine-readable output + * bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest) + */ + +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { extname, join, relative } from "node:path"; + +const ROOT = process.cwd(); +const args = process.argv.slice(2); +const flag = (name, def) => { + const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); + if (!a) return def; + const [, v] = a.split("="); + return v === undefined ? true : v; +}; +const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off +const JSON_OUT = !!flag("json", false); +const FIX_UNUSED = !!flag("fix-unused", false); + +// ---- config ---- +const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); +const DEFAULT_CONFIG = { + localesDir: "translations", + sourceLocale: "en", + // Scan the whole repo by default so keys referenced outside the obvious dirs + // (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead. + srcDirs: ["."], + srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + excludeDirs: [ + "node_modules", + "ios", + "android", + ".expo", + ".git", + "dist", + "build", + "translations", + "scripts", + ], + // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally. + ignoreUnused: [], +}; +const config = existsSync(CONFIG_PATH) + ? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) } + : DEFAULT_CONFIG; + +// ---- helpers ---- +const flatten = (obj, prefix = "", out = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); + else out[key] = v; + } + return out; +}; + +const globMatch = (key, pattern) => { + if (pattern.endsWith(".*")) + return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); + if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); + return key === pattern; +}; + +const walk = (dir, files = []) => { + let entries; + try { + entries = readdirSync(dir); + } catch { + return files; + } + for (const name of entries) { + const full = join(dir, name); + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + if (config.excludeDirs.includes(name)) continue; + walk(full, files); + } else if (config.srcExtensions.includes(extname(name))) { + files.push(full); + } + } + return files; +}; + +// ---- load source keys ---- +const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); +const sourceKeys = Object.keys( + flatten(JSON.parse(readFileSync(sourcePath, "utf8"))), +); +const sourceKeySet = new Set(sourceKeys); + +// ---- scan code ---- +const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b') +const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation +const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b." +const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // +const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y + +const usedStatic = new Set(); // keys passed to t(...) / i18nKey β€” used for MISSING detection +const dynamicPrefixes = new Set(); +const fullyDynamic = []; // { file, line } +let codeBlob = ""; // all (comment-stripped) source text β€” searched for delimited key literals + +// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as +// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers); +// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps +// `://` inside string URLs intact. +const stripComments = (src) => + src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p); + +const files = config.srcDirs.flatMap((d) => + walk(join(ROOT, d === "." ? "" : d) || ROOT), +); +for (const file of files) { + const text = readFileSync(file, "utf8"); + const clean = stripComments(text); + codeBlob += `\n${clean}`; + for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]); + for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]); + for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]); + for (const m of clean.matchAll(TPL_DYN_RE)) { + const prefix = m[1]; + if (prefix?.includes(".")) dynamicPrefixes.add(prefix); + else { + const idx = clean.slice(0, m.index).split("\n").length; + fullyDynamic.push({ file: relative(ROOT, file), line: idx }); + } + } +} + +const prefixList = [...dynamicPrefixes]; +// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in +// the code (covers t("k"), , and keys stored as bare string constants in +// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or +// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c). +const literalUsed = (key) => + codeBlob.includes(`"${key}"`) || + codeBlob.includes(`'${key}'`) || + codeBlob.includes(`\`${key}\``); +const isUsed = (key) => + literalUsed(key) || + prefixList.some((p) => key.startsWith(p)) || + config.ignoreUnused.some((g) => globMatch(key, g)); + +// ---- compute ---- +const unused = sourceKeys.filter((k) => !isUsed(k)).sort(); +// Static references are always validated, even under a dynamic prefix: a dynamic prefix only +// affects the UNUSED calculation, never MISSING. +const missing = [...usedStatic] + .filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k)) + .sort(); +// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are +// validated for MISSING. A key stored as a bare string constant and resolved via t(variable) +// counts as USED (via literalUsed β†’ not flagged unused) but its existence in en.json is not +// checked here β€” static analysis can't resolve which key a runtime variable holds. Streamyfin +// keys are static literals in practice; revisit if dynamic key constants become common. + +// ---- optional fix: strip dead keys from the source locale (en.json) ---- +const removeKey = (obj, parts) => { + const [head, ...rest] = parts; + if (!(head in obj)) return; + if (rest.length === 0) { + delete obj[head]; + return; + } + removeKey(obj[head], rest); + if ( + obj[head] && + typeof obj[head] === "object" && + Object.keys(obj[head]).length === 0 + ) + delete obj[head]; +}; +if (FIX_UNUSED && unused.length) { + // Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes + // the keys from them automatically on the next sync once they disappear from the source. + const data = JSON.parse(readFileSync(sourcePath, "utf8")); + for (const key of unused) removeKey(data, key.split(".")); + writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); + console.log( + `🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`, + ); +} + +// ---- report ---- +if (JSON_OUT) { + console.log( + JSON.stringify( + { + sourceKeys: sourceKeys.length, + missing, + unused, + dynamicPrefixes: prefixList, + fullyDynamic, + }, + null, + 2, + ), + ); +} else { + console.log( + `πŸ”‘ i18n key check β€” source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`, + ); + if (prefixList.length) + console.log( + ` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`, + ); + if (fullyDynamic.length) + console.log( + ` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`, + ); + + if (missing.length) { + console.log( + `\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) β€” ${missing.length}:`, + ); + for (const k of missing) console.log(` - ${k}`); + } else console.log("\nβœ… No missing keys."); + + if (UNUSED_MODE !== "off") { + if (unused.length) { + console.log( + `\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) β€” ${unused.length}:`, + ); + for (const k of unused) console.log(` - ${k}`); + console.log( + `\n β†’ remove with: bun scripts/check-i18n-keys.mjs --fix-unused`, + ); + console.log( + ` β†’ or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, + ); + } else console.log("\nβœ… No unused keys."); + } +} + +const fail = + missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0); +process.exit(fail ? 1 : 0); diff --git a/scripts/i18n-keys.config.json b/scripts/i18n-keys.config.json new file mode 100644 index 000000000..c5196d825 --- /dev/null +++ b/scripts/i18n-keys.config.json @@ -0,0 +1,46 @@ +{ + "localesDir": "translations", + "sourceLocale": "en", + "srcDirs": [ + "app", + "components", + "hooks", + "providers", + "utils", + "modules", + "packages", + "constants" + ], + "srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + "excludeDirs": [ + "node_modules", + "ios", + "android", + ".expo", + ".git", + "dist", + "build", + "translations" + ], + "_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call β€” allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys β€” watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).", + "ignoreUnused": [ + "watchlists.add_to_watchlist", + "watchlists.remove_from_watchlist", + "watchlists.create_one_first", + "watchlists.no_compatible_watchlists", + "pin.confirm_pin", + "pin.pins_dont_match", + "player.search_subtitles", + "player.subtitle_search", + "player.subtitle_download_hint", + "player.subtitle_tracks", + "player.using_jellyfin_server", + "player.swipe_down_settings", + "player.stopPlayback", + "player.stopPlayingTitle", + "player.stopPlayingConfirm", + "home.settings.other.show_large_home_carousel", + "home.settings.logs.delete_all_logs", + "home.suggested_episodes" + ] +} diff --git a/translations/en.json b/translations/en.json index 9dcf4c766..071dc065b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -261,43 +261,6 @@ "None": "None", "OnlyForced": "Only forced" }, - "text_color": "Text color", - "background_color": "Background color", - "outline_color": "Outline color", - "outline_thickness": "Outline thickness", - "background_opacity": "Background opacity", - "outline_opacity": "Outline opacity", - "bold_text": "Bold text", - "colors": { - "Black": "Black", - "Gray": "Gray", - "Silver": "Silver", - "White": "White", - "Maroon": "Maroon", - "Red": "Red", - "Fuchsia": "Fuchsia", - "Yellow": "Yellow", - "Olive": "Olive", - "Green": "Green", - "Teal": "Teal", - "Lime": "Lime", - "Purple": "Purple", - "Navy": "Navy", - "Blue": "Blue", - "Aqua": "Aqua" - }, - "thickness": { - "None": "None", - "Thin": "Thin", - "Normal": "Normal", - "Thick": "Thick" - }, - "subtitle_color": "Subtitle color", - "subtitle_background_color": "Background color", - "subtitle_font": "Subtitle font", - "ksplayer_title": "KSPlayer settings", - "hardware_decode": "Hardware decoding", - "hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues.", "opensubtitles_title": "OpenSubtitles", "opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.", "opensubtitles_api_key": "API key", @@ -315,25 +278,6 @@ "bottom": "Bottom" } }, - "vlc_subtitles": { - "title": "VLC subtitle settings", - "hint": "Customize subtitle appearance for the VLC player. Changes take effect on next playback.", - "text_color": "Text color", - "background_color": "Background color", - "background_opacity": "Background opacity", - "outline_color": "Outline color", - "outline_opacity": "Outline opacity", - "outline_thickness": "Outline thickness", - "bold": "Bold text", - "margin": "Bottom margin" - }, - "video_player": { - "title": "Video player", - "video_player": "Video player", - "video_player_description": "Choose which video player to use on iOS.", - "ksplayer": "KSPlayer", - "vlc": "VLC" - }, "other": { "other_title": "Other", "video_orientation": "Video orientation", @@ -351,11 +295,6 @@ "UNKNOWN": "Unknown" }, "safe_area_in_controls": "Safe area in controls", - "video_player": "Video player", - "video_players": { - "VLC_3": "VLC 3", - "VLC_4": "VLC 4 (Experimental + PiP)" - }, "show_custom_menu_links": "Show custom menu links", "show_large_home_carousel": "Show large home carousel (beta)", "hide_libraries": "Hide libraries", @@ -367,9 +306,6 @@ "max_auto_play_episode_count": "Max auto-play episode count", "disabled": "Disabled" }, - "downloads": { - "downloads_title": "Downloads" - }, "music": { "title": "Music", "playback_title": "Playback", @@ -413,23 +349,18 @@ "read_more_about_marlin": "Read more about Marlin.", "save_button": "Save", "toasts": { - "saved": "Saved", - "refreshed": "Settings refreshed from server" - }, - "refresh_from_server": "Refresh settings from server" + "saved": "Saved" + } }, "streamystats": { - "enable_streamystats": "Enable Streamystats", "disable_streamystats": "Disable Streamystats", "enable_search": "Use for search", "url": "URL", "server_url_placeholder": "http(s)://streamystats.example.com", "streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.", "read_more_about_streamystats": "Read more about Streamystats.", - "save_button": "Save", "save": "Save", "features_title": "Features", - "home_sections_title": "Home sections", "enable_movie_recommendations": "Movie recommendations", "enable_series_recommendations": "Series recommendations", "enable_promoted_watchlists": "Promoted watchlists", @@ -445,8 +376,7 @@ "refresh_from_server": "Refresh settings from server" }, "kefinTweaks": { - "watchlist_enabler": "Enable watchlist integration", - "watchlist_button": "Toggle watchlist integration" + "watchlist_enabler": "Enable watchlist integration" } }, "storage": { @@ -457,7 +387,6 @@ "delete_all_downloaded_files": "Delete all downloaded files", "music_cache_title": "Music cache", "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", - "enable_music_cache": "Enable music cache", "clear_music_cache": "Clear music cache", "music_cache_size": "{{size}} cached", "music_cache_cleared": "Music cache cleared", @@ -467,8 +396,6 @@ "clear_all_cache": "Clear all cache", "clear_all_cache_confirm": "Clear all cache?", "clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.", - "clear_all_cache_success": "Cache cleared", - "clear_all_cache_success_desc": "All cache has been cleared successfully.", "clear_all_cache_error_desc": "An error occurred while clearing the cache." }, "intro": { @@ -490,15 +417,12 @@ "system": "System" }, "toasts": { - "error_deleting_files": "Error deleting files", - "background_downloads_enabled": "Background downloads enabled", - "background_downloads_disabled": "Background downloads disabled" + "error_deleting_files": "Error deleting files" }, "security": { "title": "Security", "inactivity_timeout": { "title": "Inactivity timeout", - "description": "Auto logout after inactivity", "disabled": "Disabled", "1_minute": "1 minute", "5_minutes": "5 minutes", @@ -508,6 +432,10 @@ "4_hours": "4 hours", "24_hours": "24 hours" } + }, + "dashboard": { + "title": "Dashboard", + "sessions_title": "Sessions" } }, "sessions": { @@ -518,10 +446,7 @@ "downloads_title": "Downloads", "series": "Series", "movies": "Movies", - "queue": "Queue", "other_media": "Other media", - "queue_hint": "Queue and downloads will be lost on app restart", - "no_items_in_queue": "No items in queue", "no_downloaded_items": "No downloaded items", "delete_all_movies_button": "Delete all movies", "delete_all_series_button": "Delete all series", @@ -546,13 +471,8 @@ "failed_to_delete_all_series": "Failed to delete all series", "deleted_media_successfully": "Deleted other media successfully!", "failed_to_delete_media": "Failed to delete other media", - "download_deleted": "Download deleted", "download_cancelled": "Download cancelled", "could_not_delete_download": "Could not delete download", - "download_paused": "Download paused", - "could_not_pause_download": "Could not pause download", - "download_resumed": "Download resumed", - "could_not_resume_download": "Could not resume download", "download_completed": "Download completed", "download_failed": "Download failed", "download_failed_for_item": "Download failed for {{item}} - {{error}}", @@ -562,10 +482,7 @@ "item_already_downloading": "{{item}} is already downloading", "all_files_deleted": "All downloads deleted successfully", "files_deleted_by_type": "{{count}} {{type}} deleted", - "all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully", - "failed_to_clean_cache_directory": "Failed to clean cache directory", "could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}", - "go_to_downloads": "Go to Downloads", "file_deleted": "{{item}} deleted" } } @@ -583,16 +500,17 @@ "none": "None", "track": "Track", "cancel": "Cancel", - "stop": "Stop", "delete": "Delete", "ok": "OK", "remove": "Remove", - "next": "Next", "back": "Back", "continue": "Continue", "verifying": "Verifying...", "login": "Login", - "refresh": "Refresh" + "episodes": "Episodes", + "movies": "Movies", + "loading": "Loading…", + "seeAll": "See all" }, "search": { "search": "Search...", @@ -694,10 +612,6 @@ "could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast", "message_from_server": "Message from server: {{message}}", "next_episode": "Next episode", - "refresh_tracks": "Refresh tracks", - "audio_tracks": "Audio tracks:", - "playback_state": "Playback state:", - "index": "Index:", "continue_watching": "Continue watching", "go_back": "Go back", "downloaded_file_title": "You have this file downloaded", @@ -726,7 +640,8 @@ "stopPlayback": "Stop playback", "stopPlayingTitle": "Stop playing \"{{title}}\"?", "stopPlayingConfirm": "Are you sure you want to stop playback?", - "downloaded": "Downloaded" + "downloaded": "Downloaded", + "missing_parameters": "Missing playback parameters" }, "chapters": { "title": "Chapters", @@ -764,7 +679,6 @@ "show_more": "Show more", "show_less": "Show less", "left": "left", - "more_info": "More info", "director": "Director", "cast": "Cast", "technical_details": "Technical details", @@ -787,7 +701,8 @@ "resume_playback": "Resume playback", "resume_playback_description": "Do you want to continue where you left off or start from the beginning?", "play_from_start": "Play from start", - "continue_from": "Continue from {{time}}" + "continue_from": "Continue from {{time}}", + "no_data_available": "No data available" }, "live_tv": { "next": "Next", @@ -891,13 +806,9 @@ "playlists": "Playlists", "tracks": "Songs" }, - "filters": { - "all": "All" - }, "recently_added": "Recently added", "recently_played": "Recently played", "frequently_played": "Frequently played", - "explore": "Explore", "top_tracks": "Top songs", "play": "Play", "shuffle": "Shuffle", @@ -1031,7 +942,6 @@ "pairing": { "pair_with_phone": "Pair with phone", "pair_with_phone_title": "Log in on TV", - "pair_with_phone_description": "Scan the QR code displayed on your TV to log in", "waiting_for_phone": "Waiting for phone...", "scan_with_phone": "Scan with the Streamyfin app on your phone", "logging_in": "Logging in...", diff --git a/utils/version.ts b/utils/version.ts new file mode 100644 index 000000000..79ba2c1bf --- /dev/null +++ b/utils/version.ts @@ -0,0 +1,87 @@ +import * as Application from "expo-application"; +import Constants from "expo-constants"; + +/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin + * clientInfo auto-tracks the app version instead of a hardcoded string. */ +export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown"; + +/** Build metadata injected at build time by `app.config.js` into `extra.build`. */ +export interface BuildMeta { + commit?: string | null; + branch?: string | null; + profile?: string | null; + builtAt?: string | null; +} + +export interface VersionInfo { + /** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */ + version: string | null; + /** Build number (CFBundleVersion / versionCode), e.g. "42". */ + build: string | null; + /** Short git commit the build was made from, e.g. "a1b2c3d". */ + commit: string | null; + /** Git branch the build was made from, e.g. "develop". */ + branch: string | null; + /** EAS build profile, e.g. "production", "preview", or null for local. */ + profile: string | null; + isDev: boolean; + isProduction: boolean; + /** Graduated label for the Settings "App version" row (see tiering below). */ + display: string; +} + +/** + * Resolve a graduated version string for Settings. + * + * Tiering (most β†’ least detailed): + * - dev / local build β†’ `version Β· branch Β· commit` (full context for debugging) + * - develop / CI / preview β†’ `version Β· commit` (pin the exact source) + * - production (store / TestFlight) β†’ `version (build)` (store-correlatable; the + * build number lets TestFlight reports pin a build whose version isn't a + * published release. Note: TestFlight and the public App Store ship the same + * binary β€” telling them apart needs a runtime iOS receipt check, intentionally + * not done here.) + */ +export function getVersionInfo(): VersionInfo { + // Read native/config values defensively β€” a version string must never crash Settings + // (e.g. a dev build whose native expo-constants is out of sync with the JS). + const read = (fn: () => T): T | null => { + try { + return fn() ?? null; + } catch { + return null; + } + }; + + const version = read(() => Application.nativeApplicationVersion); + const build = read(() => Application.nativeBuildVersion); + const meta = (read(() => Constants.expoConfig?.extra?.build) ?? + {}) as BuildMeta; + const commit = meta.commit ?? null; + const branch = meta.branch ?? null; + const profile = meta.profile ?? null; + const isDev = __DEV__ === true; + const isProduction = + typeof profile === "string" && profile.startsWith("production"); + + let display: string; + if (isDev) { + display = [version ?? "dev", branch, commit].filter(Boolean).join(" Β· "); + } else if (isProduction) { + display = + version && build ? `${version} (${build})` : (version ?? build ?? "N/A"); + } else { + display = [version, commit].filter(Boolean).join(" Β· ") || version || "N/A"; + } + + return { + version, + build, + commit, + branch, + profile, + isDev, + isProduction, + display, + }; +}