Compare commits

...

27 Commits

Author SHA1 Message Date
Zlatin Balevsky
526ec45da3 Release 0.2.7 2019-06-18 15:53:54 +01:00
Zlatin Balevsky
deb7c0b4b0 exclude files present locally from search results 2019-06-18 15:45:27 +01:00
Zlatin Balevsky
e85a0c7b2c Merge branch 'source-tracking' 2019-06-18 12:22:46 +01:00
Zlatin Balevsky
7b021a47eb fix detection of moving files into a watched dir on Linux 2019-06-18 12:20:10 +01:00
Zlatin Balevsky
0c21d4d6c1 implement source tracking 2019-06-18 11:34:19 +01:00
Zlatin Balevsky
8e9f79d404 update TODO 2019-06-18 09:43:22 +01:00
Zlatin Balevsky
bf33a6ff61 Release 0.2.6 2019-06-18 09:07:27 +01:00
Zlatin Balevsky
19c8d84afd Merge branch 'file-monitor' 2019-06-18 09:01:09 +01:00
Zlatin Balevsky
6a40787863 fine log 2019-06-18 05:46:16 +01:00
Zlatin Balevsky
c698cbd737 register created directories recursively 2019-06-18 05:43:41 +01:00
Zlatin Balevsky
9c049b9301 special case mac 2019-06-18 05:26:41 +01:00
Zlatin Balevsky
84a9bb9482 watch deleting of files 2019-06-18 04:15:44 +01:00
Zlatin Balevsky
0c1008d6b3 update readme 2019-06-18 04:01:04 +01:00
Zlatin Balevsky
c46f1b1ccd delay processing of files until after 1 second after the last MODIFY event 2019-06-17 23:08:16 +01:00
Zlatin Balevsky
7e2c4d48c6 wait for UI to load before loading files 2019-06-17 22:34:19 +01:00
Zlatin Balevsky
71a919e62b shut down watcher before connection manager 2019-06-17 22:15:50 +01:00
Zlatin Balevsky
d5eb65bdc2 do not print stacktrace on clean shutdown 2019-06-17 21:58:44 +01:00
Zlatin Balevsky
aef7533bd5 make watcher thread daemon 2019-06-17 19:58:57 +01:00
Zlatin Balevsky
e78016ead4 ui panel for managing watched directories 2019-06-17 19:23:04 +01:00
Zlatin Balevsky
52ced669dd basic watching of directories 2019-06-17 16:36:12 +01:00
Zlatin Balevsky
b52fb38ede fix disabling of buttons on search tab close 2019-06-17 13:43:11 +01:00
Zlatin Balevsky
5dcef3ca05 Release 0.2.5 2019-06-17 12:53:58 +01:00
Zlatin Balevsky
eaa0e46ce5 Merge branch 'separate-incomplete-files' 2019-06-17 12:45:51 +01:00
Zlatin Balevsky
c4f48c02b6 delete incomplete file on cancel 2019-06-17 12:33:44 +01:00
Zlatin Balevsky
5c16335969 if no row is selected do not enable buttons 2019-06-17 12:26:28 +01:00
Zlatin Balevsky
546eb4e9d3 only allow one download per infohash from gui 2019-06-17 11:25:21 +01:00
Zlatin Balevsky
c3d9e852ba separate incomplete files 2019-06-17 07:49:06 +01:00
23 changed files with 367 additions and 70 deletions

View File

@@ -4,7 +4,7 @@ MuWire is an easy to use file-sharing program which offers anonymity using [I2P
It is inspired by the LimeWire Gnutella client and developped by a former LimeWire developer. It is inspired by the LimeWire Gnutella client and developped by a former LimeWire developer.
The current stable release - 0.1.5 is avaiable for download at http://muwire.com. You can find technical documentation in the "doc" folder. The current stable release - 0.2.5 is avaiable for download at http://muwire.com. You can find technical documentation in the "doc" folder.
### Building ### Building
@@ -23,7 +23,7 @@ Some of the UI tests will fail because they haven't been written yet :-/
### Running ### Running
You need to have an I2P router up and running on the same machine. After you build the application, look inside "gui/build/distributions". Untar/unzip one of the "shadow" files and then run the jar contained inside by typing "java -jar MuWire-x.y.z.jar" in a terminal or command prompt. If you use a custom I2CP host and port, create a file $HOME/.MuWire/i2p.properties and put "i2cp.tcp.host=<host>" and "i2cp.tcp.post=<port>" in there. You need to have an I2P router up and running on the same machine. After you build the application, look inside "gui/build/distributions". Untar/unzip one of the "shadow" files and then run the jar contained inside by typing "java -jar MuWire-x.y.z.jar" in a terminal or command prompt. If you use a custom I2CP host and port, create a file $HOME/.MuWire/i2p.properties and put "i2cp.tcp.host=<host>" and "i2cp.tcp.port=<port>" in there.
The first time you run MuWire it will ask you to select a nickname. This nickname will be displayed with search results, so that others can verify the file was shared by you. It is best to leave MuWire running all the time, just like I2P. The first time you run MuWire it will ask you to select a nickname. This nickname will be displayed with search results, so that others can verify the file was shared by you. It is best to leave MuWire running all the time, just like I2P.

View File

@@ -32,6 +32,10 @@ For ease of deployment for new users, and so that users do not need to run a sep
Basically any non-gui non-cli user interface Basically any non-gui non-cli user interface
##### Metadata editing and search
To enable parsing of metadata from known file types and the user editing it or adding manual metadata
### Small Items ### Small Items
* Detect if router is dead and show warning or exit * Detect if router is dead and show warning or exit
@@ -39,4 +43,3 @@ Basically any non-gui non-cli user interface
* Download file sequentially * Download file sequentially
* Unsharing of files * Unsharing of files
* Multiple-selection download, Ctrl-A * Multiple-selection download, Ctrl-A
* Automatic sharing of new files in shared directories (more like medium item)

View File

@@ -4,6 +4,7 @@ import java.util.concurrent.CountDownLatch
import com.muwire.core.Core import com.muwire.core.Core
import com.muwire.core.MuWireSettings import com.muwire.core.MuWireSettings
import com.muwire.core.UILoadedEvent
import com.muwire.core.connection.ConnectionAttemptStatus import com.muwire.core.connection.ConnectionAttemptStatus
import com.muwire.core.connection.ConnectionEvent import com.muwire.core.connection.ConnectionEvent
import com.muwire.core.connection.DisconnectionEvent import com.muwire.core.connection.DisconnectionEvent
@@ -34,7 +35,7 @@ class Cli {
Core core Core core
try { try {
core = new Core(props, home, "0.2.4") core = new Core(props, home, "0.2.7")
} catch (Exception bad) { } catch (Exception bad) {
bad.printStackTrace(System.out) bad.printStackTrace(System.out)
println "Failed to initialize core, exiting" println "Failed to initialize core, exiting"
@@ -83,7 +84,8 @@ class Cli {
} }
core.eventBus.register(AllFilesLoadedEvent.class, fileLoader) core.eventBus.register(AllFilesLoadedEvent.class, fileLoader)
core.startServices() core.startServices()
core.eventBus.publish(new UILoadedEvent())
println "waiting for files to load" println "waiting for files to load"
latch.await() latch.await()
// now we begin // now we begin

View File

@@ -53,7 +53,7 @@ class CliDownloader {
Core core Core core
try { try {
core = new Core(props, home, "0.2.4") core = new Core(props, home, "0.2.7")
} catch (Exception bad) { } catch (Exception bad) {
bad.printStackTrace(System.out) bad.printStackTrace(System.out)
println "Failed to initialize core, exiting" println "Failed to initialize core, exiting"

View File

@@ -23,6 +23,7 @@ import com.muwire.core.files.FileSharedEvent
import com.muwire.core.files.FileUnsharedEvent import com.muwire.core.files.FileUnsharedEvent
import com.muwire.core.files.HasherService import com.muwire.core.files.HasherService
import com.muwire.core.files.PersisterService import com.muwire.core.files.PersisterService
import com.muwire.core.files.DirectoryWatcher
import com.muwire.core.hostcache.CacheClient import com.muwire.core.hostcache.CacheClient
import com.muwire.core.hostcache.HostCache import com.muwire.core.hostcache.HostCache
import com.muwire.core.hostcache.HostDiscoveredEvent import com.muwire.core.hostcache.HostDiscoveredEvent
@@ -70,6 +71,8 @@ public class Core {
private final ConnectionEstablisher connectionEstablisher private final ConnectionEstablisher connectionEstablisher
private final HasherService hasherService private final HasherService hasherService
private final DownloadManager downloadManager private final DownloadManager downloadManager
private final DirectoryWatcher directoryWatcher
final FileManager fileManager
public Core(MuWireSettings props, File home, String myVersion) { public Core(MuWireSettings props, File home, String myVersion) {
this.home = home this.home = home
@@ -153,7 +156,7 @@ public class Core {
log.info "initializing file manager" log.info "initializing file manager"
FileManager fileManager = new FileManager(eventBus, props) fileManager = new FileManager(eventBus, props)
eventBus.register(FileHashedEvent.class, fileManager) eventBus.register(FileHashedEvent.class, fileManager)
eventBus.register(FileLoadedEvent.class, fileManager) eventBus.register(FileLoadedEvent.class, fileManager)
eventBus.register(FileDownloadedEvent.class, fileManager) eventBus.register(FileDownloadedEvent.class, fileManager)
@@ -162,6 +165,7 @@ public class Core {
log.info "initializing persistence service" log.info "initializing persistence service"
persisterService = new PersisterService(new File(home, "files.json"), eventBus, 15000, fileManager) persisterService = new PersisterService(new File(home, "files.json"), eventBus, 15000, fileManager)
eventBus.register(UILoadedEvent.class, persisterService)
log.info("initializing host cache") log.info("initializing host cache")
File hostStorage = new File(home, "hosts.json") File hostStorage = new File(home, "hosts.json")
@@ -213,6 +217,9 @@ public class Core {
connectionAcceptor = new ConnectionAcceptor(eventBus, connectionManager, props, connectionAcceptor = new ConnectionAcceptor(eventBus, connectionManager, props,
i2pAcceptor, hostCache, trustService, searchManager, uploadManager, connectionEstablisher) i2pAcceptor, hostCache, trustService, searchManager, uploadManager, connectionEstablisher)
log.info("initializing directory watcher")
directoryWatcher = new DirectoryWatcher(eventBus, fileManager)
eventBus.register(FileSharedEvent.class, directoryWatcher)
log.info("initializing hasher service") log.info("initializing hasher service")
hasherService = new HasherService(new FileHasher(), eventBus, fileManager) hasherService = new HasherService(new FileHasher(), eventBus, fileManager)
@@ -221,9 +228,9 @@ public class Core {
public void startServices() { public void startServices() {
hasherService.start() hasherService.start()
directoryWatcher.start()
trustService.start() trustService.start()
trustService.waitForLoad() trustService.waitForLoad()
persisterService.start()
hostCache.start() hostCache.start()
connectionManager.start() connectionManager.start()
cacheClient.start() cacheClient.start()
@@ -240,6 +247,8 @@ public class Core {
connectionAcceptor.stop() connectionAcceptor.stop()
log.info("shutting down connection establisher") log.info("shutting down connection establisher")
connectionEstablisher.stop() connectionEstablisher.stop()
log.info("shutting down directory watcher")
directoryWatcher.stop()
log.info("shutting down connection manager") log.info("shutting down connection manager")
connectionManager.shutdown() connectionManager.shutdown()
} }
@@ -268,7 +277,7 @@ public class Core {
} }
} }
Core core = new Core(props, home, "0.2.4") Core core = new Core(props, home, "0.2.7")
core.startServices() core.startServices()
// ... at the end, sleep or execute script // ... at the end, sleep or execute script

View File

@@ -1,6 +1,11 @@
package com.muwire.core package com.muwire.core
import java.util.stream.Collectors
import com.muwire.core.hostcache.CrawlerResponse import com.muwire.core.hostcache.CrawlerResponse
import com.muwire.core.util.DataUtil
import net.i2p.data.Base64
class MuWireSettings { class MuWireSettings {
@@ -10,10 +15,9 @@ class MuWireSettings {
int updateCheckInterval int updateCheckInterval
String nickname String nickname
File downloadLocation File downloadLocation
String sharedFiles
CrawlerResponse crawlerResponse CrawlerResponse crawlerResponse
boolean shareDownloadedFiles boolean shareDownloadedFiles
boolean watchSharedDirectories Set<String> watchedDirectories
MuWireSettings() { MuWireSettings() {
this(new Properties()) this(new Properties())
@@ -26,11 +30,16 @@ class MuWireSettings {
nickname = props.getProperty("nickname","MuWireUser") nickname = props.getProperty("nickname","MuWireUser")
downloadLocation = new File((String)props.getProperty("downloadLocation", downloadLocation = new File((String)props.getProperty("downloadLocation",
System.getProperty("user.home"))) System.getProperty("user.home")))
sharedFiles = props.getProperty("sharedFiles")
downloadRetryInterval = Integer.parseInt(props.getProperty("downloadRetryInterval","15")) downloadRetryInterval = Integer.parseInt(props.getProperty("downloadRetryInterval","15"))
updateCheckInterval = Integer.parseInt(props.getProperty("updateCheckInterval","36")) updateCheckInterval = Integer.parseInt(props.getProperty("updateCheckInterval","36"))
shareDownloadedFiles = Boolean.parseBoolean(props.getProperty("shareDownloadedFiles","true")) shareDownloadedFiles = Boolean.parseBoolean(props.getProperty("shareDownloadedFiles","true"))
watchSharedDirectories = Boolean.parseBoolean(props.getProperty("watchSharedDirectories","true"))
watchedDirectories = new HashSet<>()
if (props.containsKey("watchedDirectories")) {
String[] encoded = props.getProperty("watchedDirectories").split(",")
encoded.each { watchedDirectories << DataUtil.readi18nString(Base64.decode(it)) }
}
} }
void write(OutputStream out) throws IOException { void write(OutputStream out) throws IOException {
@@ -43,9 +52,14 @@ class MuWireSettings {
props.setProperty("downloadRetryInterval", String.valueOf(downloadRetryInterval)) props.setProperty("downloadRetryInterval", String.valueOf(downloadRetryInterval))
props.setProperty("updateCheckInterval", String.valueOf(updateCheckInterval)) props.setProperty("updateCheckInterval", String.valueOf(updateCheckInterval))
props.setProperty("shareDownloadedFiles", String.valueOf(shareDownloadedFiles)) props.setProperty("shareDownloadedFiles", String.valueOf(shareDownloadedFiles))
props.setProperty("watchSharedDirectories", String.valueOf(watchSharedDirectories))
if (sharedFiles != null) if (!watchedDirectories.isEmpty()) {
props.setProperty("sharedFiles", sharedFiles) String encoded = watchedDirectories.stream().
map({Base64.encode(DataUtil.encodei18nString(it))}).
collect(Collectors.joining(","))
props.setProperty("watchedDirectories", encoded)
}
props.store(out, "") props.store(out, "")
} }

View File

@@ -58,6 +58,8 @@ public class DownloadManager {
e.result.each { e.result.each {
destinations.add(it.sender.destination) destinations.add(it.sender.destination)
} }
destinations.addAll(e.sources)
destinations.remove(me.destination)
def downloader = new Downloader(eventBus, this, me, e.target, size, def downloader = new Downloader(eventBus, this, me, e.target, size,
infohash, pieceSize, connector, destinations, infohash, pieceSize, connector, destinations,

View File

@@ -85,7 +85,7 @@ class DownloadSession {
if (code.startsWith("404 ")) { if (code.startsWith("404 ")) {
log.warning("file not found") log.warning("file not found")
endpoint.close() endpoint.close()
return return false
} }
if (code.startsWith("416 ")) { if (code.startsWith("416 ")) {

View File

@@ -4,6 +4,9 @@ import com.muwire.core.InfoHash
import com.muwire.core.Persona import com.muwire.core.Persona
import com.muwire.core.connection.Endpoint import com.muwire.core.connection.Endpoint
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -18,6 +21,7 @@ import com.muwire.core.files.FileDownloadedEvent
import groovy.util.logging.Log import groovy.util.logging.Log
import net.i2p.data.Destination import net.i2p.data.Destination
import net.i2p.util.ConcurrentHashSet
@Log @Log
public class Downloader { public class Downloader {
@@ -43,8 +47,10 @@ public class Downloader {
private final Set<Destination> destinations private final Set<Destination> destinations
private final int nPieces private final int nPieces
private final File piecesFile private final File piecesFile
private final File incompleteFile
final int pieceSizePow2 final int pieceSizePow2
private final Map<Destination, DownloadWorker> activeWorkers = new ConcurrentHashMap<>() private final Map<Destination, DownloadWorker> activeWorkers = new ConcurrentHashMap<>()
private final Set<Destination> successfulDestinations = new ConcurrentHashSet<>()
private volatile boolean cancelled private volatile boolean cancelled
@@ -64,6 +70,7 @@ public class Downloader {
this.connector = connector this.connector = connector
this.destinations = destinations this.destinations = destinations
this.piecesFile = new File(incompletes, file.getName()+".pieces") this.piecesFile = new File(incompletes, file.getName()+".pieces")
this.incompleteFile = new File(incompletes, file.getName()+".part")
this.pieceSizePow2 = pieceSizePow2 this.pieceSizePow2 = pieceSizePow2
this.pieceSize = 1 << pieceSizePow2 this.pieceSize = 1 << pieceSizePow2
@@ -175,11 +182,11 @@ public class Downloader {
public void cancel() { public void cancel() {
cancelled = true cancelled = true
stop() stop()
file.delete()
synchronized(piecesFile) { synchronized(piecesFile) {
piecesFileClosed = true piecesFileClosed = true
piecesFile.delete() piecesFile.delete()
} }
incompleteFile.delete()
} }
void stop() { void stop() {
@@ -240,10 +247,11 @@ public class Downloader {
currentState = WorkerState.DOWNLOADING currentState = WorkerState.DOWNLOADING
boolean requestPerformed boolean requestPerformed
while(!pieces.isComplete()) { while(!pieces.isComplete()) {
currentSession = new DownloadSession(me.toBase64(), pieces, getInfoHash(), endpoint, file, pieceSize, length) currentSession = new DownloadSession(me.toBase64(), pieces, getInfoHash(), endpoint, incompleteFile, pieceSize, length)
requestPerformed = currentSession.request() requestPerformed = currentSession.request()
if (!requestPerformed) if (!requestPerformed)
break break
successfulDestinations.add(endpoint.destination)
writePieces() writePieces()
} }
} catch (Exception bad) { } catch (Exception bad) {
@@ -255,9 +263,15 @@ public class Downloader {
piecesFileClosed = true piecesFileClosed = true
piecesFile.delete() piecesFile.delete()
} }
try {
Files.move(incompleteFile.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (AtomicMoveNotSupportedException e) {
Files.copy(incompleteFile.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
incompleteFile.delete()
}
eventBus.publish( eventBus.publish(
new FileDownloadedEvent( new FileDownloadedEvent(
downloadedFile : new DownloadedFile(file, getInfoHash(), pieceSizePow2, Collections.emptySet()), downloadedFile : new DownloadedFile(file, getInfoHash(), pieceSizePow2, successfulDestinations),
downloader : Downloader.this)) downloader : Downloader.this))
} }

View File

@@ -3,8 +3,11 @@ package com.muwire.core.download
import com.muwire.core.Event import com.muwire.core.Event
import com.muwire.core.search.UIResultEvent import com.muwire.core.search.UIResultEvent
import net.i2p.data.Destination
class UIDownloadEvent extends Event { class UIDownloadEvent extends Event {
UIResultEvent[] result UIResultEvent[] result
Set<Destination> sources
File target File target
} }

View File

@@ -1,4 +1,139 @@
package com.muwire.core.files package com.muwire.core.files
import java.nio.file.FileSystem
import java.nio.file.FileSystems
import java.nio.file.Path
import java.nio.file.Paths
import static java.nio.file.StandardWatchEventKinds.*
import java.nio.file.WatchEvent
import java.nio.file.WatchKey
import java.nio.file.WatchService
import java.util.concurrent.ConcurrentHashMap
import com.muwire.core.EventBus
import com.muwire.core.SharedFile
import groovy.util.logging.Log
import net.i2p.util.SystemVersion
@Log
class DirectoryWatcher { class DirectoryWatcher {
private static final long WAIT_TIME = 1000
private static final WatchEvent.Kind[] kinds
static {
if (SystemVersion.isMac())
kinds = [ENTRY_MODIFY, ENTRY_DELETE]
else
kinds = [ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE]
}
private final EventBus eventBus
private final FileManager fileManager
private final Thread watcherThread, publisherThread
private final Map<File, Long> waitingFiles = new ConcurrentHashMap<>()
private WatchService watchService
private volatile boolean shutdown
DirectoryWatcher(EventBus eventBus, FileManager fileManager) {
this.eventBus = eventBus
this.fileManager = fileManager
this.watcherThread = new Thread({watch() } as Runnable, "directory-watcher")
watcherThread.setDaemon(true)
this.publisherThread = new Thread({publish()} as Runnable, "watched-files-publisher")
publisherThread.setDaemon(true)
}
void start() {
watchService = FileSystems.getDefault().newWatchService()
watcherThread.start()
publisherThread.start()
}
void stop() {
shutdown = true
watcherThread.interrupt()
publisherThread.interrupt()
watchService.close()
}
void onFileSharedEvent(FileSharedEvent e) {
if (!e.file.isDirectory())
return
Path path = e.file.getCanonicalFile().toPath()
path.register(watchService, kinds)
}
private void watch() {
try {
while(!shutdown) {
WatchKey key = watchService.take()
key.pollEvents().each {
switch(it.kind()) {
case ENTRY_CREATE: processCreated(key.watchable(), it.context()); break
case ENTRY_MODIFY: processModified(key.watchable(), it.context()); break
case ENTRY_DELETE: processDeleted(key.watchable(), it.context()); break
}
}
key.reset()
}
} catch (InterruptedException e) {
if (!shutdown)
throw e
}
}
private void processCreated(Path parent, Path path) {
File f= join(parent, path)
log.fine("created entry $f")
if (f.isDirectory())
f.toPath().register(watchService, kinds)
else
waitingFiles.put(f, System.currentTimeMillis())
}
private void processModified(Path parent, Path path) {
File f = join(parent, path)
log.fine("modified entry $f")
waitingFiles.put(f, System.currentTimeMillis())
}
private void processDeleted(Path parent, Path path) {
File f = join(parent, path)
log.fine("deleted entry $f")
SharedFile sf = fileManager.fileToSharedFile.get(f)
if (sf != null)
eventBus.publish(new FileUnsharedEvent(unsharedFile : sf))
}
private static File join(Path parent, Path path) {
File parentFile = parent.toFile().getCanonicalFile()
new File(parentFile, path.toFile().getName())
}
private void publish() {
try {
while(!shutdown) {
Thread.sleep(WAIT_TIME)
long now = System.currentTimeMillis()
def published = []
waitingFiles.each { file, timestamp ->
if (now - timestamp > WAIT_TIME) {
log.fine("publishing file $file")
eventBus.publish new FileSharedEvent(file : file)
published << file
}
}
published.each {
waitingFiles.remove(it)
}
}
} catch (InterruptedException e) {
if (!shutdown)
throw e
}
}
} }

View File

@@ -32,7 +32,7 @@ class HasherService {
private void process(File f) { private void process(File f) {
f = f.getCanonicalFile() f = f.getCanonicalFile()
if (f.isDirectory()) { if (f.isDirectory()) {
f.listFiles().each {onFileSharedEvent new FileSharedEvent(file: it) } f.listFiles().each {eventBus.publish new FileSharedEvent(file: it) }
} else { } else {
if (f.length() == 0) { if (f.length() == 0) {
eventBus.publish new FileHashedEvent(error: "Not sharing empty file $f") eventBus.publish new FileHashedEvent(error: "Not sharing empty file $f")

View File

@@ -11,6 +11,7 @@ import com.muwire.core.EventBus
import com.muwire.core.InfoHash import com.muwire.core.InfoHash
import com.muwire.core.Service import com.muwire.core.Service
import com.muwire.core.SharedFile import com.muwire.core.SharedFile
import com.muwire.core.UILoadedEvent
import com.muwire.core.util.DataUtil import com.muwire.core.util.DataUtil
import groovy.json.JsonOutput import groovy.json.JsonOutput
@@ -36,14 +37,14 @@ class PersisterService extends Service {
timer = new Timer("file persister", true) timer = new Timer("file persister", true)
} }
void start() {
timer.schedule({load()} as TimerTask, 1)
}
void stop() { void stop() {
timer.cancel() timer.cancel()
} }
void onUILoadedEvent(UILoadedEvent e) {
timer.schedule({load()} as TimerTask, 1)
}
void load() { void load() {
if (location.exists() && location.isFile()) { if (location.exists() && location.isFile()) {
def slurper = new JsonSlurper() def slurper = new JsonSlurper()

View File

@@ -1,5 +1,7 @@
package com.muwire.core.search package com.muwire.core.search
import java.util.stream.Collectors
import javax.naming.directory.InvalidSearchControlsException import javax.naming.directory.InvalidSearchControlsException
import com.muwire.core.InfoHash import com.muwire.core.InfoHash
@@ -7,6 +9,7 @@ import com.muwire.core.Persona
import com.muwire.core.util.DataUtil import com.muwire.core.util.DataUtil
import net.i2p.data.Base64 import net.i2p.data.Base64
import net.i2p.data.Destination
class ResultsParser { class ResultsParser {
public static UIResultEvent parse(Persona p, UUID uuid, def json) throws InvalidSearchResultException { public static UIResultEvent parse(Persona p, UUID uuid, def json) throws InvalidSearchResultException {
@@ -58,6 +61,7 @@ class ResultsParser {
size : size, size : size,
infohash : parsedIH, infohash : parsedIH,
pieceSize : pieceSize, pieceSize : pieceSize,
sources : Collections.emptySet(),
uuid : uuid) uuid : uuid)
} catch (Exception e) { } catch (Exception e) {
throw new InvalidSearchResultException("parsing search result failed",e) throw new InvalidSearchResultException("parsing search result failed",e)
@@ -82,11 +86,17 @@ class ResultsParser {
if (infoHash.length != InfoHash.SIZE) if (infoHash.length != InfoHash.SIZE)
throw new InvalidSearchResultException("invalid infohash size $infoHash.length") throw new InvalidSearchResultException("invalid infohash size $infoHash.length")
int pieceSize = json.pieceSize int pieceSize = json.pieceSize
Set<Destination> sources = Collections.emptySet()
if (json.sources != null)
sources = json.sources.stream().map({new Destination(it)}).collect(Collectors.toSet())
return new UIResultEvent( sender : p, return new UIResultEvent( sender : p,
name : name, name : name,
size : size, size : size,
infohash : new InfoHash(infoHash), infohash : new InfoHash(infoHash),
pieceSize : pieceSize, pieceSize : pieceSize,
sources : sources,
uuid: uuid) uuid: uuid)
} catch (Exception e) { } catch (Exception e) {
throw new InvalidSearchResultException("parsing search result failed",e) throw new InvalidSearchResultException("parsing search result failed",e)

View File

@@ -11,7 +11,9 @@ import java.util.concurrent.Executor
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ThreadFactory import java.util.concurrent.ThreadFactory
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.stream.Collectors
import com.muwire.core.DownloadedFile
import com.muwire.core.EventBus import com.muwire.core.EventBus
import com.muwire.core.InfoHash import com.muwire.core.InfoHash
@@ -54,12 +56,16 @@ class ResultsSender {
int pieceSize = it.getPieceSize() int pieceSize = it.getPieceSize()
if (pieceSize == 0) if (pieceSize == 0)
pieceSize = FileHasher.getPieceSize(length) pieceSize = FileHasher.getPieceSize(length)
Set<Destination> suggested = Collections.emptySet()
if (it instanceof DownloadedFile)
suggested = it.sources
def uiResultEvent = new UIResultEvent( sender : me, def uiResultEvent = new UIResultEvent( sender : me,
name : it.getFile().getName(), name : it.getFile().getName(),
size : length, size : length,
infohash : it.getInfoHash(), infohash : it.getInfoHash(),
pieceSize : pieceSize, pieceSize : pieceSize,
uuid : uuid uuid : uuid,
sources : suggested
) )
eventBus.publish(uiResultEvent) eventBus.publish(uiResultEvent)
} }
@@ -110,6 +116,10 @@ class ResultsSender {
} }
obj.hashList = hashListB64 obj.hashList = hashListB64
} }
if (it instanceof DownloadedFile)
obj.sources = it.sources.stream().map({dest -> dest.toBase64()}).collect(Collectors.toSet())
def json = jsonOutput.toJson(obj) def json = jsonOutput.toJson(obj)
os.writeShort((short)json.length()) os.writeShort((short)json.length())
os.write(json.getBytes(StandardCharsets.US_ASCII)) os.write(json.getBytes(StandardCharsets.US_ASCII))

View File

@@ -4,8 +4,11 @@ import com.muwire.core.Event
import com.muwire.core.InfoHash import com.muwire.core.InfoHash
import com.muwire.core.Persona import com.muwire.core.Persona
import net.i2p.data.Destination
class UIResultEvent extends Event { class UIResultEvent extends Event {
Persona sender Persona sender
Set<Destination> sources
UUID uuid UUID uuid
String name String name
long size long size

View File

@@ -1,5 +1,5 @@
group = com.muwire group = com.muwire
version = 0.2.4 version = 0.2.7
groovyVersion = 2.4.15 groovyVersion = 2.4.15
slf4jVersion = 1.7.25 slf4jVersion = 1.7.25
spockVersion = 1.1-groovy-2.4 spockVersion = 1.1-groovy-2.4

View File

@@ -118,16 +118,20 @@ class MainFrameController {
void download() { void download() {
def result = selectedResult() def result = selectedResult()
if (result == null) if (result == null)
return // TODO disable button return
if (!model.canDownload(result.infohash))
return
def file = new File(application.context.get("muwire-settings").downloadLocation, result.name) def file = new File(application.context.get("muwire-settings").downloadLocation, result.name)
def selected = builder.getVariable("result-tabs").getSelectedComponent() def selected = builder.getVariable("result-tabs").getSelectedComponent()
def group = selected.getClientProperty("mvc-group") def group = selected.getClientProperty("mvc-group")
def resultsBucket = group.model.hashBucket[result.infohash] def resultsBucket = group.model.hashBucket[result.infohash]
def sources = group.model.sourcesBucket[result.infohash]
core.eventBus.publish(new UIDownloadEvent(result : resultsBucket, target : file)) core.eventBus.publish(new UIDownloadEvent(result : resultsBucket, sources: sources, target : file))
} }
@ControllerAction @ControllerAction
@@ -150,6 +154,7 @@ class MainFrameController {
void cancel() { void cancel() {
def downloader = model.downloads[selectedDownload()].downloader def downloader = model.downloads[selectedDownload()].downloader
downloader.cancel() downloader.cancel()
model.downloadInfoHashes.remove(downloader.getInfoHash())
core.eventBus.publish(new UIDownloadCancelledEvent(downloader : downloader)) core.eventBus.publish(new UIDownloadCancelledEvent(downloader : downloader))
} }
@@ -190,6 +195,13 @@ class MainFrameController {
println "unsharing selected files" println "unsharing selected files"
} }
void saveMuWireSettings() {
File f = new File(core.home, "MuWire.properties")
f.withOutputStream {
core.muOptions.write(it)
}
}
void mvcGroupInit(Map<String, String> args) { void mvcGroupInit(Map<String, String> args) {
application.addPropertyChangeListener("core", {e-> application.addPropertyChangeListener("core", {e->
core = e.getNewValue() core = e.getNewValue()

View File

@@ -1,3 +1,4 @@
import griffon.core.GriffonApplication import griffon.core.GriffonApplication
import griffon.core.env.Metadata import griffon.core.env.Metadata
import groovy.util.logging.Log import groovy.util.logging.Log
@@ -104,12 +105,6 @@ class Ready extends AbstractLifecycleHandler {
it.propertyChange(new PropertyChangeEvent(this, "core", null, core)) it.propertyChange(new PropertyChangeEvent(this, "core", null, core))
} }
if (props.sharedFiles != null) {
props.sharedFiles.split(",").each {
core.eventBus.publish(new FileSharedEvent(file : new File(it)))
}
}
core.eventBus.publish(new UILoadedEvent()) core.eventBus.publish(new UILoadedEvent())
} }
} }

View File

@@ -9,6 +9,7 @@ import javax.swing.JTable
import com.muwire.core.Core import com.muwire.core.Core
import com.muwire.core.InfoHash import com.muwire.core.InfoHash
import com.muwire.core.MuWireSettings
import com.muwire.core.Persona import com.muwire.core.Persona
import com.muwire.core.connection.ConnectionAttemptStatus import com.muwire.core.connection.ConnectionAttemptStatus
import com.muwire.core.connection.ConnectionEvent import com.muwire.core.connection.ConnectionEvent
@@ -19,6 +20,7 @@ import com.muwire.core.files.FileDownloadedEvent
import com.muwire.core.files.FileHashedEvent import com.muwire.core.files.FileHashedEvent
import com.muwire.core.files.FileLoadedEvent import com.muwire.core.files.FileLoadedEvent
import com.muwire.core.files.FileSharedEvent import com.muwire.core.files.FileSharedEvent
import com.muwire.core.files.FileUnsharedEvent
import com.muwire.core.search.QueryEvent import com.muwire.core.search.QueryEvent
import com.muwire.core.search.UIResultBatchEvent import com.muwire.core.search.UIResultBatchEvent
import com.muwire.core.search.UIResultEvent import com.muwire.core.search.UIResultEvent
@@ -53,6 +55,7 @@ class MainFrameModel {
def downloads = [] def downloads = []
def uploads = [] def uploads = []
def shared = [] def shared = []
def watched = []
def connectionList = [] def connectionList = []
def searches = new LinkedList() def searches = new LinkedList()
def trusted = [] def trusted = []
@@ -60,11 +63,14 @@ class MainFrameModel {
@Observable int connections @Observable int connections
@Observable String me @Observable String me
@Observable boolean searchButtonsEnabled @Observable boolean downloadActionEnabled
@Observable boolean trustButtonsEnabled
@Observable boolean cancelButtonEnabled @Observable boolean cancelButtonEnabled
@Observable boolean retryButtonEnabled @Observable boolean retryButtonEnabled
private final Set<InfoHash> infoHashes = new HashSet<>() private final Set<InfoHash> infoHashes = new HashSet<>()
private final Set<InfoHash> downloadInfoHashes = new HashSet<>()
volatile Core core volatile Core core
@@ -128,9 +134,10 @@ class MainFrameModel {
core.eventBus.register(QueryEvent.class, this) core.eventBus.register(QueryEvent.class, this)
core.eventBus.register(UpdateAvailableEvent.class, this) core.eventBus.register(UpdateAvailableEvent.class, this)
core.eventBus.register(FileDownloadedEvent.class, this) core.eventBus.register(FileDownloadedEvent.class, this)
core.eventBus.register(FileUnsharedEvent.class, this)
timer.schedule({ timer.schedule({
int retryInterval = application.context.get("muwire-settings").downloadRetryInterval int retryInterval = core.muOptions.downloadRetryInterval
if (retryInterval > 0) { if (retryInterval > 0) {
retryInterval *= 60000 retryInterval *= 60000
long now = System.currentTimeMillis() long now = System.currentTimeMillis()
@@ -153,6 +160,10 @@ class MainFrameModel {
runInsideUIAsync { runInsideUIAsync {
trusted.addAll(core.trustService.good.values()) trusted.addAll(core.trustService.good.values())
distrusted.addAll(core.trustService.bad.values()) distrusted.addAll(core.trustService.bad.values())
watched.addAll(core.muOptions.watchedDirectories)
builder.getVariable("watched-directories-table").model.fireTableDataChanged()
watched.each { core.eventBus.publish(new FileSharedEvent(file : new File(it))) }
} }
}) })
@@ -171,6 +182,7 @@ class MainFrameModel {
void onDownloadStartedEvent(DownloadStartedEvent e) { void onDownloadStartedEvent(DownloadStartedEvent e) {
runInsideUIAsync { runInsideUIAsync {
downloads << e downloads << e
downloadInfoHashes.add(e.downloader.infoHash)
} }
} }
@@ -232,6 +244,17 @@ class MainFrameModel {
} }
} }
void onFileUnsharedEvent(FileUnsharedEvent e) {
InfoHash infohash = e.unsharedFile.infoHash
if (!infoHashes.remove(infohash))
return
runInsideUIAsync {
shared.remove(e.unsharedFile)
JTable table = builder.getVariable("shared-files-table")
table.model.fireTableDataChanged()
}
}
void onUploadEvent(UploadEvent e) { void onUploadEvent(UploadEvent e) {
runInsideUIAsync { runInsideUIAsync {
uploads << e.uploader uploads << e.uploader
@@ -340,4 +363,8 @@ class MainFrameModel {
return destination == other.destination return destination == other.destination
} }
} }
boolean canDownload(InfoHash hash) {
!downloadInfoHashes.contains(hash)
}
} }

View File

@@ -23,6 +23,7 @@ class SearchTabModel {
String uuid String uuid
def results = [] def results = []
def hashBucket = [:] def hashBucket = [:]
def sourcesBucket = [:]
void mvcGroupInit(Map<String, String> args) { void mvcGroupInit(Map<String, String> args) {
@@ -37,7 +38,7 @@ class SearchTabModel {
void handleResult(UIResultEvent e) { void handleResult(UIResultEvent e) {
if (uiSettings.excludeLocalResult && if (uiSettings.excludeLocalResult &&
e.sender == core.me) core.fileManager.rootToFiles.containsKey(e.infohash))
return return
runInsideUIAsync { runInsideUIAsync {
def bucket = hashBucket.get(e.infohash) def bucket = hashBucket.get(e.infohash)
@@ -46,6 +47,13 @@ class SearchTabModel {
hashBucket[e.infohash] = bucket hashBucket[e.infohash] = bucket
} }
bucket << e bucket << e
Set sourceBucket = sourcesBucket.get(e.infohash)
if (sourceBucket == null) {
sourceBucket = new HashSet()
sourcesBucket.put(e.infohash, sourceBucket)
}
sourceBucket.addAll(e.sources)
results << e results << e
JTable table = builder.getVariable("results-table") JTable table = builder.getVariable("results-table")
@@ -56,13 +64,22 @@ class SearchTabModel {
void handleResultBatch(UIResultEvent[] batch) { void handleResultBatch(UIResultEvent[] batch) {
runInsideUIAsync { runInsideUIAsync {
batch.each { batch.each {
if (uiSettings.excludeLocalResult && it.sender == core.me) if (uiSettings.excludeLocalResult &&
core.fileManager.rootToFiles.containsKey(it.infohash))
return return
def bucket = hashBucket.get(it.infohash) def bucket = hashBucket.get(it.infohash)
if (bucket == null) { if (bucket == null) {
bucket = [] bucket = []
hashBucket[it.infohash] = bucket hashBucket[it.infohash] = bucket
} }
Set sourceBucket = sourcesBucket.get(it.infohash)
if (sourceBucket == null) {
sourceBucket = new HashSet()
sourcesBucket.put(it.infohash, sourceBucket)
}
sourceBucket.addAll(it.sources)
bucket << it bucket << it
results << it results << it
} }

View File

@@ -105,9 +105,9 @@ class MainFrameView {
borderLayout() borderLayout()
tabbedPane(id : "result-tabs", constraints: BorderLayout.CENTER) tabbedPane(id : "result-tabs", constraints: BorderLayout.CENTER)
panel(constraints : BorderLayout.SOUTH) { panel(constraints : BorderLayout.SOUTH) {
button(text : "Download", enabled : bind {model.searchButtonsEnabled}, downloadAction) button(text : "Download", enabled : bind {model.downloadActionEnabled}, downloadAction)
button(text : "Trust", enabled: bind {model.searchButtonsEnabled }, trustAction) button(text : "Trust", enabled: bind {model.trustButtonsEnabled }, trustAction)
button(text : "Distrust", enabled : bind {model.searchButtonsEnabled}, distrustAction) button(text : "Distrust", enabled : bind {model.trustButtonsEnabled}, distrustAction)
} }
} }
panel (constraints : JSplitPane.BOTTOM) { panel (constraints : JSplitPane.BOTTOM) {
@@ -139,16 +139,32 @@ class MainFrameView {
panel (constraints: "uploads window"){ panel (constraints: "uploads window"){
gridLayout(cols : 1, rows : 2) gridLayout(cols : 1, rows : 2)
panel { panel {
borderLayout() gridLayout(cols : 2, rows : 1)
panel (constraints : BorderLayout.NORTH) { panel {
button(text : "Click here to share files", actionPerformed : shareFiles) borderLayout()
panel (constraints : BorderLayout.NORTH) {
button(text : "Add directories to watch", actionPerformed : watchDirectories)
}
scrollPane (constraints : BorderLayout.CENTER) {
table(id : "watched-directories-table", autoCreateRowSorter: true) {
tableModel(list : model.watched) {
closureColumn(header: "Watched Directories", type : String, read : { it })
}
}
}
} }
scrollPane ( constraints : BorderLayout.CENTER) { panel {
table(id : "shared-files-table", autoCreateRowSorter: true) { borderLayout()
tableModel(list : model.shared) { panel (constraints : BorderLayout.NORTH) {
closureColumn(header : "Name", preferredWidth : 550, type : String, read : {row -> row.file.getAbsolutePath()}) button(text : "Share files", actionPerformed : shareFiles)
closureColumn(header : "Size", preferredWidth : 50, type : Long, read : {row -> row.file.length() }) }
} scrollPane(constraints : BorderLayout.CENTER) {
table(id : "shared-files-table", autoCreateRowSorter: true) {
tableModel(list : model.shared) {
closureColumn(header : "Name", preferredWidth : 500, type : String, read : {row -> row.file.getAbsolutePath()})
closureColumn(header : "Size", preferredWidth : 100, type : Long, read : {row -> row.file.length() })
}
}
} }
} }
} }
@@ -466,11 +482,26 @@ class MainFrameView {
def shareFiles = { def shareFiles = {
def chooser = new JFileChooser() def chooser = new JFileChooser()
chooser.setDialogTitle("Select file or directory to share") chooser.setDialogTitle("Select file to share")
chooser.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES) chooser.setFileSelectionMode(JFileChooser.FILES_ONLY)
int rv = chooser.showOpenDialog(null) int rv = chooser.showOpenDialog(null)
if (rv == JFileChooser.APPROVE_OPTION) { if (rv == JFileChooser.APPROVE_OPTION) {
model.core.eventBus.publish(new FileSharedEvent(file : chooser.getSelectedFile())) model.core.eventBus.publish(new FileSharedEvent(file : chooser.getSelectedFile()))
} }
} }
def watchDirectories = {
def chooser = new JFileChooser()
chooser.setDialogTitle("Select directory to watch")
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY)
int rv = chooser.showOpenDialog(null)
if (rv == JFileChooser.APPROVE_OPTION) {
File f = chooser.getSelectedFile()
model.watched << f.getAbsolutePath()
application.context.get("muwire-settings").watchedDirectories << f.getAbsolutePath()
mvcGroup.controller.saveMuWireSettings()
builder.getVariable("watched-directories-table").model.fireTableDataChanged()
model.core.eventBus.publish(new FileSharedEvent(file : f))
}
}
} }

View File

@@ -47,8 +47,9 @@ class SearchTabView {
resultsTable = table(id : "results-table", autoCreateRowSorter : true) { resultsTable = table(id : "results-table", autoCreateRowSorter : true) {
tableModel(list: model.results) { tableModel(list: model.results) {
closureColumn(header: "Name", preferredWidth: 350, type: String, read : {row -> row.name.replace('<','_')}) closureColumn(header: "Name", preferredWidth: 350, type: String, read : {row -> row.name.replace('<','_')})
closureColumn(header: "Size", preferredWidth: 50, type: Long, read : {row -> row.size}) closureColumn(header: "Size", preferredWidth: 20, type: Long, read : {row -> row.size})
closureColumn(header: "Sources", preferredWidth: 10, type : Integer, read : { row -> model.hashBucket[row.infohash].size()}) closureColumn(header: "Direct Sources", preferredWidth: 50, type : Integer, read : { row -> model.hashBucket[row.infohash].size()})
closureColumn(header: "Possible Sources", preferredWidth : 50, type : Integer, read : {row -> model.sourcesBucket[row.infohash].size()})
closureColumn(header: "Sender", preferredWidth: 170, type: String, read : {row -> row.sender.getHumanReadableName()}) closureColumn(header: "Sender", preferredWidth: 170, type: String, read : {row -> row.sender.getHumanReadableName()})
closureColumn(header: "Trust", preferredWidth: 50, type: String, read : {row -> closureColumn(header: "Trust", preferredWidth: 50, type: String, read : {row ->
model.core.trustService.getLevel(row.sender.destination).toString() model.core.trustService.getLevel(row.sender.destination).toString()
@@ -66,7 +67,13 @@ class SearchTabView {
def selectionModel = resultsTable.getSelectionModel() def selectionModel = resultsTable.getSelectionModel()
selectionModel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION) selectionModel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
selectionModel.addListSelectionListener( { selectionModel.addListSelectionListener( {
mvcGroup.parentGroup.model.searchButtonsEnabled = true int row = resultsTable.getSelectedRow()
if (row < 0)
return
if (lastSortEvent != null)
row = resultsTable.rowSorter.convertRowIndexToModel(row)
mvcGroup.parentGroup.model.trustButtonsEnabled = true
mvcGroup.parentGroup.model.downloadActionEnabled = mvcGroup.parentGroup.model.canDownload(model.results[row].infohash)
}) })
} }
} }
@@ -105,25 +112,18 @@ class SearchTabView {
resultsTable.rowSorter.setSortsOnUpdates(true) resultsTable.rowSorter.setSortsOnUpdates(true)
JPopupMenu menu = new JPopupMenu()
JMenuItem download = new JMenuItem("Download")
download.addActionListener({mvcGroup.parentGroup.controller.download()})
menu.add(download)
JMenuItem copyHashToClipboard = new JMenuItem("Copy hash to clipboard")
copyHashToClipboard.addActionListener({mvcGroup.view.copyHashToClipboard()})
menu.add(copyHashToClipboard)
resultsTable.addMouseListener(new MouseAdapter() { resultsTable.addMouseListener(new MouseAdapter() {
@Override @Override
public void mouseClicked(MouseEvent e) { public void mouseClicked(MouseEvent e) {
if (e.button == MouseEvent.BUTTON3) if (e.button == MouseEvent.BUTTON3)
showPopupMenu(menu, e) showPopupMenu(e)
else if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2) else if (e.button == MouseEvent.BUTTON1 && e.clickCount == 2)
mvcGroup.parentGroup.controller.download() mvcGroup.parentGroup.controller.download()
} }
@Override @Override
public void mouseReleased(MouseEvent e) { public void mouseReleased(MouseEvent e) {
if (e.button == MouseEvent.BUTTON3) if (e.button == MouseEvent.BUTTON3)
showPopupMenu(menu, e) showPopupMenu(e)
} }
}) })
} }
@@ -131,12 +131,21 @@ class SearchTabView {
def closeTab = { def closeTab = {
int index = parent.indexOfTab(searchTerms) int index = parent.indexOfTab(searchTerms)
parent.removeTabAt(index) parent.removeTabAt(index)
mvcGroup.parentGroup.model.searchButtonsEnabled = false mvcGroup.parentGroup.model.trustButtonsEnabled = false
mvcGroup.parentGroup.model.downloadActionEnabled = false
mvcGroup.destroy() mvcGroup.destroy()
} }
def showPopupMenu(JPopupMenu menu, MouseEvent e) { def showPopupMenu(MouseEvent e) {
println "showing popup menu" JPopupMenu menu = new JPopupMenu()
if (mvcGroup.parentGroup.model.downloadActionEnabled) {
JMenuItem download = new JMenuItem("Download")
download.addActionListener({mvcGroup.parentGroup.controller.download()})
menu.add(download)
}
JMenuItem copyHashToClipboard = new JMenuItem("Copy hash to clipboard")
copyHashToClipboard.addActionListener({mvcGroup.view.copyHashToClipboard()})
menu.add(copyHashToClipboard)
menu.show(e.getComponent(), e.getX(), e.getY()) menu.show(e.getComponent(), e.getX(), e.getY())
} }