diff --git a/backend/backup_test.go b/backend/backup_test.go new file mode 100644 index 0000000..64f719c --- /dev/null +++ b/backend/backup_test.go @@ -0,0 +1,259 @@ +package gobridge + +import ( + "archive/zip" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + cryptozip "github.com/yeka/zip" +) + +const minimalCertPEM = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" +const minimalKeyPEM = "-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n" +const minimalConfigXML = `` + +func writeBackupZip(t *testing.T, dst string, entries map[string][]byte, fakeEncryptionBit bool) { + t.Helper() + f, err := os.Create(dst) + if err != nil { + t.Fatalf("create %s: %v", dst, err) + } + defer f.Close() + w := zip.NewWriter(f) + for name, data := range entries { + hdr := &zip.FileHeader{Name: name, Method: zip.Deflate} + if fakeEncryptionBit { + hdr.Flags |= 0x1 + } + hdr.SetMode(0o600) + writer, err := w.CreateHeader(hdr) + if err != nil { + t.Fatalf("create header %s: %v", name, err) + } + if _, err := writer.Write(data); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } +} + +func writeEncryptedZip(t *testing.T, dst string, entries map[string][]byte, password string) { + t.Helper() + f, err := os.Create(dst) + if err != nil { + t.Fatalf("create %s: %v", dst, err) + } + defer f.Close() + w := cryptozip.NewWriter(f) + for name, data := range entries { + writer, err := w.Encrypt(name, password, cryptozip.AES256Encryption) + if err != nil { + t.Fatalf("encrypt header %s: %v", name, err) + } + if _, err := writer.Write(data); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close encrypted zip: %v", err) + } +} + +func TestImportConfig_RoundTrip(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + if err := os.MkdirAll(src, 0o700); err != nil { + t.Fatal(err) + } + must := func(name, data string) { + if err := os.WriteFile(filepath.Join(src, name), []byte(data), 0o600); err != nil { + t.Fatal(err) + } + } + must(configFileName, minimalConfigXML) + must(certFileName, minimalCertPEM) + must(keyFileName, minimalKeyPEM) + + prefsPath := filepath.Join(dir, prefsFileName) + if err := os.WriteFile(prefsPath, []byte(`{"wifi_only_sync":true}`), 0o600); err != nil { + t.Fatal(err) + } + asyncPath := filepath.Join(dir, asyncFileName) + if err := os.WriteFile(asyncPath, []byte(`{"@syncup/vaults":"[]"}`), 0o600); err != nil { + t.Fatal(err) + } + extras, _ := json.Marshal([]map[string]string{ + {"name": prefsFileName, "path": prefsPath}, + {"name": asyncFileName, "path": asyncPath}, + }) + + zipPath := filepath.Join(dir, "backup.zip") + api := NewMobileAPI() + out := api.ExportConfig(src, zipPath, string(extras)) + if strings.Contains(out, `"error"`) { + t.Fatalf("export failed: %s", out) + } + + in := api.ImportConfig(zipPath, dst, "") + if strings.Contains(in, `"error"`) { + t.Fatalf("import failed: %s", in) + } + for _, name := range []string{configFileName, certFileName, keyFileName, prefsFileName, asyncFileName} { + if _, err := os.Stat(filepath.Join(dst, name)); err != nil { + t.Errorf("expected %s in dst: %v", name, err) + } + } + if !strings.Contains(in, `"importedPrefs":true`) { + t.Errorf("expected importedPrefs:true in %s", in) + } + if !strings.Contains(in, `"importedAsync":true`) { + t.Errorf("expected importedAsync:true in %s", in) + } +} + +func TestImportConfig_ForkBackup(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "dst") + zipPath := filepath.Join(dir, "fork-backup.zip") + + entries := map[string][]byte{ + "config.xml": []byte(minimalConfigXML), + "cert.pem": []byte(minimalCertPEM), + "key.pem": []byte(minimalKeyPEM), + "https-cert.pem": []byte(minimalCertPEM), + "https-key.pem": []byte(minimalKeyPEM), + "sharedpreferences.dat": []byte{0xAC, 0xED, 0x00, 0x05}, + "index-v2/000001.log": []byte("ignored"), + "index-v2/CURRENT": []byte("ignored"), + "index-v2/sub/MANIFEST-1": []byte("ignored"), + } + writeBackupZip(t, zipPath, entries, false) + + api := NewMobileAPI() + out := api.ImportConfig(zipPath, dst, "") + if strings.Contains(out, `"error"`) { + t.Fatalf("import failed: %s", out) + } + for _, name := range []string{configFileName, certFileName, keyFileName, httpsCertFileName, httpsKeyFileName} { + if _, err := os.Stat(filepath.Join(dst, name)); err != nil { + t.Errorf("expected %s after fork import: %v", name, err) + } + } + if _, err := os.Stat(filepath.Join(dst, "index-v2")); err == nil { + t.Error("index-v2/ should not be extracted") + } + if _, err := os.Stat(filepath.Join(dst, "sharedpreferences.dat")); err == nil { + t.Error("sharedpreferences.dat should not be extracted") + } + if !strings.Contains(out, `"importedPrefs":false`) { + t.Errorf("expected importedPrefs:false in %s", out) + } +} + +func TestImportConfig_EncryptedRoundTrip(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "dst") + zipPath := filepath.Join(dir, "encrypted.zip") + writeEncryptedZip(t, zipPath, map[string][]byte{ + "config.xml": []byte(minimalConfigXML), + "cert.pem": []byte(minimalCertPEM), + "key.pem": []byte(minimalKeyPEM), + "https-cert.pem": []byte(minimalCertPEM), + "index-v2/log.0": []byte("ignored"), + }, "topsecret") + + api := NewMobileAPI() + + out := api.ImportConfig(zipPath, dst, "") + if !strings.Contains(out, "password required") { + t.Errorf("expected password-required error, got %s", out) + } + + out = api.ImportConfig(zipPath, dst, "wrong") + if !strings.Contains(out, "wrong password") && !strings.Contains(out, "password") { + t.Errorf("expected wrong-password error, got %s", out) + } + + out = api.ImportConfig(zipPath, dst, "topsecret") + if strings.Contains(out, `"error"`) { + t.Fatalf("expected success with correct password, got %s", out) + } + for _, name := range []string{configFileName, certFileName, keyFileName, httpsCertFileName} { + if _, err := os.Stat(filepath.Join(dst, name)); err != nil { + t.Errorf("expected %s after encrypted import: %v", name, err) + } + } +} + +func TestImportConfig_RejectsZipSlip(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "evil.zip") + writeBackupZip(t, zipPath, map[string][]byte{ + "config.xml": []byte(minimalConfigXML), + "cert.pem": []byte(minimalCertPEM), + "key.pem": []byte(minimalKeyPEM), + "../../../etc/passwd": []byte("nope"), + }, false) + + api := NewMobileAPI() + out := api.ImportConfig(zipPath, filepath.Join(dir, "dst"), "") + if !strings.Contains(out, "unsafe zip entry") { + t.Errorf("expected zip-slip rejection, got %s", out) + } +} + +func TestImportConfig_MissingRequired(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "incomplete.zip") + writeBackupZip(t, zipPath, map[string][]byte{ + "config.xml": []byte(minimalConfigXML), + "cert.pem": []byte(minimalCertPEM), + }, false) + + api := NewMobileAPI() + out := api.ImportConfig(zipPath, filepath.Join(dir, "dst"), "") + if !strings.Contains(out, "archive missing") { + t.Errorf("expected missing-file error, got %s", out) + } +} + +func TestImportConfig_RollbackOnInvalidConfig(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "dst") + if err := os.MkdirAll(dst, 0o700); err != nil { + t.Fatal(err) + } + for _, n := range backupFiles { + if err := os.WriteFile(filepath.Join(dst, n), []byte("ORIGINAL-"+n), 0o600); err != nil { + t.Fatal(err) + } + } + + zipPath := filepath.Join(dir, "bad-xml.zip") + writeBackupZip(t, zipPath, map[string][]byte{ + "config.xml": []byte("not xml at all"), + "cert.pem": []byte(minimalCertPEM), + "key.pem": []byte(minimalKeyPEM), + }, false) + + api := NewMobileAPI() + out := api.ImportConfig(zipPath, dst, "") + if !strings.Contains(out, "error") { + t.Fatalf("expected validation error, got %s", out) + } + for _, n := range backupFiles { + data, err := os.ReadFile(filepath.Join(dst, n)) + if err != nil { + t.Fatalf("original %s missing after rollback: %v", n, err) + } + if string(data) != "ORIGINAL-"+n { + t.Errorf("rollback lost original %s: got %q", n, data) + } + } +} diff --git a/backend/go.mod b/backend/go.mod index 8c8fa87..bd250fe 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -55,18 +55,19 @@ require ( github.com/tklauser/numcpus v0.11.0 // indirect github.com/vitrun/qart v0.0.0-20160531060029-bf64b92db6b0 // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.50.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/mobile v0.0.0-20260508232728-bebd421c7fa8 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.67.6 // indirect diff --git a/backend/go.sum b/backend/go.sum index e88ffd8..25334a7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -180,6 +180,8 @@ github.com/vitrun/qart v0.0.0-20160531060029-bf64b92db6b0 h1:okhMind4q9H1OxF44gN github.com/vitrun/qart v0.0.0-20160531060029-bf64b92db6b0/go.mod h1:TTbGUfE+cXXceWtbTHq6lqcTvYPBKLNejBEbnUsQJtU= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9 h1:K8gF0eekWPEX+57l30ixxzGhHH/qscI3JCnuhbN6V4M= +github.com/yeka/zip v0.0.0-20231116150916-03d6312748a9/go.mod h1:9BnoKCcgJ/+SLhfAXj15352hTOuVmG5Gzo8xNRINfqI= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -192,15 +194,15 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c= golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8= -golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ= -golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M= +golang.org/x/mobile v0.0.0-20260508232728-bebd421c7fa8 h1:Rdmo5lL58MyzJamU436yZClTmhPhjCE+qrBk4LXicLc= +golang.org/x/mobile v0.0.0-20260508232728-bebd421c7fa8/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -209,8 +211,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -237,23 +239,23 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/mobile_api.go b/backend/mobile_api.go index 97f3d4e..519cfe6 100644 --- a/backend/mobile_api.go +++ b/backend/mobile_api.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + cryptozip "github.com/yeka/zip" ) type MobileAPI struct{} @@ -479,6 +481,287 @@ func (m *MobileAPI) RemoveDir(path string) string { return string(b) } +// ExportConfig zips config.xml, cert.pem, key.pem from srcDataDir to +// dstZipPath. extrasJSON is `[{"name":..., "path":...}, ...]` of additional +// root-level entries. +func (m *MobileAPI) ExportConfig(srcDataDir, dstZipPath, extrasJSON string) string { + if srcDataDir == "" { + srcDataDir = currentDataDir() + } + if srcDataDir == "" { + return marshalErr(errors.New("data dir not set")) + } + if dstZipPath == "" { + return marshalErr(errors.New("destination path is empty")) + } + for _, name := range backupFiles { + p := filepath.Join(srcDataDir, name) + if _, err := os.Stat(p); err != nil { + return marshalErr(errors.New("missing " + name + ": " + err.Error())) + } + } + var extras []struct { + Name string `json:"name"` + Path string `json:"path"` + } + if trimmed := strings.TrimSpace(extrasJSON); trimmed != "" && trimmed != "null" { + if err := json.Unmarshal([]byte(trimmed), &extras); err != nil { + return marshalErr(errors.New("extras json: " + err.Error())) + } + } + for _, e := range extras { + if e.Name == "" || filepath.Base(e.Name) != e.Name { + return marshalErr(errors.New("extras: invalid name " + e.Name)) + } + if e.Path == "" { + return marshalErr(errors.New("extras: empty path for " + e.Name)) + } + } + if err := os.MkdirAll(filepath.Dir(dstZipPath), 0o700); err != nil { + return marshalErr(err) + } + out, err := os.Create(dstZipPath) + if err != nil { + return marshalErr(err) + } + defer out.Close() + w := zip.NewWriter(out) + for _, name := range backupFiles { + if err := addBackupFile(w, filepath.Join(srcDataDir, name), name); err != nil { + _ = w.Close() + os.Remove(dstZipPath) + return marshalErr(err) + } + } + for _, e := range extras { + if err := addBackupFile(w, e.Path, e.Name); err != nil { + _ = w.Close() + os.Remove(dstZipPath) + return marshalErr(errors.New("extras " + e.Name + ": " + err.Error())) + } + } + if err := w.Close(); err != nil { + os.Remove(dstZipPath) + return marshalErr(err) + } + b, _ := json.Marshal(fsResultJSON{Path: dstZipPath}) + return string(b) +} + +// ImportConfig extracts config.xml, cert.pem, key.pem (required) plus +// https-cert.pem, https-key.pem, syncup-prefs.json, syncup-async.json +// (optional) from srcZipPath into dstDataDir. Syncthing-Fork extras +// (sharedpreferences.dat, files under index-v2) are skipped. password +// decrypts AES-encrypted archives; pass empty for unencrypted. Existing +// files are renamed to .bak and rolled back on failure. Daemon must be +// stopped before calling. +func (m *MobileAPI) ImportConfig(srcZipPath, dstDataDir, password string) string { + if dstDataDir == "" { + dstDataDir = currentDataDir() + } + if dstDataDir == "" { + return marshalErr(errors.New("data dir not set")) + } + if srcZipPath == "" { + return marshalErr(errors.New("source path is empty")) + } + globalMu.Lock() + running := globalClient != nil + globalMu.Unlock() + if running { + return marshalErr(errors.New("daemon is running; stop it before import")) + } + r, err := cryptozip.OpenReader(srcZipPath) + if err != nil { + return marshalErr(err) + } + defer r.Close() + + staged := make(map[string][]byte) + for _, f := range r.File { + cleaned := filepath.ToSlash(filepath.Clean(f.Name)) + if strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, "/../") { + return marshalErr(errors.New("unsafe zip entry: " + f.Name)) + } + if strings.HasSuffix(f.Name, "/") || f.FileInfo().IsDir() { + continue + } + base := filepath.Base(cleaned) + want, ok := backupWantedFile(base) + if !ok { + continue + } + if f.IsEncrypted() { + if password == "" { + return marshalErr(errors.New("password required: archive is encrypted")) + } + f.SetPassword(password) + } + if f.UncompressedSize64 > maxBackupEntryBytes { + return marshalErr(errors.New(base + " too large")) + } + rc, err := f.Open() + if err != nil { + if f.IsEncrypted() { + return marshalErr(errors.New("wrong password: " + err.Error())) + } + return marshalErr(errors.New(base + ": " + err.Error())) + } + data, err := io.ReadAll(io.LimitReader(rc, int64(maxBackupEntryBytes)+1)) + rc.Close() + if err != nil { + if f.IsEncrypted() { + return marshalErr(errors.New("wrong password (decrypt failed): " + err.Error())) + } + return marshalErr(err) + } + if int64(len(data)) > int64(maxBackupEntryBytes) { + return marshalErr(errors.New(base + " too large")) + } + staged[want] = data + } + for _, n := range backupFiles { + if _, ok := staged[n]; !ok { + return marshalErr(errors.New("archive missing " + n)) + } + } + if err := validateConfigXML(staged[configFileName]); err != nil { + return marshalErr(err) + } + if err := validatePEM(staged[certFileName]); err != nil { + return marshalErr(errors.New("invalid cert.pem: " + err.Error())) + } + if err := validatePEM(staged[keyFileName]); err != nil { + return marshalErr(errors.New("invalid key.pem: " + err.Error())) + } + if data, ok := staged[httpsCertFileName]; ok { + if err := validatePEM(data); err != nil { + delete(staged, httpsCertFileName) + } + } + if data, ok := staged[httpsKeyFileName]; ok { + if err := validatePEM(data); err != nil { + delete(staged, httpsKeyFileName) + } + } + + if err := os.MkdirAll(dstDataDir, 0o700); err != nil { + return marshalErr(err) + } + + writeOrder := append([]string(nil), backupFiles...) + for _, opt := range optionalBackupFiles { + if _, ok := staged[opt]; ok { + writeOrder = append(writeOrder, opt) + } + } + + backedUp := make([]string, 0, len(writeOrder)) + for _, n := range writeOrder { + live := filepath.Join(dstDataDir, n) + bak := live + ".bak" + _ = os.Remove(bak) + if _, err := os.Stat(live); err == nil { + if err := os.Rename(live, bak); err != nil { + rollbackBackup(dstDataDir, backedUp) + return marshalErr(err) + } + backedUp = append(backedUp, n) + } + } + for i, n := range writeOrder { + live := filepath.Join(dstDataDir, n) + if err := os.WriteFile(live, staged[n], 0o600); err != nil { + for j := 0; j < i; j++ { + os.Remove(filepath.Join(dstDataDir, writeOrder[j])) + } + rollbackBackup(dstDataDir, backedUp) + return marshalErr(err) + } + } + for _, n := range backedUp { + os.Remove(filepath.Join(dstDataDir, n+".bak")) + } + + _, importedPrefs := staged[prefsFileName] + _, importedAsync := staged[asyncFileName] + res := struct { + Path string `json:"path"` + ImportedPrefs bool `json:"importedPrefs"` + ImportedAsync bool `json:"importedAsync"` + }{Path: dstDataDir, ImportedPrefs: importedPrefs, ImportedAsync: importedAsync} + b, _ := json.Marshal(res) + return string(b) +} + +func backupWantedFile(base string) (string, bool) { + switch base { + case configFileName, certFileName, keyFileName, + httpsCertFileName, httpsKeyFileName, prefsFileName, asyncFileName: + return base, true + } + return "", false +} + +var backupFiles = []string{configFileName, certFileName, keyFileName} +var optionalBackupFiles = []string{httpsCertFileName, httpsKeyFileName, prefsFileName, asyncFileName} + +const ( + httpsCertFileName = "https-cert.pem" + httpsKeyFileName = "https-key.pem" + prefsFileName = "syncup-prefs.json" + asyncFileName = "syncup-async.json" +) + +const maxBackupEntryBytes = 32 * 1024 * 1024 + +func addBackupFile(w *zip.Writer, srcPath, name string) error { + in, err := os.Open(srcPath) + if err != nil { + return err + } + defer in.Close() + hdr := &zip.FileHeader{Name: name, Method: zip.Deflate} + hdr.SetMode(0o600) + writer, err := w.CreateHeader(hdr) + if err != nil { + return err + } + _, err = io.Copy(writer, in) + return err +} + +func validateConfigXML(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + return errors.New("config.xml is empty") + } + if !strings.Contains(trimmed, " root") + } + return nil +} + +func validatePEM(data []byte) error { + if len(data) == 0 { + return errors.New("empty") + } + s := string(data) + if !strings.Contains(s, "-----BEGIN") || !strings.Contains(s, "-----END") { + return errors.New("missing PEM markers") + } + return nil +} + +func rollbackBackup(dataDir string, backedUp []string) { + for _, n := range backedUp { + live := filepath.Join(dataDir, n) + bak := live + ".bak" + _ = os.Remove(live) + _ = os.Rename(bak, live) + } +} + // MkdirSubdir creates name under parent (must be sandboxed) and returns // the new absolute path. func (m *MobileAPI) MkdirSubdir(parent, name string) string { diff --git a/mobile-app/App.tsx b/mobile-app/App.tsx index 4b4f646..150ba44 100644 --- a/mobile-app/App.tsx +++ b/mobile-app/App.tsx @@ -14,6 +14,7 @@ import { SearchModal } from './src/screens/SearchModal'; import { CoachProvider, useCoach, type CoachTabKey } from './src/onboarding/coach/CoachContext'; import { CoachOverlay } from './src/onboarding/coach/CoachOverlay'; import { useOnboarding } from './src/onboarding/useOnboarding'; +import { AppReloadProvider } from './src/AppReload'; import { colors } from './src/components/ui'; type Tab = 'status' | 'folders' | 'devices' | 'settings'; @@ -28,14 +29,18 @@ const TABS: readonly { key: Tab; label: string }[] = [ export default function App() { return ( - - - - - - - - + + {generation => ( + + + + + + + + + )} + ); } diff --git a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt index 44e079b..99dc514 100644 --- a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt +++ b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/GoServerBridgeModule.kt @@ -599,6 +599,143 @@ class GoServerBridgeModule(reactContext: ReactApplicationContext) : // Spec is shared cross-platform so we accept the call but ignore it. } + override fun exportConfig(asyncStorageJson: String): String { + val activity = ctx.currentActivity as? MainActivity + ?: return jsonError("activity not available") + val suggested = "syncup-backup-${java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US).format(java.util.Date())}.zip" + val uri = activity.pickBackupSaveBlocking(suggested) ?: return "" + val cache = java.io.File(ctx.cacheDir, "backup-export.zip") + val prefsCache = java.io.File(ctx.cacheDir, "syncup-prefs.json") + val asyncCache = java.io.File(ctx.cacheDir, "syncup-async.json") + try { + cache.delete() + prefsCache.delete() + asyncCache.delete() + prefsCache.writeText(SyncthingPrefs.exportAsJson(ctx)) + + val extras = org.json.JSONArray().apply { + put(org.json.JSONObject().apply { + put("name", "syncup-prefs.json") + put("path", prefsCache.absolutePath) + }) + if (asyncStorageJson.isNotEmpty() && asyncStorageJson != "{}") { + asyncCache.writeText(asyncStorageJson) + put(org.json.JSONObject().apply { + put("name", "syncup-async.json") + put("path", asyncCache.absolutePath) + }) + } + }.toString() + + val result = mobileAPI.exportConfig("", cache.absolutePath, extras) + ?: return jsonError("nil result") + val obj = org.json.JSONObject(result) + if (obj.has("error")) return jsonError(obj.optString("error", "export failed")) + + ctx.contentResolver.openOutputStream(uri, "wt")?.use { out -> + java.io.FileInputStream(cache).use { input -> + input.copyTo(out) + } + } ?: return jsonError("could not open output stream") + + val displayName = queryDisplayName(uri) ?: uri.lastPathSegment ?: suggested + return org.json.JSONObject().apply { + put("ok", true) + put("path", uri.toString()) + put("displayName", displayName) + }.toString() + } catch (e: Exception) { + android.util.Log.e(NAME, "exportConfig failed", e) + return jsonError(e.message ?: "export failed") + } finally { + cache.delete() + prefsCache.delete() + asyncCache.delete() + } + } + + override fun importConfig(password: String): String { + val activity = ctx.currentActivity as? MainActivity + ?: return jsonError("activity not available") + val uri = activity.pickBackupOpenBlocking() ?: return "" + val cache = java.io.File(ctx.cacheDir, "backup-import.zip") + try { + cache.delete() + ctx.contentResolver.openInputStream(uri)?.use { input -> + java.io.FileOutputStream(cache).use { out -> + input.copyTo(out) + } + } ?: return jsonError("could not open input stream") + + val dataDir = Paths.syncthingDir(ctx) + val result = mobileAPI.importConfig(cache.absolutePath, dataDir, password) + ?: return jsonError("nil result") + val obj = org.json.JSONObject(result) + if (obj.has("error")) return jsonError(obj.optString("error", "import failed")) + + var prefsImported = false + if (obj.optBoolean("importedPrefs", false)) { + val prefsFile = java.io.File(dataDir, "syncup-prefs.json") + if (prefsFile.exists()) { + prefsImported = try { + SyncthingPrefs.importFromJson(ctx, prefsFile.readText()) + true + } catch (e: Exception) { + android.util.Log.e(NAME, "prefs import failed", e) + false + } finally { + prefsFile.delete() + } + } + } + + var asyncJson = "" + if (obj.optBoolean("importedAsync", false)) { + val asyncFile = java.io.File(dataDir, "syncup-async.json") + if (asyncFile.exists()) { + try { + asyncJson = asyncFile.readText() + } catch (e: Exception) { + android.util.Log.e(NAME, "async read failed", e) + } finally { + asyncFile.delete() + } + } + } + + return org.json.JSONObject().apply { + put("ok", true) + put("path", dataDir) + put("displayName", queryDisplayName(uri) ?: uri.lastPathSegment ?: "") + put("importedPrefs", prefsImported) + put("asyncStorageJson", asyncJson) + }.toString() + } catch (e: Exception) { + android.util.Log.e(NAME, "importConfig failed", e) + return jsonError(e.message ?: "import failed") + } finally { + cache.delete() + } + } + + private fun jsonError(msg: String): String = + org.json.JSONObject().apply { + put("ok", false) + put("error", msg) + }.toString() + + private fun queryDisplayName(uri: Uri): String? = try { + ctx.contentResolver.query( + uri, + arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), + null, null, null, + )?.use { c -> + if (c.moveToFirst()) c.getString(0) else null + } + } catch (e: Exception) { + null + } + override fun openFolderInFileManager(path: String): Boolean { // "primary:" in DocumentsUI maps to /storage/emulated/0, so a // tree URI under the app-scoped path resolves fine. diff --git a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/MainActivity.kt b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/MainActivity.kt index 8796c7e..c40ebdb 100644 --- a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/MainActivity.kt +++ b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/MainActivity.kt @@ -32,6 +32,14 @@ class MainActivity : ReactActivity() { private lateinit var safPickerLauncher: ActivityResultLauncher + private var backupSavePickerResult = AtomicReference(null) + private var backupSavePickerLatch = CountDownLatch(1) + private lateinit var backupSavePickerLauncher: ActivityResultLauncher + + private var backupOpenPickerResult = AtomicReference(null) + private var backupOpenPickerLatch = CountDownLatch(1) + private lateinit var backupOpenPickerLauncher: ActivityResultLauncher> + /** * Called from GoServerBridgeModule.pickSafFolder() on the JS thread. * Blocks until the user picks a folder or cancels, then returns the URI. @@ -44,6 +52,22 @@ class MainActivity : ReactActivity() { return safPickerResult.get() } + fun pickBackupSaveBlocking(suggestedName: String): Uri? { + backupSavePickerResult.set(null) + backupSavePickerLatch = CountDownLatch(1) + runOnUiThread { backupSavePickerLauncher.launch(suggestedName) } + backupSavePickerLatch.await() + return backupSavePickerResult.get() + } + + fun pickBackupOpenBlocking(): Uri? { + backupOpenPickerResult.set(null) + backupOpenPickerLatch = CountDownLatch(1) + runOnUiThread { backupOpenPickerLauncher.launch(arrayOf("application/zip", "application/octet-stream")) } + backupOpenPickerLatch.await() + return backupOpenPickerResult.get() + } + override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.AppTheme); @@ -55,6 +79,20 @@ class MainActivity : ReactActivity() { safPickerLatch.countDown() } + backupSavePickerLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { uri: Uri? -> + backupSavePickerResult.set(uri) + backupSavePickerLatch.countDown() + } + + backupOpenPickerLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + backupOpenPickerResult.set(uri) + backupOpenPickerLatch.countDown() + } + super.onCreate(null) requestNotificationPermissionIfNeeded() // Activity launch always grants the FGS background-start exemption, so diff --git a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt index ca3cf25..1045bfd 100644 --- a/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt +++ b/mobile-app/android/app/src/main/java/com/siddarthkay/syncup/SyncthingPrefs.kt @@ -63,4 +63,36 @@ object SyncthingPrefs { fun setStartOnBoot(context: Context, value: Boolean) { prefs(context).edit().putBoolean(KEY_START_ON_BOOT, value).apply() } + + private val backupKeys = listOf( + KEY_WIFI_ONLY_SYNC, + KEY_CHARGING_ONLY_SYNC, + KEY_ALLOW_METERED_WIFI, + KEY_ALLOW_MOBILE_DATA, + KEY_EXTERNAL_CONTROL, + KEY_START_ON_BOOT, + ) + + fun exportAsJson(context: Context): String { + val p = prefs(context) + val obj = org.json.JSONObject() + for (key in backupKeys) { + obj.put(key, p.getBoolean(key, false)) + } + return obj.toString() + } + + fun importFromJson(context: Context, json: String): Boolean { + val obj = org.json.JSONObject(json) + val editor = prefs(context).edit() + var applied = 0 + for (key in backupKeys) { + if (obj.has(key)) { + editor.putBoolean(key, obj.optBoolean(key, false)) + applied++ + } + } + editor.apply() + return applied > 0 + } } diff --git a/mobile-app/ios/syncup.xcodeproj/project.pbxproj b/mobile-app/ios/syncup.xcodeproj/project.pbxproj index 8515bcc..fba921b 100644 --- a/mobile-app/ios/syncup.xcodeproj/project.pbxproj +++ b/mobile-app/ios/syncup.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ BM01234567890ABC12345002 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BM01234567890ABC12345001 /* BackgroundManager.swift */; }; BM01234567890ABC12345004 /* BackgroundErrorNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BM01234567890ABC12345003 /* BackgroundErrorNotifier.swift */; }; BM01234567890ABC12345006 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = BM01234567890ABC12345005 /* AppShortcuts.swift */; }; + BP01234567890ABC12345002 /* BackupPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BP01234567890ABC12345001 /* BackupPicker.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; G0S1234567890ABC12345678 /* GoServerBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = G0S1234567890ABC12345679 /* GoServerBridge.mm */; }; QL01234567890ABC12345002 /* QuickLookPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = QL01234567890ABC12345001 /* QuickLookPresenter.swift */; }; @@ -38,6 +39,7 @@ BM01234567890ABC12345001 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundManager.swift; path = syncup/BackgroundManager.swift; sourceTree = ""; }; BM01234567890ABC12345003 /* BackgroundErrorNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundErrorNotifier.swift; path = syncup/BackgroundErrorNotifier.swift; sourceTree = ""; }; BM01234567890ABC12345005 /* AppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppShortcuts.swift; path = syncup/AppShortcuts.swift; sourceTree = ""; }; + BP01234567890ABC12345001 /* BackupPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackupPicker.swift; path = syncup/BackupPicker.swift; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = syncup/AppDelegate.swift; sourceTree = ""; }; FB0085F001083A8C91ADF71C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = syncup/PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -69,6 +71,7 @@ G0S1234567890ABC12345680 /* GoServerBridge.h */, G0S1234567890ABC12345679 /* GoServerBridge.mm */, SF01234567890ABC12345001 /* ScopedFolderStore.swift */, + BP01234567890ABC12345001 /* BackupPicker.swift */, QL01234567890ABC12345001 /* QuickLookPresenter.swift */, BB2F792B24A3F905000567C9 /* Supporting */, 13B07FB51A68108700A75B9A /* Images.xcassets */, @@ -362,6 +365,7 @@ 949EB339D788314DE425296D /* ExpoModulesProvider.swift in Sources */, 0C823C245A529D0F8E0CEB71 /* GoBridgeWrapper.mm in Sources */, SF01234567890ABC12345002 /* ScopedFolderStore.swift in Sources */, + BP01234567890ABC12345002 /* BackupPicker.swift in Sources */, QL01234567890ABC12345002 /* QuickLookPresenter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/mobile-app/ios/syncup/BackupPicker.swift b/mobile-app/ios/syncup/BackupPicker.swift new file mode 100644 index 0000000..0c8f65d --- /dev/null +++ b/mobile-app/ios/syncup/BackupPicker.swift @@ -0,0 +1,145 @@ +import Foundation +import UIKit +import UniformTypeIdentifiers + +@objc(BackupPicker) final class BackupPicker: NSObject { + @objc static let shared = BackupPicker() + + private let queue = DispatchQueue(label: "com.siddarthkay.syncup.backupPicker") + private var pendingDelegates: [ObjectIdentifier: NSObject] = [:] + + private override init() { + super.init() + } + + @objc func exportFileBlocking(sourcePath: String) -> String { + if Thread.isMainThread { + NSLog("BackupPicker: exportFileBlocking called on main thread; would deadlock") + return "" + } + let semaphore = DispatchSemaphore(value: 0) + var result: String = "" + + DispatchQueue.main.async { [weak self] in + guard let self = self else { semaphore.signal(); return } + guard let presenter = self.topPresentingViewController() else { + NSLog("BackupPicker: no presenter VC for export") + semaphore.signal() + return + } + let url = URL(fileURLWithPath: sourcePath) + let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true) + let delegate = BackupPickerDelegate { urls in + defer { semaphore.signal() } + guard let dest = urls.first else { return } + result = dest.path + } + picker.delegate = delegate + let key = ObjectIdentifier(picker) + self.queue.sync { self.pendingDelegates[key] = delegate } + delegate.onComplete = { [weak self] in + self?.queue.sync { self?.pendingDelegates.removeValue(forKey: key) } + } + presenter.present(picker, animated: true, completion: nil) + } + + semaphore.wait() + return result + } + + @objc func importFileBlocking(destinationPath: String) -> String { + if Thread.isMainThread { + NSLog("BackupPicker: importFileBlocking called on main thread; would deadlock") + return "" + } + let semaphore = DispatchSemaphore(value: 0) + var result: String = "" + + DispatchQueue.main.async { [weak self] in + guard let self = self else { semaphore.signal(); return } + guard let presenter = self.topPresentingViewController() else { + NSLog("BackupPicker: no presenter VC for import") + semaphore.signal() + return + } + let picker: UIDocumentPickerViewController + if #available(iOS 14.0, *) { + let types: [UTType] = [UTType.zip, UTType.data] + picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) + } else { + picker = UIDocumentPickerViewController( + documentTypes: ["public.zip-archive", "public.data"], + in: .import + ) + } + picker.allowsMultipleSelection = false + let delegate = BackupPickerDelegate { urls in + defer { semaphore.signal() } + guard let src = urls.first else { return } + let didStart = src.startAccessingSecurityScopedResource() + defer { if didStart { src.stopAccessingSecurityScopedResource() } } + do { + let dst = URL(fileURLWithPath: destinationPath) + let parent = dst.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: dst.path) { + try FileManager.default.removeItem(at: dst) + } + try FileManager.default.copyItem(at: src, to: dst) + result = dst.path + } catch { + NSLog("BackupPicker: copy failed: %@", "\(error)") + } + } + picker.delegate = delegate + let key = ObjectIdentifier(picker) + self.queue.sync { self.pendingDelegates[key] = delegate } + delegate.onComplete = { [weak self] in + self?.queue.sync { self?.pendingDelegates.removeValue(forKey: key) } + } + presenter.present(picker, animated: true, completion: nil) + } + + semaphore.wait() + return result + } + + private func topPresentingViewController() -> UIViewController? { + var window: UIWindow? + if #available(iOS 13.0, *) { + window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: { $0.isKeyWindow }) + } + if window == nil { + window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) + ?? UIApplication.shared.windows.first + } + var top = window?.rootViewController + while let presented = top?.presentedViewController { + top = presented + } + return top + } +} + +private final class BackupPickerDelegate: NSObject, UIDocumentPickerDelegate { + private let onPick: ([URL]) -> Void + var onComplete: (() -> Void)? + + init(onPick: @escaping ([URL]) -> Void) { + self.onPick = onPick + } + + func documentPicker(_ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL]) { + onPick(urls) + onComplete?() + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + onPick([]) + onComplete?() + } +} diff --git a/mobile-app/ios/syncup/GoBridgeWrapper.h b/mobile-app/ios/syncup/GoBridgeWrapper.h index e52bc2f..b455fa8 100644 --- a/mobile-app/ios/syncup/GoBridgeWrapper.h +++ b/mobile-app/ios/syncup/GoBridgeWrapper.h @@ -71,4 +71,8 @@ /// pathsJson is a JSON array of absolute paths. Asynchronous; returns immediately. + (void)previewFile:(NSString *)pathsJson startIndex:(NSInteger)startIndex; ++ (NSString * _Nonnull)exportConfig:(NSString * _Nonnull)asyncStorageJson; + ++ (NSString * _Nonnull)importConfig:(NSString * _Nonnull)password; + @end diff --git a/mobile-app/ios/syncup/GoBridgeWrapper.mm b/mobile-app/ios/syncup/GoBridgeWrapper.mm index 4e3192a..f723f32 100644 --- a/mobile-app/ios/syncup/GoBridgeWrapper.mm +++ b/mobile-app/ios/syncup/GoBridgeWrapper.mm @@ -25,6 +25,12 @@ + (instancetype _Nonnull)shared; - (void)presentWithPaths:(NSArray * _Nonnull)paths startIndex:(NSInteger)startIndex; @end +@interface BackupPicker : NSObject ++ (instancetype _Nonnull)shared; +- (NSString * _Nonnull)exportFileBlockingWithSourcePath:(NSString * _Nonnull)sourcePath; +- (NSString * _Nonnull)importFileBlockingWithDestinationPath:(NSString * _Nonnull)destinationPath; +@end + static NSString * const kNotifiedErrorCountsKey = @"com.siddarthkay.syncup.notifiedErrorCounts"; static NSString * const kVaultRegistryKey = @"com.siddarthkay.syncup.vaultRegistry"; static NSString * const kNotifiedVaultStaleKey = @"com.siddarthkay.syncup.notifiedVaultStale"; @@ -53,6 +59,8 @@ - (NSString *)removeDir:(NSString *)path; - (void)setSuspended:(BOOL)suspended; - (void)registerExternalRoot:(NSString *)path; - (void)unregisterExternalRoot:(NSString *)path; +- (NSString *)exportConfig:(NSString *)srcDataDir dstZipPath:(NSString *)dstZipPath extrasJSON:(NSString *)extrasJSON; +- (NSString *)importConfig:(NSString *)srcZipPath dstDataDir:(NSString *)dstDataDir password:(NSString *)password; @end static Class GobridgeMobileAPIClass; @@ -518,4 +526,128 @@ + (void)previewFile:(NSString *)pathsJson startIndex:(NSInteger)startIndex { } } ++ (NSString *)errorJSON:(NSString *)message { + NSDictionary *obj = @{ @"ok": @NO, @"error": message ?: @"unknown error" }; + NSError *err = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:0 error:&err]; + if (!data) return @"{\"ok\":false,\"error\":\"json error\"}"; + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @""; +} + ++ (NSString *)exportConfig:(NSString *)asyncStorageJson { + @try { + id api = [self api]; + if (!api) return [self errorJSON:@"bridge not initialized"]; + + NSString *cacheDir = NSTemporaryDirectory(); + NSString *stem = [NSString stringWithFormat:@"syncup-backup-%@.zip", + [self timestampString]]; + NSString *stagedPath = [cacheDir stringByAppendingPathComponent:stem]; + NSString *asyncPath = [cacheDir stringByAppendingPathComponent:@"syncup-async.json"]; + [[NSFileManager defaultManager] removeItemAtPath:stagedPath error:nil]; + [[NSFileManager defaultManager] removeItemAtPath:asyncPath error:nil]; + + NSMutableArray *extras = [NSMutableArray array]; + if (asyncStorageJson.length > 0 && ![asyncStorageJson isEqualToString:@"{}"]) { + NSError *writeErr = nil; + [asyncStorageJson writeToFile:asyncPath + atomically:YES + encoding:NSUTF8StringEncoding + error:&writeErr]; + if (writeErr) { + return [self errorJSON:[NSString stringWithFormat:@"async write failed: %@", writeErr.localizedDescription]]; + } + [extras addObject:@{ @"name": @"syncup-async.json", @"path": asyncPath }]; + } + NSData *extrasData = [NSJSONSerialization dataWithJSONObject:extras options:0 error:nil]; + NSString *extrasJSON = [[NSString alloc] initWithData:extrasData encoding:NSUTF8StringEncoding] ?: @"[]"; + + NSString *result = [api exportConfig:@"" dstZipPath:stagedPath extrasJSON:extrasJSON]; + [[NSFileManager defaultManager] removeItemAtPath:asyncPath error:nil]; + if (result.length == 0) return [self errorJSON:@"nil result"]; + NSData *data = [result dataUsingEncoding:NSUTF8StringEncoding]; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if ([parsed isKindOfClass:[NSDictionary class]]) { + NSString *errStr = ((NSDictionary *)parsed)[@"error"]; + if (errStr.length > 0) { + [[NSFileManager defaultManager] removeItemAtPath:stagedPath error:nil]; + return [self errorJSON:errStr]; + } + } + + NSString *destPath = [BackupPicker.shared exportFileBlockingWithSourcePath:stagedPath]; + [[NSFileManager defaultManager] removeItemAtPath:stagedPath error:nil]; + if (destPath.length == 0) return @""; + + NSDictionary *ok = @{ + @"ok": @YES, + @"path": destPath, + @"displayName": [destPath lastPathComponent] ?: stem, + }; + NSData *okData = [NSJSONSerialization dataWithJSONObject:ok options:0 error:nil]; + return [[NSString alloc] initWithData:okData encoding:NSUTF8StringEncoding] ?: @""; + } @catch (NSException *exception) { + NSLog(@"GoBridgeWrapper: exportConfig exception: %@", exception); + return [self errorJSON:exception.reason ?: @"exception"]; + } +} + ++ (NSString *)importConfig:(NSString *)password { + @try { + id api = [self api]; + if (!api) return [self errorJSON:@"bridge not initialized"]; + + NSString *cacheDir = NSTemporaryDirectory(); + NSString *stagedPath = [cacheDir stringByAppendingPathComponent:@"syncup-restore.zip"]; + NSString *picked = [BackupPicker.shared importFileBlockingWithDestinationPath:stagedPath]; + if (picked.length == 0) return @""; + + NSString *dataDir = [self dataDir] ?: @""; + NSString *result = [api importConfig:stagedPath dstDataDir:dataDir password:(password ?: @"")]; + [[NSFileManager defaultManager] removeItemAtPath:stagedPath error:nil]; + if (result.length == 0) return [self errorJSON:@"nil result"]; + + NSData *data = [result dataUsingEncoding:NSUTF8StringEncoding]; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + NSString *errStr = nil; + BOOL importedAsync = NO; + if ([parsed isKindOfClass:[NSDictionary class]]) { + errStr = ((NSDictionary *)parsed)[@"error"]; + importedAsync = [((NSDictionary *)parsed)[@"importedAsync"] boolValue]; + } + if (errStr.length > 0) return [self errorJSON:errStr]; + + NSString *asyncJson = @""; + if (importedAsync) { + NSString *asyncFile = [dataDir stringByAppendingPathComponent:@"syncup-async.json"]; + NSError *readErr = nil; + NSString *contents = [NSString stringWithContentsOfFile:asyncFile + encoding:NSUTF8StringEncoding + error:&readErr]; + if (contents) asyncJson = contents; + [[NSFileManager defaultManager] removeItemAtPath:asyncFile error:nil]; + } + + NSDictionary *ok = @{ + @"ok": @YES, + @"path": dataDir, + @"displayName": [picked lastPathComponent] ?: @"", + @"importedPrefs": @NO, + @"asyncStorageJson": asyncJson, + }; + NSData *okData = [NSJSONSerialization dataWithJSONObject:ok options:0 error:nil]; + return [[NSString alloc] initWithData:okData encoding:NSUTF8StringEncoding] ?: @""; + } @catch (NSException *exception) { + NSLog(@"GoBridgeWrapper: importConfig exception: %@", exception); + return [self errorJSON:exception.reason ?: @"exception"]; + } +} + ++ (NSString *)timestampString { + NSDateFormatter *df = [[NSDateFormatter alloc] init]; + df.dateFormat = @"yyyyMMdd-HHmmss"; + df.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + return [df stringFromDate:[NSDate date]]; +} + @end diff --git a/mobile-app/ios/syncup/GoServerBridge.h b/mobile-app/ios/syncup/GoServerBridge.h index 06907c5..5e3f842 100644 --- a/mobile-app/ios/syncup/GoServerBridge.h +++ b/mobile-app/ios/syncup/GoServerBridge.h @@ -65,6 +65,9 @@ class GoServerBridgeImpl : public facebook::react::NativeGoServerBridgeCxxSpec void; + generation: number; +} + +const Ctx = createContext(null); + +export function AppReloadProvider({ children }: { children: (generation: number) => React.ReactNode }) { + const [generation, setGeneration] = useState(0); + const reload = useCallback(() => setGeneration(g => g + 1), []); + const value = useMemo(() => ({ reload, generation }), [reload, generation]); + return {children(generation)}; +} + +export function useAppReload(): () => void { + const v = useContext(Ctx); + if (!v) { + throw new Error('useAppReload must be used inside '); + } + return v.reload; +} diff --git a/mobile-app/src/GoServerBridgeJSI.ts b/mobile-app/src/GoServerBridgeJSI.ts index e07d810..721dd22 100644 --- a/mobile-app/src/GoServerBridgeJSI.ts +++ b/mobile-app/src/GoServerBridgeJSI.ts @@ -50,6 +50,8 @@ export interface GoServerBridgeInterface { requestAllFilesAccess(): boolean; listLocalSubdirs(path: string): string; mkdirLocalSubdir(parent: string, name: string): string; + exportConfig(asyncStorageJson: string): string; + importConfig(password: string): string; } class GoServerBridgeJSI implements GoServerBridgeInterface { @@ -238,6 +240,14 @@ class GoServerBridgeJSI implements GoServerBridgeInterface { mkdirLocalSubdir(parent: string, name: string): string { return NativeGoServerBridge.mkdirLocalSubdir(parent, name); } + + exportConfig(asyncStorageJson: string): string { + return NativeGoServerBridge.exportConfig(asyncStorageJson); + } + + importConfig(password: string): string { + return NativeGoServerBridge.importConfig(password); + } } export default new GoServerBridgeJSI(); diff --git a/mobile-app/src/NativeGoServerBridge.ts b/mobile-app/src/NativeGoServerBridge.ts index 7140e5f..02e38c4 100644 --- a/mobile-app/src/NativeGoServerBridge.ts +++ b/mobile-app/src/NativeGoServerBridge.ts @@ -118,6 +118,8 @@ export interface Spec extends TurboModule { * Android (use the JS-side FilePreviewModal instead). */ readonly previewFileNative: (pathsJson: string, startIndex: number) => void; + readonly exportConfig: (asyncStorageJson: string) => string; + readonly importConfig: (password: string) => string; } export default TurboModuleRegistry.getEnforcing('GoServerBridge'); diff --git a/mobile-app/src/fs/externalFolder.ts b/mobile-app/src/fs/externalFolder.ts index 083151a..fbcff27 100644 --- a/mobile-app/src/fs/externalFolder.ts +++ b/mobile-app/src/fs/externalFolder.ts @@ -94,3 +94,29 @@ export function filesystemTypeForExternal(path: string): 'saf' | 'basic' { if (Platform.OS === 'android' && path.startsWith('content://')) return 'saf'; return 'basic'; } + +/** + * True for an Android external folder whose access depends on All Files + * Access rather than a per-tree SAF grant. Restored backups from a device + * that used All Files Access store such POSIX paths. + */ +export function externalFolderNeedsAllFilesAccess(folder: FolderConfig): boolean { + return ( + Platform.OS === 'android' && + folder.filesystemType !== 'saf' && + !folder.path.startsWith('content://') + ); +} + +/** + * Whether the daemon can currently reach an external folder. SAF folders are + * gated by a persisted tree-URI grant; POSIX folders (added via, or restored + * into, All Files Access) are gated by that permission instead — checking the + * SAF grant for them always fails and leaves a stale "revoked" banner. + */ +export function validateExternalFolderAccess(folder: FolderConfig): boolean { + if (externalFolderNeedsAllFilesAccess(folder)) { + return GoBridge.hasAllFilesAccess(); + } + return GoBridge.validateExternalFolder(folder.path); +} diff --git a/mobile-app/src/screens/FolderDetailModal.tsx b/mobile-app/src/screens/FolderDetailModal.tsx index a8401d1..289975f 100644 --- a/mobile-app/src/screens/FolderDetailModal.tsx +++ b/mobile-app/src/screens/FolderDetailModal.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, + AppState, Linking, Modal, Platform, @@ -21,7 +22,12 @@ import type { DbStatus, DeviceConfig, FolderConfig, FolderError } from '../api/t import { colors, formatBytes, Progress } from '../components/ui'; import { removeDir } from '../fs/bridgeFs'; import GoBridge from '../GoServerBridgeJSI'; -import { isExternalFolder, pickExternalFolderWithICloudWarning } from '../fs/externalFolder'; +import { + isExternalFolder, + pickExternalFolderWithICloudWarning, + externalFolderNeedsAllFilesAccess, + validateExternalFolderAccess, +} from '../fs/externalFolder'; import { FolderIgnoresEditor } from './FolderIgnoresEditor'; import { FolderAdvancedEditor } from './FolderAdvancedEditor'; import { FolderVersioningEditor } from './FolderVersioningEditor'; @@ -124,10 +130,8 @@ export function FolderDetailModal({ if (d.encryptionPassword) pwMap[d.deviceID] = d.encryptionPassword; } setEncryptionPasswords(pwMap); - // Check external-folder access on open. Covers Android SAF and iOS - // security-scoped bookmarks under one branch. if (isExternalFolder(folder, foldersRoot)) { - setExternalAccessValid(GoBridge.validateExternalFolder(folder.path)); + setExternalAccessValid(validateExternalFolderAccess(folder)); } else { setExternalAccessValid(true); } @@ -168,6 +172,22 @@ export function FolderDetailModal({ .catch(() => setObsidianInstalled(false)); }, [visible, folder, client, foldersRoot]); + const recheckExternalAccess = useCallback(() => { + if (folder && isExternalFolder(folder, foldersRoot)) { + setExternalAccessValid(validateExternalFolderAccess(folder)); + } + }, [folder, foldersRoot]); + + // Re-validate when the app returns to foreground so the banner clears after + // the user grants All Files Access in the system settings screen. + useEffect(() => { + if (!visible) return; + const sub = AppState.addEventListener('change', s => { + if (s === 'active') recheckExternalAccess(); + }); + return () => sub.remove(); + }, [visible, recheckExternalAccess]); + const peers = useMemo( () => allDevices.filter(d => d.deviceID !== info?.deviceId), [allDevices, info?.deviceId], @@ -626,14 +646,26 @@ export function FolderDetailModal({ {isExternal && !externalAccessValid && ( - Storage access was revoked. This folder cannot sync until you re-grant access. + {externalFolderNeedsAllFilesAccess(folder) + ? 'This folder is outside the app sandbox and needs the "All files access" permission to sync. This is common after restoring a backup.' + : 'Storage access was revoked. This folder cannot sync until you re-grant access.'} { + if (externalFolderNeedsAllFilesAccess(folder)) { + // A SAF pick can't restore access to a stored POSIX + // path; only the system permission can. + try { + GoBridge.requestAllFilesAccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + return; + } pickExternalFolderWithICloudWarning(picked => { if (!picked) return; - if (picked.path === folder.path) { + if (validateExternalFolderAccess(folder)) { setExternalAccessValid(true); } else { Alert.alert( @@ -644,7 +676,11 @@ export function FolderDetailModal({ }); }} > - Re-grant access + + {externalFolderNeedsAllFilesAccess(folder) + ? 'Grant All files access' + : 'Re-grant access'} + )} diff --git a/mobile-app/src/screens/SettingsScreen.tsx b/mobile-app/src/screens/SettingsScreen.tsx index a9f2cb0..57056ad 100644 --- a/mobile-app/src/screens/SettingsScreen.tsx +++ b/mobile-app/src/screens/SettingsScreen.tsx @@ -20,7 +20,9 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import GoBridge from '../GoServerBridgeJSI'; +import { useAppReload } from '../AppReload'; import { useSyncthing } from '../daemon/SyncthingContext'; +import { exportAsyncStorage, importAsyncStorage } from '../utils/asyncStorageBackup'; import { useOnboarding } from '../onboarding/useOnboarding'; import { useCoach } from '../onboarding/coach/CoachContext'; import { useCoachTarget } from '../onboarding/coach/useCoachTarget'; @@ -177,6 +179,179 @@ export function SettingsScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const reloadApp = useAppReload(); + const [backupBusy, setBackupBusy] = useState(null); + const [passwordPrompt, setPasswordPrompt] = useState<{ + retry: boolean; + resolve: (value: string | null) => void; + } | null>(null); + const [passwordDraft, setPasswordDraft] = useState(''); + + const handleExport = async () => { + if (backupBusy) return; + setBackupBusy('export'); + try { + const asyncJson = await exportAsyncStorage(); + const raw = GoBridge.exportConfig(asyncJson); + if (raw === '') return; + const res = JSON.parse(raw) as { ok?: boolean; error?: string; displayName?: string }; + if (res.ok) { + Alert.alert('Backup saved', res.displayName ? `Saved as ${res.displayName}.` : 'Backup saved.'); + } else { + Alert.alert('Backup failed', res.error || 'Unknown error'); + } + } catch (e) { + Alert.alert('Backup failed', e instanceof Error ? e.message : String(e)); + } finally { + setBackupBusy(null); + } + }; + + type ImportOutcome = + | { kind: 'cancelled' } + | { kind: 'needsPassword' } + | { kind: 'wrongPassword'; message: string } + | { kind: 'error'; message: string } + | { kind: 'ok'; importedPrefs: boolean; asyncJson: string }; + + const attemptImport = (password: string): ImportOutcome => { + let raw: string; + try { + raw = GoBridge.importConfig(password); + } catch (e) { + return { kind: 'error', message: e instanceof Error ? e.message : String(e) }; + } + if (raw === '') return { kind: 'cancelled' }; + let res: { + ok?: boolean; + error?: string; + importedPrefs?: boolean; + asyncStorageJson?: string; + }; + try { + res = JSON.parse(raw); + } catch { + return { kind: 'error', message: 'bridge returned invalid JSON' }; + } + if (res.ok) { + return { + kind: 'ok', + importedPrefs: !!res.importedPrefs, + asyncJson: typeof res.asyncStorageJson === 'string' ? res.asyncStorageJson : '', + }; + } + const msg = res.error || 'Unknown error'; + if (msg.includes('password required')) return { kind: 'needsPassword' }; + if (msg.includes('wrong password')) return { kind: 'wrongPassword', message: msg }; + return { kind: 'error', message: msg }; + }; + + const promptPassword = (retryMessage: string | null): Promise => + new Promise(resolve => { + setPasswordDraft(''); + setPasswordPrompt({ retry: !!retryMessage, resolve }); + }); + + const runImport = async () => { + setBackupBusy('import'); + try { + try { + GoBridge.stopServer(); + } catch { + // importConfig refuses if globalClient is still set; best-effort is fine + } + + let outcome = attemptImport(''); + while (outcome.kind === 'needsPassword' || outcome.kind === 'wrongPassword') { + const retryMsg = outcome.kind === 'wrongPassword' ? 'Wrong password. Try again.' : null; + const pw = await promptPassword(retryMsg); + if (pw === null || pw === '') break; + outcome = attemptImport(pw); + } + + if (outcome.kind === 'cancelled' || outcome.kind === 'needsPassword' || outcome.kind === 'wrongPassword') { + try { + GoBridge.startServer(); + } catch { + // ignore + } + return; + } + if (outcome.kind === 'error') { + try { + GoBridge.startServer(); + } catch { + // ignore + } + Alert.alert('Restore failed', outcome.message); + return; + } + + let asyncRestored = 0; + if (outcome.asyncJson) { + try { + asyncRestored = await importAsyncStorage(outcome.asyncJson); + } catch { + // ignore + } + } + + reloadApp(); + + const parts = ['Identity and config restored']; + if (outcome.importedPrefs) parts.push('device preferences restored'); + if (asyncRestored > 0) parts.push(`${asyncRestored} app settings restored`); + const summary = parts.join('; ') + '.'; + + // Folders restored from a backup often point at external-storage paths + // that only sync once All Files Access is granted. The user has no way + // to know this, so offer the permission screen directly. + let needsAllFiles = false; + if (isAndroid) { + try { + needsAllFiles = !GoBridge.hasAllFilesAccess(); + } catch { + needsAllFiles = false; + } + } + if (needsAllFiles) { + Alert.alert( + 'Restore complete', + summary + + '\n\nSome restored folders may be stored outside the app and need the "All files access" permission to sync. Grant it now?', + [ + { text: 'Later', style: 'cancel' }, + { + text: 'Grant access', + onPress: () => { + try { + GoBridge.requestAllFilesAccess(); + } catch { + // ignore; user can grant later from the folder banner + } + }, + }, + ], + ); + } else { + Alert.alert('Restore complete', summary); + } + } finally { + setBackupBusy(null); + } + }; + + const confirmImport = () => { + Alert.alert( + 'Restore backup?', + "This replaces this device's identity, folder/device config, and app settings with the contents of the chosen backup. Sync will stop briefly while files are swapped.", + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Choose file', style: 'destructive', onPress: () => { void runImport(); } }, + ], + ); + }; + const confirmRestart = () => { Alert.alert( 'Restart daemon?', @@ -461,6 +636,39 @@ export function SettingsScreen() { onClose={() => setShowQR(false)} /> + + Backup & restore + + Save this device's identity (cert.pem, key.pem), folder/device config, and + {isAndroid ? ' device preferences' : ' app settings'} to a zip you can move to another device + or keep as a recovery copy. Restoring replaces the current identity with the one in the chosen backup. + + + + {backupBusy === 'export' ? 'Working...' : 'Export backup'} + + + + + {backupBusy === 'import' ? 'Working...' : 'Restore from backup'} + + + + Syncthing-Fork backups import as-is, including password-protected (AES-256) archives; + you'll be prompted for the password. The fork's extras (https keys, index database) are + accepted but only the identity, config{isAndroid ? ', and device preferences' : ''} are + actually restored. + + + Stop app @@ -524,6 +732,66 @@ export function SettingsScreen() { )} + + {passwordPrompt && ( + { + passwordPrompt.resolve(null); + setPasswordPrompt(null); + }} + > + + + Backup password + + {passwordPrompt.retry + ? 'Wrong password. Try again.' + : 'This backup is encrypted. Enter the password it was created with.'} + + { + passwordPrompt.resolve(passwordDraft); + setPasswordPrompt(null); + }} + /> + + { + passwordPrompt.resolve(null); + setPasswordPrompt(null); + }} + > + Cancel + + { + passwordPrompt.resolve(passwordDraft); + setPasswordPrompt(null); + }} + > + + Decrypt + + + + + + + )} ); } @@ -709,4 +977,38 @@ const styles = StyleSheet.create({ }, nameSaveText: { color: '#fff', fontSize: 13, fontWeight: '600' }, nameErr: { color: colors.error, fontSize: 12, marginTop: 6 }, + pwBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.45)', + justifyContent: 'center', + paddingHorizontal: 24, + }, + pwBox: { + backgroundColor: colors.card, + borderRadius: 14, + padding: 20, + }, + pwTitle: { color: colors.text, fontSize: 17, fontWeight: '600' }, + pwBody: { color: colors.textDim, fontSize: 13, lineHeight: 18, marginTop: 8 }, + pwInput: { + marginTop: 16, + color: colors.text, + backgroundColor: colors.bg, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 15, + }, + pwButtonRow: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 16, + gap: 8, + }, + pwButton: { paddingVertical: 10, paddingHorizontal: 14, borderRadius: 8 }, + pwButtonPrimary: { backgroundColor: colors.accent }, + pwButtonText: { color: colors.accent, fontSize: 14, fontWeight: '600' }, + pwButtonPrimaryText: { color: '#fff' }, }); diff --git a/mobile-app/src/utils/asyncStorageBackup.ts b/mobile-app/src/utils/asyncStorageBackup.ts new file mode 100644 index 0000000..eda763d --- /dev/null +++ b/mobile-app/src/utils/asyncStorageBackup.ts @@ -0,0 +1,38 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// @PhotoBackup/backedUp is the per-device upload tracker; restoring it onto +// a new device would make the app think those photos were already uploaded. +const EXCLUDED_KEYS = new Set([ + '@PhotoBackup/backedUp', +]); + +export async function exportAsyncStorage(): Promise { + const keys = await AsyncStorage.getAllKeys(); + const filtered = keys.filter(k => !EXCLUDED_KEYS.has(k)); + if (filtered.length === 0) return '{}'; + const pairs = await AsyncStorage.multiGet(filtered); + const out: Record = {}; + for (const [k, v] of pairs) { + if (v !== null) out[k] = v; + } + return JSON.stringify(out); +} + +export async function importAsyncStorage(json: string): Promise { + if (!json || json === '{}') return 0; + let obj: Record; + try { + obj = JSON.parse(json) as Record; + } catch { + return 0; + } + const pairs: [string, string][] = []; + for (const [key, value] of Object.entries(obj)) { + if (typeof key !== 'string' || typeof value !== 'string') continue; + if (EXCLUDED_KEYS.has(key)) continue; + pairs.push([key, value]); + } + if (pairs.length === 0) return 0; + await AsyncStorage.multiSet(pairs); + return pairs.length; +}