Sketcher2 source code
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

713 lines
23 KiB

package com.jotuntech.sketcher.server;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.awt.image.ImageObserver;
import java.io.DataOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedByInterruptException;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.zip.DeflaterOutputStream;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import com.jotuntech.sketcher.common.BitmapLayer;
import com.jotuntech.sketcher.common.Canvas;
import com.jotuntech.sketcher.common.Layer;
import com.jotuntech.sketcher.common.Log;
import com.jotuntech.sketcher.common.StreamableUtils;
import com.jotuntech.sketcher.common.TwoWayHashMap;
import com.jotuntech.sketcher.common.User;
import com.jotuntech.sketcher.server.command.KickCommand;
import com.jotuntech.sketcher.server.command.ServerMessageCommand;
import com.jotuntech.sketcher.server.command.SignOutCommand;
public class Server extends Thread implements ImageObserver {
/** Map of client connections */
private TwoWayHashMap<Integer, Connection> connectionMap;
/** Buffer for encoding commands before transmission */
private ByteBuffer commandBuffer;
/** Array of active users - always replace instead of mutating */
private User[] users;
/** Map of connections - always replace instead of muting */
private TwoWayHashMap<Integer, Connection> safeConnectionMap;
/** Layers and pixel information */
private Canvas canvas;
/** Custom attributes for the host */
private Map<String, Object> attributes = new HashMap<String, Object>();
/** TCP port number */
private int port;
/** Flattened image of canvas */
private BufferedImage image;
/** Pixel array for image */
private int[] pixels;
/** Dirty rectangle since last merge */
private Rectangle mergeDirty;
/** Data output stream for animation data */
private DataOutputStream animationDOS;
/** Optional access controller for callback to host */
private AccessController accessController;
/** Socket channel that the server listens on */
private ServerSocketChannel serverChannel;
/** Set to inject command into server loop for broadcasting to clients */
private CommandEntry injectCommand;
/** Millisecond time of last layer cleanup */
private long lastLayerClean;
/** Voice chat server */
private VoiceServer voiceServer;
/** MOTD text */
private String motd = null;
public Server(String name, int port, int width, int height) throws IOException {
super(name);
this.port = port;
this.users = new User[0];
this.safeConnectionMap = new TwoWayHashMap<Integer, Connection>();
connectionMap = new TwoWayHashMap<Integer, Connection>();
connectionMap.addChangeListener(new ChangeListener() {
public void stateChanged(ChangeEvent e) {
/** Find number of connections with signed-in users */
int numUsers = 0;
for(Connection c : connectionMap.values()) {
if(c.getUser() == null) {
continue;
}
++numUsers;
}
/** Allocate array for new user list */
User[] newUsers = new User[numUsers];
/** Populate array with users */
int index = 0;
for(Connection c : connectionMap.values()) {
if(c.getUser() == null) {
continue;
}
newUsers[index++] = c.getUser();
}
/** Replace array atomically */
users = newUsers;
/** Replicate connection map */
TwoWayHashMap<Integer, Connection> newMap = connectionMap.copy();
safeConnectionMap = newMap;
}
});
canvas = new Canvas(width, height);
image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
pixels = ((DataBufferInt)image.getRaster().getDataBuffer()).getData();
/** Initialize flattened canvas image by filling it with white */
Graphics2D g2 = (Graphics2D)image.getGraphics();
g2.setColor(Color.WHITE);
g2.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
g2.dispose();
/** Create default layers */
TwoWayHashMap<Integer, Layer> layerMap = getCanvas().getLayerMap();
layerMap.put(new BitmapLayer("Sketch"));
layerMap.put(new BitmapLayer("Color"));
layerMap.put(new BitmapLayer("Shadow"));
layerMap.put(new BitmapLayer("Light"));
layerMap.put(new BitmapLayer("Ink"));
layerMap.put(new BitmapLayer("Doodle"));
/** Receive image updates from canvas */
canvas.addImageObserver(this);
/** Allocate buffer to hold command length and data */
commandBuffer = ByteBuffer.allocate(65538);
/** Start layer cleanup cycle at a random time for smooth operation of multiple servers */
lastLayerClean = System.currentTimeMillis() - Math.round(Math.random() * 30000);
/** Open listening socket */
serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new java.net.InetSocketAddress(port));
Log.info("Sketcher server running on port " + port);
/** Start voice chat server */
voiceServer = new VoiceServer(port + 100);
voiceServer.start();
}
public Canvas getCanvas() {
return canvas;
}
public TwoWayHashMap<Integer, Connection> getConnectionMap() {
return connectionMap;
}
public TwoWayHashMap<Integer, Connection> getSafeConnectionMap() {
return safeConnectionMap;
}
public User[] getUsers() {
return users;
}
public void setAttribute(String key, Object value) {
attributes.put(key, value);
}
public Object getAttribute(String key) {
return attributes.get(key);
}
public int getPort() {
return port;
}
public BufferedImage getImage() {
return image;
}
public void announce(String message) {
Log.info("Announce: " + message);
injectCommand = new CommandEntry(0, new ServerMessageCommand(message));
}
public void kick(String username, String reason) {
Log.info("Kick: " + username + " (" + reason + ")");
/** Kick command was initialized with a name, and not a key, so we must look it up */
for(Map.Entry<Integer, Connection> e : getSafeConnectionMap().entrySet()) {
User u = e.getValue().getUser();
if(u == null) {
continue;
}
if(username.equalsIgnoreCase(u.getName())) {
Command c = new KickCommand(e.getKey(), reason);
c.perform(this, null);
injectCommand = new CommandEntry(0, c);
break;
}
}
}
public boolean imageUpdate(Image img, int infoflags, int left, int top, int width, int height) {
if(infoflags == ImageObserver.ALLBITS) {
/** Entire image needs an update */
left = 0;
top = 0;
width = getCanvas().getWidth();
height = getCanvas().getHeight();
} else {
left = Math.max(0, Math.min(getCanvas().getWidth() - 1, left));
top = Math.max(0, Math.min(getCanvas().getHeight() - 1, top));
width = Math.max(0, Math.min(getCanvas().getWidth(), left + width) - left);
height = Math.max(0, Math.min(getCanvas().getHeight(), top + height) - top);
}
Graphics2D g2 = (Graphics2D)image.getGraphics();
/** Set clipping rectangle */
g2.setClip(left, top, width, height);
/** Composite source over destination */
AlphaComposite ac = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1);
g2.setComposite(ac);
/** Clear with white */
g2.setColor(Color.WHITE);
g2.fillRect(left, top, width, height);
/** Create a map of layers, showing which of them have phantom layers */
IdentityHashMap<Layer, Set<Layer>> selectedLayers = new IdentityHashMap<Layer, Set<Layer>>();
for(Connection c : connectionMap.values()) {
User u = c.getUser();
if(u == null) {
continue;
}
Layer l = u.getLayer();
if(l == null) {
continue;
}
Set<Layer> us = selectedLayers.get(l);
if(us == null) {
us = new HashSet<Layer>();
selectedLayers.put(l, us);
}
us.add(u.getPhantomLayer());
}
/** Paint layers over it */
for(Layer l : canvas.getLayerMap().values()) {
Set<Layer> ls = selectedLayers.get(l);
if(l instanceof BitmapLayer && ls != null) {
((BitmapLayer) l).draw(g2, ls);
} else {
l.draw(g2);
}
}
g2.dispose();
if(mergeDirty == null) {
mergeDirty = new Rectangle(left, top, width, height);
} else {
mergeDirty.union(new Rectangle(left, top, width, height));
}
if(animationDOS != null) {
synchronized(animationDOS) {
try {
animationDOS.writeLong(System.currentTimeMillis());
animationDOS.writeInt(left);
animationDOS.writeInt(top);
animationDOS.writeInt(width);
animationDOS.writeInt(height);
for(int y = top; y < top + height; y++) {
for(int x = left; x < left + width; x++) {
animationDOS.writeInt(pixels[y * image.getWidth() + x]);
}
}
} catch (IOException e) {
try { animationDOS.close(); } catch (IOException e1) { }
e.printStackTrace();
}
}
}
return true;
}
public void setAccessController(AccessController accessController) {
this.accessController = accessController;
}
public AccessController getAccessController() {
return accessController;
}
public void setAnimationFile(String name) throws IOException {
if(animationDOS != null) {
synchronized(animationDOS) {
animationDOS.close();
}
}
animationDOS = new DataOutputStream(new DeflaterOutputStream(new FileOutputStream(name, true)));
}
public void run() {
try {
while(!interrupted()) {
/** Conserve CPU time when idle */
sleep(20);
/** Run layer clean-up every 30 seconds */
if(lastLayerClean + 30000 < System.currentTimeMillis()) {
/** Cleanup now happens internally and separately on client and server */
for(Layer l : canvas.getLayerMap().values()) {
/** Clean up layer */
l.clean();
}
/** Time of last layer cleanup is now */
lastLayerClean = System.currentTimeMillis();
//Log.info("Cleaned layers.");
}
/** Accept and initialize client connections */
for(SocketChannel channel = serverChannel.accept(); channel != null; channel = serverChannel.accept()) {
channel.configureBlocking(false);
Connection connection = new Connection(channel);
Integer connectionKey = connectionMap.put(connection);
Log.debug("Accepting connection " + connectionKey + " from " + channel.socket().getRemoteSocketAddress());
}
/** Broadcast any injected command */
if(injectCommand != null) {
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(injectCommand);
}
injectCommand = null;
}
/** Loop through all client connections */
for(Map.Entry<Integer, Connection> entry : connectionMap.entrySet()) {
/** Fetch basic information about each connection */
Integer connectionKey = entry.getKey();
Connection connection = entry.getValue();
SocketChannel channel = connection.getChannel();
/** All connection specific errors are trapped by this 'try' statement */
try {
if(!channel.isConnected() && !connection.hasTimeOfDeath()) {
/** Remove closed connection from map */
connectionMap.remove(connectionKey);
/** Remove voice chat client if any */
voiceServer.removeClient(connectionKey);
if(connection.getUser() != null) {
/** Connection has closed unceremoniously. Broadcast to other clients. */
CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was closed"));
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(commandEntry);
}
Log.debug("Connection " + connectionKey + " had no time of death, sent explicit sign-out.");
}
Log.debug("Connection " + connectionKey + " was closed.");
/** The loop must be broken here to avoid a ConcurrentModificationException */
break;
} else if(connection.isTimeOfDeath()) {
/** Client signed out earlier. Close the channel. */
try { channel.close(); } catch(IOException e) { }
/** Remove from connection map */
connectionMap.remove(connectionKey);
/** Remove voice chat client if any */
voiceServer.removeClient(connectionKey);
Log.debug("Connection " + connectionKey + " has reached time of death and was closed.");
/** The loop must be broken here to avoid a ConcurrentModificationException */
break;
} else if(!connection.hasTimeOfDeath() && System.currentTimeMillis() - connection.getLastPing() > Connection.MAX_PING) {
connection.setTimeOfDeath(System.currentTimeMillis() + 5000);
CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Ping timeout"));
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(commandEntry);
}
if(connection.getUser() != null) {
Log.info(connection.getUser().getName() + " has signed out (Ping timeout).");
}
}
/** Get buffers for networking */
ByteBuffer inputBuffer = connection.getInputBuffer();
ByteBuffer outputBuffer = connection.getOutputBuffer();
ArrayBlockingQueue<CommandEntry> sendQueue = connection.getSendQueue();
//Random sendRandom = connection.getSendRandom();
//Random recvRandom = connection.getRecvRandom();
//int startPosition = outputBuffer.position();
/** Only check send queue if there is room for a minimal command in the output buffer. */
while(outputBuffer.remaining() >= 2 + 4 + 1 + 2 && sendQueue.size() > 0) {
/** Peek at the head of the queue */
CommandEntry e = sendQueue.peek();
/** Get command entry fields */
Integer k = e.getSourceKey();
Command c = e.getCommand();
/** Clear command buffer */
commandBuffer.clear();
/** Write peer key */
commandBuffer.putInt(k);
/** Write command name */
String commandName = c.getClass().getSimpleName();
commandBuffer.put((byte) (commandName.length() - 1));
for(int i = 0; i < commandName.length(); i++) {
commandBuffer.putChar(commandName.charAt(i));
}
/** Encode and write command data */
c.encode(commandBuffer);
/** Flip command buffer before reading */
commandBuffer.flip();
/** Discard command if there is no room for it in the output buffer */
if(commandBuffer.remaining() + 2 > outputBuffer.remaining()) {
break;
}
/** Append command length and command data to output buffer */
outputBuffer.putShort((short) (commandBuffer.remaining() - 1));
outputBuffer.put(commandBuffer);
/** Remove command from queue */
sendQueue.remove();
}
//for(int i = startPosition; i < outputBuffer.position(); i++) {
// outputBuffer.put(i, (byte) (outputBuffer.get(i) ^ sendRandom.nextInt(256)));
//}
/** Flip output buffer */
outputBuffer.flip();
if(outputBuffer.remaining() > 0) {
try {
/** Flush buffer */
channel.write(outputBuffer);
} catch (IOException e) {
Log.info("Connection" + connectionKey + " was broken (" + e.getMessage() + "). Sending sign-out.");
try { channel.close(); } catch(IOException e2) { }
connectionMap.remove(connectionKey);
CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was broken: " + e.getMessage()));
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(commandEntry);
}
/** Remove voice chat client if any */
voiceServer.removeClient(connectionKey);
break;
}
}
/** Prepare output buffer for append */
outputBuffer.compact();
/** Mortal connections are mute and have no voice chat audio */
if(connection.hasTimeOfDeath()) {
continue;
}
/** Append socket data to input buffer */
//startPosition = inputBuffer.position();
int readResult = channel.read(inputBuffer);
if(readResult == -1) {
/** Close the channel */
try { channel.close(); } catch(IOException e) { }
/** Remove from connection map */
connectionMap.remove(connectionKey);
/** Broadcast sign-out to other clients */
CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was closed"));
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(commandEntry);
}
/** Remove voice chat client if any */
voiceServer.removeClient(connectionKey);
Log.info("Connection " + connectionKey + " reached end of stream. Sending sign-out.");
/** The loop must be broken here to avoid a ConcurrentModificationException */
break;
} else if(readResult > 0) {
//for(int i = startPosition; i < inputBuffer.position(); i++) {
// inputBuffer.put(i, (byte) (inputBuffer.get(i) ^ recvRandom.nextInt(256)));
//}
/** Prepare input buffer for reading */
inputBuffer.flip();
/** Only check input buffer if long enough to hold a minimal command. */
while(inputBuffer.remaining() >= 2 + 4 + 1 + 2) {
/** Peek at length */
int commandLength = (inputBuffer.getShort(inputBuffer.position()) & 0xFFFF) + 1;
/** Only read input buffer if long enough to read entire command. */
if(inputBuffer.remaining() < commandLength + 2) {
break;
}
/** We already have the command length */
inputBuffer.getShort();
/** Map data into command buffer */
ByteBuffer commandBuffer = inputBuffer.slice();
commandBuffer.limit(commandLength);
/** Skip command data in input buffer */
inputBuffer.position(inputBuffer.position() + commandLength);
/** Ignore peer key from client */
commandBuffer.getInt();
/** Read command name */
int commandNameLength = (commandBuffer.get() & 0xFF) + 1;
StringBuffer commandNameBuffer = new StringBuffer();
for(int i = 0; i < commandNameLength; i++) {
commandNameBuffer.append(commandBuffer.getChar());
}
String commandName = commandNameBuffer.toString();
/** Attempt to instanciate command */
Command command = (Command) StreamableUtils.create("com.jotuntech.sketcher.server.command." + commandName, commandBuffer);
/** Wrap it in a CommandEntry in case of a broadcast */
CommandEntry commandEntry = new CommandEntry(connectionKey, command);
/** Perform the command and check the result */
switch(command.perform(this, connection)) {
/** Command wishes to be broadcast to all connections */
case Connection.SEND_ALL:
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(commandEntry);
}
break;
/** Command wishes to be broadcast to all but its own connection */
case Connection.SEND_OTHERS:
for(Connection c : connectionMap.values()) {
if(c != connection) {
c.getSendQueue().offer(commandEntry);
}
}
break;
/** Command wishes to loop back to the client */
case Connection.SEND_SELF:
connection.getSendQueue().offer(commandEntry);
break;
/** Command wishes to be anonymous */
case Connection.SEND_NONE:
break;
}
}
/** Prepare input buffer for appending */
inputBuffer.compact();
}
} catch (IOException e) {
/** Remove from connection map */
connectionMap.remove(connectionKey);
/** Close the channel */
try { channel.close(); } catch(IOException e2) { }
if(connection.getUser() != null) {
/** Broadcast sign-out to clients */
CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was broken: " + e.getMessage()));
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(commandEntry);
}
}
/** Remove voice chat client if any */
voiceServer.removeClient(connectionKey);
Log.info("Connection " + connectionKey + " was broken (" + e.getMessage() + "). Sending sign-out.");
/** The loop must be broken here to avoid a ConcurrentModificationException */
break;
} catch(Throwable t) {
/** We have crashed and must let the client know, so we let connection linger a bit */
connection.setTimeOfDeath(System.currentTimeMillis() + 5000);
if(connection.getUser() != null) {
/** Broadcast sign-out to clients */
CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was crashed: " + t.getClass().getSimpleName()));
for(Connection c : connectionMap.values()) {
c.getSendQueue().offer(commandEntry);
}
}
Log.error("Connection " + connectionKey + " crashed!");
Log.error(t);
/** Remove voice chat client if any */
voiceServer.removeClient(connectionKey);
/** The loop must be broken here to avoid a ConcurrentModificationException */
break;
}
}
}
} catch(ClosedByInterruptException e) {
Log.info("Server interrupted.");
} catch(InterruptedException e) {
Log.info("Server interrupted.");
} catch(Throwable e) {
Log.error("Server crashed.");
Log.error(e);
}
/** Shut down the voice server */
voiceServer.interrupt();
/** Close all client connections */
for(Connection c : connectionMap.values()) {
try { c.getChannel().close(); } catch(IOException e) { }
}
/** Close server socket channel */
try { serverChannel.close(); } catch(IOException e) { }
/** Empty the user list */
users = new User[0];
/** Clear the connection map */
connectionMap.clear();
}
public VoiceServer getVoiceServer() {
return voiceServer;
}
public String getMOTD() {
return motd;
}
public void setMOTD(String motd) {
this.motd = motd;
}
public void setMergeDirty(Rectangle mergeDirty) {
this.mergeDirty = mergeDirty;
}
public Rectangle getMergeDirty() {
return mergeDirty;
}
}