Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/dry-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 42 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions cmd/keyring_default.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions cmd/keyring_ios.go
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 27 additions & 0 deletions cmd/keyring_ios_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
})
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
50 changes: 0 additions & 50 deletions internal/sap/unicorn/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
61 changes: 61 additions & 0 deletions internal/sap/unicorn/cache_runtime.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions internal/sap/unicorn/library_ios.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion internal/sap/unicorn/library_unix.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build darwin || linux
//go:build (darwin && !ios) || linux

package unicorn

Expand Down
2 changes: 1 addition & 1 deletion internal/sap/unicorn/runtime_library_default.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build !windows || !arm64
//go:build !ios && (!windows || !arm64)

package unicorn

Expand Down
14 changes: 14 additions & 0 deletions resources/ios-entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>application-identifier</key>
<string>dev.majd.ipatool</string>
<key>com.apple.private.security.no-container</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>dev.majd.ipatool</string>
</array>
</dict>
</plist>
Loading
Loading