Compare commits

...

82 Commits

Author SHA1 Message Date
Zlatin Balevsky
fec81808e5 Release 0.6.5 2019-11-14 05:15:26 +00:00
Zlatin Balevsky
4db890484d do not rejoin console 2019-11-14 04:49:13 +00:00
Zlatin Balevsky
dfd5e06889 add browse ability from chat room view 2019-11-14 04:40:15 +00:00
Zlatin Balevsky
71da8e14da name button earlier 2019-11-14 04:25:45 +00:00
Zlatin Balevsky
7dc37e3e0d change button to connect/disconnect 2019-11-14 04:20:57 +00:00
Zlatin Balevsky
3de058a078 send rejoins to the console pt2 2019-11-14 03:59:01 +00:00
Zlatin Balevsky
4d70c7adce send rejoins to the console 2019-11-14 03:58:36 +00:00
Zlatin Balevsky
5b41106476 start and stop poller thread on events 2019-11-14 03:45:21 +00:00
Zlatin Balevsky
6240b22e66 fix reconnecting to server, start with fresh member list upon rejoin 2019-11-14 03:13:01 +00:00
Zlatin Balevsky
0e26f5afd7 rejoin rooms on reconnect 2019-11-14 02:40:22 +00:00
Zlatin Balevsky
114bc06dbb If the user explicitly shares a file, remove it form the negative tree. #26 2019-11-13 22:00:10 +00:00
Zlatin Balevsky
5fa2f2753c Release 0.6.4 2019-11-13 20:06:53 +00:00
Zlatin Balevsky
cacdd2a7a9 add browse and chat buttons to trusted panel 2019-11-13 19:40:28 +00:00
Zlatin Balevsky
d56f7c6184 add right-click menu to trusted table 2019-11-13 19:33:34 +00:00
Zlatin Balevsky
f7f4513109 better help and welcome message 2019-11-13 17:50:50 +00:00
Zlatin Balevsky
dd15d893ba Call for help for Web UI 2019-11-13 17:26:14 +00:00
Zlatin Balevsky
bf5ab9c82e ) 2019-11-13 14:10:26 +00:00
Zlatin Balevsky
edd5a29b10 make private chat room ids unique across servers 2019-11-13 14:09:09 +00:00
Zlatin Balevsky
38eb89f2f7 prepend server name to room id in order to make ids unique across server connections 2019-11-13 13:44:22 +00:00
Zlatin Balevsky
73f1d64428 indentation of text field 2019-11-13 12:24:21 +00:00
Zlatin Balevsky
bc1cae2d75 enable sharing of directories from button 2019-11-13 12:03:23 +00:00
Zlatin Balevsky
a0ab07a7c0 show browse status for local results correctly 2019-11-13 11:58:55 +00:00
Zlatin Balevsky
f875c379ce Release 0.6.3 2019-11-12 17:22:38 +00:00
Zlatin Balevsky
0ce9784ccf add right-click menu on the members table 2019-11-12 17:08:38 +00:00
Zlatin Balevsky
be82136e32 limit scrollback 2019-11-12 16:30:55 +00:00
Zlatin Balevsky
7d25bb9364 tidy up views 2019-11-12 16:06:31 +00:00
Zlatin Balevsky
c6e98db9d4 initialize result sender properly 2019-11-12 15:50:58 +00:00
Zlatin Balevsky
35a26e2a47 advertise chat ability in search results 2019-11-12 15:47:38 +00:00
Zlatin Balevsky
beef4af329 ui for chat options 2019-11-12 15:31:20 +00:00
Zlatin Balevsky
cec3c1bc0f disconnect on close tab 2019-11-12 14:21:47 +00:00
Zlatin Balevsky
289b958784 disconnect functionality 2019-11-12 14:19:57 +00:00
Zlatin Balevsky
e9c554d717 proper group name pt3 2019-11-12 13:53:33 +00:00
Zlatin Balevsky
1875fcddb2 proper room name pt2 2019-11-12 13:33:53 +00:00
Zlatin Balevsky
bee6154fa9 set more room tab names correctly 2019-11-12 13:26:07 +00:00
Zlatin Balevsky
1f9b171021 wip on private messages 2019-11-12 13:16:36 +00:00
Zlatin Balevsky
59c03be35e suffix for group ids 2019-11-12 12:33:18 +00:00
Zlatin Balevsky
621af96bdf wip on private chat 2019-11-12 12:20:49 +00:00
Zlatin Balevsky
bcb7016202 add myself to the room member list when joining, fix /SAY 2019-11-12 11:40:28 +00:00
Zlatin Balevsky
b1b2bcaef8 show disconnects 2019-11-12 11:34:23 +00:00
Zlatin Balevsky
eec007e83b update status only if it matches host 2019-11-12 11:11:42 +00:00
Zlatin Balevsky
3d36351a6b fetch the list of current room members when joining 2019-11-12 10:55:21 +00:00
Zlatin Balevsky
d57d2ccb71 print help message on joining 2019-11-12 04:18:35 +00:00
Zlatin Balevsky
d91f15ee54 dispatch joins to the target room 2019-11-12 03:53:38 +00:00
Zlatin Balevsky
6bc61c920d start outgoing connection 2019-11-12 00:11:26 +00:00
Zlatin Balevsky
146ed53e12 connection code 2019-11-11 23:52:34 +00:00
Zlatin Balevsky
8ebae1600b fix up chat room view 2019-11-11 23:46:43 +00:00
Zlatin Balevsky
18d19ca75e wip on joining and leaving rooms 2019-11-11 23:32:23 +00:00
Zlatin Balevsky
29e499fe9d hook up core and backend 2019-11-11 22:42:55 +00:00
Zlatin Balevsky
3db167bade send periodic pings 2019-11-11 17:54:33 +00:00
Zlatin Balevsky
bfe0ab7867 wip on hooking UI with core 2019-11-11 17:48:42 +00:00
Zlatin Balevsky
1fbb1e7932 add chat pane and associated components 2019-11-11 16:35:15 +00:00
Zlatin Balevsky
0632336cd1 add ability to start and stop chat server from UI 2019-11-11 15:16:23 +00:00
Zlatin Balevsky
aa221cd6dc server-side handling of disconnects and trust events 2019-11-11 14:54:10 +00:00
Zlatin Balevsky
29b5c55328 client-side disconnect handling 2019-11-11 13:31:00 +00:00
Zlatin Balevsky
5e7f3587df shutdown chat components 2019-11-11 13:26:25 +00:00
Zlatin Balevsky
8afd387ca6 hook up chat components with core 2019-11-11 13:21:16 +00:00
Zlatin Balevsky
5d16963d1c process join/leave/say server-side 2019-11-11 12:19:32 +00:00
Zlatin Balevsky
6080c8b308 chat client and server 2019-11-11 10:43:52 +00:00
Zlatin Balevsky
915deb1dee update readme for new shadow jar name 2019-11-11 09:13:56 +00:00
Zlatin Balevsky
8afca3dc7f Merge pull request #24 from theosotr/fix
Bugfix: Update plugin version to fix bug about shadow jar
2019-11-11 09:04:42 +00:00
Thodoris Sotiropoulos
f072d0343c Update plugin version to fix bug about shadow jar 2019-11-11 10:52:37 +02:00
Zlatin Balevsky
a549ad3d8d wip on chat 2019-11-11 04:36:43 +00:00
Zlatin Balevsky
b6f5ec7d22 wip on chat 2019-11-10 20:34:24 +00:00
Zlatin Balevsky
761bf0a177 Release 0.6.2 2019-11-10 18:31:30 +00:00
Zlatin Balevsky
bd873211c0 wip on file preview 2019-11-10 14:50:19 +00:00
Zlatin Balevsky
036971cfe5 wip on file preview 2019-11-10 13:59:01 +00:00
Zlatin Balevsky
a2637570b1 Release 0.6.1 2019-11-10 06:23:28 +00:00
Zlatin Balevsky
6012adbeab fix unsharing of files with comments 2019-11-10 06:04:57 +00:00
Zlatin Balevsky
8f6b6b0caa update test for new json format 2019-11-10 05:20:09 +00:00
Zlatin Balevsky
8f3b5aea8d store lowercases in search index 2019-11-10 05:14:31 +00:00
Zlatin Balevsky
ee098ace8e update readme 2019-11-09 20:11:03 +00:00
Zlatin Balevsky
5d8401e4bf avoid NPE, pending further investigation 2019-11-09 20:10:21 +00:00
Zlatin Balevsky
fbf9add82a Release 0.6.0 2019-11-09 19:27:36 +00:00
Zlatin Balevsky
7379263fef extended signature in cli 2019-11-09 18:34:34 +00:00
Zlatin Balevsky
7d50843754 make signed queries mandatory 2019-11-09 17:03:38 +00:00
Zlatin Balevsky
f4a2864942 add extended signature in queries to prevent replay attacks 2019-11-09 16:39:16 +00:00
Zlatin Balevsky
afaadf65a4 only set selected row if the table contains that many rows. That fixes an AIOOBE 2019-11-09 15:14:14 +00:00
Zlatin Balevsky
7bd422d6b4 another instance of unexplained npe 2019-11-09 12:36:59 +00:00
Zlatin Balevsky
3f47274f61 add option to open containing folder 2019-11-09 11:28:12 +00:00
Zlatin Balevsky
419e9a0ce6 prevent npe when..? unclear when this happens 2019-11-09 11:01:55 +00:00
Zlatin Balevsky
ac1068a681 fix show comment/certificate buttons in group-by-file mode 2019-11-09 10:53:38 +00:00
Zlatin Balevsky
549457e36f close output stream silently 2019-11-08 21:46:44 +00:00
60 changed files with 2360 additions and 46 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.
The current stable release - 0.5.9 is avaiable for download at https://muwire.com. You can find technical documentation in the "doc" folder.
The current stable release - 0.6.2 is avaiable for download at https://muwire.com. You can find technical documentation in the "doc" folder.
### Building
@@ -23,7 +23,7 @@ If you want to build binary bundles that do not depend on Java or I2P, see the h
### Running the GUI
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 gui-x.y.z.jar` in a terminal or command prompt.
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 gui-x.y.z-all.jar` in a terminal or command prompt.
If you have an I2P router running on the same machine that is all you need to do. If you use a custom I2CP host and port, create a file `i2p.properties` and put `i2cp.tcp.host=<host>` and `i2cp.tcp.port=<port>` in there. On Windows that file should go into `%HOME%\AppData\Roaming\MuWire`, on Mac into `$HOME/Library/Application Support/MuWire` and on Linux `$HOME/.MuWire`
@@ -31,10 +31,14 @@ If you have an I2P router running on the same machine that is all you need to do
### Running the CLI
Look inside `cli-lanterna/build/distributions`. Untar/unzip one of the `shadow` files and then run the jar contained inside by typing `java -jar cli-lanterna-x.y.z.jar` in a terminal. The CLI will ask you about the router host and port on startup, no need to edit any files. However, the CLI does not have an options window yet, so if you need to change any options you will need to edit the configuration files. The CLI options are documented here https://github.com/zlatinb/muwire/wiki/CLI-Configuration-Options
Look inside `cli-lanterna/build/distributions`. Untar/unzip one of the `shadow` files and then run the jar contained inside by typing `java -jar cli-lanterna-x.y.z-all.jar` in a terminal. The CLI will ask you about the router host and port on startup, no need to edit any files. However, the CLI does not have an options window yet, so if you need to change any options you will need to edit the configuration files. The CLI options are documented here https://github.com/zlatinb/muwire/wiki/CLI-Configuration-Options
The CLI is under active development and doesn't have all the features of the GUI.
### Web UI
If you are a Grails/Scala/JRuby/Kotlin developer and are interested in building a Web UI for MuWire, please get in touch. The MuWire core is written in Groovy and should be easy to integrate with any JVM-based language.
### GPG Fingerprint
```

View File

@@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.4'
classpath 'com.github.jengelman.gradle.plugins:shadow:5.2.0'
}
}

View File

@@ -32,7 +32,7 @@ import com.muwire.core.UILoadedEvent
import com.muwire.core.files.AllFilesLoadedEvent
class CliLanterna {
private static final String MW_VERSION = "0.5.10"
private static final String MW_VERSION = "0.6.5"
private static volatile Core core

View File

@@ -7,6 +7,7 @@ import com.muwire.core.search.QueryEvent
import com.muwire.core.search.SearchEvent
import com.muwire.core.search.UIResultBatchEvent
import com.muwire.core.search.UIResultEvent
import com.muwire.core.util.DataUtil
import net.i2p.crypto.DSAEngine
import net.i2p.data.Base64
@@ -45,13 +46,16 @@ class SearchModel {
def searchEvent
byte [] payload
UUID uuid = UUID.randomUUID()
long timestamp = System.currentTimeMillis()
byte [] sig2 = DataUtil.signUUID(uuid, timestamp, core.spk)
if (hashSearch) {
searchEvent = new SearchEvent(searchHash : root, uuid : UUID.randomUUID(), oobInfohash : true, compressedResults : true)
searchEvent = new SearchEvent(searchHash : root, uuid : uuid, oobInfohash : true, compressedResults : true)
payload = root
} else {
def nonEmpty = SplitPattern.termify(query)
payload = String.join(" ", nonEmpty).getBytes(StandardCharsets.UTF_8)
searchEvent = new SearchEvent(searchTerms : nonEmpty, uuid : UUID.randomUUID(), oobInfohash: true,
searchEvent = new SearchEvent(searchTerms : nonEmpty, uuid : uuid, oobInfohash: true,
searchComments : core.muOptions.searchComments, compressedResults : true)
}
@@ -61,7 +65,7 @@ class SearchModel {
core.eventBus.publish(new QueryEvent(searchEvent : searchEvent, firstHop : firstHop,
replyTo: core.me.destination, receivedOn: core.me.destination,
originator : core.me, sig: sig.data))
originator : core.me, sig: sig.data, queryTime : timestamp, sig2 : sig2))
}
void unregister() {

View File

@@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.4'
classpath 'com.github.jengelman.gradle.plugins:shadow:5.2.0'
}
}

View File

@@ -3,6 +3,12 @@ package com.muwire.core
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean
import com.muwire.core.chat.ChatDisconnectionEvent
import com.muwire.core.chat.ChatManager
import com.muwire.core.chat.ChatMessageEvent
import com.muwire.core.chat.ChatServer
import com.muwire.core.chat.UIConnectChatEvent
import com.muwire.core.chat.UIDisconnectChatEvent
import com.muwire.core.connection.ConnectionAcceptor
import com.muwire.core.connection.ConnectionEstablisher
import com.muwire.core.connection.ConnectionEvent
@@ -105,6 +111,8 @@ public class Core {
final UploadManager uploadManager
final ContentManager contentManager
final CertificateManager certificateManager
final ChatServer chatServer
final ChatManager chatManager
private final Router router
@@ -278,8 +286,16 @@ public class Core {
CertificateClient certificateClient = new CertificateClient(eventBus, i2pConnector)
eventBus.register(UIFetchCertificatesEvent.class, certificateClient)
log.info("initializing chat server")
chatServer = new ChatServer(eventBus, props, trustService, me, spk)
eventBus.with {
register(ChatMessageEvent.class, chatServer)
register(ChatDisconnectionEvent.class, chatServer)
register(TrustEvent.class, chatServer)
}
log.info "initializing results sender"
ResultsSender resultsSender = new ResultsSender(eventBus, i2pConnector, me, props, certificateManager)
ResultsSender resultsSender = new ResultsSender(eventBus, i2pConnector, me, props, certificateManager, chatServer)
log.info "initializing search manager"
SearchManager searchManager = new SearchManager(eventBus, me, resultsSender)
@@ -302,11 +318,21 @@ public class Core {
log.info("initializing connection establisher")
connectionEstablisher = new ConnectionEstablisher(eventBus, i2pConnector, props, connectionManager, hostCache)
log.info("initializing chat manager")
chatManager = new ChatManager(eventBus, me, i2pConnector, trustService, props)
eventBus.with {
register(UIConnectChatEvent.class, chatManager)
register(UIDisconnectChatEvent.class, chatManager)
register(ChatMessageEvent.class, chatManager)
register(ChatDisconnectionEvent.class, chatManager)
}
log.info("initializing acceptor")
I2PAcceptor i2pAcceptor = new I2PAcceptor(socketManager)
connectionAcceptor = new ConnectionAcceptor(eventBus, connectionManager, props,
i2pAcceptor, hostCache, trustService, searchManager, uploadManager, fileManager, connectionEstablisher,
certificateManager)
certificateManager, chatServer)
log.info("initializing directory watcher")
directoryWatcher = new DirectoryWatcher(eventBus, fileManager, home, props)
@@ -358,9 +384,9 @@ public class Core {
saveMuSettings()
log.info("shutting down trust subscriber")
trustSubscriber.stop()
log.info("shutting down download manageer")
log.info("shutting down download manager")
downloadManager.shutdown()
log.info("shutting down connection acceeptor")
log.info("shutting down connection acceptor")
connectionAcceptor.stop()
log.info("shutting down connection establisher")
connectionEstablisher.stop()
@@ -368,6 +394,10 @@ public class Core {
directoryWatcher.stop()
log.info("shutting down cache client")
cacheClient.stop()
log.info("shutting down chat server")
chatServer.stop()
log.info("shutting down chat manager")
chatManager.shutdown()
log.info("shutting down connection manager")
connectionManager.shutdown()
if (router != null) {
@@ -406,7 +436,7 @@ public class Core {
}
}
Core core = new Core(props, home, "0.5.10")
Core core = new Core(props, home, "0.6.5")
core.startServices()
// ... at the end, sleep or execute script

View File

@@ -31,6 +31,9 @@ class MuWireSettings {
boolean shareHiddenFiles
boolean searchComments
boolean browseFiles
boolean startChatServer
int maxChatConnections
boolean advertiseChat
Set<String> watchedDirectories
float downloadSequentialRatio
int hostClearInterval, hostHopelessInterval, hostRejectInterval
@@ -79,7 +82,10 @@ class MuWireSettings {
speedSmoothSeconds = Integer.valueOf(props.getProperty("speedSmoothSeconds","60"))
totalUploadSlots = Integer.valueOf(props.getProperty("totalUploadSlots","-1"))
uploadSlotsPerUser = Integer.valueOf(props.getProperty("uploadSlotsPerUser","-1"))
startChatServer = Boolean.valueOf(props.getProperty("startChatServer","false"))
maxChatConnections = Integer.valueOf(props.get("maxChatConnections", "-1"))
advertiseChat = Boolean.valueOf(props.getProperty("advertiseChat","true"))
watchedDirectories = DataUtil.readEncodedSet(props, "watchedDirectories")
watchedKeywords = DataUtil.readEncodedSet(props, "watchedKeywords")
watchedRegexes = DataUtil.readEncodedSet(props, "watchedRegexes")
@@ -127,6 +133,9 @@ class MuWireSettings {
props.setProperty("speedSmoothSeconds", String.valueOf(speedSmoothSeconds))
props.setProperty("totalUploadSlots", String.valueOf(totalUploadSlots))
props.setProperty("uploadSlotsPerUser", String.valueOf(uploadSlotsPerUser))
props.setProperty("startChatServer", String.valueOf(startChatServer))
props.setProperty("maxChatConnectios", String.valueOf(maxChatConnections))
props.setProperty("advertiseChat", String.valueOf(advertiseChat))
DataUtil.writeEncodedSet(watchedDirectories, "watchedDirectories", props)
DataUtil.writeEncodedSet(watchedKeywords, "watchedKeywords", props)

View File

@@ -0,0 +1,20 @@
package com.muwire.core.chat;
enum ChatAction {
JOIN(true, false, true),
LEAVE(false, false, true),
SAY(false, false, true),
LIST(true, true, true),
HELP(true, true, true),
INFO(true, true, true),
JOINED(true, true, false);
final boolean console;
final boolean stateless;
final boolean user;
ChatAction(boolean console, boolean stateless, boolean user) {
this.console = console;
this.stateless = stateless;
this.user = user;
}
}

View File

@@ -0,0 +1,119 @@
package com.muwire.core.chat
import java.nio.charset.StandardCharsets
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import com.muwire.core.Constants
import com.muwire.core.EventBus
import com.muwire.core.MuWireSettings
import com.muwire.core.Persona
import com.muwire.core.connection.Endpoint
import com.muwire.core.connection.I2PConnector
import com.muwire.core.trust.TrustService
import com.muwire.core.util.DataUtil
import groovy.util.logging.Log
@Log
class ChatClient implements Closeable {
private static final long REJECTION_BACKOFF = 60 * 1000
private static final Executor CONNECTOR = Executors.newCachedThreadPool()
private final I2PConnector connector
private final EventBus eventBus
private final Persona host, me
private final TrustService trustService
private final MuWireSettings settings
private volatile ChatConnection connection
private volatile boolean connectInProgress
private volatile long lastRejectionTime
private volatile Thread connectThread
ChatClient(I2PConnector connector, EventBus eventBus, Persona host, Persona me, TrustService trustService,
MuWireSettings settings) {
this.connector = connector
this.eventBus = eventBus
this.host = host
this.me = me
this.trustService = trustService
this.settings = settings
}
void connectIfNeeded() {
if (connection != null || connectInProgress || (System.currentTimeMillis() - lastRejectionTime < REJECTION_BACKOFF))
return
CONNECTOR.execute({connect()})
}
private void connect() {
connectInProgress = true
connectThread = Thread.currentThread()
Endpoint endpoint = null
try {
eventBus.publish(new ChatConnectionEvent(status : ChatConnectionAttemptStatus.CONNECTING, persona : host))
endpoint = connector.connect(host.destination)
DataOutputStream dos = new DataOutputStream(endpoint.getOutputStream())
DataInputStream dis = new DataInputStream(endpoint.getInputStream())
dos.with {
write("IRC\r\n".getBytes(StandardCharsets.US_ASCII))
write("Version:${Constants.CHAT_VERSION}\r\n".getBytes(StandardCharsets.US_ASCII))
write("Persona:${me.toBase64()}\r\n".getBytes(StandardCharsets.US_ASCII))
write("\r\n".getBytes(StandardCharsets.US_ASCII))
flush()
}
String codeString = DataUtil.readTillRN(dis)
int code = Integer.parseInt(codeString.split(" ")[0])
if (code == 429) {
eventBus.publish(new ChatConnectionEvent(status : ChatConnectionAttemptStatus.REJECTED, persona : host))
endpoint.close()
lastRejectionTime = System.currentTimeMillis()
return
}
if (code != 200)
throw new Exception("unknown code $code")
Map<String,String> headers = DataUtil.readAllHeaders(dis)
if (!headers.containsKey('Version'))
throw new Exception("Version header missing")
int version = Integer.parseInt(headers['Version'])
if (version != Constants.CHAT_VERSION)
throw new Exception("Unknown chat version $version")
connection = new ChatConnection(eventBus, endpoint, host, false, trustService, settings)
connection.start()
eventBus.publish(new ChatConnectionEvent(status : ChatConnectionAttemptStatus.SUCCESSFUL, persona : host,
connection : connection))
} catch (Exception e) {
eventBus.publish(new ChatConnectionEvent(status : ChatConnectionAttemptStatus.FAILED, persona : host))
endpoint?.close()
} finally {
connectInProgress = false
connectThread = null
}
}
void disconnected() {
connectInProgress = false
connection = null
}
@Override
public void close() {
connectThread?.interrupt()
connection?.close()
eventBus.publish(new ChatConnectionEvent(status : ChatConnectionAttemptStatus.DISCONNECTED, persona : host))
}
void ping() {
connection?.sendPing()
}
}

View File

@@ -0,0 +1,28 @@
package com.muwire.core.chat
class ChatCommand {
private final ChatAction action
private final String payload
final String source
ChatCommand(String source) {
if (source.charAt(0) != '/')
throw new Exception("command doesn't start with / $source")
int position = 1
StringBuilder sb = new StringBuilder()
while(position < source.length()) {
char c = source.charAt(position)
if (c == ' ')
break
sb.append(c)
position++
}
String command = sb.toString().toUpperCase()
action = ChatAction.valueOf(command)
if (position < source.length())
payload = source.substring(position + 1)
else
payload = ""
this.source = source
}
}

View File

@@ -0,0 +1,278 @@
package com.muwire.core.chat
import java.nio.charset.StandardCharsets
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Level
import com.muwire.core.Constants
import com.muwire.core.EventBus
import com.muwire.core.MuWireSettings
import com.muwire.core.Persona
import com.muwire.core.connection.Endpoint
import com.muwire.core.trust.TrustLevel
import com.muwire.core.trust.TrustService
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.util.logging.Log
import net.i2p.crypto.DSAEngine
import net.i2p.data.Base64
import net.i2p.data.Signature
import net.i2p.data.SigningPrivateKey
@Log
class ChatConnection implements ChatLink {
private static final long PING_INTERVAL = 20000
private static final long MAX_CHAT_AGE = 5 * 60 * 1000
private final EventBus eventBus
private final Endpoint endpoint
private final Persona persona
private final boolean incoming
private final TrustService trustService
private final MuWireSettings settings
private final AtomicBoolean running = new AtomicBoolean()
private final BlockingQueue messages = new LinkedBlockingQueue()
private final Thread reader, writer
private final LinkedList<Long> timestamps = new LinkedList<>()
private final BlockingQueue incomingEvents = new LinkedBlockingQueue()
private final DataInputStream dis
private final DataOutputStream dos
private final JsonSlurper slurper = new JsonSlurper()
private volatile long lastPingSentTime
ChatConnection(EventBus eventBus, Endpoint endpoint, Persona persona, boolean incoming,
TrustService trustService, MuWireSettings settings) {
this.eventBus = eventBus
this.endpoint = endpoint
this.persona = persona
this.incoming = incoming
this.trustService = trustService
this.settings = settings
this.dis = new DataInputStream(endpoint.getInputStream())
this.dos = new DataOutputStream(endpoint.getOutputStream())
this.reader = new Thread({readLoop()} as Runnable)
this.reader.setName("reader-${persona.getHumanReadableName()}")
this.reader.setDaemon(true)
this.writer = new Thread({writeLoop()} as Runnable)
this.writer.setName("writer-${persona.getHumanReadableName()}")
this.writer.setDaemon(true)
}
void start() {
if (!running.compareAndSet(false, true)) {
log.log(Level.WARNING,"${persona.getHumanReadableName()} already running", new Exception())
return
}
reader.start()
writer.start()
}
@Override
public boolean isUp() {
running.get()
}
@Override
public Persona getPersona() {
persona
}
@Override
public void close() {
if (!running.compareAndSet(true, false)) {
log.log(Level.WARNING,"${persona.getHumanReadableName()} already closed", new Exception())
return
}
log.info("Closing "+persona.getHumanReadableName())
reader.interrupt()
writer.interrupt()
endpoint.close()
eventBus.publish(new ChatDisconnectionEvent(persona : persona))
}
private void readLoop() {
try {
while(running.get())
read()
} catch( InterruptedException | SocketTimeoutException ignored) {
} catch (Exception e) {
log.log(Level.WARNING,"unhandled exception in reader", e)
} finally {
close()
}
}
private void writeLoop() {
try {
while(running.get()) {
def message = messages.take()
write(message)
}
} catch (InterruptedException ignore) {
} catch (Exception e) {
log.log(Level.WARNING,"unhandled exception in writer",e)
} finally {
close()
}
}
private void read() {
int length = dis.readUnsignedShort()
byte [] payload = new byte[length]
dis.readFully(payload)
def json = slurper.parse(payload)
if (json.type == null)
throw new Exception("missing json type")
switch(json.type) {
case "Ping" : break // just ignore
case "Chat" : handleChat(json); break
case "Leave": handleLeave(json); break
default :
throw new Exception("unknown json type ${json.type}")
}
}
private void write(Object message) {
byte [] payload = JsonOutput.toJson(message).bytes
dos.with {
writeShort(payload.length)
write(payload)
flush()
}
}
void sendPing() {
long now = System.currentTimeMillis()
if (now - lastPingSentTime < PING_INTERVAL)
return
def ping = [:]
ping.type = "Ping"
ping.version = 1
messages.put(ping)
lastPingSentTime = now
}
private void handleChat(def json) {
UUID uuid = UUID.fromString(json.uuid)
Persona host = fromString(json.host)
Persona sender = fromString(json.sender)
long chatTime = json.chatTime
String room = json.room
String payload = json.payload
byte [] sig = Base64.decode(json.sig)
if (!verify(uuid,host,sender,chatTime,room,payload,sig)) {
log.warning("chat didn't verify")
return
}
if (incoming) {
if (sender.destination != endpoint.destination) {
log.warning("Sender destination mismatch, dropping message")
return
}
} else {
if (host.destination != endpoint.destination) {
log.warning("Host destination mismatch, dropping message")
return
}
}
if (System.currentTimeMillis() - chatTime > MAX_CHAT_AGE) {
log.warning("Chat too old, dropping")
return
}
switch(trustService.getLevel(sender.destination)) {
case TrustLevel.TRUSTED : break
case TrustLevel.NEUTRAL :
if (!settings.allowUntrusted)
return
else
break
case TrustLevel.DISTRUSTED :
return
}
def event = new ChatMessageEvent( uuid : uuid, payload : payload, sender : sender,
host : host, room : room, chatTime : chatTime, sig : sig)
eventBus.publish(event)
if (!incoming)
incomingEvents.put(event)
}
private void handleLeave(def json) {
Persona leaver = fromString(json.persona)
eventBus.publish(new UserDisconnectedEvent(user : leaver, host : persona))
incomingEvents.put(leaver)
}
private static Persona fromString(String base64) {
new Persona(new ByteArrayInputStream(Base64.decode(base64)))
}
private static boolean verify(UUID uuid, Persona host, Persona sender, long chatTime,
String room, String payload, byte []sig) {
ByteArrayOutputStream baos = new ByteArrayOutputStream()
DataOutputStream daos = new DataOutputStream(baos)
daos.write(uuid.toString().bytes)
host.write(daos)
sender.write(daos)
daos.writeLong(chatTime)
daos.write(room.getBytes(StandardCharsets.UTF_8))
daos.write(payload.getBytes(StandardCharsets.UTF_8))
daos.close()
byte [] signed = baos.toByteArray()
def spk = sender.destination.getSigningPublicKey()
def signature = new Signature(Constants.SIG_TYPE, sig)
DSAEngine.getInstance().verifySignature(signature, signed, spk)
}
public static byte[] sign(UUID uuid, long chatTime, String room, String words, Persona sender, Persona host, SigningPrivateKey spk) {
ByteArrayOutputStream baos = new ByteArrayOutputStream()
DataOutputStream daos = new DataOutputStream(baos)
daos.with {
write(uuid.toString().bytes)
host.write(daos)
sender.write(daos)
writeLong(chatTime)
write(room.getBytes(StandardCharsets.UTF_8))
write(words.getBytes(StandardCharsets.UTF_8))
close()
}
byte [] payload = baos.toByteArray()
Signature sig = DSAEngine.getInstance().sign(payload, spk)
sig.getData()
}
void sendChat(ChatMessageEvent e) {
def chat = [:]
chat.type = "Chat"
chat.uuid = e.uuid.toString()
chat.host = e.host.toBase64()
chat.sender = e.sender.toBase64()
chat.chatTime = e.chatTime
chat.room = e.room
chat.payload = e.payload
chat.sig = Base64.encode(e.sig)
messages.put(chat)
}
void sendLeave(Persona p) {
def leave = [:]
leave.type = "Leave"
leave.persona = p.toBase64()
messages.put(leave)
}
public Object nextEvent() {
incomingEvents.take()
}
}

View File

@@ -0,0 +1,5 @@
package com.muwire.core.chat;
public enum ChatConnectionAttemptStatus {
CONNECTING, SUCCESSFUL, REJECTED, FAILED, DISCONNECTED
}

View File

@@ -0,0 +1,10 @@
package com.muwire.core.chat
import com.muwire.core.Event
import com.muwire.core.Persona
class ChatConnectionEvent extends Event {
ChatConnectionAttemptStatus status
Persona persona
ChatLink connection
}

View File

@@ -0,0 +1,8 @@
package com.muwire.core.chat
import com.muwire.core.Event
import com.muwire.core.Persona
class ChatDisconnectionEvent extends Event {
Persona persona
}

View File

@@ -0,0 +1,14 @@
package com.muwire.core.chat;
import java.io.Closeable;
import com.muwire.core.Persona;
public interface ChatLink extends Closeable {
public Persona getPersona();
public boolean isUp();
public void sendChat(ChatMessageEvent e);
public void sendLeave(Persona p);
public void sendPing();
public Object nextEvent() throws InterruptedException;
}

View File

@@ -0,0 +1,73 @@
package com.muwire.core.chat
import java.util.concurrent.ConcurrentHashMap
import com.muwire.core.EventBus
import com.muwire.core.MuWireSettings
import com.muwire.core.Persona
import com.muwire.core.connection.I2PConnector
import com.muwire.core.trust.TrustService
class ChatManager {
private final EventBus eventBus
private final Persona me
private final I2PConnector connector
private final TrustService trustService
private final MuWireSettings settings
private final Map<Persona, ChatClient> clients = new ConcurrentHashMap<>()
ChatManager(EventBus eventBus, Persona me, I2PConnector connector, TrustService trustService,
MuWireSettings settings) {
this.eventBus = eventBus
this.me = me
this.connector = connector
this.trustService = trustService
this.settings = settings
Timer timer = new Timer("chat-connector", true)
timer.schedule({connect()} as TimerTask, 1000, 1000)
}
void onUIConnectChatEvent(UIConnectChatEvent e) {
if (e.host == me) {
eventBus.publish(new ChatConnectionEvent(status : ChatConnectionAttemptStatus.SUCCESSFUL,
persona : me, connection : LocalChatLink.INSTANCE))
} else {
ChatClient client = new ChatClient(connector, eventBus, e.host, me, trustService, settings)
clients.put(e.host, client)
}
}
void onUIDisconnectChatEvent(UIDisconnectChatEvent e) {
if (e.host == me)
return
ChatClient client = clients.remove(e.host)
client?.close()
}
void onChatMessageEvent(ChatMessageEvent e) {
if (e.host == me)
return
if (e.sender != me)
return
clients[e.host]?.connection?.sendChat(e)
}
void onChatDisconnectionEvent(ChatDisconnectionEvent e) {
clients[e.persona]?.disconnected()
}
private void connect() {
clients.each { k, v ->
v.connectIfNeeded()
v.ping()
}
}
void shutdown() {
clients.each { k, v ->
v.close()
}
}
}

View File

@@ -0,0 +1,13 @@
package com.muwire.core.chat
import com.muwire.core.Event
import com.muwire.core.Persona
class ChatMessageEvent extends Event {
UUID uuid
String payload
Persona sender, host
String room
long chatTime
byte [] sig
}

View File

@@ -0,0 +1,302 @@
package com.muwire.core.chat
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Level
import java.util.stream.Collectors
import com.muwire.core.Constants
import com.muwire.core.EventBus
import com.muwire.core.MuWireSettings
import com.muwire.core.Persona
import com.muwire.core.connection.Endpoint
import com.muwire.core.trust.TrustEvent
import com.muwire.core.trust.TrustLevel
import com.muwire.core.trust.TrustService
import com.muwire.core.util.DataUtil
import groovy.util.logging.Log
import net.i2p.data.Base64
import net.i2p.data.Destination
import net.i2p.data.SigningPrivateKey
import net.i2p.util.ConcurrentHashSet
@Log
class ChatServer {
public static final String CONSOLE = "__CONSOLE__"
private final EventBus eventBus
private final MuWireSettings settings
private final TrustService trustService
private final Persona me
private final SigningPrivateKey spk
private final Map<Destination, ChatLink> connections = new ConcurrentHashMap()
private final Map<String, Set<Persona>> rooms = new ConcurrentHashMap<>()
private final Map<Persona, Set<String>> memberships = new ConcurrentHashMap<>()
private final AtomicBoolean running = new AtomicBoolean()
ChatServer(EventBus eventBus, MuWireSettings settings, TrustService trustService, Persona me, SigningPrivateKey spk) {
this.eventBus = eventBus
this.settings = settings
this.trustService = trustService
this.me = me
this.spk = spk
Timer timer = new Timer("chat-server-pinger", true)
timer.schedule({sendPings()} as TimerTask, 1000, 1000)
}
public void start() {
running.set(true)
connections.put(me.destination, LocalChatLink.INSTANCE)
joinRoom(me, CONSOLE)
echo("/SAY Welcome to my chat server! Type /HELP for list of available commands.",me.destination)
}
private void sendPings() {
connections.each { k,v ->
v.sendPing()
}
}
public void handle(Endpoint endpoint) {
InputStream is = endpoint.getInputStream()
OutputStream os = endpoint.getOutputStream()
Map<String, String> headers = DataUtil.readAllHeaders(is)
if (!headers.containsKey("Version"))
throw new Exception("Version header missing")
int version = Integer.parseInt(headers['Version'])
if (version != Constants.CHAT_VERSION)
throw new Exception("Unknown chat version $version")
if (!headers.containsKey('Persona'))
throw new Exception("Persona header missing")
Persona client = new Persona(new ByteArrayInputStream(Base64.decode(headers['Persona'])))
if (client.destination != endpoint.destination)
throw new Exception("Client destination mismatch")
if (!running.get()) {
os.write("400 Chat Not Enabled\r\n\r\n".getBytes(StandardCharsets.US_ASCII))
os.close()
endpoint.close()
return
}
if (connections.containsKey(client.destination) || connections.size() == settings.maxChatConnections) {
os.write("429 Rejected\r\n\r\n".getBytes(StandardCharsets.US_ASCII))
os.close()
endpoint.close()
return
}
os.with {
write("200 OK\r\n".getBytes(StandardCharsets.US_ASCII))
write("Version:${Constants.CHAT_VERSION}\r\n".getBytes(StandardCharsets.US_ASCII))
write("\r\n".getBytes(StandardCharsets.US_ASCII))
flush()
}
ChatConnection connection = new ChatConnection(eventBus, endpoint, client, true, trustService, settings)
connections.put(endpoint.destination, connection)
joinRoom(client, CONSOLE)
connection.start()
echo("/SAY Welcome to my chat server! Type /HELP for help on available commands",connection.endpoint.destination)
}
void onChatDisconnectionEvent(ChatDisconnectionEvent e) {
ChatConnection con = connections.remove(e.persona.destination)
if (con == null)
return
Set<String> rooms = memberships.get(e.persona)
if (rooms != null) {
rooms.each {
leaveRoom(e.persona, it)
}
}
connections.each { k, v ->
v.sendLeave(e.persona)
}
}
void onTrustEvent(TrustEvent e) {
if (e.level == TrustLevel.TRUSTED)
return
if (settings.allowUntrusted && e.level == TrustLevel.NEUTRAL)
return
ChatConnection connection = connections.remove(e.persona.destination)
connection?.close()
}
private void joinRoom(Persona p, String room) {
Set<Persona> existing = rooms.get(room)
if (existing == null) {
existing = new ConcurrentHashSet<>()
rooms.put(room, existing)
}
existing.add(p)
Set<String> membership = memberships.get(p)
if (membership == null) {
membership = new ConcurrentHashSet<>()
memberships.put(p, membership)
}
membership.add(room)
}
private void leaveRoom(Persona p, String room) {
Set<Persona> existing = rooms.get(room)
if (existing == null) {
log.warning(p.getHumanReadableName() + " leaving room they hadn't joined")
return
}
existing.remove(p)
if (existing.isEmpty())
rooms.remove(room)
Set<String> membership = memberships.get(p)
if (membership == null) {
log.warning(p.getHumanReadableName() + " didn't have any memberships")
return
}
membership.remove(room)
if (membership.isEmpty())
memberships.remove(p)
}
void onChatMessageEvent(ChatMessageEvent e) {
if (e.host != me)
return
ChatCommand command
try {
command = new ChatCommand(e.payload)
} catch (Exception badCommand) {
log.log(Level.WARNING, "bad chat command",badCommand)
return
}
if ((command.action.console && e.room != CONSOLE) ||
(!command.action.console && e.room == CONSOLE) ||
!command.action.user)
return
switch(command.action) {
case ChatAction.JOIN : processJoin(command.payload, e); break
case ChatAction.LEAVE : processLeave(e); break
case ChatAction.SAY : processSay(e); break
case ChatAction.LIST : processList(e.sender.destination); break
case ChatAction.INFO : processInfo(e.sender.destination); break
case ChatAction.HELP : processHelp(e.sender.destination); break
}
}
private void processJoin(String room, ChatMessageEvent e) {
joinRoom(e.sender, room)
rooms[room].each {
if (it == e.sender)
return
connections[it.destination].sendChat(e)
}
String payload = rooms[room].stream().filter({it != e.sender}).map({it.toBase64()})
.collect(Collectors.joining(","))
if (payload.length() == 0) {
return
}
payload = "/JOINED $payload"
long now = System.currentTimeMillis()
UUID uuid = UUID.randomUUID()
byte [] sig = ChatConnection.sign(uuid, now, room, payload, me, me, spk)
ChatMessageEvent echo = new ChatMessageEvent(
uuid : uuid,
payload : payload,
sender : me,
host : me,
room : room,
chatTime : now,
sig : sig
)
connections[e.sender.destination].sendChat(echo)
}
private void processLeave(ChatMessageEvent e) {
leaveRoom(e.sender, e.room)
rooms.getOrDefault(e.room, []).each {
if (it == e.sender)
return
connections[it.destination].sendChat(e)
}
}
private void processSay(ChatMessageEvent e) {
if (rooms.containsKey(e.room)) {
// not a private message
rooms[e.room].each {
if (it == e.sender)
return
connections[it.destination].sendChat(e)
}
} else {
Persona target = new Persona(new ByteArrayInputStream(Base64.decode(e.room)))
connections[target.destination]?.sendChat(e)
}
}
private void processList(Destination d) {
String roomList = rooms.keySet().stream().filter({it != CONSOLE}).collect(Collectors.joining("\n"))
roomList = "/SAY \nRoom List:\n"+roomList
echo(roomList, d)
}
private void processInfo(Destination d) {
String info = "/SAY \nThe address of this server is\n========\n${me.toBase64()}\n========\nCopy/paste the above and share it\n"
String connectedUsers = memberships.keySet().stream().map({it.getHumanReadableName()}).collect(Collectors.joining("\n"))
info = "${info}\nConnected Users:\n$connectedUsers\n======="
echo(info, d)
}
private void processHelp(Destination d) {
String help = """/SAY
Available commands: /JOIN /LEAVE /SAY /LIST /INFO /HELP
/JOIN <room name> - joins a room, or creates one if it does not exist. You must type this in the console
/LEAVE - leaves a room. You must type this in the room you want to leave
/SAY - optional, says something in the room you're in
/LIST - lists the existing rooms on this server. You must type this in the console
/INFO - shows information about this server. You must type this in the console
/HELP - prints this help message
"""
echo(help, d)
}
private void echo(String payload, Destination d) {
log.info "echoing $payload"
UUID uuid = UUID.randomUUID()
long now = System.currentTimeMillis()
byte [] sig = ChatConnection.sign(uuid, now, CONSOLE, payload, me, me, spk)
ChatMessageEvent echo = new ChatMessageEvent(
uuid : uuid,
payload : payload,
sender : me,
host : me,
room : CONSOLE,
chatTime : now,
sig : sig
)
connections[d]?.sendChat(echo)
}
void stop() {
if (running.compareAndSet(true, false)) {
connections.each { k, v ->
v.close()
}
}
}
}

View File

@@ -0,0 +1,49 @@
package com.muwire.core.chat
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import com.muwire.core.Persona
import groovy.util.logging.Log
@Log
class LocalChatLink implements ChatLink {
public static final LocalChatLink INSTANCE = new LocalChatLink()
private final BlockingQueue messages = new LinkedBlockingQueue()
private LocalChatLink() {}
@Override
public void close() throws IOException {
}
@Override
public void sendChat(ChatMessageEvent e) {
messages.put(e)
}
@Override
public void sendLeave(Persona p) {
messages.put(p)
}
@Override
public void sendPing() {}
@Override
public Object nextEvent() {
messages.take()
}
@Override
public boolean isUp() {
true
}
public Persona getPersona() {
null
}
}

View File

@@ -0,0 +1,8 @@
package com.muwire.core.chat
import com.muwire.core.Event
import com.muwire.core.Persona
class UIConnectChatEvent extends Event {
Persona host
}

View File

@@ -0,0 +1,8 @@
package com.muwire.core.chat
import com.muwire.core.Event
import com.muwire.core.Persona
class UIDisconnectChatEvent extends Event {
Persona host
}

View File

@@ -0,0 +1,9 @@
package com.muwire.core.chat
import com.muwire.core.Event
import com.muwire.core.Persona
class UserDisconnectedEvent extends Event {
Persona user
Persona host
}

View File

@@ -153,6 +153,10 @@ abstract class Connection implements Closeable {
query.originator = e.originator.toBase64()
if (e.sig != null)
query.sig = Base64.encode(e.sig)
if (e.queryTime > 0)
query.queryTime = e.queryTime
if (e.sig2 != null)
query.sig2 = Base64.encode(e.sig2)
messages.put(query)
}
@@ -232,7 +236,6 @@ abstract class Connection implements Closeable {
if (search.compressedResults != null)
compressedResults = search.compressedResults
byte[] sig = null
// TODO: make this mandatory at some point
if (search.sig != null) {
sig = Base64.decode(search.sig)
byte [] payload
@@ -247,8 +250,36 @@ abstract class Connection implements Closeable {
return
} else
log.info("query signature verified")
} else
} else {
log.info("no signature in query")
return
}
// TODO: make this mandatory at some point
byte[] sig2 = null
long queryTime = 0
if (search.sig2 != null) {
if (search.queryTime == null) {
log.info("extended signature but no timestamp")
return
}
sig2 = Base64.decode(search.sig2)
queryTime = search.queryTime
byte [] payload = (search.uuid + String.valueOf(queryTime)).getBytes(StandardCharsets.US_ASCII)
def spk = originator.destination.getSigningPublicKey()
def signature = new Signature(Constants.SIG_TYPE, sig2)
if (!DSAEngine.getInstance().verifySignature(signature, payload, spk)) {
log.info("extended signature didn't match uuid and timestamp")
return
} else {
log.info("extended query signature verified")
if (queryTime < System.currentTimeMillis() - Constants.MAX_QUERY_AGE) {
log.info("query too old")
return
}
}
} else
log.info("no extended signature in query")
SearchEvent searchEvent = new SearchEvent(searchTerms : search.keywords,
searchHash : infohash,
@@ -262,7 +293,9 @@ abstract class Connection implements Closeable {
originator : originator,
receivedOn : endpoint.destination,
firstHop : search.firstHop,
sig : sig )
sig : sig,
queryTime : queryTime,
sig2 : sig2 )
eventBus.publish(event)
}

View File

@@ -15,6 +15,7 @@ import com.muwire.core.EventBus
import com.muwire.core.InfoHash
import com.muwire.core.MuWireSettings
import com.muwire.core.Persona
import com.muwire.core.chat.ChatServer
import com.muwire.core.filecert.Certificate
import com.muwire.core.filecert.CertificateManager
import com.muwire.core.files.FileManager
@@ -50,6 +51,7 @@ class ConnectionAcceptor {
final FileManager fileManager
final ConnectionEstablisher establisher
final CertificateManager certificateManager
final ChatServer chatServer
final ExecutorService acceptorThread
final ExecutorService handshakerThreads
@@ -61,7 +63,8 @@ class ConnectionAcceptor {
ConnectionAcceptor(EventBus eventBus, UltrapeerConnectionManager manager,
MuWireSettings settings, I2PAcceptor acceptor, HostCache hostCache,
TrustService trustService, SearchManager searchManager, UploadManager uploadManager,
FileManager fileManager, ConnectionEstablisher establisher, CertificateManager certificateManager) {
FileManager fileManager, ConnectionEstablisher establisher, CertificateManager certificateManager,
ChatServer chatServer) {
this.eventBus = eventBus
this.manager = manager
this.settings = settings
@@ -73,6 +76,7 @@ class ConnectionAcceptor {
this.uploadManager = uploadManager
this.establisher = establisher
this.certificateManager = certificateManager
this.chatServer = chatServer
acceptorThread = Executors.newSingleThreadExecutor { r ->
def rv = new Thread(r)
@@ -154,12 +158,17 @@ class ConnectionAcceptor {
case (byte)'C':
processCERTIFICATES(e)
break
case (byte)'I':
processIRC(e)
break
default:
throw new Exception("Invalid read $read")
}
} catch (Exception ex) {
log.log(Level.WARNING, "incoming connection failed",ex)
e.getOutputStream().close()
try {
e.getOutputStream().close()
} catch (Exception ignore) {}
e.close()
eventBus.publish new ConnectionEvent(endpoint: e, incoming: true, leaf: null, status: ConnectionAttemptStatus.FAILED)
}
@@ -208,7 +217,9 @@ class ConnectionAcceptor {
os.writeShort(json.bytes.length)
os.write(json.bytes)
}
e.outputStream.close()
try {
e.outputStream.close()
} catch (Exception ignored) {}
e.close()
eventBus.publish(new ConnectionEvent(endpoint: e, incoming: true, leaf: leaf, status: ConnectionAttemptStatus.REJECTED))
}
@@ -295,6 +306,10 @@ class ConnectionAcceptor {
throw new IOException("No Sender header")
if (!headers.containsKey("Count"))
throw new IOException("No Count header")
boolean chat = false
if (headers.containsKey('Chat'))
chat = Boolean.parseBoolean(headers['Chat'])
byte [] personaBytes = Base64.decode(headers['Sender'])
Persona sender = new Persona(new ByteArrayInputStream(personaBytes))
@@ -313,6 +328,7 @@ class ConnectionAcceptor {
dis.readFully(payload)
def json = slurper.parse(payload)
results[i] = ResultsParser.parse(sender, resultsUUID, json)
results[i].chat = chat
}
eventBus.publish(new UIResultBatchEvent(uuid: resultsUUID, results: results))
} catch (IOException bad) {
@@ -494,5 +510,14 @@ class ConnectionAcceptor {
e.close()
}
}
private void processIRC(Endpoint e) {
byte[] IRC = new byte[4]
DataInputStream dis = new DataInputStream(e.getInputStream())
dis.readFully(IRC)
if (IRC != "RC\r\n".getBytes(StandardCharsets.US_ASCII))
throw new Exception("Invalid IRC connection")
chatServer.handle(e)
}
}

View File

@@ -276,6 +276,57 @@ public class Downloader {
activeWorkers.put(d, newWorker)
executorService.submit(newWorker)
}
boolean isSequential() {
pieces.ratio == 0f
}
File generatePreview() {
int lastCompletePiece = pieces.firstIncomplete() - 1
if (lastCompletePiece == -1)
return null
if (lastCompletePiece < -1)
return file
long previewableLength = (lastCompletePiece + 1) * ((long)pieceSize)
// generate name
long now = System.currentTimeMillis()
File previewFile
File parentFile = file.getParentFile()
int lastDot = file.getName().lastIndexOf('.')
if (lastDot < 0)
previewFile = new File(parentFile, file.getName() + "." + String.valueOf(now) + ".mwpreview")
else {
String name = file.getName().substring(0, lastDot)
String extension = file.getName().substring(lastDot + 1)
String previewName = name + "." + String.valueOf(now) + ".mwpreview."+extension
previewFile = new File(parentFile, previewName)
}
// copy
InputStream is = null
OutputStream os = null
try {
is = new BufferedInputStream(new FileInputStream(incompleteFile))
os = new BufferedOutputStream(new FileOutputStream(previewFile))
byte [] tmp = new byte[0x1 << 13]
long totalCopied = 0
while(totalCopied < previewableLength) {
int read = is.read(tmp, 0, (int)Math.min(tmp.length, previewableLength - totalCopied))
if (read < 0)
throw new IOException("EOF?")
os.write(tmp, 0, read)
totalCopied += read
}
return previewFile
} catch (IOException bad) {
log.log(Level.WARNING,"Preview failed",bad)
return null
} finally {
try {is?.close() } catch (IOException ignore) {}
try {os?.close() } catch (IOException ignore) {}
}
}
class DownloadWorker implements Runnable {
private final Destination destination

View File

@@ -108,6 +108,10 @@ class Pieces {
partials.clear()
}
synchronized int firstIncomplete() {
done.nextClearBit(0)
}
synchronized void write(PrintWriter writer) {
for (int i = done.nextSetBit(0); i >= 0; i = done.nextSetBit(i+1)) {
writer.println(i)

View File

@@ -143,6 +143,7 @@ class FileManager {
String comment = sf.getComment()
if (comment != null) {
comment = DataUtil.readi18nString(Base64.decode(comment))
Set<File> existingComment = commentToFile.get(comment)
if (existingComment != null) {
existingComment.remove(sf.getFile())
@@ -229,7 +230,7 @@ class FileManager {
return files
Set<SharedFile> rv = new HashSet<>()
files.each {
if (it.getPieceSize() != 0)
if (it != null && it.getPieceSize() != 0)
rv.add(it)
}
rv

View File

@@ -7,7 +7,7 @@ class FileTree {
private final TreeNode root = new TreeNode()
private final Map<File, TreeNode> fileToNode = new ConcurrentHashMap<>()
void add(File file) {
synchronized void add(File file) {
List<File> path = new ArrayList<>()
path.add(file)
while (file.getParentFile() != null) {
@@ -31,7 +31,7 @@ class FileTree {
}
}
boolean remove(File file) {
synchronized boolean remove(File file) {
TreeNode node = fileToNode.remove(file)
if (node == null) {
return false

View File

@@ -13,6 +13,8 @@ class QueryEvent extends Event {
Persona originator
Destination receivedOn
byte[] sig
long queryTime
byte[] sig2
String toString() {
"searchEvent: $searchEvent firstHop:$firstHop, replyTo:${replyTo.toBase32()}" +

View File

@@ -1,6 +1,7 @@
package com.muwire.core.search
import com.muwire.core.SharedFile
import com.muwire.core.chat.ChatServer
import com.muwire.core.connection.Endpoint
import com.muwire.core.connection.I2PConnector
import com.muwire.core.filecert.CertificateManager
@@ -48,13 +49,16 @@ class ResultsSender {
private final EventBus eventBus
private final MuWireSettings settings
private final CertificateManager certificateManager
private final ChatServer chatServer
ResultsSender(EventBus eventBus, I2PConnector connector, Persona me, MuWireSettings settings, CertificateManager certificateManager) {
ResultsSender(EventBus eventBus, I2PConnector connector, Persona me, MuWireSettings settings,
CertificateManager certificateManager, ChatServer chatServer) {
this.connector = connector;
this.eventBus = eventBus
this.me = me
this.settings = settings
this.certificateManager = certificateManager
this.chatServer = chatServer
}
void sendResults(UUID uuid, SharedFile[] results, Destination target, boolean oobInfohash, boolean compressedResults) {
@@ -80,9 +84,11 @@ class ResultsSender {
infohash : it.getInfoHash(),
pieceSize : pieceSize,
uuid : uuid,
browse : settings.browseFiles,
sources : suggested,
comment : comment,
certificates : certificates
certificates : certificates,
chat : chatServer.running.get() && settings.advertiseChat
)
uiResultEvents << uiResultEvent
}
@@ -130,6 +136,8 @@ class ResultsSender {
os.write("RESULTS $uuid\r\n".getBytes(StandardCharsets.US_ASCII))
os.write("Sender: ${me.toBase64()}\r\n".getBytes(StandardCharsets.US_ASCII))
os.write("Count: $results.length\r\n".getBytes(StandardCharsets.US_ASCII))
boolean chat = chatServer.running.get() && settings.advertiseChat
os.write("Chat: $chat\r\n".getBytes(StandardCharsets.US_ASCII))
os.write("\r\n".getBytes(StandardCharsets.US_ASCII))
DataOutputStream dos = new DataOutputStream(new GZIPOutputStream(os))
results.each {

View File

@@ -39,10 +39,11 @@ class SearchIndex {
split.each { if (it.length() > 0) rv << it }
// then just by ' '
source.split(' ').each { if (it.length() > 0) rv << it }
source.toLowerCase().split(' ').each { if (it.length() > 0) rv << it }
// and add original string
rv << source
rv << source.toLowerCase()
rv.toArray(new String[0])
}

View File

@@ -17,6 +17,7 @@ class UIResultEvent extends Event {
String comment
boolean browse
int certificates
boolean chat
@Override
public String toString() {

View File

@@ -13,6 +13,7 @@ import com.muwire.core.files.FileSharedEvent
import com.muwire.core.search.QueryEvent
import com.muwire.core.search.SearchEvent
import com.muwire.core.search.UIResultBatchEvent
import com.muwire.core.util.DataUtil
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
@@ -176,9 +177,12 @@ class UpdateClient {
signer = payload.signer
log.info("starting search for new version hash $payload.infoHash")
Signature sig = DSAEngine.getInstance().sign(updateInfoHash.getRoot(), spk)
def searchEvent = new SearchEvent(searchHash : updateInfoHash.getRoot(), uuid : UUID.randomUUID(), oobInfohash : true, persona : me)
UUID uuid = UUID.randomUUID()
long timestamp = System.currentTimeMillis()
byte [] sig2 = DataUtil.signUUID(uuid, timestamp, spk)
def searchEvent = new SearchEvent(searchHash : updateInfoHash.getRoot(), uuid : uuid, oobInfohash : true, persona : me)
def queryEvent = new QueryEvent(searchEvent : searchEvent, firstHop : true, replyTo : me.destination,
receivedOn : me.destination, originator : me, sig : sig.data)
receivedOn : me.destination, originator : me, sig : sig.data, queryTime : timestamp, sig2 : sig2)
eventBus.publish(queryEvent)
}
}

View File

@@ -5,6 +5,8 @@ import net.i2p.crypto.SigType;
public class Constants {
public static final byte PERSONA_VERSION = (byte)1;
public static final byte FILE_CERT_VERSION = (byte)2;
public static final int CHAT_VERSION = 1;
public static final SigType SIG_TYPE = SigType.EdDSA_SHA512_Ed25519;
public static final int MAX_HEADER_SIZE = 0x1 << 14;
@@ -13,4 +15,6 @@ public class Constants {
public static final int MAX_RESULTS = 0x1 << 16;
public static final int MAX_COMMENT_LENGTH = 0x1 << 15;
public static final long MAX_QUERY_AGE = 5 * 60 * 1000L;
}

View File

@@ -15,11 +15,15 @@ import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import com.muwire.core.Constants;
import net.i2p.crypto.DSAEngine;
import net.i2p.data.Base64;
import net.i2p.data.Signature;
import net.i2p.data.SigningPrivateKey;
import net.i2p.util.ConcurrentHashSet;
public class DataUtil {
@@ -203,4 +207,10 @@ public class DataUtil {
.collect(Collectors.joining(","));
props.setProperty(property, encoded);
}
public static byte[] signUUID(UUID uuid, long timestamp, SigningPrivateKey spk) {
byte [] payload = (uuid.toString() + String.valueOf(timestamp)).getBytes(StandardCharsets.US_ASCII);
Signature sig = DSAEngine.getInstance().sign(payload, spk);
return sig.getData();
}
}

View File

@@ -1,5 +1,7 @@
package com.muwire.core.files
import static org.junit.jupiter.api.Assertions.assertAll
import org.junit.Before
import org.junit.Test
@@ -9,6 +11,9 @@ import com.muwire.core.MuWireSettings
import com.muwire.core.SharedFile
import com.muwire.core.search.ResultsEvent
import com.muwire.core.search.SearchEvent
import com.muwire.core.util.DataUtil
import net.i2p.data.Base64
class FileManagerTest {
@@ -185,4 +190,39 @@ class FileManagerTest {
assert results == null
}
@Test
void testComplicatedScenario() {
// this tries to reproduce an NPE when un-sharing then sharing again and searching
String comment = "same comment"
comment = Base64.encode(DataUtil.encodei18nString(comment))
File f1 = new File("MuWire-0.5.10.AppImage")
InfoHash ih1 = InfoHash.fromHashList(new byte[32])
SharedFile sf1 = new SharedFile(f1, ih1, 0)
sf1.setComment(comment)
manager.onFileLoadedEvent(new FileLoadedEvent(loadedFile : sf1))
manager.onFileUnsharedEvent(new FileUnsharedEvent(unsharedFile : sf1, deleted : true))
File f2 = new File("MuWire-0.6.0.AppImage")
InfoHash ih2 = InfoHash.fromHashList(new byte[64])
SharedFile sf2 = new SharedFile(f2, ih2, 0)
sf2.setComment(comment)
manager.onFileLoadedEvent(new FileLoadedEvent(loadedFile : sf2))
manager.onSearchEvent(new SearchEvent(searchTerms : ["muwire"]))
Thread.sleep(20)
assert results != null
assert results.results.size() == 1
assert results.results.contains(sf2)
results = null
manager.onSearchEvent(new SearchEvent(searchTerms : ['comment'], searchComments : true, oobInfohash : true))
Thread.sleep(20)
assert results != null
assert results.results.size() == 1
assert results.results.contains(sf2)
}
}

View File

@@ -8,6 +8,7 @@ import com.muwire.core.Destinations
import com.muwire.core.Persona
import com.muwire.core.Personas
import groovy.json.JsonSlurper
import net.i2p.data.Base64
import net.i2p.data.Destination
@@ -55,13 +56,16 @@ class TrustServiceTest {
service.onTrustEvent new TrustEvent(level: TrustLevel.DISTRUSTED, persona: personas.persona2)
Thread.sleep(250)
JsonSlurper slurper = new JsonSlurper()
def trusted = new HashSet<>()
persistGood.eachLine {
trusted.add(new Persona(new ByteArrayInputStream(Base64.decode(it))))
def json = slurper.parseText(it)
trusted.add(new Persona(new ByteArrayInputStream(Base64.decode(json.persona))))
}
def distrusted = new HashSet<>()
persistBad.eachLine {
distrusted.add(new Persona(new ByteArrayInputStream(Base64.decode(it))))
def json = slurper.parseText(it)
distrusted.add(new Persona(new ByteArrayInputStream(Base64.decode(json.persona))))
}
assert trusted.size() == 1

View File

@@ -1,5 +1,5 @@
group = com.muwire
version = 0.5.10
version = 0.6.5
i2pVersion = 0.9.43
groovyVersion = 2.4.15
slf4jVersion = 1.7.25

View File

@@ -9,7 +9,7 @@ buildscript {
classpath 'org.kt3k.gradle.plugin:coveralls-gradle-plugin:2.8.2'
classpath 'nl.javadude.gradle.plugins:license-gradle-plugin:0.11.0'
classpath 'org.gradle.api.plugins:gradle-izpack-plugin:0.2.3'
classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.4'
classpath 'com.github.jengelman.gradle.plugins:shadow:5.2.0'
classpath 'com.github.cr0:gradle-macappbundle-plugin:3.1.0'
classpath 'org.kordamp.gradle:stats-gradle-plugin:0.2.2'
classpath 'com.github.ben-manes:gradle-versions-plugin:0.17.0'

View File

@@ -106,4 +106,19 @@ mvcGroups {
view = 'com.muwire.gui.SharedFileView'
controller = 'com.muwire.gui.SharedFileController'
}
'download-preview' {
model = "com.muwire.gui.DownloadPreviewModel"
view = "com.muwire.gui.DownloadPreviewView"
controller = "com.muwire.gui.DownloadPreviewController"
}
'chat-server' {
model = 'com.muwire.gui.ChatServerModel'
view = 'com.muwire.gui.ChatServerView'
controller = 'com.muwire.gui.ChatServerController'
}
'chat-room' {
model = 'com.muwire.gui.ChatRoomModel'
view = 'com.muwire.gui.ChatRoomView'
controller = 'com.muwire.gui.ChatRoomController'
}
}

View File

@@ -0,0 +1,270 @@
package com.muwire.gui
import griffon.core.artifact.GriffonController
import griffon.core.controller.ControllerAction
import griffon.inject.MVCMember
import griffon.metadata.ArtifactProviderFor
import groovy.util.logging.Log
import net.i2p.crypto.DSAEngine
import net.i2p.data.Base64
import net.i2p.data.DataHelper
import net.i2p.data.Signature
import java.nio.charset.StandardCharsets
import java.util.logging.Level
import javax.annotation.Nonnull
import javax.swing.JOptionPane
import com.muwire.core.Persona
import com.muwire.core.chat.ChatCommand
import com.muwire.core.chat.ChatAction
import com.muwire.core.chat.ChatConnection
import com.muwire.core.chat.ChatMessageEvent
import com.muwire.core.chat.ChatServer
import com.muwire.core.trust.TrustEvent
import com.muwire.core.trust.TrustLevel
@Log
@ArtifactProviderFor(GriffonController)
class ChatRoomController {
@MVCMember @Nonnull
ChatRoomModel model
@MVCMember @Nonnull
ChatRoomView view
boolean leftRoom
@ControllerAction
void say() {
String words = view.sayField.text
view.sayField.setText(null)
ChatCommand command
try {
command = new ChatCommand(words)
} catch (Exception nope) {
command = new ChatCommand("/SAY $words")
}
if (!command.action.user) {
JOptionPane.showMessageDialog(null, "$words is not a user command","Invalid Command", JOptionPane.ERROR_MESSAGE)
return
}
long now = System.currentTimeMillis()
if (command.action == ChatAction.SAY && command.payload.length() > 0) {
String toShow = DataHelper.formatTime(now) + " <" + model.core.me.getHumanReadableName() + "> "+command.payload
view.roomTextArea.append(toShow)
view.roomTextArea.append('\n')
trimLines()
}
if (command.action == ChatAction.JOIN) {
String newRoom = command.payload
if (!mvcGroup.parentGroup.childrenGroups.containsKey(newRoom)) {
def params = [:]
params['core'] = model.core
params['tabName'] = model.host.getHumanReadableName() + "-chat-rooms"
params['room'] = newRoom
params['console'] = false
params['host'] = model.host
params['roomTabName'] = newRoom
mvcGroup.parentGroup.createMVCGroup("chat-room", model.host.getHumanReadableName()+"-"+newRoom, params)
}
}
if (command.action == ChatAction.LEAVE && !model.console) {
leftRoom = true
view.closeTab.call()
}
String room = model.console ? ChatServer.CONSOLE : model.room
UUID uuid = UUID.randomUUID()
byte [] sig = ChatConnection.sign(uuid, now, room, command.source, model.core.me, model.host, model.core.spk)
def event = new ChatMessageEvent(uuid : uuid,
payload : command.source,
sender : model.core.me,
host : model.host,
room : room,
chatTime : now,
sig : sig)
model.core.eventBus.publish(event)
}
@ControllerAction
void privateMessage() {
Persona p = view.getSelectedPersona()
if (p == null)
return
String groupId = model.host.getHumanReadableName() + "-" + p.getHumanReadableName() +"-private-chat"
if (p != model.core.me && !mvcGroup.parentGroup.childrenGroups.containsKey(groupId)) {
def params = [:]
params['core'] = model.core
params['tabName'] = model.tabName
params['room'] = p.toBase64()
params['privateChat'] = true
params['host'] = model.host
params['roomTabName'] = p.getHumanReadableName()
mvcGroup.parentGroup.createMVCGroup("chat-room", groupId, params)
}
}
void markTrusted() {
Persona p = view.getSelectedPersona()
if (p == null)
return
String reason = JOptionPane.showInputDialog("Enter reason (optional)")
model.core.eventBus.publish(new TrustEvent(persona : p, level : TrustLevel.TRUSTED, reason : reason))
view.refreshMembersTable()
}
void markDistrusted() {
Persona p = view.getSelectedPersona()
if (p == null)
return
String reason = JOptionPane.showInputDialog("Enter reason (optional)")
model.core.eventBus.publish(new TrustEvent(persona : p, level : TrustLevel.DISTRUSTED, reason : reason))
view.refreshMembersTable()
}
void markNeutral() {
Persona p = view.getSelectedPersona()
if (p == null)
return
model.core.eventBus.publish(new TrustEvent(persona : p, level : TrustLevel.NEUTRAL))
view.refreshMembersTable()
}
void browse() {
Persona p = view.getSelectedPersona()
if (p == null)
return
String groupId = p.getHumanReadableName() + "-browse"
def params = [:]
params['host'] = p
params['core'] = model.core
mvcGroup.createMVCGroup("browse",groupId,params)
}
void leaveRoom() {
if (leftRoom)
return
leftRoom = true
long now = System.currentTimeMillis()
UUID uuid = UUID.randomUUID()
byte [] sig = ChatConnection.sign(uuid, now, model.room, "/LEAVE", model.core.me, model.host, model.core.spk)
def event = new ChatMessageEvent(uuid : uuid,
payload : "/LEAVE",
sender : model.core.me,
host : model.host,
room : model.room,
chatTime : now,
sig : sig)
model.core.eventBus.publish(event)
}
void handleChatMessage(ChatMessageEvent e) {
ChatCommand command
try {
command = new ChatCommand(e.payload)
} catch (Exception bad) {
log.log(Level.WARNING,"bad chat command",bad)
return
}
log.info("$model.room processing $command.action")
switch(command.action) {
case ChatAction.SAY : processSay(e, command.payload);break
case ChatAction.JOIN : processJoin(e.timestamp, e.sender); break
case ChatAction.JOINED : processJoined(command.payload); break
case ChatAction.LEAVE : processLeave(e.timestamp, e.sender); break
}
}
private void processSay(ChatMessageEvent e, String text) {
String toDisplay = DataHelper.formatTime(e.timestamp) + " <"+e.sender.getHumanReadableName()+"> " + text + "\n"
runInsideUIAsync {
view.roomTextArea.append(toDisplay)
trimLines()
}
}
private void processJoin(long timestamp, Persona p) {
String toDisplay = DataHelper.formatTime(timestamp) + " " + p.getHumanReadableName() + " joined the room\n"
runInsideUIAsync {
model.members.add(p)
view.roomTextArea.append(toDisplay)
trimLines()
view.membersTable?.model?.fireTableDataChanged()
}
}
private void processJoined(String list) {
runInsideUIAsync {
list.split(",").each {
Persona p = new Persona(new ByteArrayInputStream(Base64.decode(it)))
model.members.add(p)
}
view.membersTable?.model?.fireTableDataChanged()
}
}
private void processLeave(long timestamp, Persona p) {
String toDisplay = DataHelper.formatTime(timestamp) + " " + p.getHumanReadableName() + " left the room\n"
runInsideUIAsync {
model.members.remove(p)
view.roomTextArea.append(toDisplay)
trimLines()
view.membersTable?.model?.fireTableDataChanged()
}
}
void handleLeave(Persona p) {
String toDisplay = DataHelper.formatTime(System.currentTimeMillis()) + " " + p.getHumanReadableName() + " disconnected\n"
runInsideUIAsync {
if (model.members.remove(p)) {
view.roomTextArea.append(toDisplay)
trimLines()
view.membersTable?.model?.fireTableDataChanged()
}
}
}
private void trimLines() {
if (model.settings.maxChatLines < 0)
return
while(view.roomTextArea.getLineCount() > model.settings.maxChatLines) {
int line0Start = view.roomTextArea.getLineStartOffset(0)
int line0End = view.roomTextArea.getLineEndOffset(0)
view.roomTextArea.replaceRange(null, line0Start, line0End)
}
}
void rejoinRoom() {
if (model.room == "Console")
return
model.members.clear()
model.members.add(model.core.me)
UUID uuid = UUID.randomUUID()
long now = System.currentTimeMillis()
String join = "/JOIN $model.room"
byte [] sig = ChatConnection.sign(uuid, now, ChatServer.CONSOLE, join, model.core.me, model.host, model.core.spk)
def event = new ChatMessageEvent(
uuid : uuid,
payload : join,
sender : model.core.me,
host : model.host,
room : ChatServer.CONSOLE,
chatTime : now,
sig : sig
)
model.core.eventBus.publish(event)
}
}

View File

@@ -0,0 +1,28 @@
package com.muwire.gui
import griffon.core.artifact.GriffonController
import griffon.core.controller.ControllerAction
import griffon.inject.MVCMember
import griffon.metadata.ArtifactProviderFor
import javax.annotation.Nonnull
import com.muwire.core.chat.UIDisconnectChatEvent
@ArtifactProviderFor(GriffonController)
class ChatServerController {
@MVCMember @Nonnull
ChatServerModel model
@ControllerAction
void disconnect() {
switch(model.buttonText) {
case "Disconnect" :
model.buttonText = "Connect"
model.core.eventBus.publish(new UIDisconnectChatEvent(host : model.host))
break
case "Connect" :
model.connect()
break
}
}
}

View File

@@ -0,0 +1,13 @@
package com.muwire.gui
import griffon.core.artifact.GriffonController
import griffon.core.controller.ControllerAction
import griffon.inject.MVCMember
import griffon.metadata.ArtifactProviderFor
import javax.annotation.Nonnull
@ArtifactProviderFor(GriffonController)
class DownloadPreviewController {
@MVCMember @Nonnull
DownloadPreviewModel model
}

View File

@@ -7,10 +7,13 @@ import griffon.core.mvc.MVCGroup
import griffon.core.mvc.MVCGroupConfiguration
import griffon.inject.MVCMember
import griffon.metadata.ArtifactProviderFor
import groovy.json.StringEscapeUtils
import net.i2p.crypto.DSAEngine
import net.i2p.data.Base64
import net.i2p.data.Signature
import net.i2p.data.SigningPrivateKey
import java.awt.Desktop
import java.awt.event.ActionEvent
import java.nio.charset.StandardCharsets
@@ -42,6 +45,7 @@ import com.muwire.core.trust.TrustLevel
import com.muwire.core.trust.TrustSubscriptionEvent
import com.muwire.core.upload.HashListUploader
import com.muwire.core.upload.Uploader
import com.muwire.core.util.DataUtil
@ArtifactProviderFor(GriffonController)
class MainFrameController {
@@ -120,9 +124,10 @@ class MainFrameController {
Signature sig = DSAEngine.getInstance().sign(payload, core.spk)
long timestamp = System.currentTimeMillis()
core.eventBus.publish(new QueryEvent(searchEvent : searchEvent, firstHop : firstHop,
replyTo: core.me.destination, receivedOn: core.me.destination,
originator : core.me, sig : sig.data))
originator : core.me, sig : sig.data, queryTime : timestamp, sig2 : DataUtil.signUUID(uuid, timestamp, core.spk)))
}
@@ -139,14 +144,16 @@ class MainFrameController {
byte [] infoHashBytes = Base64.decode(infoHash)
Signature sig = DSAEngine.getInstance().sign(infoHashBytes, core.spk)
long timestamp = System.currentTimeMillis()
byte [] sig2 = DataUtil.signUUID(uuid, timestamp, core.spk)
def searchEvent = new SearchEvent(searchHash : Base64.decode(infoHash), uuid:uuid,
oobInfohash: true, persona : core.me)
core.eventBus.publish(new QueryEvent(searchEvent : searchEvent, firstHop : true,
replyTo: core.me.destination, receivedOn: core.me.destination,
originator : core.me, sig : sig.data))
originator : core.me, sig : sig.data, queryTime : timestamp, sig2 : sig2))
}
private int selectedDownload() {
def downloadsTable = builder.getVariable("downloads-table")
def selected = downloadsTable.getSelectedRow()
@@ -197,6 +204,14 @@ class MainFrameController {
downloader.pause()
core.eventBus.publish(new UIDownloadPausedEvent())
}
@ControllerAction
void preview() {
def downloader = model.downloads[selectedDownload()].downloader
def params = [:]
params['downloader'] = downloader
mvcGroup.createMVCGroup("download-preview", params)
}
@ControllerAction
void clear() {
@@ -219,8 +234,11 @@ class MainFrameController {
int row = view.getSelectedTrustTablesRow(tableName)
if (row < 0)
return
String reason = null
if (level != TrustLevel.NEUTRAL)
reason = JOptionPane.showInputDialog("Enter reason (optional)")
builder.getVariable(tableName).model.fireTableDataChanged()
core.eventBus.publish(new TrustEvent(persona : list[row].persona, level : level))
core.eventBus.publish(new TrustEvent(persona : list[row].persona, level : level, reason : reason))
}
@ControllerAction
@@ -307,6 +325,31 @@ class MainFrameController {
return null
model.subscriptions[row]
}
@ControllerAction
void browseFromTrusted() {
int row = view.getSelectedTrustTablesRow("trusted-table")
if (row < 0)
return
Persona p = model.trusted[row].persona
String groupId = p.getHumanReadableName() + "-browse"
def params = [:]
params['host'] = p
params['core'] = model.core
mvcGroup.createMVCGroup("browse",groupId,params)
}
@ControllerAction
void chatFromTrusted() {
int row = view.getSelectedTrustTablesRow("trusted-table")
if (row < 0)
return
Persona p = model.trusted[row].persona
startChat(p)
view.showChatWindow.call()
}
void unshareSelectedFile() {
def sf = view.selectedSharedFiles()
@@ -383,7 +426,7 @@ class MainFrameController {
@ControllerAction
void showFileDetails() {
def selected = view.selectedSharedFiles()
if (selected.size() != 1) {
if (selected == null || selected.size() != 1) {
JOptionPane.showMessageDialog(null, "Please select only one file to view it's details")
return
}
@@ -392,6 +435,64 @@ class MainFrameController {
params['core'] = core
mvcGroup.createMVCGroup("shared-file", params)
}
@ControllerAction
void openContainingFolder() {
def selected = view.selectedSharedFiles()
if (selected == null || selected.size() != 1) {
JOptionPane.showMessageDialog(null, "Please select only one file to open it's containing folder")
return
}
try {
Desktop.getDesktop().open(selected[0].file.getParentFile())
} catch (Exception ignored) {}
}
@ControllerAction
void startChatServer() {
model.core.chatServer.start()
model.chatServerRunning = true
if (!mvcGroup.getChildrenGroups().containsKey("local-chat-server")) {
def params = [:]
params['core'] = model.core
params['host'] = model.core.me
mvcGroup.createMVCGroup("chat-server","local-chat-server", params)
}
}
@ControllerAction
void stopChatServer() {
model.core.chatServer.stop()
model.chatServerRunning = false
}
@ControllerAction
void connectChatServer() {
String address = JOptionPane.showInputDialog("Copy/paste the address of the server here")
if (address == null)
return
Persona p
try {
p = new Persona(new ByteArrayInputStream(Base64.decode(address)))
} catch (Exception bad) {
JOptionPane.showMessageDialog(null, "Invalid server address", "Invalid server address", JOptionPane.ERROR_MESSAGE)
return
}
startChat(p)
}
void startChat(Persona p) {
if (!mvcGroup.getChildrenGroups().containsKey(p.getHumanReadableName())) {
def params = [:]
params['core'] = model.core
params['host'] = p
mvcGroup.createMVCGroup("chat-server", p.getHumanReadableName(), params)
} else
mvcGroup.getChildrenGroups().get(p.getHumanReadableName()).model.connect()
}
void saveMuWireSettings() {
core.saveMuSettings()

View File

@@ -139,6 +139,22 @@ class OptionsController {
String trustListInterval = view.trustListIntervalField.text
model.trustListInterval = trustListInterval
settings.trustListInterval = Integer.parseInt(trustListInterval)
boolean startChatServer = view.startChatServerCheckbox.model.isSelected()
model.startChatServer = startChatServer
settings.startChatServer = startChatServer
String maxChatConnections = view.maxChatConnectionsField.text
model.maxChatConnections = Integer.parseInt(maxChatConnections)
settings.maxChatConnections = Integer.parseInt(maxChatConnections)
boolean advertiseChat = view.advertiseChatCheckbox.model.isSelected()
model.advertiseChat = advertiseChat
settings.advertiseChat = advertiseChat
int maxChatLines = Integer.parseInt(view.maxChatLinesField.text)
model.maxChatLines = maxChatLines
uiSettings.maxChatLines = maxChatLines
core.saveMuSettings()

View File

@@ -99,13 +99,24 @@ class SearchTabController {
if (sender == null)
return
String groupId = sender.getHumanReadableName()
String groupId = sender.getHumanReadableName() + "-browse"
Map<String,Object> params = new HashMap<>()
params['host'] = sender
params['core'] = core
mvcGroup.createMVCGroup("browse", groupId, params)
}
@ControllerAction
void chat() {
def sender = view.selectedSender()
if (sender == null)
return
def parent = mvcGroup.parentGroup
parent.controller.startChat(sender)
parent.view.showChatWindow.call()
}
@ControllerAction
void showComment() {

View File

@@ -0,0 +1,28 @@
package com.muwire.gui
import com.muwire.core.Core
import com.muwire.core.Persona
import griffon.core.artifact.GriffonModel
import griffon.transform.Observable
import griffon.metadata.ArtifactProviderFor
@ArtifactProviderFor(GriffonModel)
class ChatRoomModel {
Core core
Persona host
String tabName
String room
boolean console
boolean privateChat
String roomTabName
def members = []
UISettings settings
void mvcGroupInit(Map<String,String> args) {
members.add(core.me)
settings = application.context.get("ui-settings")
}
}

View File

@@ -0,0 +1,148 @@
package com.muwire.gui
import java.util.logging.Level
import com.muwire.core.Core
import com.muwire.core.Persona
import com.muwire.core.chat.ChatCommand
import com.muwire.core.chat.ChatAction
import com.muwire.core.chat.ChatConnectionAttemptStatus
import com.muwire.core.chat.ChatConnectionEvent
import com.muwire.core.chat.ChatLink
import com.muwire.core.chat.ChatMessageEvent
import com.muwire.core.chat.UIConnectChatEvent
import griffon.core.artifact.GriffonModel
import griffon.transform.Observable
import groovy.util.logging.Log
import griffon.metadata.ArtifactProviderFor
@Log
@ArtifactProviderFor(GriffonModel)
class ChatServerModel {
Persona host
Core core
@Observable boolean disconnectActionEnabled
@Observable String buttonText = "Disconnect"
@Observable ChatConnectionAttemptStatus status
volatile ChatLink link
volatile Thread poller
volatile boolean running
void mvcGroupInit(Map<String, String> params) {
disconnectActionEnabled = host != core.me // can't disconnect from myself
core.eventBus.register(ChatConnectionEvent.class, this)
connect()
}
void connect() {
runInsideUIAsync {
buttonText = "Disconnect"
}
core.eventBus.publish(new UIConnectChatEvent(host : host))
}
void mvcGroupDestroy() {
stopPoller()
core.eventBus.unregister(ChatConnectionEvent.class, this)
}
private void startPoller() {
if (running)
return
running = true
poller = new Thread({eventLoop()} as Runnable)
poller.setDaemon(true)
poller.start()
}
private void stopPoller() {
running = false
poller?.interrupt()
link = null
}
void onChatConnectionEvent(ChatConnectionEvent e) {
if (e.persona != host)
return
runInsideUIAsync {
status = e.status
}
if (e.status == ChatConnectionAttemptStatus.SUCCESSFUL) {
ChatLink link = e.connection
if (link == null)
return
this.link = e.connection
startPoller()
mvcGroup.childrenGroups.each {k,v ->
v.controller.rejoinRoom()
}
} else {
stopPoller()
}
}
private void eventLoop() {
Thread.sleep(1000)
while(running) {
ChatLink link = this.link
if (link == null || !link.isUp()) {
Thread.sleep(100)
continue
}
Object event = link.nextEvent()
if (event instanceof ChatMessageEvent)
handleChatMessage(event)
else if (event instanceof Persona)
handleLeave(event)
else
throw new IllegalArgumentException("event type $event")
}
}
private void handleChatMessage(ChatMessageEvent e) {
ChatCommand chatCommand
try {
chatCommand = new ChatCommand(e.payload)
} catch (Exception badCommand) {
log.log(Level.WARNING,"bad chat command",badCommand)
return
}
String room = e.room
if (chatCommand.action == ChatAction.JOIN) {
room = chatCommand.payload
}
if (chatCommand.action == ChatAction.SAY &&
room == core.me.toBase64()) {
String groupId = host.getHumanReadableName()+"-"+e.sender.getHumanReadableName() + "-private-chat"
if (!mvcGroup.childrenGroups.containsKey(groupId)) {
def params = [:]
params['core'] = core
params['tabName'] = host.getHumanReadableName() + "-chat-rooms"
params['room'] = e.sender.toBase64()
params['privateChat'] = true
params['host'] = host
params['roomTabName'] = e.sender.getHumanReadableName()
mvcGroup.createMVCGroup("chat-room",groupId, params)
}
room = groupId
} else
room = host.getHumanReadableName()+"-"+room
mvcGroup.childrenGroups[room]?.controller?.handleChatMessage(e)
}
private void handleLeave(Persona p) {
mvcGroup.childrenGroups.each { k, v ->
v.controller.handleLeave(p)
}
}
}

View File

@@ -0,0 +1,12 @@
package com.muwire.gui
import com.muwire.core.download.Downloader
import griffon.core.artifact.GriffonModel
import griffon.transform.Observable
import griffon.metadata.ArtifactProviderFor
@ArtifactProviderFor(GriffonModel)
class DownloadPreviewModel {
Downloader downloader
}

View File

@@ -100,6 +100,7 @@ class MainFrameModel {
@Observable boolean retryButtonEnabled
@Observable boolean pauseButtonEnabled
@Observable boolean clearButtonEnabled
@Observable boolean previewButtonEnabled
@Observable String resumeButtonText
@Observable boolean addCommentButtonEnabled
@Observable boolean subscribeButtonEnabled
@@ -116,6 +117,9 @@ class MainFrameModel {
@Observable boolean uploadsPaneButtonEnabled
@Observable boolean monitorPaneButtonEnabled
@Observable boolean trustPaneButtonEnabled
@Observable boolean chatPaneButtonEnabled
@Observable boolean chatServerRunning
@Observable Downloader downloader
@@ -217,6 +221,8 @@ class MainFrameModel {
core.eventBus.publish(new ContentControlEvent(term : it, regex: true, add: true))
}
chatServerRunning = core.chatServer.running.get()
timer.schedule({
if (core.shutdown.get())
return
@@ -251,6 +257,10 @@ class MainFrameModel {
uploadsPaneButtonEnabled = true
monitorPaneButtonEnabled = true
trustPaneButtonEnabled = true
chatPaneButtonEnabled = true
if (core.muOptions.startChatServer)
controller.startChatServer()
}
})

View File

@@ -56,6 +56,11 @@ class OptionsModel {
@Observable boolean trustLists
@Observable String trustListInterval
// chat options
@Observable boolean startChatServer
@Observable int maxChatConnections
@Observable boolean advertiseChat
@Observable int maxChatLines
void mvcGroupInit(Map<String, String> args) {
MuWireSettings settings = application.context.get("muwire-settings")
@@ -104,5 +109,10 @@ class OptionsModel {
searchExtraHop = settings.searchExtraHop
trustLists = settings.allowTrustLists
trustListInterval = String.valueOf(settings.trustListInterval)
startChatServer = settings.startChatServer
maxChatConnections = settings.maxChatConnections
advertiseChat = settings.advertiseChat
maxChatLines = uiSettings.maxChatLines
}
}

View File

@@ -24,6 +24,7 @@ class SearchTabModel {
@Observable boolean browseActionEnabled
@Observable boolean viewCommentActionEnabled
@Observable boolean viewCertificatesActionEnabled
@Observable boolean chatActionEnabled
@Observable boolean groupedByFile
Core core

View File

@@ -0,0 +1,173 @@
package com.muwire.gui
import griffon.core.artifact.GriffonView
import griffon.inject.MVCMember
import griffon.metadata.ArtifactProviderFor
import javax.swing.JMenuItem
import javax.swing.JPopupMenu
import javax.swing.JSplitPane
import javax.swing.ListSelectionModel
import javax.swing.SwingConstants
import javax.swing.SpringLayout.Constraints
import com.muwire.core.Persona
import java.awt.BorderLayout
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.annotation.Nonnull
@ArtifactProviderFor(GriffonView)
class ChatRoomView {
@MVCMember @Nonnull
FactoryBuilderSupport builder
@MVCMember @Nonnull
ChatRoomModel model
@MVCMember @Nonnull
ChatRoomController controller
def pane
def parent
def sayField
def roomTextArea
def membersTable
def lastMembersTableSortEvent
void initUI() {
int rowHeight = application.context.get("row-height")
if (model.console || model.privateChat) {
pane = builder.panel {
borderLayout()
panel(constraints : BorderLayout.CENTER) {
gridLayout(rows : 1, cols : 1)
scrollPane {
roomTextArea = textArea(editable : false, lineWrap : true, wrapStyleWord : true)
}
}
panel(constraints : BorderLayout.SOUTH) {
borderLayout()
label(text : "Say something here: ", constraints : BorderLayout.WEST)
sayField = textField(actionPerformed : {controller.say()}, constraints : BorderLayout.CENTER)
button(text : "Say", constraints : BorderLayout.EAST, sayAction)
}
}
} else {
pane = builder.panel {
borderLayout()
panel(constraints : BorderLayout.CENTER) {
gridLayout(rows : 1, cols : 1)
splitPane(orientation : JSplitPane.HORIZONTAL_SPLIT, continuousLayout : true, dividerLocation : 300) {
panel {
gridLayout(rows : 1, cols : 1)
scrollPane {
membersTable = table(autoCreateRowSorter : true, rowHeight : rowHeight) {
tableModel(list : model.members) {
closureColumn(header : "Name", preferredWidth: 100, type: String, read : {it.getHumanReadableName()})
closureColumn(header : "Trust Status", preferredWidth: 30, type : String, read : {String.valueOf(model.core.trustService.getLevel(it.destination))})
}
}
}
}
panel {
gridLayout(rows : 1, cols : 1)
scrollPane {
roomTextArea = textArea(editable : false, lineWrap : true, wrapStyleWord : true)
}
}
}
}
panel(constraints : BorderLayout.SOUTH) {
borderLayout()
label(text : "Say something here: ", constraints : BorderLayout.WEST)
sayField = textField(actionPerformed : {controller.say()}, constraints : BorderLayout.CENTER)
button(text : "Say", constraints : BorderLayout.EAST, sayAction)
}
}
}
}
void mvcGroupInit(Map<String,String> args) {
parent = mvcGroup.parentGroup.view.builder.getVariable(model.tabName)
parent.addTab(model.roomTabName, pane)
int index = parent.indexOfComponent(pane)
parent.setSelectedIndex(index)
def tabPanel = builder.panel {
borderLayout()
panel (constraints : BorderLayout.CENTER) {
label(text : model.roomTabName)
}
button(icon : imageIcon("/close_tab.png"), preferredSize: [20, 20], constraints : BorderLayout.EAST,
actionPerformed : closeTab )
}
if (!model.console)
parent.setTabComponentAt(index, tabPanel)
if (membersTable != null) {
membersTable.rowSorter.addRowSorterListener({evt -> lastMembersTableSortEvent = evt})
membersTable.rowSorter.setSortsOnUpdates(true)
membersTable.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
membersTable.addMouseListener(new MouseAdapter() {
public void mouseClicked(MouseEvent e) {
if (e.button == MouseEvent.BUTTON1 && e.clickCount > 1) {
controller.privateMessage()
} else if (e.isPopupTrigger() || e.button == MouseEvent.BUTTON3)
showPopupMenu(e)
}
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger() || e.button == MouseEvent.BUTTON3)
showPopupMenu(e)
}
})
}
}
private void showPopupMenu(MouseEvent e) {
JPopupMenu menu = new JPopupMenu()
JMenuItem privateChat = new JMenuItem("Start Private Chat")
privateChat.addActionListener({controller.privateMessage()})
menu.add(privateChat)
JMenuItem browse = new JMenuItem("Browse")
browse.addActionListener({controller.browse()})
menu.add(browse)
JMenuItem markTrusted = new JMenuItem("Mark Trusted")
markTrusted.addActionListener({controller.markTrusted()})
menu.add(markTrusted)
JMenuItem markNeutral = new JMenuItem("Mark Neutral")
markNeutral.addActionListener({controller.markNeutral()})
menu.add(markNeutral)
JMenuItem markDistrusted = new JMenuItem("Mark Distrusted")
markDistrusted.addActionListener({controller.markDistrusted()})
menu.add(markDistrusted)
menu.show(e.getComponent(), e.getX(), e.getY())
}
Persona getSelectedPersona() {
int selectedRow = membersTable.getSelectedRow()
if (selectedRow < 0)
return null
if (lastMembersTableSortEvent != null)
selectedRow = membersTable.rowSorter.convertRowIndexToModel(selectedRow)
model.members[selectedRow]
}
void refreshMembersTable() {
int selectedRow = membersTable.getSelectedRow()
membersTable.model.fireTableDataChanged()
membersTable.selectionModel.setSelectionInterval(selectedRow, selectedRow)
}
def closeTab = {
int index = parent.indexOfComponent(pane)
parent.removeTabAt(index)
controller.leaveRoom()
mvcGroup.destroy()
}
}

View File

@@ -0,0 +1,81 @@
package com.muwire.gui
import griffon.core.artifact.GriffonView
import griffon.inject.MVCMember
import griffon.metadata.ArtifactProviderFor
import javax.swing.SwingConstants
import com.muwire.core.chat.ChatServer
import java.awt.BorderLayout
import javax.annotation.Nonnull
@ArtifactProviderFor(GriffonView)
class ChatServerView {
@MVCMember @Nonnull
FactoryBuilderSupport builder
@MVCMember @Nonnull
ChatServerModel model
@MVCMember @Nonnull
ChatServerController controller
def pane
def parent
void initUI() {
pane = builder.panel {
borderLayout()
tabbedPane(id : model.host.getHumanReadableName()+"-chat-rooms", constraints : BorderLayout.CENTER)
panel(constraints : BorderLayout.SOUTH) {
gridLayout(rows : 1, cols : 3)
panel {}
panel {
button(text : bind {model.buttonText}, enabled : bind {model.disconnectActionEnabled}, disconnectAction)
}
panel {
label(text : "Connection Status ")
label(text : bind {model.status.toString()})
}
}
}
}
void mvcGroupInit(Map<String,String> args) {
parent = mvcGroup.parentGroup.view.builder.getVariable("chat-tabs")
parent.addTab(model.host.getHumanReadableName(), pane)
int index = parent.indexOfComponent(pane)
parent.setSelectedIndex(index)
def tabPanel
builder.with {
tabPanel = panel {
borderLayout()
panel (constraints : BorderLayout.CENTER) {
String text = model.host == model.core.me ? "Local Server" : model.host.getHumanReadableName()
label(text : text)
}
button(icon : imageIcon("/close_tab.png"), preferredSize: [20, 20], constraints : BorderLayout.EAST,
actionPerformed : closeTab )
}
}
parent.setTabComponentAt(index, tabPanel)
def params = [:]
params['core'] = model.core
params['tabName'] = model.host.getHumanReadableName() + "-chat-rooms"
params['room'] = 'Console'
params['roomTabName'] = 'Console'
params['console'] = true
params['host'] = model.host
mvcGroup.createMVCGroup("chat-room",model.host.getHumanReadableName()+"-"+ChatServer.CONSOLE, params)
}
def closeTab = {
controller.disconnect()
int index = parent.indexOfComponent(pane)
parent.removeTabAt(index)
mvcGroup.destroy()
}
}

View File

@@ -0,0 +1,61 @@
package com.muwire.gui
import griffon.core.artifact.GriffonView
import griffon.inject.MVCMember
import griffon.metadata.ArtifactProviderFor
import javax.swing.Box
import javax.swing.JDialog
import javax.swing.JOptionPane
import javax.swing.SwingConstants
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.annotation.Nonnull
@ArtifactProviderFor(GriffonView)
class DownloadPreviewView {
@MVCMember @Nonnull
FactoryBuilderSupport builder
@MVCMember @Nonnull
DownloadPreviewModel model
def mainFrame
def dialog
def panel
void initUI() {
mainFrame = application.windowManager.findWindow("main-frame")
dialog = new JDialog(mainFrame, "Generating Preview", true)
panel = builder.panel {
vbox {
label(text : "Generating preview for "+model.downloader.file.getName())
Box.createVerticalGlue()
progressBar(indeterminate : true)
}
}
dialog.getContentPane().add(panel)
dialog.pack()
dialog.setResizable(false)
dialog.setLocationRelativeTo(mainFrame)
dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE)
dialog.addWindowListener(new WindowAdapter() {
public void windowClosed(WindowEvent e) {
mainFrame.setVisible(false)
mvcGroup.destroy()
}
})
}
void mvcGroupInit(Map<String, String> args) {
if (!model.downloader.isSequential())
JOptionPane.showMessageDialog(mainFrame, "This download is not sequential, there may not be much to preview")
DownloadPreviewer previewer = new DownloadPreviewer(model.downloader, this)
previewer.execute()
dialog.show()
}
}

View File

@@ -142,6 +142,7 @@ class MainFrameView {
if (settings.showMonitor)
button(text: "Monitor", enabled: bind{model.monitorPaneButtonEnabled},actionPerformed : showMonitorWindow)
button(text: "Trust", enabled:bind{model.trustPaneButtonEnabled},actionPerformed : showTrustWindow)
button(text: "Chat", enabled : bind{model.chatPaneButtonEnabled}, actionPerformed : showChatWindow)
}
panel(id: "top-panel", constraints: BorderLayout.CENTER) {
cardLayout()
@@ -216,6 +217,7 @@ class MainFrameView {
button(text: "Pause", enabled : bind {model.pauseButtonEnabled}, pauseAction)
button(text: bind { model.resumeButtonText }, enabled : bind {model.retryButtonEnabled}, resumeAction)
button(text: "Cancel", enabled : bind {model.cancelButtonEnabled }, cancelAction)
button(text: "Preview", enabled : bind {model.previewButtonEnabled}, previewAction)
button(text: "Clear Done", enabled : bind {model.clearButtonEnabled}, clearAction)
}
}
@@ -304,7 +306,7 @@ class MainFrameView {
radioButton(text : "Table", selected : false, buttonGroup : sharedViewType, actionPerformed : showSharedFilesTable)
}
panel {
button(text : "Share files", actionPerformed : shareFiles)
button(text : "Share", actionPerformed : shareFiles)
button(text : "Add Comment", enabled : bind {model.addCommentButtonEnabled}, addCommentAction)
button(text : "Certify", enabled : bind {model.addCommentButtonEnabled}, issueCertificateAction)
}
@@ -432,6 +434,8 @@ class MainFrameView {
button(text : "Subscribe", enabled : bind {model.subscribeButtonEnabled}, constraints : gbc(gridx: 0, gridy : 0), subscribeAction)
button(text : "Mark Neutral", enabled : bind {model.markNeutralFromTrustedButtonEnabled}, constraints : gbc(gridx: 1, gridy: 0), markNeutralFromTrustedAction)
button(text : "Mark Distrusted", enabled : bind {model.markDistrustedButtonEnabled}, constraints : gbc(gridx: 2, gridy:0), markDistrustedAction)
button(text : "Browse", constraints:gbc(gridx:3, gridy:0), browseFromTrustedAction)
button(text : "Chat", constraints : gbc(gridx:4, gridy:0), chatFromTrustedAction)
}
}
panel (border : etchedBorder()){
@@ -474,6 +478,15 @@ class MainFrameView {
}
}
}
panel(constraints : "chat window") {
borderLayout()
tabbedPane(id : "chat-tabs", constraints : BorderLayout.CENTER)
panel(constraints : BorderLayout.SOUTH) {
button(text : "Start Chat Server", enabled : bind {!model.chatServerRunning}, startChatServerAction)
button(text : "Stop Chat Server", enabled : bind {model.chatServerRunning}, stopChatServerAction)
button(text : "Connect To Remote Server", connectChatServerAction)
}
}
}
panel (border: etchedBorder(), constraints : BorderLayout.SOUTH) {
borderLayout()
@@ -498,7 +511,9 @@ class MainFrameView {
public boolean importData(TransferHandler.TransferSupport support) {
def files = support.getTransferable().getTransferData(DataFlavor.javaFileListFlavor)
files.each {
model.core.eventBus.publish(new FileSharedEvent(file : it))
File canonical = it.getCanonicalFile()
model.core.fileManager.negativeTree.remove(canonical)
model.core.eventBus.publish(new FileSharedEvent(file : canonical))
}
showUploadsWindow.call()
true
@@ -536,6 +551,7 @@ class MainFrameView {
model.cancelButtonEnabled = false
model.retryButtonEnabled = false
model.pauseButtonEnabled = false
model.previewButtonEnabled = false
model.downloader = null
downloadDetailsPanel.getLayout().show(downloadDetailsPanel,"select-download")
return
@@ -544,6 +560,7 @@ class MainFrameView {
if (downloader == null)
return
model.downloader = downloader
model.previewButtonEnabled = true
downloadDetailsPanel.getLayout().show(downloadDetailsPanel,"download-selected")
switch(downloader.getCurrentState()) {
case Downloader.DownloadState.CONNECTING :
@@ -608,6 +625,9 @@ class MainFrameView {
JMenuItem certifySelectedFiles = new JMenuItem("Certify selected files")
certifySelectedFiles.addActionListener({mvcGroup.controller.issueCertificate()})
sharedFilesMenu.add(certifySelectedFiles)
JMenuItem openContainingFolder = new JMenuItem("Open containing folder")
openContainingFolder.addActionListener({mvcGroup.controller.openContainingFolder()})
sharedFilesMenu.add(openContainingFolder)
JMenuItem showFileDetails = new JMenuItem("Show file details")
showFileDetails.addActionListener({mvcGroup.controller.showFileDetails()})
sharedFilesMenu.add(showFileDetails)
@@ -761,6 +781,34 @@ class MainFrameView {
model.markNeutralFromTrustedButtonEnabled = true
}
})
JPopupMenu trustMenu = new JPopupMenu()
JMenuItem subscribeItem = new JMenuItem("Subscribe")
subscribeItem.addActionListener({mvcGroup.controller.subscribe()})
trustMenu.add(subscribeItem)
JMenuItem markNeutralItem = new JMenuItem("Mark Neutral")
markNeutralItem.addActionListener({mvcGroup.controller.markNeutralFromTrusted()})
trustMenu.add(markNeutralItem)
JMenuItem markDistrustedItem = new JMenuItem("Mark Distrusted")
markDistrustedItem.addActionListener({mvcGroup.controller.markDistrusted()})
trustMenu.add(markDistrustedItem)
JMenuItem browseItem = new JMenuItem("Browse")
browseItem.addActionListener({mvcGroup.controller.browseFromTrusted()})
trustMenu.add(browseItem)
JMenuItem chatItem = new JMenuItem("Chat")
chatItem.addActionListener({mvcGroup.controller.chatFromTrusted()})
trustMenu.add(chatItem)
trustedTable.addMouseListener(new MouseAdapter() {
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger() || e.button == MouseEvent.BUTTON3)
showPopupMenu(trustMenu, e)
}
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger() || e.button == MouseEvent.BUTTON3)
showPopupMenu(trustMenu, e)
}
})
// distrusted table
def distrustedTable = builder.getVariable("distrusted-table")
@@ -980,6 +1028,7 @@ class MainFrameView {
model.uploadsPaneButtonEnabled = true
model.monitorPaneButtonEnabled = true
model.trustPaneButtonEnabled = true
model.chatPaneButtonEnabled = true
}
def showDownloadsWindow = {
@@ -990,6 +1039,7 @@ class MainFrameView {
model.uploadsPaneButtonEnabled = true
model.monitorPaneButtonEnabled = true
model.trustPaneButtonEnabled = true
model.chatPaneButtonEnabled = true
}
def showUploadsWindow = {
@@ -1000,6 +1050,7 @@ class MainFrameView {
model.uploadsPaneButtonEnabled = false
model.monitorPaneButtonEnabled = true
model.trustPaneButtonEnabled = true
model.chatPaneButtonEnabled = true
}
def showMonitorWindow = {
@@ -1010,6 +1061,7 @@ class MainFrameView {
model.uploadsPaneButtonEnabled = true
model.monitorPaneButtonEnabled = false
model.trustPaneButtonEnabled = true
model.chatPaneButtonEnabled = true
}
def showTrustWindow = {
@@ -1020,6 +1072,18 @@ class MainFrameView {
model.uploadsPaneButtonEnabled = true
model.monitorPaneButtonEnabled = true
model.trustPaneButtonEnabled = false
model.chatPaneButtonEnabled = true
}
def showChatWindow = {
def cardsPanel = builder.getVariable("cards-panel")
cardsPanel.getLayout().show(cardsPanel, "chat window")
model.searchesPaneButtonEnabled = true
model.downloadsPaneButtonEnabled = true
model.uploadsPaneButtonEnabled = true
model.monitorPaneButtonEnabled = true
model.trustPaneButtonEnabled = true
model.chatPaneButtonEnabled = false
}
def showSharedFilesTable = {
@@ -1037,13 +1101,14 @@ class MainFrameView {
def shareFiles = {
def chooser = new JFileChooser()
chooser.setFileHidingEnabled(!model.core.muOptions.shareHiddenFiles)
chooser.setDialogTitle("Select file to share")
chooser.setFileSelectionMode(JFileChooser.FILES_ONLY)
chooser.setDialogTitle("Select files or directories to share")
chooser.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES)
chooser.setMultiSelectionEnabled(true)
int rv = chooser.showOpenDialog(null)
if (rv == JFileChooser.APPROVE_OPTION) {
chooser.getSelectedFiles().each {
File canonical = it.getCanonicalFile()
model.core.fileManager.negativeTree.remove(canonical)
model.core.eventBus.publish(new FileSharedEvent(file : canonical))
}
}

View File

@@ -33,6 +33,7 @@ class OptionsView {
def u
def bandwidth
def trust
def chat
def retryField
def updateField
@@ -71,6 +72,11 @@ class OptionsView {
def allowTrustListsCheckbox
def trustListIntervalField
def startChatServerCheckbox
def maxChatConnectionsField
def advertiseChatCheckbox
def maxChatLinesField
def buttonsPanel
def mainFrame
@@ -267,6 +273,23 @@ class OptionsView {
}
panel(constraints : gbc(gridx: 0, gridy : 1, weighty: 100))
}
chat = builder.panel {
gridBagLayout()
panel (border : titledBorder(title : "Chat Settings", border : etchedBorder(), titlePosition : TitledBorder.TOP),
constraints : gbc(gridx : 0, gridy : 0, fill : GridBagConstraints.HORIZONTAL, weightx: 100)) {
gridBagLayout()
label(text : "Start chat server on startup", constraints : gbc(gridx: 0, gridy: 0, anchor: GridBagConstraints.LINE_START, weightx: 100))
startChatServerCheckbox = checkBox(selected : bind{model.startChatServer}, constraints : gbc(gridx:1, gridy:0, anchor:GridBagConstraints.LINE_END))
label(text : "Maximum chat connections (-1 means unlimited)", constraints : gbc(gridx: 0, gridy:1, anchor:GridBagConstraints.LINE_START, weightx:100))
maxChatConnectionsField = textField(text : bind {model.maxChatConnections}, constraints : gbc(gridx: 1, gridy : 1, anchor:GridBagConstraints.LINE_END))
label(text : "Advertise chat ability in search results", constraints : gbc(gridx: 0, gridy:2, anchor:GridBagConstraints.LINE_START, weightx:100))
advertiseChatCheckbox = checkBox(selected : bind{model.advertiseChat}, constraints : gbc(gridx:1, gridy:2, anchor:GridBagConstraints.LINE_END))
label(text : "Maximum lines of scrollback (-1 means unlimited)", constraints : gbc(gridx:0, gridy:3, anchor : GridBagConstraints.LINE_START, weightx: 100))
maxChatLinesField = textField(text : bind{model.maxChatLines}, constraints : gbc(gridx:1, gridy: 3, anchor: GridBagConstraints.LINE_END))
}
panel(constraints : gbc(gridx: 0, gridy : 1, weighty: 100))
}
buttonsPanel = builder.panel {
@@ -286,6 +309,7 @@ class OptionsView {
tabbedPane.addTab("Bandwidth", bandwidth)
}
tabbedPane.addTab("Trust", trust)
tabbedPane.addTab("Chat", chat)
JPanel panel = new JPanel()
panel.setLayout(new BorderLayout())

View File

@@ -74,6 +74,7 @@ class SearchTabView {
closureColumn(header : "Sender", preferredWidth : 500, type: String, read : {row -> row.getHumanReadableName()})
closureColumn(header : "Results", preferredWidth : 20, type: Integer, read : {row -> model.sendersBucket[row].size()})
closureColumn(header : "Browse", preferredWidth : 20, type: Boolean, read : {row -> model.sendersBucket[row].first().browse})
closureColumn(header : "Chat", preferredWidth : 20, type : Boolean, read : {row -> model.sendersBucket[row].first().chat})
closureColumn(header : "Trust", preferredWidth : 50, type: String, read : { row ->
model.core.trustService.getLevel(row.destination).toString()
})
@@ -84,6 +85,7 @@ class SearchTabView {
gridLayout(rows: 1, cols : 2)
panel (border : etchedBorder()){
button(text : "Browse Host", enabled : bind {model.browseActionEnabled}, browseAction)
button(text : "Chat", enabled : bind{model.chatActionEnabled}, chatAction)
}
panel (border : etchedBorder()){
button(text : "Trust", enabled: bind {model.trustButtonsEnabled }, trustAction)
@@ -154,6 +156,14 @@ class SearchTabView {
}
count
})
closureColumn(header : "Chat Hosts", preferredWidth : 20, type : Integer, read : {
int count = 0
model.hashBucket[it].each {
if (it.chat)
count++
}
count
})
}
}
}
@@ -177,6 +187,7 @@ class SearchTabView {
tableModel(list : model.senders2) {
closureColumn(header : "Sender", preferredWidth : 350, type : String, read : {it.sender.getHumanReadableName()})
closureColumn(header : "Browse", preferredWidth : 20, type : Boolean, read : {it.browse})
closureColumn(header : "Chat", preferredWidth : 20, type : Boolean, read : {it.chat})
closureColumn(header : "Comment", preferredWidth : 20, type : Boolean, read : {it.comment != null})
closureColumn(header : "Certificates", preferredWidth : 20, type: Integer, read : {it.certificates})
closureColumn(header : "Trust", preferredWidth : 50, type : String, read : {
@@ -189,6 +200,7 @@ class SearchTabView {
gridLayout(rows : 1, cols : 2)
panel (border : etchedBorder()) {
button(text : "Browse Host", enabled : bind {model.browseActionEnabled}, browseAction)
button(text : "Chat", enabled : bind{model.chatActionEnabled}, chatAction)
button(text : "View Comment", enabled : bind {model.viewCommentActionEnabled}, showCommentAction)
button(text : "View Certificates", enabled : bind {model.viewCertificatesActionEnabled}, viewCertificatesAction)
}
@@ -314,10 +326,12 @@ class SearchTabView {
if (row < 0) {
model.trustButtonsEnabled = false
model.browseActionEnabled = false
model.chatActionEnabled = false
return
} else {
Persona sender = model.senders[row]
model.browseActionEnabled = model.sendersBucket[sender].first().browse
model.chatActionEnabled = model.sendersBucket[sender].first().chat
model.trustButtonsEnabled = true
model.results.clear()
model.results.addAll(model.sendersBucket[sender])
@@ -337,6 +351,7 @@ class SearchTabView {
if (e == null) {
model.trustButtonsEnabled = false
model.browseActionEnabled = false
model.chatActionEnabled = false
model.viewCertificatesActionEnabled = false
return
}
@@ -346,7 +361,8 @@ class SearchTabView {
model.senders2.addAll(results)
int selectedRow = sendersTable2.getSelectedRow()
sendersTable2.model.fireTableDataChanged()
sendersTable2.selectionModel.setSelectionInterval(selectedRow,selectedRow)
if (selectedRow < results.size())
sendersTable2.selectionModel.setSelectionInterval(selectedRow,selectedRow)
})
resultsTable2.addMouseListener(new MouseAdapter() {
@@ -367,14 +383,16 @@ class SearchTabView {
selectionModel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
selectionModel.addListSelectionListener({
int row = selectedSenderRow()
if (row < 0) {
if (row < 0 || model.senders2[row] == null) {
model.browseActionEnabled = false
model.chatActionEnabled = false
model.viewCertificatesActionEnabled = false
model.trustButtonsEnabled = false
model.viewCommentActionEnabled = false
return
}
model.browseActionEnabled = model.senders2[row].browse
model.chatActionEnabled = model.senders2[row].chat
model.trustButtonsEnabled = true
model.viewCommentActionEnabled = model.senders2[row].comment != null
model.viewCertificatesActionEnabled = model.senders2[row].certificates > 0
@@ -438,6 +456,17 @@ class SearchTabView {
if (lastResults2SortEvent != null)
selectedRow = resultsTable2.rowSorter.convertRowIndexToModel(selectedRow)
InfoHash infohash = model.results2[selectedRow]
Persona sender = selectedSender()
if (sender == null) // really shouldn't happen
return model.hashBucket[infohash].first()
for (UIResultEvent candidate : model.hashBucket[infohash]) {
if (candidate.sender == sender)
return candidate
}
// also shouldn't happen
return model.hashBucket[infohash].first()
} else {
int[] selectedRows = resultsTable.getSelectedRows()
@@ -492,7 +521,7 @@ class SearchTabView {
if (row < 0)
return null
if (model.groupedByFile)
return model.senders2[row].sender
return model.senders2[row]?.sender
else
return model.senders[row]
}

View File

@@ -0,0 +1,36 @@
package com.muwire.gui
import java.awt.Desktop
import javax.swing.JDialog
import javax.swing.JOptionPane
import javax.swing.SwingWorker
import com.muwire.core.download.Downloader
class DownloadPreviewer extends SwingWorker {
private final Downloader downloader
private final DownloadPreviewView view
DownloadPreviewer(Downloader downloader, DownloadPreviewView view) {
this.downloader = downloader
this.view = view
}
@Override
protected Object doInBackground() throws Exception {
downloader.generatePreview()
}
@Override
public void done() {
File previewFile = get()
view.dialog.setVisible(false)
view.mvcGroup.destroy()
if (previewFile == null)
JOptionPane.showMessageDialog(null, "Generating preview file failed", "Preview Failed", JOptionPane.ERROR_MESSAGE)
else
Desktop.getDesktop().open(previewFile)
}
}

View File

@@ -19,6 +19,7 @@ class UISettings {
boolean clearUploads
boolean storeSearchHistory
boolean groupByFile
int maxChatLines
Set<String> searchHistory
Set<String> openTabs
@@ -38,6 +39,7 @@ class UISettings {
clearUploads = Boolean.parseBoolean(props.getProperty("clearUploads","false"))
storeSearchHistory = Boolean.parseBoolean(props.getProperty("storeSearchHistory","true"))
groupByFile = Boolean.parseBoolean(props.getProperty("groupByFile","false"))
maxChatLines = Integer.parseInt(props.getProperty("maxChatLines","-1"))
searchHistory = DataUtil.readEncodedSet(props, "searchHistory")
openTabs = DataUtil.readEncodedSet(props, "openTabs")
@@ -59,6 +61,7 @@ class UISettings {
props.setProperty("clearUploads", String.valueOf(clearUploads))
props.setProperty("storeSearchHistory", String.valueOf(storeSearchHistory))
props.setProperty("groupByFile", String.valueOf(groupByFile))
props.setProperty("maxChatLines", String.valueOf(maxChatLines))
if (font != null)
props.setProperty("font", font)