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
This commit is contained in:
Audrius Butkevicius 2017-12-07 07:08:24 +00:00 committed by Jakob Borg
parent c005b8dcb0
commit 445c4edeca
22 changed files with 771 additions and 284 deletions

View File

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

View File

@ -1080,14 +1080,7 @@ func defaultConfig(cfgFile string) *config.Wrapper {
if !noDefaultFolder { if !noDefaultFolder {
l.Infoln("Default folder created and/or linked to new config") l.Infoln("Default folder created and/or linked to new config")
defaultFolder = config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, locations[locDefFolder]) defaultFolder = config.NewFolderConfiguration(myID, "default", "Default Folder", 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
} else { } else {
l.Infoln("We will skip creation of a default folder on first start since the proper envvar is set") 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 { for i := range raw.Folders {
raw.Folders[i].Paused = paused 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) l.Fatalln("Cannot adjust paused state:", err)
} }
} }

View File

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

View File

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

View File

@ -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.", "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 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}}?", "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 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", "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!", "Be careful!": "Be careful!",
"Bugs": "Bugs", "Bugs": "Bugs",
"CPU Utilization": "CPU Utilization", "CPU Utilization": "CPU Utilization",
@ -43,12 +45,14 @@
"Configured": "Configured", "Configured": "Configured",
"Connection Error": "Connection Error", "Connection Error": "Connection Error",
"Connection Type": "Connection Type", "Connection Type": "Connection Type",
"Connections": "Connections",
"Copied from elsewhere": "Copied from elsewhere", "Copied from elsewhere": "Copied from elsewhere",
"Copied from original": "Copied from original", "Copied from original": "Copied from original",
"Copyright © 2014-2016 the following Contributors:": "Copyright © 2014-2016 the following Contributors:", "Copyright © 2014-2016 the following Contributors:": "Copyright © 2014-2016 the following Contributors:",
"Copyright © 2014-2017 the following Contributors:": "Copyright © 2014-2017 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}}.", "Creating ignore patterns, overwriting an existing file at {%path%}.": "Creating ignore patterns, overwriting an existing file at {{path}}.",
"Danger!": "Danger!", "Danger!": "Danger!",
"Default Folder Path": "Default Folder Path",
"Deleted": "Deleted", "Deleted": "Deleted",
"Device": "Device", "Device": "Device",
"Device \"{%name%}\" ({%device%} at {%address%}) wants to connect. Add new device?": "Device \"{{name}}\" ({{device}} at {{address}}) wants to connect. Add new 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 Address": "GUI Listen Address",
"GUI Listen Addresses": "GUI Listen Addresses", "GUI Listen Addresses": "GUI Listen Addresses",
"GUI Theme": "GUI Theme", "GUI Theme": "GUI Theme",
"General": "General",
"Generate": "Generate", "Generate": "Generate",
"Global Changes": "Global Changes", "Global Changes": "Global Changes",
"Global Discovery": "Global Discovery", "Global Discovery": "Global Discovery",
@ -156,6 +161,7 @@
"Override Changes": "Override Changes", "Override Changes": "Override Changes",
"Path": "Path", "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 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 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).", "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", "Pause": "Pause",
@ -264,6 +270,8 @@
"Time": "Time", "Time": "Time",
"Trash Can File Versioning": "Trash Can File Versioning", "Trash Can File Versioning": "Trash Can File Versioning",
"Type": "Type", "Type": "Type",
"Unavailable": "Unavailable",
"Unavailable/Disabled by administrator or maintainer": "Unavailable/Disabled by administrator or maintainer",
"Undecided (will prompt)": "Undecided (will prompt)", "Undecided (will prompt)": "Undecided (will prompt)",
"Unknown": "Unknown", "Unknown": "Unknown",
"Unshared": "Unshared", "Unshared": "Unshared",

View File

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

View File

@ -24,31 +24,59 @@
</div> </div>
<div ng-if="editingExisting" class="well well-sm text-monospace" select-on-click>{{currentDevice.deviceID}}</div> <div ng-if="editingExisting" class="well well-sm text-monospace" select-on-click>{{currentDevice.deviceID}}</div>
</div> </div>
<div class="form-group"> <div class="row">
<label translate for="name">Device Name</label> <div class="col-md-6">
<input id="name" class="form-control" type="text" ng-model="currentDevice.name" /> <div class="form-group">
<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> <label translate for="name">Device Name</label>
<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> <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>
<div class="form-group"> <div class="row">
<label translate for="addresses">Addresses</label> <div class="col-md-6">
<input ng-disabled="currentDevice.deviceID == myID" id="addresses" class="form-control" type="text" ng-model="currentDevice._addressesStr"></input> <div class="form-group">
<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> <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>
<div class="form-group"> <div class="row">
<label translate>Compression</label> <div class="col-md-6">
<select class="form-control" ng-model="currentDevice.compression"> <div class="form-group">
<option value="always" translate>All Data</option> <div class="checkbox">
<option value="metadata" translate>Metadata Only</option> <label>
<option value="never" translate>Off</option> <input type="checkbox" ng-model="currentDevice.introducer">
</select> <span translate>Introducer</span>
</div> <p translate class="help-block">Add devices from the introducer to our device list, for mutually shared folders.</p>
<div class="form-group"> </label>
<div class="checkbox"> </div>
<label> </div>
<input type="checkbox" ng-model="currentDevice.introducer"> <span translate>Introducer</span> </div>
</label> <div class="col-md-6">
<p translate class="help-block">Add devices from the introducer to our device list, for mutually shared folders.</p> <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> </div>
<div class="row"> <div class="row">

View File

@ -1,36 +1,180 @@
<modal id="settings" status="default" icon="cog" heading="{{'Settings' | translate}}" large="yes" closeable="yes"> <modal id="settings" status="default" icon="cog" heading="{{'Settings' | translate}}" large="yes" closeable="yes">
<div class="modal-body"> <div class="modal-body">
<form role="form" name="settingsEditor"> <form role="form" name="settingsEditor">
<div class="row"> <ul class="nav nav-tabs">
<li class="active"><a data-toggle="tab" href="#settings-general" translate>General</a></li>
<div class="col-md-6"> <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"> <div class="form-group">
<label translate for="DeviceName">Device Name</label> <label translate for="DeviceName">Device Name</label>
<input id="DeviceName" class="form-control" type="text" ng-model="tmpOptions.deviceName"/> <input id="DeviceName" class="form-control" type="text" ng-model="tmpOptions.deviceName"/>
</div> </div>
<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="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">
<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 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"> <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> <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"/> <input id="ListenAddressesStr" class="form-control" type="text" ng-model="tmpOptions._listenAddressesStr"/>
</div> </div>
<div class="row">
<div class="form-group" ng-class="{'has-error': settingsEditor.MaxRecvKbps.$invalid && settingsEditor.MaxRecvKbps.$dirty}"> <div class="col-md-6">
<label translate for="MaxRecvKbps">Incoming Rate Limit (KiB/s)</label> <div class="form-group" ng-class="{'has-error': settingsEditor.MaxRecvKbps.$invalid && settingsEditor.MaxRecvKbps.$dirty}">
<input id="MaxRecvKbps" name="MaxRecvKbps" class="form-control" type="number" ng-model="tmpOptions.maxRecvKbps" min="0"/> <label translate for="MaxRecvKbps">Incoming Rate Limit (KiB/s)</label>
<p class="help-block"> <input id="MaxRecvKbps" name="MaxRecvKbps" class="form-control" type="number" ng-model="tmpOptions.maxRecvKbps" min="0"/>
<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 class="help-block">
</p> <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>
<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 class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group">
@ -51,7 +195,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-group"> <div class="form-group">
@ -72,106 +215,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<div class="clearfix"></div> <div class="col-md-6">
<div class="form-group"> <div class="form-group">
<label translate for="GlobalAnnServersStr">Global Discovery Servers</label> <label translate for="GlobalAnnServersStr">Global Discovery Servers</label>
<input ng-disabled="!tmpOptions.globalAnnounceEnabled" id="GlobalAnnServersStr" class="form-control" type="text" ng-model="tmpOptions._globalAnnounceServersStr"/> <input ng-disabled="!tmpOptions.globalAnnounceEnabled" id="GlobalAnnServersStr" class="form-control" type="text" ng-model="tmpOptions._globalAnnounceServersStr"/>
</div> </div>
<div>
<div class="form-horizontal"> <div class="col-md-6">
<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>
</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>
</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>
</div> </div>
</form> </form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,7 @@ import (
"github.com/syncthing/syncthing/lib/stats" "github.com/syncthing/syncthing/lib/stats"
"github.com/syncthing/syncthing/lib/sync" "github.com/syncthing/syncthing/lib/sync"
"github.com/syncthing/syncthing/lib/upgrade" "github.com/syncthing/syncthing/lib/upgrade"
"github.com/syncthing/syncthing/lib/util"
"github.com/syncthing/syncthing/lib/versioner" "github.com/syncthing/syncthing/lib/versioner"
"github.com/syncthing/syncthing/lib/weakhash" "github.com/syncthing/syncthing/lib/weakhash"
"github.com/thejerf/suture" "github.com/thejerf/suture"
@ -892,6 +893,8 @@ func (m *Model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
} }
dbLocation := filepath.Dir(m.db.Location()) 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 // See issue #3802 - in short, we can't send modern symlink entries to older
// clients. // clients.
@ -901,6 +904,13 @@ func (m *Model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
dropSymlinks = true 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() m.fmut.Lock()
var paused []string var paused []string
for _, folder := range cm.Folders { 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) 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 continue
} }
if !folder.DisableTempIndexes { if !folder.DisableTempIndexes {
tempIndexFolders = append(tempIndexFolders, folder.ID) tempIndexFolders = append(tempIndexFolders, folder.ID)
} }
@ -1021,8 +1032,7 @@ func (m *Model) ClusterConfig(deviceID protocol.DeviceID, cm protocol.ClusterCon
} }
} }
var changed = false if deviceCfg.Introducer {
if deviceCfg := m.cfg.Devices()[deviceID]; deviceCfg.Introducer {
foldersDevices, introduced := m.handleIntroductions(deviceCfg, cm) foldersDevices, introduced := m.handleIntroductions(deviceCfg, cm)
if introduced { if introduced {
changed = true changed = true
@ -1063,6 +1073,11 @@ func (m *Model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm
// the folder. // the folder.
nextDevice: nextDevice:
for _, device := range folder.Devices { for _, device := range folder.Devices {
// No need to share with self.
if device.ID == m.id {
continue
}
foldersDevices.set(device.ID, folder.ID) foldersDevices.set(device.ID, folder.ID)
if _, ok := m.cfg.Devices()[device.ID]; !ok { 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 // We don't yet share this folder with this device. Add the device
// to sharing list of the folder. // 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 changed = true
} }
} }
@ -1089,7 +1105,7 @@ func (m *Model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm
return foldersDevices, changed 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 { func (m *Model) handleDeintroductions(introducerCfg config.DeviceConfiguration, cm protocol.ClusterConfig, foldersDevices folderDeviceSet) bool {
changed := false changed := false
foldersIntroducedByOthers := make(folderDeviceSet) foldersIntroducedByOthers := make(folderDeviceSet)
@ -1142,6 +1158,51 @@ func (m *Model) handleDeintroductions(introducerCfg config.DeviceConfiguration,
return changed 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) { func (m *Model) introduceDevice(device protocol.Device, introducerCfg config.DeviceConfiguration) {
addresses := []string{"dynamic"} addresses := []string{"dynamic"}
for _, addr := range device.Addresses { for _, addr := range device.Addresses {
@ -1170,18 +1231,17 @@ func (m *Model) introduceDevice(device protocol.Device, introducerCfg config.Dev
m.cfg.SetDevice(newDeviceCfg) m.cfg.SetDevice(newDeviceCfg)
} }
func (m *Model) introduceDeviceToFolder(device protocol.Device, folder protocol.Folder, introducerCfg config.DeviceConfiguration) { func (m *Model) shareFolderWithDeviceLocked(deviceID protocol.DeviceID, folder string, introducer protocol.DeviceID) config.Waiter {
l.Infof("Sharing folder %s with %v (vouched for by introducer %v)", folder.Description(), device.ID, introducerCfg.DeviceID) m.deviceFolders[deviceID] = append(m.deviceFolders[deviceID], folder)
m.folderDevices.set(deviceID, folder)
m.deviceFolders[device.ID] = append(m.deviceFolders[device.ID], folder.ID) folderCfg := m.cfg.Folders()[folder]
m.folderDevices.set(device.ID, folder.ID)
folderCfg := m.cfg.Folders()[folder.ID]
folderCfg.Devices = append(folderCfg.Devices, config.FolderDeviceConfiguration{ folderCfg.Devices = append(folderCfg.Devices, config.FolderDeviceConfiguration{
DeviceID: device.ID, DeviceID: deviceID,
IntroducedBy: introducerCfg.DeviceID, IntroducedBy: introducer,
}) })
m.cfg.SetFolder(folderCfg) w, _ := m.cfg.SetFolder(folderCfg)
return w
} }
// Closed is called when a connection has been closed // 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) conn.ClusterConfig(cm)
device, ok := m.cfg.Devices()[deviceID] 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 device.Name = hello.DeviceName
m.cfg.SetDevice(device) m.cfg.SetDevice(device)
m.cfg.Save() m.cfg.Save()
@ -2366,10 +2426,10 @@ func (m *Model) CommitConfiguration(from, to config.Configuration) bool {
if _, ok := fromFolders[folderID]; !ok { if _, ok := fromFolders[folderID]; !ok {
// A folder was added. // A folder was added.
if cfg.Paused { if cfg.Paused {
l.Infoln(m, "Paused folder", cfg.Description()) l.Infoln("Paused folder", cfg.Description())
cfg.CreateRoot() cfg.CreateRoot()
} else { } else {
l.Infoln(m, "Adding folder", cfg.Description()) l.Infoln("Adding folder", cfg.Description())
m.AddFolder(cfg) m.AddFolder(cfg)
m.StartFolder(folderID) m.StartFolder(folderID)
} }
@ -2388,8 +2448,12 @@ func (m *Model) CommitConfiguration(from, to config.Configuration) bool {
// Check if anything differs, apart from the label. // Check if anything differs, apart from the label.
toCfgCopy := toCfg toCfgCopy := toCfg
fromCfgCopy := fromCfg fromCfgCopy := fromCfg
fromCfgCopy.Label = "" util.CopyMatchingTag(&toCfgCopy, &fromCfgCopy, "restart", func(v string) bool {
toCfgCopy.Label = "" 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) { if !reflect.DeepEqual(fromCfgCopy, toCfgCopy) {
m.RestartFolder(toCfg) 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 // Some options don't require restart as those components handle it fine
// by themselves. // by themselves.
from.Options.URAccepted = to.Options.URAccepted
from.Options.URSeen = to.Options.URSeen // Copy fields that do not have the field set to true
from.Options.URUniqueID = to.Options.URUniqueID util.CopyMatchingTag(&from.Options, &to.Options, "restart", func(v string) bool {
from.Options.ListenAddresses = to.Options.ListenAddresses if len(v) > 0 && v != "true" {
from.Options.RelaysEnabled = to.Options.RelaysEnabled panic(fmt.Sprintf(`unexpected struct value: %s. expected untagged or "true"`, v))
from.Options.UnackedNotificationIDs = to.Options.UnackedNotificationIDs }
from.Options.MaxRecvKbps = to.Options.MaxRecvKbps return v != "true"
from.Options.MaxSendKbps = to.Options.MaxSendKbps })
from.Options.LimitBandwidthInLan = to.Options.LimitBandwidthInLan
from.Options.StunKeepaliveS = to.Options.StunKeepaliveS
from.Options.StunServers = to.Options.StunServers
// All of the other generic options require restart. Or at least they may; // All of the other generic options require restart. Or at least they may;
// removing this check requires going through those options carefully and // removing this check requires going through those options carefully and
// making sure there are individual services that handle them correctly. // making sure there are individual services that handle them correctly.

View File

@ -18,6 +18,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@ -36,13 +37,14 @@ var device1, device2 protocol.DeviceID
var defaultConfig *config.Wrapper var defaultConfig *config.Wrapper
var defaultFolderConfig config.FolderConfiguration var defaultFolderConfig config.FolderConfiguration
var defaultFs fs.Filesystem var defaultFs fs.Filesystem
var defaultAutoAcceptCfg config.Configuration
func init() { func init() {
device1, _ = protocol.DeviceIDFromString("AIR6LPZ-7K4PTTV-UXQSMUU-CPQ5YWH-OEDFIIQ-JUG777G-2YQXXR5-YD6AWQR") device1, _ = protocol.DeviceIDFromString("AIR6LPZ-7K4PTTV-UXQSMUU-CPQ5YWH-OEDFIIQ-JUG777G-2YQXXR5-YD6AWQR")
device2, _ = protocol.DeviceIDFromString("GYRZZQB-IRNPV4Z-T7TC52W-EQYJ3TT-FDQW6MW-DFLMU42-SSSU6EM-FBK2VAY") device2, _ = protocol.DeviceIDFromString("GYRZZQB-IRNPV4Z-T7TC52W-EQYJ3TT-FDQW6MW-DFLMU42-SSSU6EM-FBK2VAY")
defaultFs = fs.NewFilesystem(fs.FilesystemTypeBasic, "testdata") 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}} defaultFolderConfig.Devices = []config.FolderDeviceConfiguration{{DeviceID: device1}}
_defaultConfig := config.Configuration{ _defaultConfig := config.Configuration{
Folders: []config.FolderConfiguration{defaultFolderConfig}, Folders: []config.FolderConfiguration{defaultFolderConfig},
@ -53,6 +55,17 @@ func init() {
}, },
} }
defaultConfig = config.Wrap("/tmp/test", _defaultConfig) 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{ 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) { func TestRequest(t *testing.T) {
db := db.OpenMemory() db := db.OpenMemory()
@ -609,20 +636,6 @@ func TestIntroducer(t *testing.T) {
return false 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{ wcfg, m := newState(config.Configuration{
Devices: []config.DeviceConfiguration{ 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) { func changeIgnores(t *testing.T, m *Model, expected []string) {
arrEqual := func(a, b []string) bool { arrEqual := func(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
@ -1920,7 +2164,9 @@ func TestIssue4357(t *testing.T) {
defer m.Stop() defer m.Stop()
// Force the model to wire itself and add the folders // 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) t.Error(err)
} }
@ -1931,7 +2177,9 @@ func TestIssue4357(t *testing.T) {
newCfg := wrapper.RawCopy() newCfg := wrapper.RawCopy()
newCfg.Folders[0].Paused = true 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) t.Error(err)
} }
@ -1943,7 +2191,9 @@ func TestIssue4357(t *testing.T) {
t.Error("should still have folder in config") 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) t.Error(err)
} }
@ -1952,7 +2202,9 @@ func TestIssue4357(t *testing.T) {
} }
// Add the folder back, should be running // 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) t.Error(err)
} }
@ -1964,7 +2216,9 @@ func TestIssue4357(t *testing.T) {
} }
// Should not panic when removing a running folder. // 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) t.Error(err)
} }
@ -2066,7 +2320,7 @@ func TestIssue2782(t *testing.T) {
db := db.OpenMemory() db := db.OpenMemory()
m := NewModel(defaultConfig, protocol.LocalDeviceID, "syncthing", "dev", db, nil) 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.StartFolder("default")
m.ServeBackground() m.ServeBackground()
defer m.Stop() defer m.Stop()
@ -2111,7 +2365,7 @@ func TestIndexesForUnknownDevicesDropped(t *testing.T) {
func TestSharedWithClearedOnDisconnect(t *testing.T) { func TestSharedWithClearedOnDisconnect(t *testing.T) {
dbi := db.OpenMemory() dbi := db.OpenMemory()
fcfg := config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, "testdata") fcfg := config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, "testdata")
fcfg.Devices = []config.FolderDeviceConfiguration{ fcfg.Devices = []config.FolderDeviceConfiguration{
{DeviceID: device1}, {DeviceID: device1},
{DeviceID: device2}, {DeviceID: device2},
@ -2177,7 +2431,7 @@ func TestSharedWithClearedOnDisconnect(t *testing.T) {
cfg = cfg.Copy() cfg = cfg.Copy()
cfg.Devices = cfg.Devices[:1] cfg.Devices = cfg.Devices[:1]
if err := wcfg.Replace(cfg); err != nil { if _, err := wcfg.Replace(cfg); err != nil {
t.Error(err) t.Error(err)
} }
@ -2350,7 +2604,7 @@ func TestNoRequestsFromPausedDevices(t *testing.T) {
dbi := db.OpenMemory() dbi := db.OpenMemory()
fcfg := config.NewFolderConfiguration("default", fs.FilesystemTypeBasic, "testdata") fcfg := config.NewFolderConfiguration(protocol.LocalDeviceID, "default", "default", fs.FilesystemTypeBasic, "testdata")
fcfg.Devices = []config.FolderDeviceConfiguration{ fcfg.Devices = []config.FolderDeviceConfiguration{
{DeviceID: device1}, {DeviceID: device1},
{DeviceID: device2}, {DeviceID: device2},

View File

@ -216,7 +216,7 @@ func TestRequestVersioningSymlinkAttack(t *testing.T) {
panic("Failed to create temporary testing dir") panic("Failed to create temporary testing dir")
} }
cfg := defaultConfig.RawCopy() 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{ cfg.Folders[0].Devices = []config.FolderDeviceConfiguration{
{DeviceID: device1}, {DeviceID: device1},
{DeviceID: device2}, {DeviceID: device2},
@ -292,7 +292,7 @@ func setupModelWithConnection() (*Model, *fakeConnection, string) {
panic("Failed to create temporary testing dir") panic("Failed to create temporary testing dir")
} }
cfg := defaultConfig.RawCopy() 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{ cfg.Folders[0].Devices = []config.FolderDeviceConfiguration{
{DeviceID: device1}, {DeviceID: device1},
{DeviceID: device2}, {DeviceID: device2},

View File

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

View File

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

View File

@ -7,6 +7,7 @@
package util package util
import ( import (
"fmt"
"net/url" "net/url"
"reflect" "reflect"
"sort" "sort"
@ -70,6 +71,31 @@ func SetDefaults(data interface{}) error {
return nil 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 // UniqueStrings returns a list on unique strings, trimming and sorting them
// at the same time. // at the same time.
func UniqueStrings(ss []string) []string { func UniqueStrings(ss []string) []string {

View File

@ -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")
}
}