diff --git a/.github/workflows/dry-build.yml b/.github/workflows/dry-build.yml
index b0d50cf4..9e9883d4 100644
--- a/.github/workflows/dry-build.yml
+++ b/.github/workflows/dry-build.yml
@@ -63,3 +63,14 @@ jobs:
CGO_ENABLED: 1
CGO_CFLAGS: -mmacosx-version-min=10.15
CGO_LDFLAGS: -mmacosx-version-min=10.15
+ build_ios:
+ name: Build for iOS
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.25.0"
+ cache: true
+ - run: brew install ldid cmake
+ - run: ./tools/build-ios.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 36e475b4..2b79c15a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -108,6 +108,48 @@ jobs:
name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
path: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}
if-no-files-found: error
+ build_ios:
+ name: Build for iOS
+ runs-on: macos-latest
+ needs: [get_version, test]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.25.0"
+ cache: true
+ - run: brew install ldid cmake
+ - run: ./tools/build-ios.sh "ipatool-$VERSION-ios-arm64"
+ env:
+ VERSION: ${{ needs.get_version.outputs.version }}
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
+ path: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
+ if-no-files-found: error
+ release_ios:
+ name: Release for iOS
+ runs-on: ubuntu-latest
+ needs: [get_version, build_ios, release_windows]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
+ path: bin
+ - run: chmod +x "bin/$FILE" && tar -czvf "$FILE.tar.gz" "bin/$FILE"
+ env:
+ FILE: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64
+ - run: ./tools/sha256sum.sh "$TARBALL" > "$TARBALL.sha256sum"
+ env:
+ TARBALL: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.tar.gz
+ - uses: svenstaro/upload-release-action@v2
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ file: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.*
+ tag: ${{ github.ref }}
+ overwrite: false
+ file_glob: true
release_windows:
name: Release for Windows
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index b442acf2..66233d1c 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
## Requirements
-- A supported operating system (macOS, Linux or Windows).
+- A supported operating system (macOS, Linux, Windows or iOS).
- An Apple Account already configured to use the App Store.
## Installation
diff --git a/cmd/common.go b/cmd/common.go
index 14dc01cd..da3c4186 100644
--- a/cmd/common.go
+++ b/cmd/common.go
@@ -61,7 +61,7 @@ func newCookieJar(stateDirectory string) http.CookieJar {
// newKeychain returns a new keychain instance.
func newKeychain(stateDirectory string, logger log.Logger, interactive bool) keychain.Keychain {
- ring := util.Must(keyring.Open(keyring.Config{
+ ring := util.Must(openKeyring(keyring.Config{
AllowedBackends: []keyring.BackendType{
keyring.KeychainBackend,
keyring.SecretServiceBackend,
diff --git a/cmd/keyring_default.go b/cmd/keyring_default.go
new file mode 100644
index 00000000..a8ac6ae1
--- /dev/null
+++ b/cmd/keyring_default.go
@@ -0,0 +1,12 @@
+//go:build !ios
+
+package cmd
+
+import (
+ "github.com/byteness/keyring"
+ "github.com/majd/ipatool/v2/pkg/keychain"
+)
+
+func openKeyring(config keyring.Config) (keychain.Keyring, error) {
+ return keyring.Open(config) //nolint:wrapcheck
+}
diff --git a/cmd/keyring_ios.go b/cmd/keyring_ios.go
new file mode 100644
index 00000000..85650ccb
--- /dev/null
+++ b/cmd/keyring_ios.go
@@ -0,0 +1,48 @@
+package cmd
+
+import (
+ "fmt"
+
+ gokeychain "github.com/byteness/go-keychain"
+ "github.com/byteness/keyring"
+ "github.com/majd/ipatool/v2/pkg/keychain"
+)
+
+// iosKeyring fixes the update query in keyring v1.9.0, which includes attributes
+// that SecItemUpdate rejects on iOS (kSecMatchLimit and kSecReturnAttributes).
+type iosKeyring struct {
+ keyring.Keyring
+ service string
+}
+
+func openKeyring(config keyring.Config) (keychain.Keyring, error) {
+ ring, err := keyring.Open(config)
+ if err != nil {
+ return nil, fmt.Errorf("open iOS keyring: %w", err)
+ }
+
+ return &iosKeyring{Keyring: ring, service: config.ServiceName}, nil
+}
+
+func (k *iosKeyring) Set(item keyring.Item) error {
+ query := gokeychain.NewItem()
+ query.SetSecClass(gokeychain.SecClassGenericPassword)
+ query.SetService(k.service)
+ query.SetAccount(item.Key)
+
+ attributes := gokeychain.NewItem()
+ attributes.SetData(item.Data)
+ attributes.SetLabel(item.Label)
+ attributes.SetDescription(item.Description)
+
+ err := gokeychain.UpdateItem(query, attributes)
+ if err == gokeychain.ErrorItemNotFound {
+ return k.Keyring.Set(item) //nolint:wrapcheck
+ }
+
+ if err != nil {
+ return fmt.Errorf("update iOS keyring item: %w", err)
+ }
+
+ return nil
+}
diff --git a/cmd/keyring_ios_test.go b/cmd/keyring_ios_test.go
new file mode 100644
index 00000000..8dde8863
--- /dev/null
+++ b/cmd/keyring_ios_test.go
@@ -0,0 +1,27 @@
+package cmd
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/byteness/keyring"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("iOS keyring", func() {
+ It("persists and updates credentials without invalid SecItemUpdate parameters", func() {
+ ring, err := openKeyring(keyring.Config{
+ AllowedBackends: []keyring.BackendType{keyring.KeychainBackend},
+ ServiceName: fmt.Sprintf("ipatool-test-%d", time.Now().UnixNano()),
+ })
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(func() { Expect(ring.Remove("account")).To(Succeed()) })
+ Expect(ring.Set(keyring.Item{Key: "account", Data: []byte("first"), Label: "ipatool"})).To(Succeed())
+ Expect(ring.Set(keyring.Item{Key: "account", Data: []byte("updated"), Label: "ipatool"})).To(Succeed())
+ item, err := ring.Get("account")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(item.Data).To(Equal([]byte("updated")))
+ Expect(item.Label).To(Equal("ipatool"))
+ })
+})
diff --git a/go.mod b/go.mod
index c798f336..68d82d20 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/avast/retry-go v3.0.0+incompatible
github.com/blacktop/go-macho v1.1.282
github.com/bodgit/sevenzip v1.6.3
+ github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4
github.com/byteness/keyring v1.9.0
github.com/ebitengine/purego v0.10.2
github.com/juju/persistent-cookiejar v1.0.0
@@ -29,7 +30,6 @@ require (
github.com/blacktop/go-dwarf v1.0.14 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
- github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/byteness/go-libsecret v0.0.0-20260108215642-107379d3dee0 // indirect
github.com/byteness/percent v0.2.2 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
diff --git a/internal/sap/unicorn/cache.go b/internal/sap/unicorn/cache.go
index 73785028..d2d14465 100644
--- a/internal/sap/unicorn/cache.go
+++ b/internal/sap/unicorn/cache.go
@@ -12,61 +12,11 @@ import (
"net/http"
"os"
"path/filepath"
- "runtime"
"strings"
- "time"
)
const maxArtifactSize = 64 << 20
-var artifactHTTPClient = &http.Client{Timeout: 2 * time.Minute}
-
-type runtimePaths struct {
- library string
- dependencies []string
-}
-
-func cachedRuntimePaths(ctx context.Context) (runtimePaths, error) {
- goos := runtime.GOOS
- if goos == "linux" && linuxUsesMusl() {
- goos = "linux-musl"
- }
-
- selected, err := artifactFor(goos, runtime.GOARCH)
- if err != nil {
- return runtimePaths{}, err
- }
-
- cache, err := os.UserCacheDir()
- if err != nil {
- return runtimePaths{}, fmt.Errorf("locate user cache: %w", err)
- }
-
- root := filepath.Join(cache, "ipatool", "unicorn", unicornVersion)
- paths := runtimePaths{dependencies: make([]string, 0, len(selected.dependencies))}
-
- for _, dependency := range selected.dependencies {
- path, err := ensureLibrary(ctx, root, dependency, artifactHTTPClient)
- if err != nil {
- return runtimePaths{}, err
- }
-
- paths.dependencies = append(paths.dependencies, path)
- }
-
- paths.library, err = ensureLibrary(ctx, root, selected, artifactHTTPClient)
- if err != nil {
- return runtimePaths{}, err
- }
-
- paths.library, err = prepareRuntimeLibrary(paths.library)
- if err != nil {
- return runtimePaths{}, err
- }
-
- return paths, nil
-}
-
func linuxUsesMusl() bool {
return linuxUsesMuslFor("/proc/self/exe", muslLoaderInstalled)
}
diff --git a/internal/sap/unicorn/cache_runtime.go b/internal/sap/unicorn/cache_runtime.go
new file mode 100644
index 00000000..9451e4c5
--- /dev/null
+++ b/internal/sap/unicorn/cache_runtime.go
@@ -0,0 +1,61 @@
+//go:build !ios
+
+package unicorn
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+)
+
+var artifactHTTPClient = &http.Client{Timeout: 2 * time.Minute}
+
+type runtimePaths struct {
+ library string
+ dependencies []string
+}
+
+func cachedRuntimePaths(ctx context.Context) (runtimePaths, error) {
+ goos := runtime.GOOS
+ if goos == "linux" && linuxUsesMusl() {
+ goos = "linux-musl"
+ }
+
+ selected, err := artifactFor(goos, runtime.GOARCH)
+ if err != nil {
+ return runtimePaths{}, err
+ }
+
+ cache, err := os.UserCacheDir()
+ if err != nil {
+ return runtimePaths{}, fmt.Errorf("locate user cache: %w", err)
+ }
+
+ root := filepath.Join(cache, "ipatool", "unicorn", unicornVersion)
+ paths := runtimePaths{dependencies: make([]string, 0, len(selected.dependencies))}
+
+ for _, dependency := range selected.dependencies {
+ path, err := ensureLibrary(ctx, root, dependency, artifactHTTPClient)
+ if err != nil {
+ return runtimePaths{}, err
+ }
+
+ paths.dependencies = append(paths.dependencies, path)
+ }
+
+ paths.library, err = ensureLibrary(ctx, root, selected, artifactHTTPClient)
+ if err != nil {
+ return runtimePaths{}, err
+ }
+
+ paths.library, err = prepareRuntimeLibrary(paths.library)
+ if err != nil {
+ return runtimePaths{}, err
+ }
+
+ return paths, nil
+}
diff --git a/internal/sap/unicorn/library_ios.go b/internal/sap/unicorn/library_ios.go
new file mode 100644
index 00000000..f9b8fff4
--- /dev/null
+++ b/internal/sap/unicorn/library_ios.go
@@ -0,0 +1,22 @@
+package unicorn
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/ebitengine/purego"
+)
+
+// iOS builds statically link Unicorn because the desktop runtime artifacts
+// cannot be loaded on iOS. tools/build-ios.sh exports its symbols for purego.
+func openLibrary(ctx context.Context) (library, error) {
+ if err := ctx.Err(); err != nil {
+ return library{}, fmt.Errorf("load Unicorn library: %w", err)
+ }
+
+ if _, err := purego.Dlsym(purego.RTLD_DEFAULT, "uc_version"); err != nil {
+ return library{}, fmt.Errorf("unicorn is not linked; build with tools/build-ios.sh: %w", err)
+ }
+
+ return library{handle: purego.RTLD_DEFAULT, close: func() error { return nil }}, nil
+}
diff --git a/internal/sap/unicorn/library_unix.go b/internal/sap/unicorn/library_unix.go
index ce918e82..50bb9055 100644
--- a/internal/sap/unicorn/library_unix.go
+++ b/internal/sap/unicorn/library_unix.go
@@ -1,4 +1,4 @@
-//go:build darwin || linux
+//go:build (darwin && !ios) || linux
package unicorn
diff --git a/internal/sap/unicorn/runtime_library_default.go b/internal/sap/unicorn/runtime_library_default.go
index e8b19f81..7d603fe2 100644
--- a/internal/sap/unicorn/runtime_library_default.go
+++ b/internal/sap/unicorn/runtime_library_default.go
@@ -1,4 +1,4 @@
-//go:build !windows || !arm64
+//go:build !ios && (!windows || !arm64)
package unicorn
diff --git a/resources/ios-entitlements.plist b/resources/ios-entitlements.plist
new file mode 100644
index 00000000..441964df
--- /dev/null
+++ b/resources/ios-entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ application-identifier
+ dev.majd.ipatool
+ com.apple.private.security.no-container
+
+ keychain-access-groups
+
+ dev.majd.ipatool
+
+
+
diff --git a/tools/build-ios.sh b/tools/build-ios.sh
new file mode 100755
index 00000000..cd833bf0
--- /dev/null
+++ b/tools/build-ios.sh
@@ -0,0 +1,42 @@
+#!/bin/sh
+set -eu
+
+# Build a standalone executable for jailbroken arm64 devices running iOS 15+.
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+OUTPUT=${1:-ipatool-ios-arm64}
+VERSION=${VERSION:-dev}
+
+for TOOL in ldid cmake; do
+ command -v "$TOOL" >/dev/null 2>&1 || {
+ echo "$TOOL is required; install it with 'brew install ldid cmake'." >&2
+ exit 1
+ }
+done
+
+SDKROOT=$(xcrun --sdk iphoneos --show-sdk-path)
+CC=$(xcrun --sdk iphoneos --find clang)
+export SDKROOT CC
+export IPHONEOS_DEPLOYMENT_TARGET=15.0
+export GOOS=ios GOARCH=arm64 CGO_ENABLED=1
+
+BUILD_DIR=$(mktemp -d /tmp/ipatool-ios.XXXXXX)
+trap 'rm -rf "$BUILD_DIR"' EXIT HUP INT TERM
+
+# Keep this version in sync with internal/sap/unicorn/artifact.go.
+curl -fL --retry 3 https://github.com/unicorn-engine/unicorn/archive/refs/tags/2.1.4.tar.gz -o "$BUILD_DIR/unicorn.tar.gz"
+echo "ea8863f095a0136388694e5a6063afd9bb7650e30243dd6251af59c5ce5601f4 $BUILD_DIR/unicorn.tar.gz" | shasum -a 256 -c -
+tar -xzf "$BUILD_DIR/unicorn.tar.gz" -C "$BUILD_DIR"
+# Use iOS W^X memory protection for Unicorn, including nested Go callbacks.
+patch -d "$BUILD_DIR/unicorn-2.1.4" -p1 < "$ROOT/tools/patches/unicorn-ios.patch"
+ARCHFLAGS='-arch arm64' cmake -S "$BUILD_DIR/unicorn-2.1.4" -B "$BUILD_DIR/build" \
+ -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT="$SDKROOT" \
+ -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \
+ -DCMAKE_C_FLAGS='-target arm64-apple-ios15.0' -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_SHARED_LIBS=OFF -DUNICORN_ARCH=x86 -DUNICORN_BUILD_TESTS=OFF -DUNICORN_INSTALL=OFF
+cmake --build "$BUILD_DIR/build" --parallel "$(sysctl -n hw.ncpu)"
+
+# purego resolves the statically linked Unicorn API through dlsym.
+export CGO_LDFLAGS="${CGO_LDFLAGS:-} -Wl,-force_load,$BUILD_DIR/build/libunicorn.a -Wl,-export_dynamic"
+cd "$ROOT"
+go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o "$OUTPUT" .
+ldid -S"$ROOT/resources/ios-entitlements.plist" "$OUTPUT"
diff --git a/tools/patches/unicorn-ios.patch b/tools/patches/unicorn-ios.patch
new file mode 100644
index 00000000..5f834734
--- /dev/null
+++ b/tools/patches/unicorn-ios.patch
@@ -0,0 +1,76 @@
+--- a/qemu/configure
++++ b/qemu/configure
+@@ -2152,7 +2152,7 @@
+ if [ "$darwin" = "yes" ] ; then
+ cat > $TMPC << EOF
+ #include
+-int main() { pthread_jit_write_protect_supported_np(); return 0;}
++int main() { pthread_jit_write_protect_supported_np(); pthread_jit_write_protect_np(0); return 0;}
+ EOF
+ if ! compile_prog ""; then
+ have_pthread_jit_protect='no'
+--- a/qemu/include/tcg/tcg-apple-jit.h
++++ b/qemu/include/tcg/tcg-apple-jit.h
+@@ -104,13 +104,11 @@
+ #endif
+
+
+-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && (defined(__arm__) || defined(__aarch64__))
++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__)
+
+-/* write protect enable = write disable */
+-static inline void jit_write_protect(int enabled)
+-{
+- return pthread_jit_write_protect_np(enabled);
+-}
++struct uc_struct;
++void ipatool_jit_write_protect(struct uc_struct *uc, int enabled);
++#define jit_write_protect(enabled) ipatool_jit_write_protect(uc, enabled)
+
+ #define JIT_CALLBACK_GUARD(x) \
+ { \
+--- a/qemu/accel/tcg/translate-all.c
++++ b/qemu/accel/tcg/translate-all.c
+@@ -1019,7 +1019,7 @@
+ static inline void *alloc_code_gen_buffer(struct uc_struct *uc)
+ {
+ TCGContext *tcg_ctx = uc->tcg_ctx;
+- int prot = PROT_WRITE | PROT_READ | PROT_EXEC;
++ int prot = PROT_WRITE | PROT_READ;
+ int flags = MAP_PRIVATE | MAP_ANONYMOUS;
+ size_t size = tcg_ctx->code_gen_buffer_size;
+ void *buf;
+@@ -2172,7 +2172,20 @@
+ }
+
+
+-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && (defined(__arm__) || defined(__aarch64__))
++/* iOS CLI processes cannot use MAP_JIT outside an app sandbox. Toggle the
++ * translation buffer between writable and executable instead of using RWX. */
++void ipatool_jit_write_protect(struct uc_struct *uc, int enabled)
++{
++ TCGContext *ctx = uc->tcg_ctx;
++ if (ctx && ctx->initial_buffer &&
++ mprotect(ctx->initial_buffer, ctx->initial_buffer_size,
++ PROT_READ | (enabled ? PROT_EXEC : PROT_WRITE)) != 0) {
++ perror("protect Unicorn translation buffer");
++ abort();
++ }
++}
++
++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__)
+ static bool tb_exec_is_locked(struct uc_struct *uc)
+ {
+ return uc->current_executable;
+--- a/uc.c
++++ b/uc.c
+@@ -35,8 +35,7 @@
+ static uc_err uc_snapshot(uc_engine *uc);
+ static uc_err uc_restore_latest_snapshot(uc_engine *uc);
+
+-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && \
+- (defined(__arm__) || defined(__aarch64__))
++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__)
+ static void save_jit_state(uc_engine *uc)
+ {
+ if (!uc->nested) {