diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0efc524..5c14a50 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,9 +1,9 @@ # Prova il codice e pubblica l'immagine Docker su GitHub Container Registry (ghcr.io). # -# - Pull request: prova i test e la compilazione dell'immagine (amd64), senza pubblicare. -# - Push su main: pubblica `latest` e `sha-` (solo amd64: è la compilazione veloce). +# - Pull request: esegue i test Linux e Windows, senza pubblicare immagini. +# - Push su main: pubblica `latest` e `sha-` per amd64 e arm64. # - Tag `vX.Y.Z` (per esempio `git tag v0.2.0 && git push --tags`): pubblica `X.Y.Z`, `X.Y` e -# `latest`, per amd64 e arm64 (l'arm64 gira sotto emulazione: richiede parecchi minuti). +# `latest`, per amd64 e arm64 (build nativi paralleli, senza QEMU). # # L'immagine: ghcr.io//altkeeper. Se il repo è privato lo è anche il pacchetto: per # usarlo da un server serve `docker login ghcr.io` con un token che possa leggere i pacchetti, @@ -28,6 +28,8 @@ jobs: contents: read steps: - uses: actions/checkout@v7 + - name: Check localized web UI + run: node scripts/check-web-ui.cjs - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test --locked @@ -44,6 +46,8 @@ jobs: contents: read steps: - uses: actions/checkout@v7 + - name: Check localized web UI + run: node scripts/check-web-ui.cjs - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test --locked @@ -61,25 +65,79 @@ jobs: if ($LASTEXITCODE -ne 0 -or -not ($output -match '^uso: altkeeper')) { throw "Italian CLI help failed: $output" } image: - needs: test - runs-on: ubuntu-latest + # Build architectures concurrently on native runners; PRs run tests only. + if: github.event_name != 'pull_request' + needs: [test, test-windows] + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write steps: - uses: actions/checkout@v7 - - - uses: docker/setup-qemu-action@v4 + # No QEMU: Rust is compiled natively for each target. - uses: docker/setup-buildx-action@v4 - - # Il login serve solo per pubblicare: le pull request non hanno il permesso di scrivere. - uses: docker/login-action@v4 - if: github.event_name != 'pull_request' with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + - id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + # Immutable per-arch pushes; only the final manifest receives tags. + outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=altkeeper-${{ matrix.arch }} + cache-to: type=gha,scope=altkeeper-${{ matrix.arch }},mode=max + - name: Export image digest + run: | + mkdir -p "${RUNNER_TEMP}/digests" + digest="${{ steps.build.outputs.digest }}" + test -n "${digest}" + touch "${RUNNER_TEMP}/digests/${digest#sha256:}" + - uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + image-manifest: + # Publish tags only after both architecture builds succeed. + if: github.event_name != 'pull_request' + needs: [image] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v6 with: @@ -89,14 +147,18 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} type=sha - - - uses: docker/build-push-action@v7 - with: - context: . - # I tag di versione pubblicano anche arm64; il resto solo amd64 (più rapido). - platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Create multi-platform manifest + working-directory: ${{ runner.temp }}/digests + env: + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} + run: | + # Never publish a partial multi-architecture manifest. + test "$(find . -maxdepth 1 -type f | wc -l)" -eq 2 + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) + - name: Inspect published manifest + env: + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} + run: | + docker buildx imagetools inspect "$(jq -r '.tags[0]' <<< "$DOCKER_METADATA_OUTPUT_JSON")" diff --git a/Cargo.lock b/Cargo.lock index 332f00a..8f2668a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,8 +86,10 @@ dependencies = [ "rustls", "serde", "serde_json", + "tempfile", "tokio", "tracing-subscriber", + "zip", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b817b89..3bbfc9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,8 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } +tempfile = "3.27" +zip = { version = "8.6", default-features = false, features = ["deflate"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } # La copia in vendor/ è isideload 0.4.6 (upstream 8ca39b62) con due modifiche locali: diff --git a/Dockerfile b/Dockerfile index 9b5fd76..36fa38e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ RUN cargo build --release --locked FROM debian:bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates tzdata \ + && apt-get install -y --no-install-recommends ca-certificates tzdata curl \ && rm -rf /var/lib/apt/lists/* COPY --from=build /src/target/release/altkeeper /usr/local/bin/altkeeper WORKDIR /data diff --git a/README.md b/README.md index 28eb3c8..80b48cc 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,79 @@ answers with errors, wait before trying again instead of repeating it. `renew` looks at each app's newest profile, renews the ones with `--min-days` days left or fewer (default 3), and signs in to Apple **only if something is due**. +**Installed-app safety filter (default for scheduled/CLI `renew` and web “Renew expiring apps”):** +Before checking expiry, AltKeeper queries the phone's installed user apps through installation_proxy. +Profiles that do not correspond to installed apps are skipped. **If that lookup fails (including +a timeout), AltKeeper explicitly falls back to renewing *all* known profiles for the configured +Apple team, ignoring the expiry threshold.** This fallback is logged as WARNING/FALLBACK. +A successful lookup returning zero apps skips all renewals. + +To renew a specific app, use `altkeeper renew --bundle --force` or the per-app +**Renew** button on the web page. The manual `--force` and web “Renew all now” operations +do not check installed apps; `--installed-only` can be used with CLI `--force` to opt in. +No unattended schedule is enabled by merely running the Docker container (default: OFF). + +**Optional built-in scheduler in the web UI:** Use **Automatic renewal** to enable/disable a +once-daily job and choose a time in **Asia/Seoul (KST, UTC+09:00)**. The default clock +time is 06:00 KST and the switch is initially OFF. When enabled, AltKeeper runs +the same installed-app-aware renewal as **Renew expiring apps**, with the documented +all-profile fallback if installed-app listing fails. The server needs a saved +Apple ID password, must be running, and the iPhone must be reachable. +Only one scheduled *attempt* is made per KST calendar day (including failures). +A server that starts after the scheduled time on a day with no attempt will run +that day's job when it starts; enabling or changing the scheduled time after +today's slot does **not** trigger an immediate refresh. The next slot is tomorrow. +The schedule and last attempted day are stored in `state/auto-renew.json` under +the persistent `/data` volume, surviving Docker restarts. No host systemd, +cron, privileged host socket, or extra package is needed. **Do not enable +an external cron/systemd renewal job at the same time** unless deliberate. + + +The web page also lists **individual provisioning profiles** (including older versions) with a +**Delete profile** button. Every removal requires confirmation, is limited to the saved Apple team, +and saves then verifies a private `profili-salvati/.mobileprovision` backup before removal. +Removing a profile currently needed by an installed app can prevent that app from opening. +To restore a backup: `altkeeper profile-restore profili-salvati/.mobileprovision`. +Neither profile deletion nor renewal removes Apple Developer App IDs. + +**Grouped app management and retry controls:** The web UI groups extension profiles with +their host app when their bundle ID contains the saved team's extension marker +(e.g. `com.kdt.livecontainer.TEAM.LiveProcess` under `com.kdt.livecontainer`). +The expandable **Your apps** card renews *every* member profile in a group, rather +than only the selected host profile. Installed-app filtering also checks the +host app for an extension; the earliest-expiring member determines when the entire +group is due. The web preview lists groups that appear due, but the actual run +performs a fresh phone check and may fall back to all team profiles if the +installed-app inventory fails. + +The web scheduler's **renewal threshold** is adjustable from **1 to 6 days**, +default **3 days**. On automatic renewal failure it retries up to **two** more +times, **30 minutes apart** (up to 3 attempts in total). Authentication and +rate-limit failures are not automatically retried. Enabling Discord failure +notifications requires saving a webhook URL in the web UI. The secret is saved in +the private `state/discord-webhook` file and is never returned to the browser; +only a boolean indicates configuration. The Docker image includes `curl` to send +an alert only after the last failed attempt (or a non-retryable failure). +Successful automatic renewals do not send Discord messages. + +The web UI also lists previously saved profile backups and offers an explicit +**Restore** action after confirmation. Restoration verifies the saved profile +UUID and Apple Team ID and does not change its expiry date. The original file +is retained. The app summary shows the last known per-group profile-install +result from `state/app-renew-status.json`, and **Preview renewals** provides a +non-authoritative overview without signing in to Apple. + + +**Monthly backup cleanup:** When the web service (`serve`, including Docker `serve --altserver`) +is running, it checks once a day whether a cleanup is due. Every **30 days after the last +successful sweep** (including the first startup), it removes only saved `.mobileprovision` +files whose embedded expiry date is at least **30 days in the past**. A verified UUID/file name +match is required; malformed, unreadable, future-dated or unrelated files are preserved. +A cleanup log line reports the number removed. The last successful sweep is recorded at +`state/profile-backup-last-cleanup` so Docker restarts do not reset the schedule. +No iPhone communication, Apple login, cron job, or new systemd timer is required. +This cleanup does not touch live iPhone profiles or Apple Developer App IDs. + ### 6. Automatic renewal (optional) A safety net that does not depend on the iPhone running AltStore. @@ -260,6 +333,20 @@ The image holds nothing secret: pairing, account and state live in the `/data` v uses the host network, because it needs Bonjour to find the iPhone and to be found by AltStore; ports 8787 (web) and 49500 (AltServer) must be free. + +**Fast IPA upload over Wi-Fi (experimental):** After signing, AltKeeper repackages the +signed `.app` (including any custom name/Bundle ID and selected extensions) into one +temporary IPA, stages that file over AFC, then asks iOS to install it. This avoids +thousands of remote AFC operations for apps with many small files. The temporary +IPA is removed automatically; peak memory is bounded by streaming both packaging +and upload. USB installs keep the existing directory-based path. + +For an immediate rollback on Linux, set `ALTKEEPER_INSTALL_MODE=directory` in the +AltKeeper container's `environment:` in `docker-compose.yml`, then recreate +the container. This restores the previous Wi-Fi directory-transfer behavior +without re-signing existing apps. Test on a disposable app first; compatibility +with a real iPhone must be confirmed before considering it production-verified. + A GitHub Actions workflow (`.github/workflows/docker.yml`) runs the tests on Linux and Windows, checks `--help` in both CLI languages, and publishes the image to `ghcr.io//altkeeper` on pushes to `main` (amd64) and on `vX.Y.Z` tags (amd64 and arm64). If the repository is private, the @@ -502,7 +589,7 @@ If it says it cannot find the server, watch the DNS with | `pair-usb [file]` | creates the remote pairing over USB (once) | | `login ` | signs in with 2FA and saves the account | | `apps` | lists your App IDs and their expiry dates | -| `renew [--dry-run] [--force] [--min-days N] [--phone ip:port]` | renews the profiles that are due | +| `renew [--dry-run] [--force] [--min-days N] [--phone ip:port] [--bundle ID] [--installed-only]` | renews profiles, optionally one bundle or with installed-app filtering | | `altserver [--port N] [--dir folder] [--phone ip:port] [--dns-domain D --dns-address IP]` | acts as AltServer for AltStore | | `serve [--bind ip:port] [--pin PIN] [--dir folder] [--phone ip:port] [--altserver] [--altserver-port N]` | web page (and, with `--altserver`, AltServer) | | `profile-remove ` | takes one profile off the phone, saving a copy in `profili-salvati/` | diff --git a/docs/KOREAN_UI_REVIEW.md b/docs/KOREAN_UI_REVIEW.md new file mode 100644 index 0000000..e351678 --- /dev/null +++ b/docs/KOREAN_UI_REVIEW.md @@ -0,0 +1,107 @@ +# AltKeeper 한국어 웹 UI 번역 — 검수 승인 및 적용 + +> 상태: **검수 승인 및 PR #8에 구현** — 한국어가 기본값이며 웹 상단의 언어 선택에서 English로 전환할 수 있습니다. 선택한 언어는 브라우저에 저장됩니다. + +## 번역 원칙 + +- UI의 **AltKeeper**, **Apple ID**, **Discord**, **IPA**, **UUID**, **KST** 등 식별자와 제품명은 번역하지 않음. +- `Renew`는 '갱신', `Install`은 '설치', `Provisioning profile`은 '프로비저닝 프로파일'로 통일. +- 이 앱 목록은 휴대전화의 프로파일을 기반으로 하므로, 설치 앱 조회에 실패했을 때도 표시할 수 있음. 따라서 '설치된 앱' 대신 **앱 목록** 사용. +- 실패 로그·Apple 원문 오류는 정확한 문제 진단을 위해 번역보다는 원문 유지 + 짧은 한국어 설명 병기를 권장. +- 구현: **한국어 기본 / English 전환**. 브라우저 선택값을 저장하며 서버 API 및 영어 진단 로그 내용은 보존합니다. + +## 주요 UI 문구 + +| 영어 원문 | 제안 한국어 | +|---|---| +| Keeps your iPhone apps alive by renewing their profiles. | 아이폰 앱의 프로파일을 갱신해 사용 기간을 유지합니다. | +| iPhone | 아이폰 | +| Checking… | 확인 중… | +| iPhone reachable | 아이폰 연결됨 | +| iPhone unreachable | 아이폰 연결 안 됨 | +| Not checked yet | 아직 확인하지 않음 | +| Check again | 다시 확인 | +| Your apps | 앱 목록 | +| Renew app | 이 앱 갱신 | +| Details | 상세 정보 | +| Less | 접기 | +| Renew expiring apps | 만료 임박 앱 갱신 | +| Renew all now | 전체 갱신 | +| Preview renewals | 갱신 대상 미리보기 | +| Automatic renewal | 자동 갱신 | +| OFF | 꺼짐 | +| ON | 켜짐 | +| Automatic renewal disabled | 자동 갱신이 꺼져 있습니다 | +| Daily time (KST) | 매일 실행 시간 (KST) | +| Renew when days remaining | 만료까지 남은 기간이 | +| 1 day / 2 days / … / 6 days | 1일 / 2일 / … / 6일 | +| Enable auto-renew | 자동 갱신 켜기 | +| Save settings | 설정 저장 | +| Turn off | 끄기 | +| Discord failure notifications | Discord 실패 알림 | +| Not configured | 설정 안 됨 | +| Configured | 설정됨 | +| Save webhook | 웹훅 저장 | +| Clear webhook | 웹훅 삭제 | +| Send test notification | 테스트 알림 보내기 | +| Test notification sent. Check your Discord channel. | 테스트 알림을 보냈습니다. Discord 채널을 확인하세요. | +| Configure a Discord webhook first. | 먼저 Discord 웹훅을 설정하세요. | +| Provisioning profiles on iPhone | 아이폰의 프로비저닝 프로파일 | +| Delete profile | 프로파일 삭제 | +| Show | 펼치기 | +| Hide | 접기 | +| Saved profile backups | 저장된 프로파일 백업 | +| Restore | 복원 | +| Install an app from this PC | 이 PC에서 IPA 설치 | +| IPA file | IPA 파일 | +| Sign and install | 서명 후 설치 | +| Uploading… | 업로드 중… | +| Apple ID (email) | Apple ID (이메일) | +| Password | 비밀번호 | +| Show password | 비밀번호 표시 | +| Change account | 계정 변경 | +| Sign in | 로그인 | +| Code | 인증 코드 | +| Submit code | 코드 확인 | +| Resend code | 코드 다시 보내기 | +| Recent renewals | 최근 갱신 기록 | +| For use on your home network only. | 가정 내 네트워크에서만 사용하세요. | + +## 설명 문구 (의미 보존 필요) + +| 위치 | 제안 한국어 | +|---|---| +| 자동 갱신 안내 | 서버가 실행 중일 때 하루 한 번 만료가 임박한 앱을 갱신합니다. 설치된 앱을 조회하지 못하면 저장된 Apple Team의 모든 프로파일을 갱신 대상으로 사용합니다. 기본값은 꺼짐입니다. | +| 자동 갱신 재시도 | 예약된 갱신에 실패하면 30분 간격으로 최대 2회 재시도합니다. 모든 시도가 실패했거나 재시도할 수 없는 인증 오류일 때만 Discord 알림을 보냅니다. | +| Discord 알림 안내 | 자동 갱신이 최종 실패했을 때만 알림을 보냅니다. | +| 프로파일 삭제 안내 | 갱신 후에도 이전 프로파일이 남을 수 있습니다. 사용 중인 프로파일을 삭제하면 앱이 실행되지 않을 수 있습니다. 삭제 전에 서버에 검증된 백업을 저장합니다. | +| 프로파일 복원 안내 | UUID로 백업 프로파일을 복원합니다. 복원해도 원래 만료일은 연장되지 않으며, 백업 파일은 유지됩니다. | +| IPA 설치 안내 | IPA를 선택하면 저장된 Apple ID로 서명한 뒤 Wi-Fi를 통해 아이폰에 설치합니다. Apple ID 비밀번호를 서버에 저장해야 합니다. 설치하는 동안 아이폰 화면을 잠금 해제한 상태로 유지하는 것을 권장합니다. | +| Apple ID 저장 안내 | 자동 갱신을 위해 서버에 비밀번호를 저장합니다. 비밀번호는 접근이 제한된 서버 파일에 평문으로 저장됩니다. | +| Apple 로그인 주의 | 한 번에 한 번만 로그인하세요. 반복된 실패는 Apple의 일시적인 로그인 제한을 유발할 수 있습니다. | +| 최근 갱신 기록 없음 | 아직 기록이 없습니다. 자동 갱신을 설정하면 매일 지정된 시간에 실행됩니다. | +| 페어링 누락 | 아이폰 페어링 정보가 없습니다. USB 연결로 페어링한 뒤 README 안내를 따르세요. | + +## 동적 상태 메시지 제안 + +| 영어 문구 | 한국어 제안 | +|---|---| +| expires in 6 days | 6일 후 만료 | +| expires today | 오늘 만료 | +| expired 1 day ago | 1일 전 만료 | +| 2 profiles | 프로파일 2개 | +| Renewing… | 갱신 중… | +| Another operation is in progress. | 다른 작업이 진행 중입니다. | +| The iPhone must be reachable on Wi-Fi. | 아이폰이 Wi-Fi로 연결되어 있어야 합니다. | +| Nothing to renew | 갱신할 항목이 없습니다 | +| Installation completed. | 설치가 완료되었습니다. | +| Error: Installation failed: … | 설치 실패: … (원본 오류 보존) | + +## 검수 결과 및 구현 참고 + +1. '프로비저닝 프로파일'은 기술적으로 정확하지만 길어 UI에선 '프로파일'로 줄일지 여부. +2. 한국어 기본 표시 vs 브라우저 언어 자동 감지 vs 수동 선택(영어로 다시 바꿀 수 있도록). +3. '앱 목록'의 앱 표시는 프로파일 기반이며, 실제 설치 여부를 항상 확정하지 않는다는 설명을 보일지. +4. 일본어/영어로 출력되는 기기 진단 로그는 **원문 보존**할지, 한국어 요약을 함께 제공할지. + +**검수 승인에 따라 정적·동적 웹 문구를 한국어로 적용했습니다.** 원본 진단 로그와 서버 오류 전문은 정확한 디버깅을 위해 영어로 유지합니다. 같은 버튼 영역의 버튼은 동일 크기와 가운데 정렬을 사용하고, 좁은 화면에서는 버튼이 줄바꿈됩니다. diff --git a/scripts/check-web-ui.cjs b/scripts/check-web-ui.cjs new file mode 100644 index 0000000..69184db --- /dev/null +++ b/scripts/check-web-ui.cjs @@ -0,0 +1,169 @@ +"use strict"; +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const path = require("node:path"); + +// This dashboard is shipped as include_str!(\"web/index.html\"), not a separate JS file. +// Compile its exact embedded script to catch JavaScript regressions that cargo test misses. +const html = fs.readFileSync(path.join(__dirname, "../src/web/index.html"), "utf8"); +const embedded = html.match(/ + diff --git a/vendor/isideload/src/sideload/application.rs b/vendor/isideload/src/sideload/application.rs index 49e1c58..bcebf48 100644 --- a/vendor/isideload/src/sideload/application.rs +++ b/vendor/isideload/src/sideload/application.rs @@ -16,8 +16,9 @@ use rootcause::option_ext::OptionExt; use rootcause::prelude::*; use sha1::Digest; use sha2::Sha256; -use std::io::Write; -use std::path::PathBuf; +use std::collections::HashSet; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use tracing::{info, warn}; use zip::ZipArchive; @@ -28,6 +29,166 @@ pub struct Application { //pub temp_path: PathBuf, } +/// Installation metadata, read from IPA Info.plist files without unpacking the archive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpaExtensionInfo { + pub bundle_id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpaManifest { + pub bundle_id: String, + pub name: String, + pub extensions: Vec, +} + +fn display_name(info: &plist::Dictionary, fallback: &str) -> String { + info.get("CFBundleDisplayName") + .or_else(|| info.get("CFBundleName")) + .and_then(|v| v.as_string()) + .unwrap_or(fallback) + .to_string() +} + +/// Read the main bundle identifier and extension manifests. This is read-only: +/// neither signing nor Apple registration happens until the user confirms. +pub fn inspect_ipa(path: &Path) -> Result { + let file = File::open(path).context("Could not open IPA for inspection")?; + let mut archive = ZipArchive::new(file).context("IPA is not a valid ZIP archive")?; + let mut main = None; + let mut extensions = Vec::new(); + let mut ids = HashSet::new(); + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let entry_name = entry.name().to_owned(); + let parts: Vec<_> = entry_name.split('/').collect(); + let is_main = parts.len() == 3 && parts[0] == "Payload" + && parts[1].ends_with(".app") && parts[2] == "Info.plist"; + let is_extension = parts.len() == 5 && parts[0] == "Payload" + && parts[1].ends_with(".app") && parts[2] == "PlugIns" + && parts[3].ends_with(".appex") && parts[4] == "Info.plist"; + if !is_main && !is_extension { continue; } + if entry.size() > 512 * 1024 { + bail!(SideloadError::InvalidBundle("Info.plist exceeds 512 KiB".into())); + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + let info: plist::Dictionary = plist::from_bytes(&bytes) + .context("Cannot parse Info.plist")?; + let bundle_id = info.get("CFBundleIdentifier") + .and_then(|v| v.as_string()).ok_or_report() + .context("Missing CFBundleIdentifier")?.to_string(); + if !ids.insert(bundle_id.clone()) { + bail!(SideloadError::InvalidBundle("Duplicate bundle identifier".into())); + } + let name = display_name(&info, if is_main { + parts[1].trim_end_matches(".app") + } else { + parts[3].trim_end_matches(".appex") + }); + if is_main { + if main.replace((bundle_id, name)).is_some() { + bail!(SideloadError::InvalidBundle("Multiple apps in IPA".into())); + } + } else { + extensions.push(IpaExtensionInfo { bundle_id, name }); + } + } + let (bundle_id, name) = main.ok_or_report() + .context("IPA is missing Payload/*.app/Info.plist")?; + extensions.sort_by(|a, b| a.bundle_id.cmp(&b.bundle_id)); + Ok(IpaManifest { bundle_id, name, extensions }) +} + +/// Replace quoted display-name values in OpenStep InfoPlist.strings without +/// disturbing other localized strings. Binary plist is handled separately. +fn replace_localized_display_name(text: &str, display_name: &str) -> String { + let quoted_name = display_name.replace('\\', "\\\\").replace('"', "\\\""); + let mut value = text.to_string(); + for key in ["CFBundleDisplayName", "CFBundleName"] { + for prefix in [format!("\"{key}\""), key.to_string()] { + let mut cursor = 0; + while let Some(offset) = value[cursor..].find(&prefix) { + let end_key = cursor + offset + prefix.len(); + let bytes = value.as_bytes(); + let mut p = end_key; + while p < bytes.len() && bytes[p].is_ascii_whitespace() { p += 1; } + if bytes.get(p) != Some(&b'=') { cursor = end_key; continue; } + p += 1; + while p < bytes.len() && bytes[p].is_ascii_whitespace() { p += 1; } + if bytes.get(p) != Some(&b'"') { cursor = end_key; continue; } + let first = p + 1; + let mut last = first; + let mut escaped = false; + while last < bytes.len() { + let current = bytes[last]; + if current == b'"' && !escaped { break; } + if current == b'\\' && !escaped { escaped = true; } + else { escaped = false; } + last += 1; + } + if last == bytes.len() { cursor = end_key; continue; } + value.replace_range(first..last, "ed_name); + cursor = first + quoted_name.len() + 1; + } + } + } + value +} + +fn localized_name_bytes(original: &[u8], name: &str) -> Result, Report> { + if let Ok(mut dict) = plist::from_bytes::(original) { + for key in ["CFBundleDisplayName", "CFBundleName"] { + if dict.contains_key(key) { + dict.insert(key.into(), plist::Value::String(name.into())); + } + } + let mut result = Vec::new(); + plist::to_writer_binary(&mut result, &dict)?; + return Ok(result); + } + let le = original.starts_with(&[0xff, 0xfe]); + let be = original.starts_with(&[0xfe, 0xff]); + let text = if le || be { + let mut chunks = original[2..].chunks_exact(2); + let words: Vec = chunks.by_ref().map(|c| if le { + u16::from_le_bytes([c[0], c[1]]) + } else { + u16::from_be_bytes([c[0], c[1]]) + }).collect(); + if !chunks.remainder().is_empty() { + bail!(SideloadError::InvalidBundle("Invalid UTF-16 localization file".into())); + } + String::from_utf16(&words)? + } else { + String::from_utf8(original.to_vec())? + }; + let updated = replace_localized_display_name(&text, name); + if updated == text { return Ok(original.to_vec()); } + if le || be { + let mut out = if le { vec![0xff, 0xfe] } else { vec![0xfe, 0xff] }; + for c in updated.encode_utf16() { + let encoded = if le { c.to_le_bytes() } else { c.to_be_bytes() }; + out.extend_from_slice(&encoded); + } + Ok(out) + } else { + Ok(updated.into_bytes()) + } +} + +/// Accept a conservative reverse-DNS identifier, without the signer-added team suffix. +pub fn valid_custom_bundle_id(id: &str) -> bool { + if id.len() > 160 || id.len() < 3 || !id.contains('.') { return false; } + id.split('.').all(|part| { + !part.is_empty() && part.len() <= 63 + && part.as_bytes()[0].is_ascii_alphanumeric() + && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) +} + + impl Application { pub fn new(path: PathBuf) -> Result { if !isideload_vfs::fs::metadata(&path).is_ok() { @@ -108,6 +269,78 @@ impl Application { }) } + /// Exclude unchecked .appex bundles before registering App IDs or signing. + /// This mutates only the extracted temporary copy, never the original IPA. + pub fn retain_extensions(&mut self, keep_ids: &[String]) -> Result<(), Report> { + let keep: HashSet<&str> = keep_ids.iter().map(String::as_str).collect(); + let known: HashSet<&str> = self.bundle.app_extensions().iter() + .filter_map(|e| e.bundle_identifier()).collect(); + for id in &keep { + if !known.contains(id) { + bail!(SideloadError::InvalidBundle( + format!("Selected extension is not present in the IPA: {id}"), + )); + } + } + let unwanted: Vec = self.bundle.app_extensions().iter() + .filter(|e| !e.bundle_identifier().is_some_and(|id| keep.contains(id))) + .map(|e| e.bundle_dir.clone()).collect(); + for path in &unwanted { + isideload_vfs::fs::remove_dir_all(path) + .context("Could not remove an excluded extension")?; + } + // Re-scan the bundle so removed extensions' nested frameworks and + // dylibs are not left in the signing tree. + self.bundle = Bundle::new(self.bundle.bundle_dir.clone())?; + Ok(()) + } + + /// Apply requested new-install identity before the existing team-ID signing rewrite. + /// The original IPA is untouched; this only edits the extracted temp bundle. + pub fn apply_install_identity( + &mut self, + display_name: Option<&str>, + custom_bundle_id: Option<&str>, + ) -> Result<(), Report> { + if let Some(custom_id) = custom_bundle_id { + if !valid_custom_bundle_id(custom_id) { + bail!(SideloadError::InvalidBundle("Invalid custom Bundle ID".into())); + } + let original = self.main_bundle_id()?; + if original != custom_id { + self.update_bundle_id(&original, custom_id)?; + for ext in self.bundle.app_extensions_mut() { + ext.write_info()?; + } + } + } + if let Some(name) = display_name { + if name.is_empty() || name.chars().count() > 40 || name.chars().any(char::is_control) { + bail!(SideloadError::InvalidBundle("Invalid custom display name".into())); + } + self.bundle.app_info.insert("CFBundleDisplayName".into(), plist::Value::String(name.into())); + self.bundle.app_info.insert("CFBundleName".into(), plist::Value::String(name.into())); + // iOS prefers localized InfoPlist.strings names over CFBundleDisplayName. + // Update the temporary IPA extraction only, not any installed app. + for entry in isideload_vfs::fs::read_dir(&self.bundle.bundle_dir)? { + let entry = entry?; + let localized = entry.path(); + if localized.extension().is_none_or(|ext| ext != "lproj") { continue; } + let path = localized.join("InfoPlist.strings"); + if !isideload_vfs::fs::metadata(&path).is_ok() { continue; } + let old = isideload_vfs::fs::read(&path)?; + if old.len() > 1024 * 1024 { + bail!(SideloadError::InvalidBundle("Localized display-name file too large".into())); + } + let new = localized_name_bytes(&old, name)?; + if old != new { + isideload_vfs::fs::write(&path, &new)?; + } + } + } + Ok(()) + } + pub fn get_special_app(&self) -> Option { let bundle_id = self.bundle.bundle_identifier().unwrap_or(""); let special_app = match bundle_id { diff --git a/vendor/isideload/src/sideload/sideloader.rs b/vendor/isideload/src/sideload/sideloader.rs index e0b7934..f7789bd 100644 --- a/vendor/isideload/src/sideload/sideloader.rs +++ b/vendor/isideload/src/sideload/sideloader.rs @@ -75,6 +75,9 @@ impl Sideloader { progress_callback: Option, pairing_file: Option<&[u8]>, device_udid: Option<&str>, + selected_extensions: Option<&[String]>, + display_name: Option<&str>, + custom_bundle_id: Option<&str>, ) -> Result<(PathBuf, Option), Report> where F: Fn(f32) -> Fut, @@ -100,7 +103,15 @@ impl Sideloader { } let mut app = Application::new(app_path)?; + if let Some(selected) = selected_extensions { + app.retain_extensions(selected)?; + } + // Detect special app type before changing its identity. let special = app.get_special_app(); + if custom_bundle_id.is_some() && special.is_some() { + bail!("Custom Bundle IDs are disabled for special apps (AltStore, SideStore, LiveContainer)."); + } + app.apply_install_identity(display_name, custom_bundle_id)?; let main_bundle_id = app.main_bundle_id()?; let main_app_name = app.main_app_name()?; @@ -273,6 +284,9 @@ impl Sideloader { progress_callback, pairing_file, Some(&device_info.udid), + None, + None, + None, ) .await?;