Browse Source

gui, lib/config, lib/model: Support auto-accepting folders (fixes #2299)

Also introduces a new Waiter interface for config changes and segments the
configuration GUI.

GitHub-Pull-Request: https://github.com/syncthing/syncthing/pull/4551
Audrius Butkevicius 7 years ago
parent
commit
445c4edeca

+ 5 - 5
cmd/syncthing/gui.go

@@ -112,12 +112,12 @@ type configIntf interface {
 	GUI() config.GUIConfiguration
 	RawCopy() config.Configuration
 	Options() config.OptionsConfiguration
-	Replace(cfg config.Configuration) error
+	Replace(cfg config.Configuration) (config.Waiter, error)
 	Subscribe(c config.Committer)
 	Folders() map[string]config.FolderConfiguration
 	Devices() map[protocol.DeviceID]config.DeviceConfiguration
-	SetDevice(config.DeviceConfiguration) error
-	SetDevices([]config.DeviceConfiguration) error
+	SetDevice(config.DeviceConfiguration) (config.Waiter, error)
+	SetDevices([]config.DeviceConfiguration) (config.Waiter, error)
 	Save() error
 	ListenAddresses() []string
 	RequiresRestart() bool
@@ -809,7 +809,7 @@ func (s *apiService) postSystemConfig(w http.ResponseWriter, r *http.Request) {
 
 	// Activate and save
 
-	if err := s.cfg.Replace(to); err != nil {
+	if _, err := s.cfg.Replace(to); err != nil {
 		l.Warnln("Replacing config:", err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -1201,7 +1201,7 @@ func (s *apiService) makeDevicePauseHandler(paused bool) http.HandlerFunc {
 			cfgs = append(cfgs, cfg)
 		}
 
-		if err := s.cfg.SetDevices(cfgs); err != nil {
+		if _, err := s.cfg.SetDevices(cfgs); err != nil {
 			http.Error(w, err.Error(), 500)
 		}
 	}

+ 2 - 9
cmd/syncthing/main.go

@@ -1080,14 +1080,7 @@ func defaultConfig(cfgFile string) *config.Wrapper {
 
 	if !noDefaultFolder {
 		l.Infoln("Default folder created and/or linked to new config")
-		defaultFolder = config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, locations[locDefFolder])
-		defaultFolder.Label = "Default Folder"
-		defaultFolder.RescanIntervalS = 60
-		defaultFolder.FSWatcherDelayS = 10
-		defaultFolder.MinDiskFree = config.Size{Value: 1, Unit: "%"}
-		defaultFolder.Devices = []config.FolderDeviceConfiguration{{DeviceID: myID}}
-		defaultFolder.AutoNormalize = true
-		defaultFolder.MaxConflicts = -1
+		defaultFolder = config.NewFolderConfiguration(myID, "default", "Default Folder", fs.FilesystemTypeBasic, locations[locDefFolder])
 	} else {
 		l.Infoln("We will skip creation of a default folder on first start since the proper envvar is set")
 	}
@@ -1331,7 +1324,7 @@ func setPauseState(cfg *config.Wrapper, paused bool) {
 	for i := range raw.Folders {
 		raw.Folders[i].Paused = paused
 	}
-	if err := cfg.Replace(raw); err != nil {
+	if _, err := cfg.Replace(raw); err != nil {
 		l.Fatalln("Cannot adjust paused state:", err)
 	}
 }

+ 6 - 6
cmd/syncthing/mocked_config_test.go

@@ -34,8 +34,8 @@ func (c *mockedConfig) Options() config.OptionsConfiguration {
 	return config.OptionsConfiguration{}
 }
 
-func (c *mockedConfig) Replace(cfg config.Configuration) error {
-	return nil
+func (c *mockedConfig) Replace(cfg config.Configuration) (config.Waiter, error) {
+	return nil, nil
 }
 
 func (c *mockedConfig) Subscribe(cm config.Committer) {}
@@ -48,12 +48,12 @@ func (c *mockedConfig) Devices() map[protocol.DeviceID]config.DeviceConfiguratio
 	return nil
 }
 
-func (c *mockedConfig) SetDevice(config.DeviceConfiguration) error {
-	return nil
+func (c *mockedConfig) SetDevice(config.DeviceConfiguration) (config.Waiter, error) {
+	return nil, nil
 }
 
-func (c *mockedConfig) SetDevices([]config.DeviceConfiguration) error {
-	return nil
+func (c *mockedConfig) SetDevices([]config.DeviceConfiguration) (config.Waiter, error) {
+	return nil, nil
 }
 
 func (c *mockedConfig) Save() error {

+ 4 - 0
gui/default/assets/css/overrides.css

@@ -367,3 +367,7 @@ ul.three-columns li, ul.two-columns li {
          width: 100%;
     }
 }
+
+.tab-content {
+    padding-top: 10px;
+}

+ 8 - 0
gui/default/assets/lang/lang-en.json

@@ -28,8 +28,10 @@
    "Any devices configured on an introducer device will be added to this device as well.": "Any devices configured on an introducer device will be added to this device as well.",
    "Are you sure you want to remove device {%name%}?": "Are you sure you want to remove device {{name}}?",
    "Are you sure you want to remove folder {%label%}?": "Are you sure you want to remove folder {{label}}?",
+   "Auto Accept": "Auto Accept",
    "Automatic upgrade now offers the choice between stable releases and release candidates.": "Automatic upgrade now offers the choice between stable releases and release candidates.",
    "Automatic upgrades": "Automatic upgrades",
+   "Automatically create or share folders that this device advertises at the default path.": "Automatically create or share folders that this device advertises at the default path.",
    "Be careful!": "Be careful!",
    "Bugs": "Bugs",
    "CPU Utilization": "CPU Utilization",
@@ -43,12 +45,14 @@
    "Configured": "Configured",
    "Connection Error": "Connection Error",
    "Connection Type": "Connection Type",
+   "Connections": "Connections",
    "Copied from elsewhere": "Copied from elsewhere",
    "Copied from original": "Copied from original",
    "Copyright © 2014-2016 the following Contributors:": "Copyright © 2014-2016 the following Contributors:",
    "Copyright © 2014-2017 the following Contributors:": "Copyright © 2014-2017 the following Contributors:",
    "Creating ignore patterns, overwriting an existing file at {%path%}.": "Creating ignore patterns, overwriting an existing file at {{path}}.",
    "Danger!": "Danger!",
+   "Default Folder Path": "Default Folder Path",
    "Deleted": "Deleted",
    "Device": "Device",
    "Device \"{%name%}\" ({%device%} at {%address%}) wants to connect. Add new device?": "Device \"{{name}}\" ({{device}} at {{address}}) wants to connect. Add new device?",
@@ -101,6 +105,7 @@
    "GUI Listen Address": "GUI Listen Address",
    "GUI Listen Addresses": "GUI Listen Addresses",
    "GUI Theme": "GUI Theme",
+   "General": "General",
    "Generate": "Generate",
    "Global Changes": "Global Changes",
    "Global Discovery": "Global Discovery",
@@ -156,6 +161,7 @@
    "Override Changes": "Override Changes",
    "Path": "Path",
    "Path to the folder on the local computer. Will be created if it does not exist. The tilde character (~) can be used as a shortcut for": "Path to the folder on the local computer. Will be created if it does not exist. The tilde character (~) can be used as a shortcut for",
+   "Path where new auto accepted folders will be created, as well as the default suggested path when adding new folders via the UI. Tilde character (~) expands to {%tilde%}.": "Path where new auto accepted folders will be created, as well as the default suggested path when adding new folders via the UI. Tilde character (~) expands to {{tilde}}.",
    "Path where versions should be stored (leave empty for the default .stversions directory in the shared folder).": "Path where versions should be stored (leave empty for the default .stversions directory in the shared folder).",
    "Path where versions should be stored (leave empty for the default .stversions folder in the folder).": "Path where versions should be stored (leave empty for the default .stversions folder in the folder).",
    "Pause": "Pause",
@@ -264,6 +270,8 @@
    "Time": "Time",
    "Trash Can File Versioning": "Trash Can File Versioning",
    "Type": "Type",
+   "Unavailable": "Unavailable",
+   "Unavailable/Disabled by administrator or maintainer": "Unavailable/Disabled by administrator or maintainer",
    "Undecided (will prompt)": "Undecided (will prompt)",
    "Unknown": "Unknown",
    "Unshared": "Unshared",

+ 1 - 1
gui/default/index.html

@@ -402,7 +402,7 @@
                       <th><span class="fa fa-fw fa-share-alt"></span>&nbsp;<span translate>Shared With</span></th>
                       <td class="text-right" ng-attr-title="{{sharesFolder(folder)}}">{{sharesFolder(folder)}}</td>
                     </tr>
-                    <tr>
+                    <tr ng-if="folderStats[folder.id].lastScan">
                       <th><span class="fa fa-fw fa-clock-o"></span>&nbsp;<span translate>Last Scan</span></th>
                       <td translate ng-if="folderStats[folder.id].lastScanDays >= 365" class="text-right">Never</td>
                       <td ng-if="folderStats[folder.id].lastScanDays < 365" class="text-right">

+ 51 - 23
gui/default/syncthing/device/editDeviceModalView.html

@@ -24,31 +24,59 @@
         </div>
         <div ng-if="editingExisting" class="well well-sm text-monospace" select-on-click>{{currentDevice.deviceID}}</div>
       </div>
-      <div class="form-group">
-        <label translate for="name">Device Name</label>
-        <input id="name" class="form-control" type="text" ng-model="currentDevice.name" />
-        <p translate ng-if="currentDevice.deviceID == myID" class="help-block">Shown instead of Device ID in the cluster status. Will be advertised to other devices as an optional default name.</p>
-        <p translate ng-if="currentDevice.deviceID != myID" class="help-block">Shown instead of Device ID in the cluster status. Will be updated to the name the device advertises if left empty.</p>
-      </div>
-      <div class="form-group">
-        <label translate for="addresses">Addresses</label>
-        <input ng-disabled="currentDevice.deviceID == myID" id="addresses" class="form-control" type="text" ng-model="currentDevice._addressesStr"></input>
-        <p translate class="help-block">Enter comma separated  ("tcp://ip:port", "tcp://host:port") addresses or "dynamic" to perform automatic discovery of the address.</p>
+      <div class="row">
+        <div class="col-md-6">
+          <div class="form-group">
+            <label translate for="name">Device Name</label>
+            <input id="name" class="form-control" type="text" ng-model="currentDevice.name" />
+            <p translate ng-if="currentDevice.deviceID == myID" class="help-block">Shown instead of Device ID in the cluster status. Will be advertised to other devices as an optional default name.</p>
+            <p translate ng-if="currentDevice.deviceID != myID" class="help-block">Shown instead of Device ID in the cluster status. Will be updated to the name the device advertises if left empty.</p>
+          </div>
+        </div>
+        <div class="col-md-6">
+          <div class="form-group">
+            <label translate for="addresses">Addresses</label>
+            <input ng-disabled="currentDevice.deviceID == myID" id="addresses" class="form-control" type="text" ng-model="currentDevice._addressesStr"></input>
+            <p translate class="help-block">Enter comma separated  ("tcp://ip:port", "tcp://host:port") addresses or "dynamic" to perform automatic discovery of the address.</p>
+          </div>
+        </div>
       </div>
-      <div class="form-group">
-        <label translate>Compression</label>
-        <select class="form-control" ng-model="currentDevice.compression">
-          <option value="always" translate>All Data</option>
-          <option value="metadata" translate>Metadata Only</option>
-          <option value="never" translate>Off</option>
-        </select>
+      <div class="row">
+        <div class="col-md-6">
+          <div class="form-group">
+            <label translate>Compression</label>
+            <select class="form-control" ng-model="currentDevice.compression">
+              <option value="always" translate>All Data</option>
+              <option value="metadata" translate>Metadata Only</option>
+              <option value="never" translate>Off</option>
+            </select>
+          </div>
+        </div>
+        <div class="col-md-6">
+        </div>
       </div>
-      <div class="form-group">
-        <div class="checkbox">
-          <label>
-            <input type="checkbox" ng-model="currentDevice.introducer"> <span translate>Introducer</span>
-          </label>
-          <p translate class="help-block">Add devices from the introducer to our device list, for mutually shared folders.</p>
+      <div class="row">
+        <div class="col-md-6">
+          <div class="form-group">
+            <div class="checkbox">
+              <label>
+                <input type="checkbox" ng-model="currentDevice.introducer">
+                <span translate>Introducer</span>
+                <p translate class="help-block">Add devices from the introducer to our device list, for mutually shared folders.</p>
+              </label>
+            </div>
+          </div>
+        </div>
+        <div class="col-md-6">
+          <div class="form-group">
+            <div class="checkbox">
+              <label>
+                <input type="checkbox" ng-model="currentDevice.autoAcceptFolders">
+                <span translate>Auto Accept</span>
+                <p translate class="help-block">Automatically create or share folders that this device advertises at the default path.</p>
+              </label>
+            </div>
+          </div>
         </div>
       </div>
       <div class="row">

+ 170 - 117
gui/default/syncthing/settings/settingsModalView.html

@@ -1,36 +1,180 @@
 <modal id="settings" status="default" icon="cog" heading="{{'Settings' | translate}}" large="yes" closeable="yes">
   <div class="modal-body">
     <form role="form" name="settingsEditor">
-      <div class="row">
-
-        <div class="col-md-6">
+      <ul class="nav nav-tabs">
+        <li class="active"><a data-toggle="tab" href="#settings-general" translate>General</a></li>
+        <li><a data-toggle="tab" href="#settings-gui" translate>GUI</a></li>
+        <li><a data-toggle="tab" href="#settings-connections" translate>Connections</a></li>
+      </ul>
+      <div class="tab-content">
+        <div id="settings-general" class="tab-pane fade in active">
           <div class="form-group">
             <label translate for="DeviceName">Device Name</label>
             <input id="DeviceName" class="form-control" type="text" ng-model="tmpOptions.deviceName"/>
           </div>
 
-          <div class="form-group">
-            <label translate for="ListenAddressesStr">Sync Protocol Listen Addresses</label>&emsp;<a href="https://docs.syncthing.net/users/config.html#listen-addresses" target="_blank"><span class="fa fa-fw fa-book"></span>&nbsp;<span translate>Help</span></a>
-
-            <input id="ListenAddressesStr" class="form-control" type="text" ng-model="tmpOptions._listenAddressesStr"/>
+          <div class="row">
+            <div class="col-md-6">
+              <div class="form-horizontal">
+                <div class="form-group" ng-class="{'has-error': settingsEditor.minHomeDiskFree.$invalid && settingsEditor.minHomeDiskFree.$dirty}">
+                  <label class="col-xs-12" for="minHomeDiskFree"><span translate>Minimum Free Disk Space</span></label><br/>
+                  <div class="col-xs-9"><input name="minHomeDiskFree" id="minHomeDiskFree" class="form-control" type="number" ng-model="tmpOptions.minHomeDiskFree.value" required=""  aria-required="true" min="0" step="0.01"/></div>
+                  <div class="col-xs-3"><select class="col-sm-3 form-control" ng-model="tmpOptions.minHomeDiskFree.unit">
+                    <option value="%">%</option>
+                    <option value="kB">kB</option>
+                    <option value="MB">MB</option>
+                    <option value="GB">GB</option>
+                    <option value="TB">TB</option>
+                  </select></div>
+                  <p class="col-xs-12 help-block">
+                    <span translate ng-show="settingsEditor.minHomeDiskFree.$invalid">Enter a non-negative number (e.g., "2.35") and select a unit. Percentages are as part of the total disk size.</span>
+                    <span translate ng-hide="settingsEditor.minHomeDiskFree.$invalid">This setting controls the free space required on the home (i.e., index database) disk.</span>
+                  </p>
+                </div>
+              </div>
+            </div>
+            <div class="col-md-6">
+              <div class="form-group">
+                <label translate>API Key</label>
+                <div class="input-group">
+                  <input type="text" readonly class="text-monospace form-control" value="{{tmpGUI.apiKey || '-'}}"/>
+                  <span class="input-group-btn">
+                    <button type="button" class="btn btn-default btn-secondary" ng-click="setAPIKey(tmpGUI)">
+                      <span class="fa fa-repeat"></span>&nbsp;<span translate>Generate</span>
+                    </button>
+                  </span>
+                </div>
+              </div>
+            </div>
           </div>
 
-          <div class="form-group" ng-class="{'has-error': settingsEditor.MaxRecvKbps.$invalid && settingsEditor.MaxRecvKbps.$dirty}">
-            <label translate for="MaxRecvKbps">Incoming Rate Limit (KiB/s)</label>
-            <input id="MaxRecvKbps" name="MaxRecvKbps" class="form-control" type="number" ng-model="tmpOptions.maxRecvKbps" min="0"/>
-            <p class="help-block">
-              <span translate ng-if="settingsEditor.MaxRecvKbps.$error.min && settingsEditor.MaxRecvKbps.$dirty">The rate limit must be a non-negative number (0: no limit)</span>
-            </p>
+          <div class="row">
+            <div class="col-md-6">
+              <div class="form-group">
+                <div ng-if="tmpOptions.upgrades != 'candidate'">
+                  <label translate for="urVersion">Anonymous Usage Reporting</label> (<a href="" translate data-toggle="modal" data-target="#urPreview">Preview</a>)
+                  <select class="form-control" id="urVersion" ng-model="tmpOptions._urAcceptedStr">
+                    <option ng-repeat="n in urVersions()" value="{{n}}">{{'Version' | translate}} {{n}}</option>
+                    <!-- 1 does not exist, as we did not support incremental formats back then. -->
+                    <option value="0" translate>Undecided (will prompt)</option>
+                    <option value="-1" translate>Disabled</option>
+                  </select>
+                </div>
+                <p class="help-block" ng-if="tmpOptions.upgrades == 'candidate'">
+                  <span translate>Usage reporting is always enabled for candidate releases.</span> (<a href="" translate data-toggle="modal" data-target="#urPreview">Preview</a>)
+                </p>
+              </div>
+            </div>
+            <div class="col-md-6">
+              <div class="form-group">
+                <label translate>Automatic upgrades</label>&emsp;<a href="https://docs.syncthing.net/users/releases.html" target="_blank"><span class="fa fa-fw fa-book"></span>&nbsp;<span translate>Help</span></a>
+                <select class="form-control" ng-model="tmpOptions.upgrades" ng-if="upgradeInfo">
+                  <option value="none" translate>No upgrades</option>
+                  <option value="stable" translate>Stable releases only</option>
+                  <option value="candidate" translate>Stable releases and release candidates</option>
+                </select>
+                <p class="help-block" ng-if="!upgradeInfo">
+                  <span translate>Unavailable/Disabled by administrator or maintainer</span>
+                </p>
+              </div>
+            </div>
           </div>
-
-          <div class="form-group" ng-class="{'has-error': settingsEditor.MaxSendKbps.$invalid && settingsEditor.MaxSendKbps.$dirty}">
-            <label translate for="MaxSendKbps">Outgoing Rate Limit (KiB/s)</label>
-            <input id="MaxSendKbps" name="MaxSendKbps" class="form-control" type="number" ng-model="tmpOptions.maxSendKbps" min="0"/>
+          <div class="form-group">
+            <label translate for="urVersion">Default Folder Path</label>
+            <input id="DefaultFolderPath" class="form-control" type="text" ng-model="tmpOptions.defaultFolderPath"/>
             <p class="help-block">
-              <span translate ng-if="settingsEditor.MaxSendKbps.$error.min && settingsEditor.MaxSendKbps.$dirty">The rate limit must be a non-negative number (0: no limit)</span>
+              <span translate translate-value-tilde="{{system.tilde}}">
+                Path where new auto accepted folders will be created, as well as the default suggested path when adding new folders via the UI. Tilde character (~) expands to {%tilde%}.
+              </span>
             </p>
           </div>
+        </div>
 
+        <div id="settings-gui" class="tab-pane fade">
+          <div class="form-group"  ng-class="{'has-error': settingsEditor.Address.$invalid && settingsEditor.Address.$dirty}">
+            <label translate for="Address">GUI Listen Address</label>&emsp;<a href="https://docs.syncthing.net/users/guilisten.html" target="_blank"><span class="fa fa-fw fa-book"></span>&nbsp;<span translate>Help</span></a>
+            <input id="Address" name="Address" class="form-control" type="text" ng-model="tmpGUI.address" ng-pattern="/.*:0*((102[4-9])|(10[3-9][0-9])|(1[1-9][0-9][0-9])|([2-9][0-9][0-9][0-9])|([1-6]\d{4}))$/"/>
+              <p class="help-block" ng-show="settingsEditor.Address.$invalid" translate>
+                Enter a non-privileged port number (1024 - 65535).
+              </p>
+          </div>
+          <div class="row">
+            <div class="col-md-6">
+              <div class="form-group">
+                <label translate for="User">GUI Authentication User</label>
+                <input id="User" class="form-control" type="text" ng-model="tmpGUI.user"/>
+              </div>
+            </div>
+            <div class="col-md-6">
+              <div class="form-group">
+                <label translate for="Password">GUI Authentication Password</label>
+                <input id="Password" class="form-control" type="password" ng-model="tmpGUI.password" ng-trim="false"/>
+              </div>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-6">
+              <div class="form-group">
+                <div class="checkbox">
+                  <label>
+                    <input id="UseTLS" type="checkbox" ng-model="tmpGUI.useTLS"/> <span translate>Use HTTPS for GUI</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+            <div class="col-md-6">
+              <div class="form-group">
+                <div class="checkbox">
+                  <label>
+                    <input id="StartBrowser" type="checkbox" ng-model="tmpOptions.startBrowser"/> <span translate>Start Browser</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-6">
+              <div class="form-group">
+                <label translate>GUI Theme</label>
+                <select class="form-control" ng-model="tmpGUI.theme" ng-if="themes.length > 1">
+                  <option ng-repeat="theme in themes.sort()" value="{{ theme }}">
+                    {{ themeName(theme) }}
+                  </option>
+                </select>
+                <p class="help-block" ng-if="themes.length < 2">
+                  <span translate>Unavailable</span>
+                </p>
+              </div>
+            </div>
+            <div class="col-md-6">
+            </div>
+          </div>
+        </div>
+        <div id="settings-connections" class="tab-pane fade">
+          <div class="form-group">
+            <label translate for="ListenAddressesStr">Sync Protocol Listen Addresses</label>&emsp;<a href="https://docs.syncthing.net/users/config.html#listen-addresses" target="_blank"><span class="fa fa-fw fa-book"></span>&nbsp;<span translate>Help</span></a>
+            <input id="ListenAddressesStr" class="form-control" type="text" ng-model="tmpOptions._listenAddressesStr"/>
+          </div>
+          <div class="row">
+            <div class="col-md-6">
+              <div class="form-group" ng-class="{'has-error': settingsEditor.MaxRecvKbps.$invalid && settingsEditor.MaxRecvKbps.$dirty}">
+                <label translate for="MaxRecvKbps">Incoming Rate Limit (KiB/s)</label>
+                <input id="MaxRecvKbps" name="MaxRecvKbps" class="form-control" type="number" ng-model="tmpOptions.maxRecvKbps" min="0"/>
+                <p class="help-block">
+                  <span translate ng-if="settingsEditor.MaxRecvKbps.$error.min && settingsEditor.MaxRecvKbps.$dirty">The rate limit must be a non-negative number (0: no limit)</span>
+                </p>
+              </div>
+            </div>
+            <div class="col-md-6">
+              <div class="form-group" ng-class="{'has-error': settingsEditor.MaxSendKbps.$invalid && settingsEditor.MaxSendKbps.$dirty}">
+                <label translate for="MaxSendKbps">Outgoing Rate Limit (KiB/s)</label>
+                <input id="MaxSendKbps" name="MaxSendKbps" class="form-control" type="number" ng-model="tmpOptions.maxSendKbps" min="0"/>
+                <p class="help-block">
+                  <span translate ng-if="settingsEditor.MaxSendKbps.$error.min && settingsEditor.MaxSendKbps.$dirty">The rate limit must be a non-negative number (0: no limit)</span>
+                </p>
+              </div>
+            </div>
+          </div>
           <div class="row">
             <div class="col-md-6">
               <div class="form-group">
@@ -51,7 +195,6 @@
               </div>
             </div>
           </div>
-
           <div class="row">
             <div class="col-md-6">
               <div class="form-group">
@@ -72,106 +215,16 @@
               </div>
             </div>
           </div>
-
-          <div class="clearfix"></div>
-          <div class="form-group">
-            <label translate for="GlobalAnnServersStr">Global Discovery Servers</label>
-            <input ng-disabled="!tmpOptions.globalAnnounceEnabled" id="GlobalAnnServersStr" class="form-control" type="text" ng-model="tmpOptions._globalAnnounceServersStr"/>
-          </div>
-
-          <div class="form-horizontal">
-            <div class="form-group" ng-class="{'has-error': settingsEditor.minHomeDiskFree.$invalid && settingsEditor.minHomeDiskFree.$dirty}">
-              <label class="col-xs-12" for="minHomeDiskFree"><span translate>Minimum Free Disk Space</span></label><br/>
-              <div class="col-xs-9"><input name="minHomeDiskFree" id="minHomeDiskFree" class="form-control" type="number" ng-model="tmpOptions.minHomeDiskFree.value" required=""  aria-required="true" min="0" step="0.01"/></div>
-              <div class="col-xs-3"><select class="col-sm-3 form-control" ng-model="tmpOptions.minHomeDiskFree.unit">
-                <option value="%">%</option>
-                <option value="kB">kB</option>
-                <option value="MB">MB</option>
-                <option value="GB">GB</option>
-                <option value="TB">TB</option>
-              </select></div>
-              <p class="col-xs-12 help-block">
-                <span translate ng-show="settingsEditor.minHomeDiskFree.$invalid">Enter a non-negative number (e.g., "2.35") and select a unit. Percentages are as part of the total disk size.</span>
-                <span translate ng-hide="settingsEditor.minHomeDiskFree.$invalid">This setting controls the free space required on the home (i.e., index database) disk.</span>
-              </p>
-            </div>
-          </div>
-
-        </div>
-
-        <div class="col-md-6">
-          <div class="form-group"  ng-class="{'has-error': settingsEditor.Address.$invalid && settingsEditor.Address.$dirty}">
-            <label translate for="Address">GUI Listen Address</label>&emsp;<a href="https://docs.syncthing.net/users/guilisten.html" target="_blank"><span class="fa fa-fw fa-book"></span>&nbsp;<span translate>Help</span></a>
-            <input id="Address" name="Address" class="form-control" type="text" ng-model="tmpGUI.address" ng-pattern="/.*:0*((102[4-9])|(10[3-9][0-9])|(1[1-9][0-9][0-9])|([2-9][0-9][0-9][0-9])|([1-6]\d{4}))$/"/>
-              <p class="help-block" ng-show="settingsEditor.Address.$invalid" translate>
-                Enter a non-privileged port number (1024 - 65535).
-              </p>
-          </div>
-          <div class="form-group">
-            <label translate for="User">GUI Authentication User</label>
-            <input id="User" class="form-control" type="text" ng-model="tmpGUI.user"/>
-          </div>
-          <div class="form-group">
-            <label translate for="Password">GUI Authentication Password</label>
-            <input id="Password" class="form-control" type="password" ng-model="tmpGUI.password" ng-trim="false"/>
-          </div>
-          <div class="form-group">
-            <div class="checkbox">
-              <label>
-                <input id="UseTLS" type="checkbox" ng-model="tmpGUI.useTLS"/> <span translate>Use HTTPS for GUI</span>
-              </label>
-            </div>
-          </div>
-          <div class="form-group">
-            <div class="checkbox">
-              <label>
-                <input id="StartBrowser" type="checkbox" ng-model="tmpOptions.startBrowser"/> <span translate>Start Browser</span>
-              </label>
+          <div class="row">
+            <div class="col-md-6">
+              <div class="form-group">
+                <label translate for="GlobalAnnServersStr">Global Discovery Servers</label>
+                <input ng-disabled="!tmpOptions.globalAnnounceEnabled" id="GlobalAnnServersStr" class="form-control" type="text" ng-model="tmpOptions._globalAnnounceServersStr"/>
+              </div>
+            <div>
+            <div class="col-md-6">
             </div>
           </div>
-          <div class="form-group" ng-if="upgradeInfo">
-            <label translate>Automatic upgrades</label>&emsp;<a href="https://docs.syncthing.net/users/releases.html" target="_blank"><span class="fa fa-fw fa-book"></span>&nbsp;<span translate>Help</span></a>
-            <select class="form-control" ng-model="tmpOptions.upgrades">
-              <option value="none" translate>No upgrades</option>
-              <option value="stable" translate>Stable releases only</option>
-              <option value="candidate" translate>Stable releases and release candidates</option>
-            </select>
-          </div>
-
-          <div class="form-group">
-            <div ng-if="tmpOptions.upgrades != 'candidate'">
-              <label translate for="urVersion">Anonymous Usage Reporting</label> (<a href="" translate data-toggle="modal" data-target="#urPreview">Preview</a>)
-              <select class="form-control" id="urVersion" ng-model="tmpOptions._urAcceptedStr">
-                <option ng-repeat="n in urVersions()" value="{{n}}">{{'Version' | translate}} {{n}}</option>
-                <!-- 1 does not exist, as we did not support incremental formats back then. -->
-                <option value="0" translate>Undecided (will prompt)</option>
-                <option value="-1" translate>Disabled</option>
-              </select>
-            </div>
-            <p class="help-block" ng-if="tmpOptions.upgrades == 'candidate'">
-              <span translate>Usage reporting is always enabled for candidate releases.</span> (<a href="" translate data-toggle="modal" data-target="#urPreview">Preview</a>)
-            </p>
-          </div>
-
-          <hr />
-
-          <div class="form-group">
-            <label translate>API Key</label>
-            <div class="well well-sm text-monospace" select-on-click>{{tmpGUI.apiKey || "-"}}</div>
-            <button type="button" class="btn btn-sm btn-default" ng-click="setAPIKey(tmpGUI)">
-              <span class="fa fa-repeat"></span>&nbsp;<span translate>Generate</span>
-            </button>
-          </div>
-
-          <div class="form-group" ng-if="themes.length > 1">
-            <label translate>GUI Theme</label>
-            <select class="form-control" ng-model="tmpGUI.theme">
-              <option ng-repeat="theme in themes.sort()" value="{{ theme }}">
-                {{ themeName(theme) }}
-              </option>
-            </select>
-          </div>
-
         </div>
       </div>
     </form>

+ 3 - 3
lib/config/commit_test.go

@@ -52,7 +52,7 @@ func TestReplaceCommit(t *testing.T) {
 	// Replace config. We should get back a clean response and the config
 	// should change.
 
-	err := w.Replace(Configuration{Version: 1})
+	_, err := w.Replace(Configuration{Version: 1})
 	if err != nil {
 		t.Fatal("Should not have a validation error:", err)
 	}
@@ -69,7 +69,7 @@ func TestReplaceCommit(t *testing.T) {
 	sub0 := requiresRestart{committed: make(chan struct{}, 1)}
 	w.Subscribe(sub0)
 
-	err = w.Replace(Configuration{Version: 2})
+	_, err = w.Replace(Configuration{Version: 2})
 	if err != nil {
 		t.Fatal("Should not have a validation error:", err)
 	}
@@ -87,7 +87,7 @@ func TestReplaceCommit(t *testing.T) {
 
 	w.Subscribe(validationError{})
 
-	err = w.Replace(Configuration{Version: 3})
+	_, err = w.Replace(Configuration{Version: 3})
 	if err == nil {
 		t.Fatal("Should have a validation error")
 	}

+ 1 - 1
lib/config/config_test.go

@@ -826,7 +826,7 @@ func TestSharesRemovedOnDeviceRemoval(t *testing.T) {
 		t.Error("Should have less devices")
 	}
 
-	err = wrapper.Replace(raw)
+	_, err = wrapper.Replace(raw)
 	if err != nil {
 		t.Errorf("Failed: %s", err)
 	}

+ 1 - 0
lib/config/deviceconfiguration.go

@@ -19,6 +19,7 @@ type DeviceConfiguration struct {
 	IntroducedBy             protocol.DeviceID    `xml:"introducedBy,attr" json:"introducedBy"`
 	Paused                   bool                 `xml:"paused" json:"paused"`
 	AllowedNetworks          []string             `xml:"allowedNetwork,omitempty" json:"allowedNetworks"`
+	AutoAcceptFolders        bool                 `xml:"autoAcceptFolders" json:"autoAcceptFolders"`
 }
 
 func NewDeviceConfiguration(id protocol.DeviceID, name string) DeviceConfiguration {

+ 12 - 5
lib/config/folderconfiguration.go

@@ -24,7 +24,7 @@ var (
 const DefaultMarkerName = ".stfolder"
 
 type FolderConfiguration struct {
-	ID                    string                      `xml:"id,attr" json:"id"`
+	ID                    string                      `xml:"id,attr" json:"id" restart:"false"`
 	Label                 string                      `xml:"label,attr" json:"label"`
 	FilesystemType        fs.FilesystemType           `xml:"filesystemType" json:"filesystemType"`
 	Path                  string                      `xml:"path,attr" json:"path"`
@@ -62,11 +62,18 @@ type FolderDeviceConfiguration struct {
 	IntroducedBy protocol.DeviceID `xml:"introducedBy,attr" json:"introducedBy"`
 }
 
-func NewFolderConfiguration(id string, fsType fs.FilesystemType, path string) FolderConfiguration {
+func NewFolderConfiguration(myID protocol.DeviceID, id, label string, fsType fs.FilesystemType, path string) FolderConfiguration {
 	f := FolderConfiguration{
-		ID:             id,
-		FilesystemType: fsType,
-		Path:           path,
+		ID:              id,
+		Label:           label,
+		RescanIntervalS: 60,
+		FSWatcherDelayS: 10,
+		MinDiskFree:     Size{Value: 1, Unit: "%"},
+		Devices:         []FolderDeviceConfiguration{{DeviceID: myID}},
+		AutoNormalize:   true,
+		MaxConflicts:    -1,
+		FilesystemType:  fsType,
+		Path:            path,
 	}
 	f.prepare()
 	return f

+ 12 - 12
lib/config/optionsconfiguration.go

@@ -96,11 +96,11 @@ func (WeakHashSelectionMethod) ParseDefault(value string) (interface{}, error) {
 
 type OptionsConfiguration struct {
 	ListenAddresses         []string                `xml:"listenAddress" json:"listenAddresses" default:"default"`
-	GlobalAnnServers        []string                `xml:"globalAnnounceServer" json:"globalAnnounceServers" json:"globalAnnounceServer" default:"default"`
-	GlobalAnnEnabled        bool                    `xml:"globalAnnounceEnabled" json:"globalAnnounceEnabled" default:"true"`
-	LocalAnnEnabled         bool                    `xml:"localAnnounceEnabled" json:"localAnnounceEnabled" default:"true"`
-	LocalAnnPort            int                     `xml:"localAnnouncePort" json:"localAnnouncePort" default:"21027"`
-	LocalAnnMCAddr          string                  `xml:"localAnnounceMCAddr" json:"localAnnounceMCAddr" default:"[ff12::8384]:21027"`
+	GlobalAnnServers        []string                `xml:"globalAnnounceServer" json:"globalAnnounceServers" json:"globalAnnounceServer" default:"default" restart:"true"`
+	GlobalAnnEnabled        bool                    `xml:"globalAnnounceEnabled" json:"globalAnnounceEnabled" default:"true" restart:"true"`
+	LocalAnnEnabled         bool                    `xml:"localAnnounceEnabled" json:"localAnnounceEnabled" default:"true" restart:"true"`
+	LocalAnnPort            int                     `xml:"localAnnouncePort" json:"localAnnouncePort" default:"21027" restart:"true"`
+	LocalAnnMCAddr          string                  `xml:"localAnnounceMCAddr" json:"localAnnounceMCAddr" default:"[ff12::8384]:21027" restart:"true"`
 	MaxSendKbps             int                     `xml:"maxSendKbps" json:"maxSendKbps"`
 	MaxRecvKbps             int                     `xml:"maxRecvKbps" json:"maxRecvKbps"`
 	ReconnectIntervalS      int                     `xml:"reconnectionIntervalS" json:"reconnectionIntervalS" default:"60"`
@@ -117,21 +117,21 @@ type OptionsConfiguration struct {
 	URURL                   string                  `xml:"urURL" json:"urURL" default:"https://data.syncthing.net/newdata"`
 	URPostInsecurely        bool                    `xml:"urPostInsecurely" json:"urPostInsecurely" default:"false"` // For testing
 	URInitialDelayS         int                     `xml:"urInitialDelayS" json:"urInitialDelayS" default:"1800"`
-	RestartOnWakeup         bool                    `xml:"restartOnWakeup" json:"restartOnWakeup" default:"true"`
-	AutoUpgradeIntervalH    int                     `xml:"autoUpgradeIntervalH" json:"autoUpgradeIntervalH" default:"12"` // 0 for off
-	UpgradeToPreReleases    bool                    `xml:"upgradeToPreReleases" json:"upgradeToPreReleases"`              // when auto upgrades are enabled
-	KeepTemporariesH        int                     `xml:"keepTemporariesH" json:"keepTemporariesH" default:"24"`         // 0 for off
-	CacheIgnoredFiles       bool                    `xml:"cacheIgnoredFiles" json:"cacheIgnoredFiles" default:"false"`
+	RestartOnWakeup         bool                    `xml:"restartOnWakeup" json:"restartOnWakeup" default:"true" restart:"true"`
+	AutoUpgradeIntervalH    int                     `xml:"autoUpgradeIntervalH" json:"autoUpgradeIntervalH" default:"12" restart:"true"` // 0 for off
+	UpgradeToPreReleases    bool                    `xml:"upgradeToPreReleases" json:"upgradeToPreReleases" restart:"true"`              // when auto upgrades are enabled
+	KeepTemporariesH        int                     `xml:"keepTemporariesH" json:"keepTemporariesH" default:"24"`                        // 0 for off
+	CacheIgnoredFiles       bool                    `xml:"cacheIgnoredFiles" json:"cacheIgnoredFiles" default:"false" restart:"true"`
 	ProgressUpdateIntervalS int                     `xml:"progressUpdateIntervalS" json:"progressUpdateIntervalS" default:"5"`
 	LimitBandwidthInLan     bool                    `xml:"limitBandwidthInLan" json:"limitBandwidthInLan" default:"false"`
 	MinHomeDiskFree         Size                    `xml:"minHomeDiskFree" json:"minHomeDiskFree" default:"1 %"`
-	ReleasesURL             string                  `xml:"releasesURL" json:"releasesURL" default:"https://upgrades.syncthing.net/meta.json"`
+	ReleasesURL             string                  `xml:"releasesURL" json:"releasesURL" default:"https://upgrades.syncthing.net/meta.json" restart:"true"`
 	AlwaysLocalNets         []string                `xml:"alwaysLocalNet" json:"alwaysLocalNets"`
 	OverwriteRemoteDevNames bool                    `xml:"overwriteRemoteDeviceNamesOnConnect" json:"overwriteRemoteDeviceNamesOnConnect" default:"false"`
 	TempIndexMinBlocks      int                     `xml:"tempIndexMinBlocks" json:"tempIndexMinBlocks" default:"10"`
 	UnackedNotificationIDs  []string                `xml:"unackedNotificationID" json:"unackedNotificationIDs"`
 	TrafficClass            int                     `xml:"trafficClass" json:"trafficClass"`
-	WeakHashSelectionMethod WeakHashSelectionMethod `xml:"weakHashSelectionMethod" json:"weakHashSelectionMethod"`
+	WeakHashSelectionMethod WeakHashSelectionMethod `xml:"weakHashSelectionMethod" json:"weakHashSelectionMethod" restart:"true"`
 	StunServers             []string                `xml:"stunServer" json:"stunServers" default:"default"`
 	StunKeepaliveS          int                     `xml:"stunKeepaliveSeconds" json:"stunKeepaliveSeconds" default:"24"`
 	KCPNoDelay              bool                    `xml:"kcpNoDelay" json:"kcpNoDelay" default:"false"`

+ 32 - 39
lib/config/wrapper.go

@@ -45,6 +45,15 @@ type Committer interface {
 	String() string
 }
 
+// Waiter allows to wait for the given config operation to complete.
+type Waiter interface {
+	Wait()
+}
+
+type noopWaiter struct{}
+
+func (noopWaiter) Wait() {}
+
 // A wrapper around a Configuration that manages loads, saves and published
 // notifications of changes to registered Handlers
 
@@ -130,37 +139,25 @@ func (w *Wrapper) RawCopy() Configuration {
 	return w.cfg.Copy()
 }
 
-// ReplaceBlocking swaps the current configuration object for the given one,
-// and waits for subscribers to be notified.
-func (w *Wrapper) ReplaceBlocking(cfg Configuration) error {
-	w.mut.Lock()
-	wg := sync.NewWaitGroup()
-	err := w.replaceLocked(cfg, wg)
-	w.mut.Unlock()
-	wg.Wait()
-	return err
-}
-
 // Replace swaps the current configuration object for the given one.
-func (w *Wrapper) Replace(cfg Configuration) error {
+func (w *Wrapper) Replace(cfg Configuration) (Waiter, error) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
-
-	return w.replaceLocked(cfg, nil)
+	return w.replaceLocked(cfg)
 }
 
-func (w *Wrapper) replaceLocked(to Configuration, wg sync.WaitGroup) error {
+func (w *Wrapper) replaceLocked(to Configuration) (Waiter, error) {
 	from := w.cfg
 
 	if err := to.clean(); err != nil {
-		return err
+		return noopWaiter{}, err
 	}
 
 	for _, sub := range w.subs {
 		l.Debugln(sub, "verifying configuration")
 		if err := sub.VerifyConfiguration(from, to); err != nil {
 			l.Debugln(sub, "rejected config:", err)
-			return err
+			return noopWaiter{}, err
 		}
 	}
 
@@ -168,23 +165,19 @@ func (w *Wrapper) replaceLocked(to Configuration, wg sync.WaitGroup) error {
 	w.deviceMap = nil
 	w.folderMap = nil
 
-	w.notifyListeners(from, to, wg)
-
-	return nil
+	return w.notifyListeners(from, to), nil
 }
 
-func (w *Wrapper) notifyListeners(from, to Configuration, wg sync.WaitGroup) {
-	if wg != nil {
-		wg.Add(len(w.subs))
-	}
+func (w *Wrapper) notifyListeners(from, to Configuration) Waiter {
+	wg := sync.NewWaitGroup()
+	wg.Add(len(w.subs))
 	for _, sub := range w.subs {
 		go func(commiter Committer) {
 			w.notifyListener(commiter, from.Copy(), to.Copy())
-			if wg != nil {
-				wg.Done()
-			}
+			wg.Done()
 		}(sub)
 	}
+	return wg
 }
 
 func (w *Wrapper) notifyListener(sub Committer, from, to Configuration) {
@@ -211,7 +204,7 @@ func (w *Wrapper) Devices() map[protocol.DeviceID]DeviceConfiguration {
 
 // SetDevices adds new devices to the configuration, or overwrites existing
 // devices with the same ID.
-func (w *Wrapper) SetDevices(devs []DeviceConfiguration) error {
+func (w *Wrapper) SetDevices(devs []DeviceConfiguration) (Waiter, error) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
 
@@ -231,17 +224,17 @@ func (w *Wrapper) SetDevices(devs []DeviceConfiguration) error {
 		}
 	}
 
-	return w.replaceLocked(newCfg, nil)
+	return w.replaceLocked(newCfg)
 }
 
 // SetDevice adds a new device to the configuration, or overwrites an existing
 // device with the same ID.
-func (w *Wrapper) SetDevice(dev DeviceConfiguration) error {
+func (w *Wrapper) SetDevice(dev DeviceConfiguration) (Waiter, error) {
 	return w.SetDevices([]DeviceConfiguration{dev})
 }
 
 // RemoveDevice removes the device from the configuration
-func (w *Wrapper) RemoveDevice(id protocol.DeviceID) error {
+func (w *Wrapper) RemoveDevice(id protocol.DeviceID) (Waiter, error) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
 
@@ -255,10 +248,10 @@ func (w *Wrapper) RemoveDevice(id protocol.DeviceID) error {
 		}
 	}
 	if !removed {
-		return nil
+		return noopWaiter{}, nil
 	}
 
-	return w.replaceLocked(newCfg, nil)
+	return w.replaceLocked(newCfg)
 }
 
 // Folders returns a map of folders. Folder structures should not be changed,
@@ -277,7 +270,7 @@ func (w *Wrapper) Folders() map[string]FolderConfiguration {
 
 // SetFolder adds a new folder to the configuration, or overwrites an existing
 // folder with the same ID.
-func (w *Wrapper) SetFolder(fld FolderConfiguration) error {
+func (w *Wrapper) SetFolder(fld FolderConfiguration) (Waiter, error) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
 
@@ -294,7 +287,7 @@ func (w *Wrapper) SetFolder(fld FolderConfiguration) error {
 		newCfg.Folders = append(w.cfg.Folders, fld)
 	}
 
-	return w.replaceLocked(newCfg, nil)
+	return w.replaceLocked(newCfg)
 }
 
 // Options returns the current options configuration object.
@@ -305,12 +298,12 @@ func (w *Wrapper) Options() OptionsConfiguration {
 }
 
 // SetOptions replaces the current options configuration object.
-func (w *Wrapper) SetOptions(opts OptionsConfiguration) error {
+func (w *Wrapper) SetOptions(opts OptionsConfiguration) (Waiter, error) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
 	newCfg := w.cfg.Copy()
 	newCfg.Options = opts
-	return w.replaceLocked(newCfg, nil)
+	return w.replaceLocked(newCfg)
 }
 
 // GUI returns the current GUI configuration object.
@@ -321,12 +314,12 @@ func (w *Wrapper) GUI() GUIConfiguration {
 }
 
 // SetGUI replaces the current GUI configuration object.
-func (w *Wrapper) SetGUI(gui GUIConfiguration) error {
+func (w *Wrapper) SetGUI(gui GUIConfiguration) (Waiter, error) {
 	w.mut.Lock()
 	defer w.mut.Unlock()
 	newCfg := w.cfg.Copy()
 	newCfg.GUI = gui
-	return w.replaceLocked(newCfg, nil)
+	return w.replaceLocked(newCfg)
 }
 
 // IgnoredDevice returns whether or not connection attempts from the given

+ 2 - 1
lib/connections/service.go

@@ -14,6 +14,7 @@ import (
 	"net/url"
 	"sort"
 	"strings"
+	stdsync "sync"
 	"time"
 
 	"github.com/syncthing/syncthing/lib/config"
@@ -758,7 +759,7 @@ func dialParallel(deviceID protocol.DeviceID, dialTargets []dialTarget) (interna
 	for _, prio := range priorities {
 		tgts := dialTargetBuckets[prio]
 		res := make(chan internalConn, len(tgts))
-		wg := sync.NewWaitGroup()
+		wg := stdsync.WaitGroup{}
 		for _, tgt := range tgts {
 			wg.Add(1)
 			go func(tgt dialTarget) {

+ 91 - 29
lib/model/model.go

@@ -33,6 +33,7 @@ import (
 	"github.com/syncthing/syncthing/lib/stats"
 	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/upgrade"
+	"github.com/syncthing/syncthing/lib/util"
 	"github.com/syncthing/syncthing/lib/versioner"
 	"github.com/syncthing/syncthing/lib/weakhash"
 	"github.com/thejerf/suture"
@@ -892,6 +893,8 @@ func (m *Model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 	}
 
 	dbLocation := filepath.Dir(m.db.Location())
+	changed := false
+	deviceCfg := m.cfg.Devices()[deviceID]
 
 	// See issue #3802 - in short, we can't send modern symlink entries to older
 	// clients.
@@ -901,6 +904,13 @@ func (m *Model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 		dropSymlinks = true
 	}
 
+	// Needs to happen outside of the fmut, as can cause CommitConfiguration
+	if deviceCfg.AutoAcceptFolders {
+		for _, folder := range cm.Folders {
+			changed = m.handleAutoAccepts(deviceCfg, folder) || changed
+		}
+	}
+
 	m.fmut.Lock()
 	var paused []string
 	for _, folder := range cm.Folders {
@@ -927,6 +937,7 @@ func (m *Model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 			l.Infof("Unexpected folder %s sent from device %q; ensure that the folder exists and that this device is selected under \"Share With\" in the folder configuration.", folder.Description(), deviceID)
 			continue
 		}
+
 		if !folder.DisableTempIndexes {
 			tempIndexFolders = append(tempIndexFolders, folder.ID)
 		}
@@ -1021,8 +1032,7 @@ func (m *Model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
 		}
 	}
 
-	var changed = false
-	if deviceCfg := m.cfg.Devices()[deviceID]; deviceCfg.Introducer {
+	if deviceCfg.Introducer {
 		foldersDevices, introduced := m.handleIntroductions(deviceCfg, cm)
 		if introduced {
 			changed = true
@@ -1063,6 +1073,11 @@ func (m *Model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm
 		// the folder.
 	nextDevice:
 		for _, device := range folder.Devices {
+			// No need to share with self.
+			if device.ID == m.id {
+				continue
+			}
+
 			foldersDevices.set(device.ID, folder.ID)
 
 			if _, ok := m.cfg.Devices()[device.ID]; !ok {
@@ -1081,7 +1096,8 @@ func (m *Model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm
 
 			// We don't yet share this folder with this device. Add the device
 			// to sharing list of the folder.
-			m.introduceDeviceToFolder(device, folder, introducerCfg)
+			l.Infof("Sharing folder %s with %v (vouched for by introducer %v)", folder.Description(), device.ID, introducerCfg.DeviceID)
+			m.shareFolderWithDeviceLocked(device.ID, folder.ID, introducerCfg.DeviceID)
 			changed = true
 		}
 	}
@@ -1089,7 +1105,7 @@ func (m *Model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm
 	return foldersDevices, changed
 }
 
-// handleIntroductions handles removals of devices/shares that are removed by an introducer device
+// handleDeintroductions handles removals of devices/shares that are removed by an introducer device
 func (m *Model) handleDeintroductions(introducerCfg config.DeviceConfiguration, cm protocol.ClusterConfig, foldersDevices folderDeviceSet) bool {
 	changed := false
 	foldersIntroducedByOthers := make(folderDeviceSet)
@@ -1142,6 +1158,51 @@ func (m *Model) handleDeintroductions(introducerCfg config.DeviceConfiguration,
 	return changed
 }
 
+// handleAutoAccepts handles adding and sharing folders for devices that have
+// AutoAcceptFolders set to true.
+func (m *Model) handleAutoAccepts(deviceCfg config.DeviceConfiguration, folder protocol.Folder) bool {
+	if _, ok := m.cfg.Folder(folder.ID); !ok {
+		defaultPath := m.cfg.Options().DefaultFolderPath
+		defaultPathFs := fs.NewFilesystem(fs.FilesystemTypeBasic, defaultPath)
+		for _, path := range []string{folder.Label, folder.ID} {
+			if _, err := defaultPathFs.Lstat(path); !fs.IsNotExist(err) {
+				continue
+			}
+
+			fcfg := config.NewFolderConfiguration(m.id, folder.ID, folder.Label, fs.FilesystemTypeBasic, filepath.Join(defaultPath, path))
+
+			// Need to wait for the waiter, as this calls CommitConfiguration,
+			// which sets up the folder and as we return from this call,
+			// ClusterConfig starts poking at m.folderFiles and other things
+			// that might not exist until the config is committed.
+			w, _ := m.cfg.SetFolder(fcfg)
+			w.Wait()
+
+			// This needs to happen under a lock.
+			m.fmut.Lock()
+			w = m.shareFolderWithDeviceLocked(deviceCfg.DeviceID, folder.ID, protocol.DeviceID{})
+			m.fmut.Unlock()
+			w.Wait()
+			l.Infof("Auto-accepted %s folder %s at path %s", deviceCfg.DeviceID, folder.Description(), fcfg.Path)
+			return true
+		}
+		l.Infof("Failed to auto-accept folder %s from %s due to path conflict", folder.Description(), deviceCfg.DeviceID)
+		return false
+	}
+
+	// Folder already exists.
+	if !m.folderSharedWith(folder.ID, deviceCfg.DeviceID) {
+		m.fmut.Lock()
+		w := m.shareFolderWithDeviceLocked(deviceCfg.DeviceID, folder.ID, protocol.DeviceID{})
+		m.fmut.Unlock()
+		w.Wait()
+		l.Infof("Shared %s with %s due to auto-accept", folder.ID, deviceCfg.DeviceID)
+		return true
+	}
+
+	return false
+}
+
 func (m *Model) introduceDevice(device protocol.Device, introducerCfg config.DeviceConfiguration) {
 	addresses := []string{"dynamic"}
 	for _, addr := range device.Addresses {
@@ -1170,18 +1231,17 @@ func (m *Model) introduceDevice(device protocol.Device, introducerCfg config.Dev
 	m.cfg.SetDevice(newDeviceCfg)
 }
 
-func (m *Model) introduceDeviceToFolder(device protocol.Device, folder protocol.Folder, introducerCfg config.DeviceConfiguration) {
-	l.Infof("Sharing folder %s with %v (vouched for by introducer %v)", folder.Description(), device.ID, introducerCfg.DeviceID)
+func (m *Model) shareFolderWithDeviceLocked(deviceID protocol.DeviceID, folder string, introducer protocol.DeviceID) config.Waiter {
+	m.deviceFolders[deviceID] = append(m.deviceFolders[deviceID], folder)
+	m.folderDevices.set(deviceID, folder)
 
-	m.deviceFolders[device.ID] = append(m.deviceFolders[device.ID], folder.ID)
-	m.folderDevices.set(device.ID, folder.ID)
-
-	folderCfg := m.cfg.Folders()[folder.ID]
+	folderCfg := m.cfg.Folders()[folder]
 	folderCfg.Devices = append(folderCfg.Devices, config.FolderDeviceConfiguration{
-		DeviceID:     device.ID,
-		IntroducedBy: introducerCfg.DeviceID,
+		DeviceID:     deviceID,
+		IntroducedBy: introducer,
 	})
-	m.cfg.SetFolder(folderCfg)
+	w, _ := m.cfg.SetFolder(folderCfg)
+	return w
 }
 
 // Closed is called when a connection has been closed
@@ -1486,7 +1546,7 @@ func (m *Model) AddConnection(conn connections.Connection, hello protocol.HelloR
 	conn.ClusterConfig(cm)
 
 	device, ok := m.cfg.Devices()[deviceID]
-	if ok && (device.Name == "" || m.cfg.Options().OverwriteRemoteDevNames) {
+	if ok && (device.Name == "" || m.cfg.Options().OverwriteRemoteDevNames) && hello.DeviceName != "" {
 		device.Name = hello.DeviceName
 		m.cfg.SetDevice(device)
 		m.cfg.Save()
@@ -2366,10 +2426,10 @@ func (m *Model) CommitConfiguration(from, to config.Configuration) bool {
 		if _, ok := fromFolders[folderID]; !ok {
 			// A folder was added.
 			if cfg.Paused {
-				l.Infoln(m, "Paused folder", cfg.Description())
+				l.Infoln("Paused folder", cfg.Description())
 				cfg.CreateRoot()
 			} else {
-				l.Infoln(m, "Adding folder", cfg.Description())
+				l.Infoln("Adding folder", cfg.Description())
 				m.AddFolder(cfg)
 				m.StartFolder(folderID)
 			}
@@ -2388,8 +2448,12 @@ func (m *Model) CommitConfiguration(from, to config.Configuration) bool {
 		// Check if anything differs, apart from the label.
 		toCfgCopy := toCfg
 		fromCfgCopy := fromCfg
-		fromCfgCopy.Label = ""
-		toCfgCopy.Label = ""
+		util.CopyMatchingTag(&toCfgCopy, &fromCfgCopy, "restart", func(v string) bool {
+			if len(v) > 0 && v != "false" {
+				panic(fmt.Sprintf(`unexpected struct value: %s. expected untagged or "false"`, v))
+			}
+			return v == "false"
+		})
 
 		if !reflect.DeepEqual(fromCfgCopy, toCfgCopy) {
 			m.RestartFolder(toCfg)
@@ -2432,17 +2496,15 @@ func (m *Model) CommitConfiguration(from, to config.Configuration) bool {
 
 	// Some options don't require restart as those components handle it fine
 	// by themselves.
-	from.Options.URAccepted = to.Options.URAccepted
-	from.Options.URSeen = to.Options.URSeen
-	from.Options.URUniqueID = to.Options.URUniqueID
-	from.Options.ListenAddresses = to.Options.ListenAddresses
-	from.Options.RelaysEnabled = to.Options.RelaysEnabled
-	from.Options.UnackedNotificationIDs = to.Options.UnackedNotificationIDs
-	from.Options.MaxRecvKbps = to.Options.MaxRecvKbps
-	from.Options.MaxSendKbps = to.Options.MaxSendKbps
-	from.Options.LimitBandwidthInLan = to.Options.LimitBandwidthInLan
-	from.Options.StunKeepaliveS = to.Options.StunKeepaliveS
-	from.Options.StunServers = to.Options.StunServers
+
+	// Copy fields that do not have the field set to true
+	util.CopyMatchingTag(&from.Options, &to.Options, "restart", func(v string) bool {
+		if len(v) > 0 && v != "true" {
+			panic(fmt.Sprintf(`unexpected struct value: %s. expected untagged or "true"`, v))
+		}
+		return v != "true"
+	})
+
 	// All of the other generic options require restart. Or at least they may;
 	// removing this check requires going through those options carefully and
 	// making sure there are individual services that handle them correctly.

+ 278 - 24
lib/model/model_test.go

@@ -18,6 +18,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"strconv"
+	"strings"
 	"sync"
 	"testing"
 	"time"
@@ -36,13 +37,14 @@ var device1, device2 protocol.DeviceID
 var defaultConfig *config.Wrapper
 var defaultFolderConfig config.FolderConfiguration
 var defaultFs fs.Filesystem
+var defaultAutoAcceptCfg config.Configuration
 
 func init() {
 	device1, _ = protocol.DeviceIDFromString("AIR6LPZ-7K4PTTV-UXQSMUU-CPQ5YWH-OEDFIIQ-JUG777G-2YQXXR5-YD6AWQR")
 	device2, _ = protocol.DeviceIDFromString("GYRZZQB-IRNPV4Z-T7TC52W-EQYJ3TT-FDQW6MW-DFLMU42-SSSU6EM-FBK2VAY")
 	defaultFs = fs.NewFilesystem(fs.FilesystemTypeBasic, "testdata")
 
-	defaultFolderConfig = config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, "testdata")
+	defaultFolderConfig = config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, "testdata")
 	defaultFolderConfig.Devices = []config.FolderDeviceConfiguration{{DeviceID: device1}}
 	_defaultConfig := config.Configuration{
 		Folders: []config.FolderConfiguration{defaultFolderConfig},
@@ -53,6 +55,17 @@ func init() {
 		},
 	}
 	defaultConfig = config.Wrap("/tmp/test", _defaultConfig)
+	defaultAutoAcceptCfg = config.Configuration{
+		Devices: []config.DeviceConfiguration{
+			{
+				DeviceID:          device1,
+				AutoAcceptFolders: true,
+			},
+		},
+		Options: config.OptionsConfiguration{
+			DefaultFolderPath: "testdata",
+		},
+	}
 }
 
 var testDataExpected = map[string]protocol.FileInfo{
@@ -87,6 +100,20 @@ func init() {
 	}
 }
 
+func newState(cfg config.Configuration) (*config.Wrapper, *Model) {
+	db := db.OpenMemory()
+
+	wcfg := config.Wrap("/tmp/test", cfg)
+
+	m := NewModel(wcfg, protocol.LocalDeviceID, "syncthing", "dev", db, nil)
+	for _, folder := range cfg.Folders {
+		m.AddFolder(folder)
+	}
+	m.ServeBackground()
+	m.AddConnection(&fakeConnection{id: device1}, protocol.HelloResult{})
+	return wcfg, m
+}
+
 func TestRequest(t *testing.T) {
 	db := db.OpenMemory()
 
@@ -609,20 +636,6 @@ func TestIntroducer(t *testing.T) {
 		return false
 	}
 
-	newState := func(cfg config.Configuration) (*config.Wrapper, *Model) {
-		db := db.OpenMemory()
-
-		wcfg := config.Wrap("/tmp/test", cfg)
-
-		m := NewModel(wcfg, protocol.LocalDeviceID, "syncthing", "dev", db, nil)
-		for _, folder := range cfg.Folders {
-			m.AddFolder(folder)
-		}
-		m.ServeBackground()
-		m.AddConnection(&fakeConnection{id: device1}, protocol.HelloResult{})
-		return wcfg, m
-	}
-
 	wcfg, m := newState(config.Configuration{
 		Devices: []config.DeviceConfiguration{
 			{
@@ -970,6 +983,237 @@ func TestIntroducer(t *testing.T) {
 	}
 }
 
+func TestAutoAcceptRejected(t *testing.T) {
+	// Nothing happens if AutoAcceptFolders not set
+	tcfg := defaultAutoAcceptCfg.Copy()
+	tcfg.Devices[0].AutoAcceptFolders = false
+	wcfg, m := newState(tcfg)
+	id := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id))
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id,
+				Label: id,
+			},
+		},
+	})
+
+	if _, ok := wcfg.Folder(id); ok || m.folderSharedWith(id, device1) {
+		t.Error("unexpected shared", id)
+	}
+}
+
+func TestAutoAcceptNewFolder(t *testing.T) {
+	// New folder
+	wcfg, m := newState(defaultAutoAcceptCfg)
+	id := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id))
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id,
+				Label: id,
+			},
+		},
+	})
+	if _, ok := wcfg.Folder(id); !ok || !m.folderSharedWith(id, device1) {
+		t.Error("expected shared", id)
+	}
+}
+
+func TestAutoAcceptMultipleFolders(t *testing.T) {
+	// Multiple new folders
+	wcfg, m := newState(defaultAutoAcceptCfg)
+	id1 := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id1))
+	id2 := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id2))
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id1,
+				Label: id1,
+			},
+			{
+				ID:    id2,
+				Label: id2,
+			},
+		},
+	})
+	if _, ok := wcfg.Folder(id1); !ok || !m.folderSharedWith(id1, device1) {
+		t.Error("expected shared", id1)
+	}
+	if _, ok := wcfg.Folder(id2); !ok || !m.folderSharedWith(id2, device1) {
+		t.Error("expected shared", id2)
+	}
+}
+
+func TestAutoAcceptExistingFolder(t *testing.T) {
+	// Existing folder
+	id := srand.String(8)
+	idOther := srand.String(8) // To check that path does not get changed.
+	defer os.RemoveAll(filepath.Join("testdata", id))
+
+	tcfg := defaultAutoAcceptCfg.Copy()
+	tcfg.Folders = []config.FolderConfiguration{
+		{
+			ID:   id,
+			Path: filepath.Join("testdata", idOther), // To check that path does not get changed.
+		},
+	}
+	wcfg, m := newState(tcfg)
+	if _, ok := wcfg.Folder(id); !ok || m.folderSharedWith(id, device1) {
+		t.Error("missing folder, or shared", id)
+	}
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id,
+				Label: id,
+			},
+		},
+	})
+
+	if fcfg, ok := wcfg.Folder(id); !ok || !m.folderSharedWith(id, device1) || fcfg.Path != filepath.Join("testdata", idOther) {
+		t.Error("missing folder, or unshared, or path changed", id)
+	}
+}
+
+func TestAutoAcceptNewAndExistingFolder(t *testing.T) {
+	// New and existing folder
+	id1 := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id1))
+	id2 := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id2))
+
+	tcfg := defaultAutoAcceptCfg.Copy()
+	tcfg.Folders = []config.FolderConfiguration{
+		{
+			ID:   id1,
+			Path: filepath.Join("testdata", id1), // from previous test case, to verify that path doesn't get changed.
+		},
+	}
+	wcfg, m := newState(tcfg)
+	if _, ok := wcfg.Folder(id1); !ok || m.folderSharedWith(id1, device1) {
+		t.Error("missing folder, or shared", id1)
+	}
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id1,
+				Label: id1,
+			},
+			{
+				ID:    id2,
+				Label: id2,
+			},
+		},
+	})
+
+	for i, id := range []string{id1, id2} {
+		if _, ok := wcfg.Folder(id); !ok || !m.folderSharedWith(id, device1) {
+			t.Error("missing folder, or unshared", i, id)
+		}
+	}
+}
+
+func TestAutoAcceptAlreadyShared(t *testing.T) {
+	// Already shared
+	id := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id))
+	tcfg := defaultAutoAcceptCfg.Copy()
+	tcfg.Folders = []config.FolderConfiguration{
+		{
+			ID:   id,
+			Path: filepath.Join("testdata", id),
+			Devices: []config.FolderDeviceConfiguration{
+				{
+					DeviceID: device1,
+				},
+			},
+		},
+	}
+	wcfg, m := newState(tcfg)
+	if _, ok := wcfg.Folder(id); !ok || !m.folderSharedWith(id, device1) {
+		t.Error("missing folder, or not shared", id)
+	}
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id,
+				Label: id,
+			},
+		},
+	})
+
+	if _, ok := wcfg.Folder(id); !ok || !m.folderSharedWith(id, device1) {
+		t.Error("missing folder, or not shared", id)
+	}
+}
+
+func TestAutoAcceptNameConflict(t *testing.T) {
+	id := srand.String(8)
+	label := srand.String(8)
+	os.MkdirAll(filepath.Join("testdata", id), 0777)
+	os.MkdirAll(filepath.Join("testdata", label), 0777)
+	defer os.RemoveAll(filepath.Join("testdata", id))
+	defer os.RemoveAll(filepath.Join("testdata", label))
+	wcfg, m := newState(defaultAutoAcceptCfg)
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id,
+				Label: label,
+			},
+		},
+	})
+	if _, ok := wcfg.Folder(id); ok || m.folderSharedWith(id, device1) {
+		t.Error("unexpected folder", id)
+	}
+}
+
+func TestAutoAcceptPrefersLabel(t *testing.T) {
+	// Prefers label, falls back to ID.
+	wcfg, m := newState(defaultAutoAcceptCfg)
+	id := srand.String(8)
+	label := srand.String(8)
+	defer os.RemoveAll(filepath.Join("testdata", id))
+	defer os.RemoveAll(filepath.Join("testdata", label))
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id,
+				Label: label,
+			},
+		},
+	})
+	if fcfg, ok := wcfg.Folder(id); !ok || !m.folderSharedWith(id, device1) || !strings.HasSuffix(fcfg.Path, label) {
+		t.Error("expected shared, or wrong path", id, label, fcfg.Path)
+	}
+}
+
+func TestAutoAcceptFallsBackToID(t *testing.T) {
+	// Prefers label, falls back to ID.
+	wcfg, m := newState(defaultAutoAcceptCfg)
+	id := srand.String(8)
+	label := srand.String(8)
+	os.MkdirAll(filepath.Join("testdata", label), 0777)
+	defer os.RemoveAll(filepath.Join("testdata", label))
+	defer os.RemoveAll(filepath.Join("testdata", id))
+	m.ClusterConfig(device1, protocol.ClusterConfig{
+		Folders: []protocol.Folder{
+			{
+				ID:    id,
+				Label: label,
+			},
+		},
+	})
+	if fcfg, ok := wcfg.Folder(id); !ok || !m.folderSharedWith(id, device1) || !strings.HasSuffix(fcfg.Path, id) {
+		t.Error("expected shared, or wrong path", id, label, fcfg.Path)
+	}
+}
+
 func changeIgnores(t *testing.T, m *Model, expected []string) {
 	arrEqual := func(a, b []string) bool {
 		if len(a) != len(b) {
@@ -1920,7 +2164,9 @@ func TestIssue4357(t *testing.T) {
 	defer m.Stop()
 
 	// Force the model to wire itself and add the folders
-	if err := wrapper.ReplaceBlocking(cfg); err != nil {
+	p, err := wrapper.Replace(cfg)
+	p.Wait()
+	if err != nil {
 		t.Error(err)
 	}
 
@@ -1931,7 +2177,9 @@ func TestIssue4357(t *testing.T) {
 	newCfg := wrapper.RawCopy()
 	newCfg.Folders[0].Paused = true
 
-	if err := wrapper.ReplaceBlocking(newCfg); err != nil {
+	p, err = wrapper.Replace(newCfg)
+	p.Wait()
+	if err != nil {
 		t.Error(err)
 	}
 
@@ -1943,7 +2191,9 @@ func TestIssue4357(t *testing.T) {
 		t.Error("should still have folder in config")
 	}
 
-	if err := wrapper.ReplaceBlocking(config.Configuration{}); err != nil {
+	p, err = wrapper.Replace(config.Configuration{})
+	p.Wait()
+	if err != nil {
 		t.Error(err)
 	}
 
@@ -1952,7 +2202,9 @@ func TestIssue4357(t *testing.T) {
 	}
 
 	// Add the folder back, should be running
-	if err := wrapper.ReplaceBlocking(cfg); err != nil {
+	p, err = wrapper.Replace(cfg)
+	p.Wait()
+	if err != nil {
 		t.Error(err)
 	}
 
@@ -1964,7 +2216,9 @@ func TestIssue4357(t *testing.T) {
 	}
 
 	// Should not panic when removing a running folder.
-	if err := wrapper.ReplaceBlocking(config.Configuration{}); err != nil {
+	p, err = wrapper.Replace(config.Configuration{})
+	p.Wait()
+	if err != nil {
 		t.Error(err)
 	}
 
@@ -2066,7 +2320,7 @@ func TestIssue2782(t *testing.T) {
 
 	db := db.OpenMemory()
 	m := NewModel(defaultConfig, protocol.LocalDeviceID, "syncthing", "dev", db, nil)
-	m.AddFolder(config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, "~/"+testName+"/synclink/"))
+	m.AddFolder(config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, "~/"+testName+"/synclink/"))
 	m.StartFolder("default")
 	m.ServeBackground()
 	defer m.Stop()
@@ -2111,7 +2365,7 @@ func TestIndexesForUnknownDevicesDropped(t *testing.T) {
 func TestSharedWithClearedOnDisconnect(t *testing.T) {
 	dbi := db.OpenMemory()
 
-	fcfg := config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, "testdata")
+	fcfg := config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, "testdata")
 	fcfg.Devices = []config.FolderDeviceConfiguration{
 		{DeviceID: device1},
 		{DeviceID: device2},
@@ -2177,7 +2431,7 @@ func TestSharedWithClearedOnDisconnect(t *testing.T) {
 	cfg = cfg.Copy()
 	cfg.Devices = cfg.Devices[:1]
 
-	if err := wcfg.Replace(cfg); err != nil {
+	if _, err := wcfg.Replace(cfg); err != nil {
 		t.Error(err)
 	}
 
@@ -2350,7 +2604,7 @@ func TestNoRequestsFromPausedDevices(t *testing.T) {
 
 	dbi := db.OpenMemory()
 
-	fcfg := config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, "testdata")
+	fcfg := config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, "testdata")
 	fcfg.Devices = []config.FolderDeviceConfiguration{
 		{DeviceID: device1},
 		{DeviceID: device2},

+ 2 - 2
lib/model/requests_test.go

@@ -216,7 +216,7 @@ func TestRequestVersioningSymlinkAttack(t *testing.T) {
 		panic("Failed to create temporary testing dir")
 	}
 	cfg := defaultConfig.RawCopy()
-	cfg.Folders[0] = config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, tmpFolder)
+	cfg.Folders[0] = config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, tmpFolder)
 	cfg.Folders[0].Devices = []config.FolderDeviceConfiguration{
 		{DeviceID: device1},
 		{DeviceID: device2},
@@ -292,7 +292,7 @@ func setupModelWithConnection() (*Model, *fakeConnection, string) {
 		panic("Failed to create temporary testing dir")
 	}
 	cfg := defaultConfig.RawCopy()
-	cfg.Folders[0] = config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, tmpFolder)
+	cfg.Folders[0] = config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, tmpFolder)
 	cfg.Folders[0].Devices = []config.FolderDeviceConfiguration{
 		{DeviceID: device1},
 		{DeviceID: device2},

+ 2 - 3
lib/nat/registry.go

@@ -7,9 +7,8 @@
 package nat
 
 import (
+	"sync"
 	"time"
-
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type DiscoverFunc func(renewal, timeout time.Duration) []Device
@@ -21,7 +20,7 @@ func Register(provider DiscoverFunc) {
 }
 
 func discoverAll(renewal, timeout time.Duration) map[string]Device {
-	wg := sync.NewWaitGroup()
+	wg := &sync.WaitGroup{}
 	wg.Add(len(providers))
 
 	c := make(chan Device)

+ 2 - 2
lib/upnp/upnp.go

@@ -44,11 +44,11 @@ import (
 	"net/url"
 	"runtime"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/lib/dialer"
 	"github.com/syncthing/syncthing/lib/nat"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 func init() {
@@ -85,7 +85,7 @@ func Discover(renewal, timeout time.Duration) []nat.Device {
 
 	resultChan := make(chan IGD)
 
-	wg := sync.NewWaitGroup()
+	wg := &sync.WaitGroup{}
 
 	for _, intf := range interfaces {
 		// Interface flags seem to always be 0 on Windows

+ 26 - 0
lib/util/utils.go

@@ -7,6 +7,7 @@
 package util
 
 import (
+	"fmt"
 	"net/url"
 	"reflect"
 	"sort"
@@ -70,6 +71,31 @@ func SetDefaults(data interface{}) error {
 	return nil
 }
 
+// CopyMatchingTag copies fields tagged tag:"value" from "from" struct onto "to" struct.
+func CopyMatchingTag(from interface{}, to interface{}, tag string, shouldCopy func(value string) bool) {
+	fromStruct := reflect.ValueOf(from).Elem()
+	fromType := fromStruct.Type()
+
+	toStruct := reflect.ValueOf(to).Elem()
+	toType := toStruct.Type()
+
+	if fromType != toType {
+		panic(fmt.Sprintf("non equal types: %s != %s", fromType, toType))
+	}
+
+	for i := 0; i < toStruct.NumField(); i++ {
+		fromField := fromStruct.Field(i)
+		toField := toStruct.Field(i)
+
+		structTag := toType.Field(i).Tag
+
+		v := structTag.Get(tag)
+		if shouldCopy(v) {
+			toField.Set(fromField)
+		}
+	}
+}
+
 // UniqueStrings returns a list on unique strings, trimming and sorting them
 // at the same time.
 func UniqueStrings(ss []string) []string {

+ 58 - 0
lib/util/utils_test.go

@@ -169,3 +169,61 @@ func TestAddress(t *testing.T) {
 		}
 	}
 }
+
+func TestCopyMatching(t *testing.T) {
+	type Nested struct {
+		A int
+	}
+	type Test struct {
+		CopyA  int
+		CopyB  []string
+		CopyC  Nested
+		CopyD  *Nested
+		NoCopy int `restart:"true"`
+	}
+
+	from := Test{
+		CopyA: 1,
+		CopyB: []string{"friend", "foe"},
+		CopyC: Nested{
+			A: 2,
+		},
+		CopyD: &Nested{
+			A: 3,
+		},
+		NoCopy: 4,
+	}
+
+	to := Test{
+		CopyA: 11,
+		CopyB: []string{"foot", "toe"},
+		CopyC: Nested{
+			A: 22,
+		},
+		CopyD: &Nested{
+			A: 33,
+		},
+		NoCopy: 44,
+	}
+
+	// Copy empty fields
+	CopyMatchingTag(&from, &to, "restart", func(v string) bool {
+		return v != "true"
+	})
+
+	if to.CopyA != 1 {
+		t.Error("CopyA")
+	}
+	if len(to.CopyB) != 2 || to.CopyB[0] != "friend" || to.CopyB[1] != "foe" {
+		t.Error("CopyB")
+	}
+	if to.CopyC.A != 2 {
+		t.Error("CopyC")
+	}
+	if to.CopyD.A != 3 {
+		t.Error("CopyC")
+	}
+	if to.NoCopy != 44 {
+		t.Error("NoCopy")
+	}
+}