Release 3.8.0.2: app.zip updates, lock hygiene, secret-safe getState.
This commit is contained in:
+128
-33
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.0"
|
||||
|
||||
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
|
||||
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
|
||||
const BuildNumber = 1
|
||||
const BuildNumber = 2
|
||||
|
||||
// DefaultManifestURL is the update feed (hosted in the project git repo).
|
||||
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
|
||||
@@ -82,21 +82,44 @@ func PlatformKey() string {
|
||||
return runtime.GOOS + "-" + runtime.GOARCH
|
||||
}
|
||||
|
||||
// AssetFor returns download URL + checksum for the given platform key.
|
||||
// Asset kinds returned by ResolveAsset.
|
||||
const (
|
||||
AssetKindBinary = "bin"
|
||||
AssetKindAppZip = "appzip"
|
||||
)
|
||||
|
||||
// AssetFor returns Mach-O / exe URL + checksum for the platform key.
|
||||
func (m Manifest) AssetFor(key string) (url, sha string, ok bool) {
|
||||
url, sha, _, ok = m.ResolveAsset(key, false)
|
||||
return url, sha, ok
|
||||
}
|
||||
|
||||
// ResolveAsset picks binary or .app.zip. PreferAppZip when running inside Navis.app.
|
||||
func (m Manifest) ResolveAsset(key string, preferAppZip bool) (url, sha, kind string, ok bool) {
|
||||
if key == "" {
|
||||
key = PlatformKey()
|
||||
}
|
||||
if m.Platforms != nil {
|
||||
if a, found := m.Platforms[key]; found && strings.TrimSpace(a.URL) != "" {
|
||||
return strings.TrimSpace(a.URL), strings.TrimSpace(a.SHA256), true
|
||||
if a, found := m.Platforms[key]; found {
|
||||
if preferAppZip {
|
||||
zu := strings.TrimSpace(a.ZipURL)
|
||||
zs := strings.TrimSpace(a.ZipSHA256)
|
||||
if zu != "" && zs != "" {
|
||||
return zu, zs, AssetKindAppZip, true
|
||||
}
|
||||
}
|
||||
u := strings.TrimSpace(a.URL)
|
||||
s := strings.TrimSpace(a.SHA256)
|
||||
if u != "" {
|
||||
return u, s, AssetKindBinary, true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy single-asset manifests are windows-amd64.
|
||||
if (key == "windows-amd64" || key == "windows-386") && strings.TrimSpace(m.URL) != "" {
|
||||
return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), true
|
||||
return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), AssetKindBinary, true
|
||||
}
|
||||
return "", "", false
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
// Check fetches the manifest and compares versions for this platform.
|
||||
@@ -116,7 +139,15 @@ func Check(ctx context.Context, manifestURL string) (Status, error) {
|
||||
st.Notes = m.Notes
|
||||
st.Mandatory = m.Mandatory
|
||||
|
||||
url, sha, ok := m.AssetFor(key)
|
||||
preferZip := false
|
||||
if runtime.GOOS == "darwin" {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, ok := AppBundleRoot(exe); ok {
|
||||
preferZip = true
|
||||
}
|
||||
}
|
||||
}
|
||||
url, sha, _, ok := m.ResolveAsset(key, preferZip)
|
||||
if !ok {
|
||||
st.Error = fmt.Sprintf("нет сборки для %s", key)
|
||||
st.Available = false
|
||||
@@ -247,60 +278,124 @@ func fileSHA256(path string) (string, error) {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// preparedUpdate is a verified download ready for platform Apply.
|
||||
type preparedUpdate struct {
|
||||
Latest string
|
||||
URL string
|
||||
SHA256 string
|
||||
ExePath string
|
||||
TmpPath string
|
||||
Kind string // AssetKindBinary | AssetKindAppZip
|
||||
AppRoot string // set when Kind is appzip
|
||||
}
|
||||
|
||||
// AppBundleRoot returns the .app path when exe lives in Contents/MacOS.
|
||||
func AppBundleRoot(exe string) (string, bool) {
|
||||
exe = filepath.Clean(exe)
|
||||
const marker = ".app" + string(filepath.Separator) + "Contents" + string(filepath.Separator) + "MacOS"
|
||||
idx := strings.Index(exe, marker)
|
||||
if idx < 0 {
|
||||
// Also accept forward-slash form after EvalSymlinks on some volumes.
|
||||
idx = strings.Index(exe, ".app/Contents/MacOS")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return exe[:idx+len(".app")], true
|
||||
}
|
||||
return exe[:idx+len(".app")], true
|
||||
}
|
||||
|
||||
// prepareDownload fetches the update next to the running binary as temp download.
|
||||
// Refuses when already on/newer than the feed product version (no re-apply loop).
|
||||
func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, exePath, tmpPath string, err error) {
|
||||
st, err := Check(ctx, manifestURL)
|
||||
p, err := prepareUpdate(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
return p.Latest, p.URL, p.SHA256, p.ExePath, p.TmpPath, nil
|
||||
}
|
||||
|
||||
func prepareUpdate(ctx context.Context, manifestURL string) (preparedUpdate, error) {
|
||||
st, err := Check(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
if !st.Available {
|
||||
if st.Error != "" {
|
||||
return "", "", "", "", "", fmt.Errorf("%s", st.Error)
|
||||
return preparedUpdate{}, fmt.Errorf("%s", st.Error)
|
||||
}
|
||||
return "", "", "", "", "", fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
|
||||
}
|
||||
if !allowedDownloadURL(st.URL) {
|
||||
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
return preparedUpdate{}, fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
exe, err = filepath.Abs(exe)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil && resolved != "" {
|
||||
exe = resolved
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(exe), ".navis-update-download")
|
||||
_ = os.Remove(tmp)
|
||||
// Also clear legacy leftover from older updaters.
|
||||
_ = os.Remove(exe + ".new")
|
||||
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
wantSHA := st.SHA256
|
||||
if wantSHA == "" {
|
||||
if man, err := fetchManifest(ctx, manifestURL); err == nil {
|
||||
if _, sha2, ok := man.AssetFor(PlatformKey()); ok {
|
||||
wantSHA = sha2
|
||||
}
|
||||
|
||||
preferZip := false
|
||||
appRoot := ""
|
||||
if runtime.GOOS == "darwin" {
|
||||
if root, ok := AppBundleRoot(exe); ok {
|
||||
preferZip = true
|
||||
appRoot = root
|
||||
}
|
||||
}
|
||||
|
||||
man, err := fetchManifest(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
url, wantSHA, kind, ok := man.ResolveAsset(PlatformKey(), preferZip)
|
||||
if !ok || url == "" {
|
||||
return preparedUpdate{}, fmt.Errorf("нет ассета обновления для %s", PlatformKey())
|
||||
}
|
||||
if !allowedDownloadURL(url) {
|
||||
return preparedUpdate{}, fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
}
|
||||
if wantSHA == "" {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
|
||||
return preparedUpdate{}, fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
|
||||
}
|
||||
// Prefer .app.zip when available; otherwise replace Mach-O inside the bundle (legacy).
|
||||
if kind != AssetKindAppZip {
|
||||
appRoot = ""
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
if appRoot != "" {
|
||||
dir = filepath.Dir(appRoot) // sibling of .app
|
||||
}
|
||||
tmpName := ".navis-update-download"
|
||||
if kind == AssetKindAppZip {
|
||||
tmpName = ".navis-update-app.zip"
|
||||
}
|
||||
tmp := filepath.Join(dir, tmpName)
|
||||
_ = os.Remove(tmp)
|
||||
_ = os.Remove(exe + ".new")
|
||||
if err := downloadFile(ctx, url, tmp); err != nil {
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
sum, err := fileSHA256(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", err
|
||||
return preparedUpdate{}, err
|
||||
}
|
||||
if !strings.EqualFold(sum, wantSHA) {
|
||||
_ = os.Remove(tmp)
|
||||
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
||||
return preparedUpdate{}, fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
return st.Latest, st.URL, wantSHA, exe, tmp, nil
|
||||
return preparedUpdate{
|
||||
Latest: st.Latest,
|
||||
URL: url,
|
||||
SHA256: wantSHA,
|
||||
ExePath: exe,
|
||||
TmpPath: tmp,
|
||||
Kind: kind,
|
||||
AppRoot: appRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -12,21 +12,28 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Apply downloads the new binary and schedules replacement + relaunch after exit.
|
||||
// Apply downloads the new binary or .app.zip and schedules replacement + relaunch after exit.
|
||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL)
|
||||
p, err := prepareUpdate(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(tmp, 0o755)
|
||||
dir := filepath.Dir(exe)
|
||||
if p.Kind == AssetKindAppZip {
|
||||
return applyAppZip(p)
|
||||
}
|
||||
return applyBinary(p)
|
||||
}
|
||||
|
||||
func applyBinary(p preparedUpdate) (string, error) {
|
||||
_ = os.Chmod(p.TmpPath, 0o755)
|
||||
dir := filepath.Dir(p.ExePath)
|
||||
scriptPath := filepath.Join(dir, "navis-update.sh")
|
||||
pid := os.Getpid()
|
||||
|
||||
script := "#!/bin/bash\n" +
|
||||
"set -e\n" +
|
||||
"EXE=" + shellQuote(exe) + "\n" +
|
||||
"NEW=" + shellQuote(tmp) + "\n" +
|
||||
"EXE=" + shellQuote(p.ExePath) + "\n" +
|
||||
"NEW=" + shellQuote(p.TmpPath) + "\n" +
|
||||
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
||||
"sleep 0.5\n" +
|
||||
@@ -43,7 +50,53 @@ func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("start updater: %w", err)
|
||||
}
|
||||
return latest, nil
|
||||
return p.Latest, nil
|
||||
}
|
||||
|
||||
func applyAppZip(p preparedUpdate) (string, error) {
|
||||
if p.AppRoot == "" {
|
||||
return "", fmt.Errorf("update: empty app root")
|
||||
}
|
||||
parent := filepath.Dir(p.AppRoot)
|
||||
scriptPath := filepath.Join(parent, "navis-update-app.sh")
|
||||
pid := os.Getpid()
|
||||
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
|
||||
if identity == "" {
|
||||
identity = "-"
|
||||
}
|
||||
|
||||
script := "#!/bin/bash\n" +
|
||||
"set -e\n" +
|
||||
"APP=" + shellQuote(p.AppRoot) + "\n" +
|
||||
"ZIP=" + shellQuote(p.TmpPath) + "\n" +
|
||||
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||
"ID=" + shellQuote(identity) + "\n" +
|
||||
"PARENT=$(dirname \"$APP\")\n" +
|
||||
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
||||
"sleep 0.5\n" +
|
||||
"TMP=$(mktemp -d \"$PARENT/.navis-update-XXXXXX\")\n" +
|
||||
"unzip -q \"$ZIP\" -d \"$TMP\"\n" +
|
||||
"NEW=$(find \"$TMP\" -maxdepth 3 -name 'Navis.app' -type d | head -n 1)\n" +
|
||||
"if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" +
|
||||
"rm -rf \"$APP.bak\"\n" +
|
||||
"mv \"$APP\" \"$APP.bak\"\n" +
|
||||
"mv \"$NEW\" \"$APP\"\n" +
|
||||
"if command -v codesign >/dev/null 2>&1; then\n" +
|
||||
" codesign -s \"$ID\" --force --deep --options runtime \"$APP\" 2>/dev/null || codesign -s \"$ID\" --force --deep \"$APP\" || true\n" +
|
||||
" xattr -cr \"$APP\" 2>/dev/null || true\n" +
|
||||
"fi\n" +
|
||||
"rm -rf \"$APP.bak\" \"$TMP\" \"$ZIP\" " + shellQuote(scriptPath) + "\n" +
|
||||
"open \"$APP\"\n"
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command("/bin/bash", scriptPath)
|
||||
cmd.Dir = parent
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("start app updater: %w", err)
|
||||
}
|
||||
return p.Latest, nil
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
@@ -58,5 +111,13 @@ func CleanupStaleDownloads() {
|
||||
}
|
||||
exe, _ = filepath.Abs(exe)
|
||||
_ = os.Remove(exe + ".new")
|
||||
_ = os.Remove(filepath.Join(filepath.Dir(exe), ".navis-update-download"))
|
||||
dir := filepath.Dir(exe)
|
||||
_ = os.Remove(filepath.Join(dir, ".navis-update-download"))
|
||||
_ = os.Remove(filepath.Join(dir, ".navis-update-app.zip"))
|
||||
if root, ok := AppBundleRoot(exe); ok {
|
||||
parent := filepath.Dir(root)
|
||||
_ = os.Remove(filepath.Join(parent, ".navis-update-app.zip"))
|
||||
_ = os.Remove(filepath.Join(parent, "navis-update-app.sh"))
|
||||
_ = os.Remove(filepath.Join(parent, "navis-update.sh"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,37 @@ func TestCompare(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAssetPrefersZip(t *testing.T) {
|
||||
m := Manifest{
|
||||
Platforms: map[string]PlatformAsset{
|
||||
"darwin-arm64": {
|
||||
URL: "https://git.evilfox.cc/bin",
|
||||
SHA256: "aaa",
|
||||
ZipURL: "https://git.evilfox.cc/app.zip",
|
||||
ZipSHA256: "bbb",
|
||||
},
|
||||
},
|
||||
}
|
||||
u, s, k, ok := m.ResolveAsset("darwin-arm64", true)
|
||||
if !ok || k != AssetKindAppZip || u == "" || s != "bbb" {
|
||||
t.Fatalf("zip: ok=%v kind=%s url=%s sha=%s", ok, k, u, s)
|
||||
}
|
||||
u, s, k, ok = m.ResolveAsset("darwin-arm64", false)
|
||||
if !ok || k != AssetKindBinary || s != "aaa" {
|
||||
t.Fatalf("bin: ok=%v kind=%s sha=%s", ok, k, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppBundleRoot(t *testing.T) {
|
||||
root, ok := AppBundleRoot("/Applications/Navis.app/Contents/MacOS/Navis")
|
||||
if !ok || root != "/Applications/Navis.app" {
|
||||
t.Fatalf("got %q ok=%v", root, ok)
|
||||
}
|
||||
if _, ok := AppBundleRoot("/usr/local/bin/Navis"); ok {
|
||||
t.Fatal("expected bare binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestAssetFor(t *testing.T) {
|
||||
m := Manifest{
|
||||
URL: "https://git.evilfox.cc/legacy.exe",
|
||||
|
||||
Reference in New Issue
Block a user