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;
+}