Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a070a41
feat(macos): define ARM64 guest profile and emulator paths
DeathofBrain Nov 5, 2026
55f5530
chore: remove test changes from macOS profile PR
DeathofBrain Nov 5, 2026
47e52c2
Merge upstream main and retain 1080p resolution defaults
DeathofBrain Nov 5, 2026
5ad74e7
feat: implement Darwin process runtime primitives
DeathofBrain Nov 5, 2026
1432755
Merge remote-tracking branch 'origin/main' into codex/darwin-process-…
DeathofBrain Nov 5, 2026
fcefa74
refactor: separate host routing from platform backends
DeathofBrain Nov 5, 2026
41fa471
Merge main into codex/darwin-process-runtime and resolve host conflicts
DeathofBrain Nov 5, 2026
a24cd4c
Merge latest main and retain portable host test imports
DeathofBrain Nov 5, 2026
f829e44
Merge remote-tracking branch 'origin/main' into codex/macos-arm64-pro…
DeathofBrain Nov 5, 2026
acd96b5
test: sync existing platform expectations for ARM64 profiles
DeathofBrain Nov 5, 2026
36197b7
Merge remote-tracking branch 'origin/main' into codex/macos-arm64-pro…
DeathofBrain Nov 5, 2026
e1ddfd2
Merge latest main and retain host routing for compact and logcat
DeathofBrain Nov 5, 2026
81ef060
Merge implemented macOS stages 1 and 2
DeathofBrain Nov 5, 2026
5adf4c7
feat: add macOS host and ARM64 boot preflight checks
DeathofBrain Nov 5, 2026
6f92a12
feat: fetch pinned Darwin ARM64 SDK components
DeathofBrain Nov 5, 2026
d22e58a
Merge remote-tracking branch 'origin/main' into codex/macos-1-api35-r…
DeathofBrain Nov 6, 2026
22e3dbf
fix: select Android API level from the host platform table
DeathofBrain Nov 6, 2026
71bb88c
Merge current main and preserve host routing for new upstream calls
DeathofBrain Nov 6, 2026
489f193
Merge branch 'codex/macos-1-api35-review' into codex/macos-review-base
DeathofBrain Nov 6, 2026
d8e8a46
Merge branch 'codex/macos-review-base' into codex/macos-4-review
DeathofBrain Nov 6, 2026
dd5463c
fix: check the platform API level in Darwin image metadata
DeathofBrain Nov 6, 2026
f32641c
Merge branch 'codex/macos-4-review' into codex/macos-6-review
DeathofBrain Nov 6, 2026
5c9f495
fix: pin Darwin system image to API 35 revision 9
DeathofBrain Nov 6, 2026
e7baf75
fix: identify Darwin SDK busy paths by directory identity
DeathofBrain Nov 6, 2026
4531acb
fix: use named macOS system image constant
DeathofBrain Nov 6, 2026
8c61f79
Merge branch 'codex/macos-1-api35-review' into codex/macos-review-base
DeathofBrain Nov 6, 2026
b8769d4
Merge branch 'codex/macos-review-base' into codex/macos-4-review
DeathofBrain Nov 6, 2026
1f7835f
Merge branch 'codex/macos-4-review' into codex/macos-6-review
DeathofBrain Nov 6, 2026
702f23b
Merge main into macOS preflight checks
DeathofBrain Nov 6, 2026
3d6c561
Merge commit '702f23b1bb60c944dc8e80a405642b395a5539cd' into codex/ma…
DeathofBrain Nov 6, 2026
7a315db
fix: align macOS component consent with manager download sources
DeathofBrain Nov 6, 2026
ea308bf
Merge main and preserve platform-specific component installation
DeathofBrain Nov 6, 2026
447c956
feat: prepare Google Play ARM64 components on macOS
DeathofBrain Nov 6, 2026
33afbf6
feat: protect macOS update settings with Keychain
DeathofBrain Nov 6, 2026
bd5103a
Merge main into macOS Keychain settings
DeathofBrain Nov 7, 2026
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
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis
- `install` runs `prepare` first if needed, then swaps in `sdk\emulator` and the other files in the package. All instances, manager windows and MCP clients must be closed first; if any program is using the files, it changes nothing (exit code 5), and the staged new version is kept so the next run can swap it in directly;
- `recover` finishes or rolls back a swap that was left unfinished last time (it also runs first at the start of every step).
- The update source is chosen in the manager's "Settings" (`设置`): GitHub (the latest Release of `ModAVD/ModAVD`, the full package) or MirrorChyan (downloads need a CDK, and it can provide delta packages; on errors it downloads from GitHub instead and says so in the result's `notices`). The CDK is encrypted with Windows DPAPI and stored in `%LOCALAPPDATA%\ModAVD\update-settings.json`; only this Windows user can decrypt it, and it is not written to logs and does not appear on the command line or in API responses. For now the manager can only change the settings and check for updates; the "Update" (`更新`) button is not available.
- On macOS, update settings and component download sources use `~/Library/Application Support/ModAVD/update-settings.json` and `components-settings.json`. The CDK is encrypted with AES-GCM using a random wrapping key kept in the current user's Keychain. A locked or unauthorized Keychain makes saving fail without plaintext storage; copying the settings to another user or device does not transfer that key.
- Only files from the package are swapped: `avd\`, `mas-avd.json` (only `components.emulator` is changed and an `updateHistory` entry is recorded), `sdk\platform-tools`, `sdk\system-images`, `logs\` and so on are never touched; `sdk\emulator` is replaced as a whole. A root folder that sets `sdkRoot` in `mas-avd.json` is not updated.
- `--automas-version` is the version of the calling AUTO-MAS: if the first line of the new version's announcement requires a higher AUTO-MAS, it does not install.
- Exit codes: being unable to check (offline, rate-limited, a GitHub error) is 9 (`update_unavailable`), a downloaded package that fails verification is also 9 (`update_rejected`), and a program in use or an update already in progress is 5.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd
- `install` 需要时先 `prepare`,再换上 `sdk\emulator` 和包里的其他文件。要先关掉所有实例、管理器窗口和 MCP 客户端,有程序在用就什么都不动(退出码 5),暂存的新版留着,下次直接换;
- `recover` 补完或退回上次没做完的换上(每一步开始前也会先做)。
- 更新源在管理器的「设置」里选:GitHub(`ModAVD/ModAVD` 的最新 Release,整包)或 Mirror 酱(填了 CDK 才能下载,能拿到增量包;出错时改从 GitHub 下,并在结果的 `notices` 里写明)。CDK 用 Windows DPAPI 加密存在 `%LOCALAPPDATA%\ModAVD\update-settings.json`,只有这个 Windows 用户能解开,不写日志、不出现在命令行和接口返回里。管理器里暂时只能改设置和检查更新,「更新」按钮不可用。
- macOS 的更新设置和组件下载源分别保存在 `~/Library/Application Support/ModAVD/update-settings.json` 和 `components-settings.json`。CDK 使用 AES-GCM 加密,随机包装密钥留在当前用户的 Keychain;钥匙串锁定或未授权时保存失败,不会降级为明文存储。复制设置文件到其他用户或设备不会同时转移密钥。
- 只换包里的文件:`avd\`、`mas-avd.json`(只改 `components.emulator` 并记一条 `updateHistory`)、`sdk\platform-tools`、`sdk\system-images`、`logs\` 等一律不碰;`sdk\emulator` 整个换掉。根目录在 `mas-avd.json` 里设了 `sdkRoot` 的不更新。
- `--automas-version` 是调用方 AUTO-MAS 的版本:新版的公告第一行要求更高的 AUTO-MAS 时不装。
- 退出码:查不了(断网、限流、GitHub 出错)是 9(`update_unavailable`),下载的包没通过核对也是 9(`update_rejected`),有程序在用、正在更新是 5。
Expand Down
28 changes: 14 additions & 14 deletions internal/components/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"

"modavd/internal/errs"
"modavd/internal/root"
"modavd/internal/host"
)

// SourceSettings is the component download source selected by the user. An
Expand All @@ -31,19 +32,11 @@ type sourceSettingsFile struct {
// settings, but a separate file so the two settings cannot overwrite one
// another.
var sourceSettingsPath = func() string {
var base string
if root.CurrentPlatform().Windows() {
base = os.Getenv("LOCALAPPDATA")
if base == "" {
base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
}
} else {
base, _ = os.UserConfigDir()
if base == "" {
return ""
}
base := host.UserSettingsDir()
if base == "" {
return ""
}
return filepath.Join(base, "ModAVD", "components-settings.json")
return filepath.Join(base, "components-settings.json")
}

// LoadSourceSettings reads the per-user component source. Missing, malformed,
Expand Down Expand Up @@ -85,7 +78,14 @@ func SaveSourceSettings(mirror string, urls []string) (SourceSettings, error) {
return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败")
}
path := sourceSettingsPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if path == "" {
return SourceSettings{}, errs.New(errs.Internal, "找不到当前用户的配置目录,无法保存组件下载源")
}
dirMode := os.FileMode(0o755)
if runtime.GOOS == "darwin" {
dirMode = 0o700
}
if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil {
return SourceSettings{}, errs.Wrap(errs.Internal, err, "保存组件下载源失败: "+err.Error())
}
tmp := path + ".tmp"
Expand Down
3 changes: 3 additions & 0 deletions internal/host/darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ func DiskFree(path string) (uint64, error) { return macos.DiskFree
func AvailableMemoryMB() (uint64, error) { return macos.AvailableMemoryMB() }
func MetalDevices(ctx context.Context) ([]string, error) { return macos.MetalDevices(ctx) }
func RenameNoReplace(src, dst string) error { return macos.RenameNoReplace(src, dst) }

func Protect(data, entropy []byte) ([]byte, error) { return macos.Protect(data, entropy) }
func Unprotect(data, entropy []byte) ([]byte, error) { return macos.Unprotect(data, entropy) }
9 changes: 9 additions & 0 deletions internal/host/protect_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows && (!darwin || !cgo)

package host

// Protect requires Windows DPAPI or the native macOS Keychain backend.
func Protect([]byte, []byte) ([]byte, error) { return nil, errUnsupported }

// Unprotect requires Windows DPAPI or the native macOS Keychain backend.
func Unprotect([]byte, []byte) ([]byte, error) { return nil, errUnsupported }
26 changes: 26 additions & 0 deletions internal/host/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package host

import (
"os"
"path/filepath"
"runtime"
)

// UserSettingsDir is shared by update and component source settings. Windows
// retains its LOCALAPPDATA convention; macOS uses Application Support in the
// current user's home directory, independently of any ModAVD installation.
func UserSettingsDir() string {
var base string
if runtime.GOOS == "windows" {
base = os.Getenv("LOCALAPPDATA")
if base == "" {
base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
}
} else {
base, _ = os.UserConfigDir()
if base == "" {
return ""
}
}
return filepath.Join(base, "ModAVD")
}
6 changes: 0 additions & 6 deletions internal/host/unsupported_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,6 @@ func VolumeFileSystem(string) (string, error) { return "", errUnsupported }
// MoveFileReplaceThrough calls MoveFileExW (REPLACE_EXISTING | WRITE_THROUGH).
func MoveFileReplaceThrough(string, string) error { return errUnsupported }

// Protect calls CryptProtectData (DPAPI) for the current Windows user.
func Protect([]byte, []byte) ([]byte, error) { return nil, errUnsupported }

// Unprotect calls CryptUnprotectData (DPAPI) for the current Windows user.
func Unprotect([]byte, []byte) ([]byte, error) { return nil, errUnsupported }

// SetEmulatorWindowsVisible finds the emulator window and calls ShowWindow on Windows.
func SetEmulatorWindowsVisible(uint32, bool) (int, error) { return 0, errUnsupported }

Expand Down
128 changes: 128 additions & 0 deletions internal/macos/protect_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//go:build darwin && cgo

package macos

/*
#cgo LDFLAGS: -framework Security -framework CoreFoundation -framework Foundation -framework LocalAuthentication
#include <stdlib.h>
#include <stdint.h>
int32_t modavd_wrapping_key(const char *service, const char *account, unsigned char *key, int create);
*/
import "C"

import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"unsafe"
)

// Each purpose gets an independent random AES key in this user's Keychain.
// Only the encrypted, versioned envelope is written to the settings file.
const wrappingKeyService = "org.modavd.manager.settings.v1"

var wrappingKeyMu sync.Mutex

// The last byte is the envelope version. Unknown versions are rejected before
// accessing Keychain; raw Windows DPAPI blobs are never mistaken for this form.
var protectedHeader = []byte{'M', 'o', 'd', 'A', 'V', 'D', 'K', 1}

func wrappingKey(entropy []byte, create bool) ([]byte, error) {
wrappingKeyMu.Lock()
defer wrappingKeyMu.Unlock()
digest := sha256.Sum256(entropy)
service := C.CString(wrappingKeyService)
defer C.free(unsafe.Pointer(service))
account := C.CString(hex.EncodeToString(digest[:]))
defer C.free(unsafe.Pointer(account))
key := make([]byte, 32)
flag := C.int(0)
if create {
flag = 1
}
status := int32(C.modavd_wrapping_key(service, account, (*C.uchar)(unsafe.Pointer(&key[0])), flag))
if status != 0 {
clear(key)
return nil, keychainError(status)
}
return key, nil
}

func keychainError(status int32) error {
var reason string
switch status {
case -25300:
reason = "没有对应的包装密钥(设置可能来自其他用户或设备)"
case -25308, -25293:
reason = "钥匙串已锁定或未授权;请先解锁并授权当前应用"
case -25291:
reason = "钥匙串当前不可用"
case -26275:
reason = "包装密钥格式损坏"
default:
reason = "无法读取或保存包装密钥"
}
return fmt.Errorf("macOS Keychain:%s(OSStatus %d)", reason, status)
}

func protectedCipher(entropy []byte, create bool) (cipher.AEAD, error) {
key, err := wrappingKey(entropy, create)
if err != nil {
return nil, err
}
defer clear(key)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}

func protectedAAD(entropy []byte) []byte {
aad := make([]byte, 0, len(protectedHeader)+len(entropy))
aad = append(aad, protectedHeader...)
return append(aad, entropy...)
}

// Protect encrypts data with AES-256-GCM using a purpose-specific random key
// kept in the current user's Keychain. A locked or unauthorized Keychain fails
// without opening an authentication dialog or falling back to a plaintext key.
func Protect(data, entropy []byte) ([]byte, error) {
gcm, err := protectedCipher(entropy, true)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("生成设置加密随机数:%w", err)
}
envelope := make([]byte, len(protectedHeader), len(protectedHeader)+len(nonce)+len(data)+gcm.Overhead())
copy(envelope, protectedHeader)
envelope = append(envelope, nonce...)
return gcm.Seal(envelope, nonce, data, protectedAAD(entropy)), nil
}

// Unprotect authenticates the version, purpose and ciphertext before returning
// any plaintext. It never creates a key when the original key is missing.
func Unprotect(data, entropy []byte) ([]byte, error) {
// Standard GCM uses a 12-byte nonce and 16-byte tag, including empty data.
if len(data) < len(protectedHeader)+12+16 || !bytes.Equal(data[:len(protectedHeader)], protectedHeader) {
return nil, errors.New("macOS 加密设置的格式或版本无效")
}
gcm, err := protectedCipher(entropy, false)
if err != nil {
return nil, err
}
body := data[len(protectedHeader):]
plain, err := gcm.Open(nil, body[:gcm.NonceSize()], body[gcm.NonceSize():], protectedAAD(entropy))
if err != nil {
return nil, errors.New("macOS 加密设置校验失败(内容被修改或用途不匹配)")
}
return plain, nil
}
70 changes: 70 additions & 0 deletions internal/macos/protect_darwin.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//go:build darwin && cgo

#import <Foundation/Foundation.h>
#import <LocalAuthentication/LocalAuthentication.h>
#import <Security/Security.h>
#include <stdint.h>
#include <string.h>

// Queries are always noninteractive: CLI and background manager requests must
// return an error when Keychain needs consent, rather than opening a modal UI.
int32_t modavd_wrapping_key(const char *service, const char *account,
unsigned char *key, int create) {
@autoreleasepool {
NSString *serviceString = [NSString stringWithUTF8String:service];
NSString *accountString = [NSString stringWithUTF8String:account];
if (!serviceString || !accountString) return errSecParam;
LAContext *context = [[LAContext alloc] init];
context.interactionNotAllowed = YES;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSMutableDictionary *query = [@{
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
(__bridge id)kSecAttrService: serviceString,
(__bridge id)kSecAttrAccount: accountString,
(__bridge id)kSecAttrSynchronizable: @NO,
(__bridge id)kSecUseAuthenticationContext: context,
// The file-based login Keychain also needs this explicit flag;
// LAContext alone covers the data-protection Keychain backend.
(__bridge id)kSecUseAuthenticationUI: (__bridge id)kSecUseAuthenticationUIFail,
(__bridge id)kSecReturnData: @YES,
(__bridge id)kSecMatchLimit: (__bridge id)kSecMatchLimitOne,
} mutableCopy];
#pragma clang diagnostic pop
CFTypeRef result = NULL;
BOOL created = NO;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
if (status == errSecItemNotFound && create) {
status = SecRandomCopyBytes(kSecRandomDefault, 32, key);
if (status == errSecSuccess) {
[query removeObjectForKey:(__bridge id)kSecReturnData];
[query removeObjectForKey:(__bridge id)kSecMatchLimit];
query[(__bridge id)kSecValueData] = [NSData dataWithBytes:key length:32];
status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
created = status == errSecSuccess;
if (status == errSecDuplicateItem) {
// Another manager won the creation race. Read its key;
// never replace it or publish data encrypted with our key.
[query removeObjectForKey:(__bridge id)kSecValueData];
query[(__bridge id)kSecReturnData] = @YES;
query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
}
}
}
if (result) {
if (status == errSecSuccess && CFGetTypeID(result) == CFDataGetTypeID()
&& CFDataGetLength((CFDataRef)result) == 32) {
memcpy(key, CFDataGetBytePtr((CFDataRef)result), 32);
} else if (status == errSecSuccess) {
status = errSecDecode;
}
CFRelease(result);
} else if (status == errSecSuccess && !created) {
status = errSecDecode;
}
[query release];
[context release];
return status;
}
}
2 changes: 1 addition & 1 deletion internal/ui/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9,
`update.Status` 的全部字段(和 `modavd update --json` 的 `data` 相同:`root`、`updatable`、`not_updatable_reason`、`current_version`、`current_build`、`source`、`cdk_set`、`checked`、`check_error`、`latest_version`、`latest_source`、`update_available`、`required_automas`、`release_url`、`phase`、`prepared_version`、`last_result`、`recovered`、`updated`、`message`、`notices`),加上 `checking`、`checked_at`(RFC 3339,本次启动后没查过是 `""`)、`error`(上次检查的错误对象,没有是 `null`)、`install_by_cli`(现在恒为 `true`:管理器里不能安装,由 AUTO-MAS 调 `modavd update install`)。

- 只有点「检查更新」才联网;没有自动检查。
- 更新源和 CDK 是本机这个 Windows 用户的设置(`%LOCALAPPDATA%\ModAVD\update-settings.json`),命令行更新时按它来。
- 更新源和 CDK 是当前系统用户的设置,命令行更新时按它来。Windows 使用 `%LOCALAPPDATA%\ModAVD\update-settings.json` 和 DPAPI;macOS 使用 `~/Library/Application Support/ModAVD/update-settings.json`,CDK 的随机 AES-GCM 包装密钥保存在当前用户的 Keychain。钥匙串锁定或未授权时保存失败,不弹授权框,不保存明文密钥。

## 生命周期

Expand Down
3 changes: 3 additions & 0 deletions internal/update/platform_other_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ func updateTree(t *testing.T, dir string) []string {
}

func TestSaveSettingsUnsupported(t *testing.T) {
if nativeDarwinSettings && root.CurrentPlatform().Supported() {
t.Skip("native macOS supports per-user settings")
}
if _, err := SaveSettings(SourceGitHub, nil); errs.CodeOf(err) != errs.Unsupported {
t.Fatalf("SaveSettings: %v", err)
}
Expand Down
Loading
Loading