diff --git a/plugins/meet/pom.xml b/plugins/meet/pom.xml
index f9c969b32..137ec530b 100644
--- a/plugins/meet/pom.xml
+++ b/plugins/meet/pom.xml
@@ -10,10 +10,10 @@
meet
- 0.1
+ 0.0.5
- Meetings Plugin
- Adds support for Openfire Meetings to the Spark IM client.
+ Pade Meetings Plugin
+ Adds support for Pade Meetings to the Spark IM client.
@@ -24,4 +24,4 @@
-
+
\ No newline at end of file
diff --git a/plugins/meet/readme.md b/plugins/meet/readme.md
index 996338477..8780b1454 100644
--- a/plugins/meet/readme.md
+++ b/plugins/meet/readme.md
@@ -1,2 +1,17 @@
-This is a plugin for Spark that allows users to join audio and video conferences hosted by Openfire Meetings.
+This is a plugin for Spark that allows users to join audio and video conferences hosted by [Openfire Meetings](https://github.com/igniterealtime/community-plugins/tree/master/ofmeet). See the documentation for more details. It provides a button from a Multi User Chat (MUC) room or chat window within the Spark client, to open a Chrome window using the same URL as the Jitsi Meet web client. It therefore assumes you have Chrome installed and configured as your default browser. It works, but the user experience is not ideal.
+It uses Electron instead of depending on Chrome installed and configured as the default browser.
+
+ 
+
+It is a much better user experience than opening a Chrome browser window out of context somewhere else.
+It does not do desktop sharing, but does whole screen sharing. All Electron runtime platforms (windows, Linux & OSX) are supported. I have only tested it at home on my windows win32 desktop.
+Feeback will be appreciated from Linux and OSX users.
+
+The sparkmeet.jar plugin is built using the plugin ANT build.xml file. The following targets can be used
+
+1. win32 - Windows 32 Only
+1. win - Bothe win32 and win64
+1. linux - Linux32 & linux64
+1. osx - OSX 64
+1. all - Multi-platform support. The plugin will be over 200MB. Spark takes over a minute to start the very first time the plugin is deployed.
diff --git a/plugins/meet/src/main/java/de/mxro/process/ProcessListener.java b/plugins/meet/src/main/java/de/mxro/process/ProcessListener.java
new file mode 100644
index 000000000..03f1c997e
--- /dev/null
+++ b/plugins/meet/src/main/java/de/mxro/process/ProcessListener.java
@@ -0,0 +1,37 @@
+package de.mxro.process;
+
+/**
+ * A listener to intercept outputs from the process.
+ *
+ * @author Max
+ *
+ */
+public interface ProcessListener {
+
+ /**
+ * When the process wrote a line to its standard output stream.
+ *
+ * @param line
+ */
+ public void onOutputLine(String line);
+
+ /**
+ * When the process wrote a line to its error output stream.
+ *
+ * @param line
+ */
+ public void onErrorLine(String line);
+
+ /**
+ * When the output stream is closed.
+ */
+ public void onProcessQuit(int returnValue);
+
+ /**
+ * When an unexpected error is thrown while interacting with the process.
+ *
+ * @param t
+ */
+ public void onError(Throwable t);
+
+}
diff --git a/plugins/meet/src/main/java/de/mxro/process/Spawn.java b/plugins/meet/src/main/java/de/mxro/process/Spawn.java
new file mode 100644
index 000000000..7984634fa
--- /dev/null
+++ b/plugins/meet/src/main/java/de/mxro/process/Spawn.java
@@ -0,0 +1,129 @@
+package de.mxro.process;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+import de.mxro.process.internal.Engine;
+
+public class Spawn {
+
+ /**
+ * Start a new process.
+ *
+ * @param command
+ * @param listener
+ * @param folder
+ * @return
+ */
+ public static XProcess startProcess(final String command, final File folder, final ProcessListener listener) {
+ return startProcess(command.split(" "), folder, listener);
+ }
+
+ public static XProcess startProcess(final String[] command, final File folder, final ProcessListener listener) {
+ return Engine.startProcess(command, listener, folder);
+ }
+
+ public static String runCommand(final String command, final File folder) {
+ return runCommand(command.split(" "), folder);
+ }
+
+ /**
+ * Runs a command in a new process and stops the process thereafter.
+ *
+ * @param command
+ * @param folder
+ */
+ public static String runCommand(final String[] command, final File folder) {
+
+ final CountDownLatch latch = new CountDownLatch(2);
+
+ final List exceptions = Collections.synchronizedList(new LinkedList());
+
+ final List output = Collections.synchronizedList(new LinkedList());
+
+ latch.countDown();
+ final XProcess process = startProcess(command, folder, new ProcessListener() {
+
+ @Override
+ public void onProcessQuit(final int returnValue) {
+ latch.countDown();
+ }
+
+ @Override
+ public void onOutputLine(final String line) {
+ output.add(line);
+ }
+
+ @Override
+ public void onErrorLine(final String line) {
+ output.add(line);
+ }
+
+ @Override
+ public void onError(final Throwable t) {
+ exceptions.add(t);
+ }
+ });
+
+ try {
+ latch.await();
+ // Thread.sleep(300); // just wait for input to gobble in
+ } catch (final InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (exceptions.size() > 0) {
+ throw new RuntimeException(exceptions.get(0));
+ }
+
+ final StringBuilder sb = new StringBuilder();
+ for (final String line : new ArrayList(output)) {
+ sb.append(line + "\n");
+ }
+
+ process.destory();
+ return sb.toString();
+ }
+
+ public interface Callback {
+ public void onDone(Type t);
+ }
+
+ /**
+ * Runs bash scripts (*.sh) in a UNIX environment.
+ *
+ * @param bashScriptFile
+ * @return
+ */
+ public static String runBashScript(final File bashScriptFile) {
+ return runCommand("/bin/bash -c " + bashScriptFile.getAbsolutePath(), bashScriptFile.getParentFile());
+ }
+
+ public static boolean isWindows() {
+ return System.getProperty("os.name").startsWith("Windows");
+ }
+
+ public static String sh(final File folder, final String bashCommand) {
+
+ if (!isWindows()) {
+ return runCommand(new String[] { "/bin/bash", "-c", bashCommand }, folder);
+ } else {
+ return runCommand(new String[] { "cmd.exe", "/C", bashCommand }, folder);
+ }
+ }
+
+ /**
+ * Executes a bash command.
+ *
+ * @param bashCommand
+ * @return
+ */
+ public static String sh(final String bashCommand) {
+ return sh(null, bashCommand);
+ }
+
+}
diff --git a/plugins/meet/src/main/java/de/mxro/process/XProcess.java b/plugins/meet/src/main/java/de/mxro/process/XProcess.java
new file mode 100644
index 000000000..a8e9743c2
--- /dev/null
+++ b/plugins/meet/src/main/java/de/mxro/process/XProcess.java
@@ -0,0 +1,24 @@
+package de.mxro.process;
+
+/**
+ * A wrapper for {@link java.lang.Process}.
+ *
+ * @author Max Rohde
+ *
+ */
+public interface XProcess {
+
+ /**
+ * Call to push the specified {@link String} into the started processes
+ * input.
+ *
+ * @param line
+ */
+ public void sendLine(String line);
+
+ /**
+ * Try to destroy the process
+ */
+ public void destory();
+
+}
diff --git a/plugins/meet/src/main/java/de/mxro/process/internal/Engine.java b/plugins/meet/src/main/java/de/mxro/process/internal/Engine.java
new file mode 100644
index 000000000..bfc92833e
--- /dev/null
+++ b/plugins/meet/src/main/java/de/mxro/process/internal/Engine.java
@@ -0,0 +1,183 @@
+package de.mxro.process.internal;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.lang.ProcessBuilder.Redirect;
+import java.util.Date;
+import java.util.concurrent.atomic.AtomicLong;
+
+import de.mxro.process.ProcessListener;
+import de.mxro.process.XProcess;
+
+public class Engine {
+
+ public static XProcess startProcess(final String[] command,
+ final ProcessListener listener, final File folder) {
+
+ final ProcessBuilder pb;
+ final Process process;
+
+ try {
+
+ pb = new ProcessBuilder(command);
+ pb.directory(folder);
+ pb.redirectOutput(Redirect.PIPE);
+ pb.redirectInput(Redirect.PIPE);
+ process = pb.start();
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ final AtomicLong lastOutput = new AtomicLong();
+ final long startTime = new Date().getTime();
+ lastOutput.set(0);
+
+
+
+ final OutputStream outputStream = process.getOutputStream();
+ final InputStream inputStream = process.getInputStream();
+ final InputStream errorStream = process.getErrorStream();
+
+
+ //BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
+
+
+// String line;
+// try {
+// line = reader.readLine();
+// while (line != null && !line.trim().equals("--EOF--")) {
+// listener.onOutputLine(line);
+// line = reader.readLine();
+// }
+// } catch (IOException e1) {
+// // TODO Auto-generated catch block
+// e1.printStackTrace();
+// }
+
+
+ final StreamReader streamReader = new StreamReader(inputStream,
+ new StreamListener() {
+
+ @Override
+ public void onOutputLine(final String line) {
+ //System.out.println("Receiving line");
+ lastOutput.set(new Date().getTime());
+ listener.onOutputLine(line);
+ }
+
+ @Override
+ public void onError(final Throwable t) {
+ listener.onError(new Exception(
+ "Error while reading standard output", t));
+ }
+
+ @Override
+ public void onClosed() {
+
+ }
+ });
+
+
+
+ final StreamReader errorStreamReader = new StreamReader(errorStream,
+ new StreamListener() {
+
+ @Override
+ public void onOutputLine(final String line) {
+ lastOutput.set(new Date().getTime());
+ listener.onErrorLine(line);
+ }
+
+ @Override
+ public void onError(final Throwable t) {
+ listener.onError(new Exception(
+ "Error while reading error output", t));
+ }
+
+ @Override
+ public void onClosed() {
+
+ }
+ });
+
+ new Thread() {
+
+ @Override
+ public void run() {
+ errorStreamReader.read();
+ }
+
+ }.start();
+
+ new Thread() {
+
+ @Override
+ public void run() {
+ try {
+ streamReader.read();
+
+ final int returnValue = process.waitFor();
+
+ //while (lastOutput.get() == 0 && new Date().getTime() - startTime < 500) {
+ // Thread.sleep(10);
+ //}
+
+ //while ( new Date().getTime() - lastOutput.get() < 1000) {
+ // System.out.println("DELAY ... "+(new Date().getTime() - lastOutput.get()));
+ // Thread.sleep(500);
+ //}
+ //System.out.println("processes finished with "+returnValue);
+
+ listener.onProcessQuit(returnValue);
+ } catch (final InterruptedException e) {
+ listener.onError(e);
+ return;
+ }
+
+ }
+
+ }.start();
+
+ return new XProcess() {
+
+ @Override
+ public synchronized void sendLine(final String line) {
+ final BufferedWriter writer = new BufferedWriter(
+ new OutputStreamWriter(outputStream));
+ try {
+ writer.append(line);
+ } catch (final IOException e) {
+ listener.onError(e);
+ }
+ }
+
+ @Override
+ public void destory() {
+ process.destroy();
+ try {
+ process.waitFor();
+ } catch (final InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+
+ try {
+ errorStreamReader.stop();
+
+ outputStream.close();
+ inputStream.close();
+ errorStream.close();
+
+ } catch (final IOException e) {
+ listener.onError(e);
+ }
+
+ }
+ };
+
+ }
+
+}
diff --git a/plugins/meet/src/main/java/de/mxro/process/internal/StreamListener.java b/plugins/meet/src/main/java/de/mxro/process/internal/StreamListener.java
new file mode 100644
index 000000000..220898027
--- /dev/null
+++ b/plugins/meet/src/main/java/de/mxro/process/internal/StreamListener.java
@@ -0,0 +1,11 @@
+package de.mxro.process.internal;
+
+public interface StreamListener {
+
+ public void onOutputLine(String line);
+
+ public void onClosed();
+
+ public void onError(Throwable t);
+
+}
diff --git a/plugins/meet/src/main/java/de/mxro/process/internal/StreamReader.java b/plugins/meet/src/main/java/de/mxro/process/internal/StreamReader.java
new file mode 100644
index 000000000..05040130e
--- /dev/null
+++ b/plugins/meet/src/main/java/de/mxro/process/internal/StreamReader.java
@@ -0,0 +1,100 @@
+package de.mxro.process.internal;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+
+public class StreamReader {
+
+ private final class WorkerThread extends Thread {
+ private final StreamListener listener;
+ private final InputStream stream;
+
+ private transient int timeout;
+
+ private WorkerThread(final StreamListener listener,
+ final InputStream stream) {
+ this.listener = listener;
+ this.stream = stream;
+ this.timeout = 10;
+ }
+
+ @Override
+ public void run() {
+ final BufferedReader reader = new BufferedReader(
+ new InputStreamReader(stream));
+ try {
+ String read;
+
+ this.timeout = 10;
+
+ read = reader.readLine();
+ while (!stop && read != null && !read.trim().equals("--EOF--")) {
+ if (stop) {
+ stopReader();
+ return;
+ }
+
+ if (read != null) {
+ listener.onOutputLine(read);
+ }
+ // waitForInput();
+ read = reader.readLine();
+ }
+
+ } catch (final Exception e) {
+ listener.onError(e);
+ }
+
+ }
+
+ /**
+ * Wait longer and longer to not keep CPU busy.
+ */
+ private final void waitForInput() {
+ try {
+ Thread.sleep(this.timeout);
+ } catch (final InterruptedException e) {
+ throw new RuntimeException();
+ }
+
+ if (this.timeout < 2000) {
+ this.timeout = this.timeout + (this.timeout);
+ }
+
+ }
+
+ private void stopReader() throws IOException {
+ //stream.close();
+ stopped = true;
+ listener.onClosed();
+ }
+ }
+
+ private final Thread t;
+ private volatile boolean stop = false;
+ private volatile boolean stopped = false;
+
+ public void read() {
+ t.run();
+ }
+
+ public void stop() {
+
+ if (stopped) {
+ return;
+ }
+ stop = true;
+ //while (!stopped) {
+ // Thread.yield();
+ //}
+ }
+
+ public StreamReader(final InputStream stream, final StreamListener listener) {
+ super();
+ this.t = new WorkerThread(listener, stream);
+
+ }
+
+}
diff --git a/plugins/meet/src/main/java/org/jitsi/util/OSUtils.java b/plugins/meet/src/main/java/org/jitsi/util/OSUtils.java
new file mode 100644
index 000000000..dc91f95d7
--- /dev/null
+++ b/plugins/meet/src/main/java/org/jitsi/util/OSUtils.java
@@ -0,0 +1,188 @@
+/*
+ * Jitsi, the OpenSource Java VoIP and Instant Messaging client.
+ *
+ * Distributable under LGPL license.
+ * See terms of license at gnu.org.
+ */
+package org.jitsi.util;
+
+/**
+ * Utility fields for OS detection.
+ *
+ * @author Sebastien Vincent
+ * @author Lubomir Marinov
+ */
+public class OSUtils
+{
+
+ /** true if architecture is 32 bit. */
+ public static final boolean IS_32_BIT;
+
+ /** true if architecture is 64 bit. */
+ public static final boolean IS_64_BIT;
+
+ /** true if OS is Android */
+ public static final boolean IS_ANDROID;
+
+ /** true if OS is Linux. */
+ public static final boolean IS_LINUX;
+
+ /** true if OS is Linux 32-bit. */
+ public static final boolean IS_LINUX32;
+
+ /** true if OS is Linux 64-bit. */
+ public static final boolean IS_LINUX64;
+
+ /** true if OS is MacOSX. */
+ public static final boolean IS_MAC;
+
+ /** true if OS is MacOSX 32-bit. */
+ public static final boolean IS_MAC32;
+
+ /** true if OS is MacOSX 64-bit. */
+ public static final boolean IS_MAC64;
+
+ /** true if OS is Windows. */
+ public static final boolean IS_WINDOWS;
+
+ /** true if OS is Windows 32-bit. */
+ public static final boolean IS_WINDOWS32;
+
+ /** true if OS is Windows 64-bit. */
+ public static final boolean IS_WINDOWS64;
+
+ /** true if OS is Windows Vista. */
+ public static final boolean IS_WINDOWS_VISTA;
+
+ /** true if OS is Windows 7. */
+ public static final boolean IS_WINDOWS_7;
+
+ /** true if OS is Windows 8. */
+ public static final boolean IS_WINDOWS_8;
+
+ /** true if OS is FreeBSD. */
+ public static final boolean IS_FREEBSD;
+
+ static
+ {
+ // OS
+ String osName = System.getProperty("os.name");
+
+ if (osName == null)
+ {
+ IS_ANDROID = false;
+ IS_LINUX = false;
+ IS_MAC = false;
+ IS_WINDOWS = false;
+ IS_WINDOWS_VISTA = false;
+ IS_WINDOWS_7 = false;
+ IS_WINDOWS_8 = false;
+ IS_FREEBSD = false;
+ }
+ else if (osName.startsWith("Linux"))
+ {
+ String javaVmName = System.getProperty("java.vm.name");
+
+ if ((javaVmName != null) && javaVmName.equalsIgnoreCase("Dalvik"))
+ {
+ IS_ANDROID = true;
+ IS_LINUX = false;
+ }
+ else
+ {
+ IS_ANDROID = false;
+ IS_LINUX = true;
+ }
+ IS_MAC = false;
+ IS_WINDOWS = false;
+ IS_WINDOWS_VISTA = false;
+ IS_WINDOWS_7 = false;
+ IS_WINDOWS_8 = false;
+ IS_FREEBSD = false;
+ }
+ else if (osName.startsWith("Mac"))
+ {
+ IS_ANDROID = false;
+ IS_LINUX = false;
+ IS_MAC = true;
+ IS_WINDOWS = false;
+ IS_WINDOWS_VISTA = false;
+ IS_WINDOWS_7 = false;
+ IS_WINDOWS_8 = false;
+ IS_FREEBSD = false;
+ }
+ else if (osName.startsWith("Windows"))
+ {
+ IS_ANDROID = false;
+ IS_LINUX = false;
+ IS_MAC = false;
+ IS_WINDOWS = true;
+ IS_WINDOWS_VISTA = (osName.indexOf("Vista") != -1);
+ IS_WINDOWS_7 = (osName.indexOf("7") != -1);
+ IS_WINDOWS_8 = (osName.indexOf("8") != -1);
+ IS_FREEBSD = false;
+ }
+ else if (osName.startsWith("FreeBSD"))
+ {
+ IS_ANDROID = false;
+ IS_LINUX = false;
+ IS_MAC = false;
+ IS_WINDOWS = false;
+ IS_WINDOWS_VISTA = false;
+ IS_WINDOWS_7 = false;
+ IS_WINDOWS_8 = false;
+ IS_FREEBSD = true;
+ }
+ else
+ {
+ IS_ANDROID = false;
+ IS_LINUX = false;
+ IS_MAC = false;
+ IS_WINDOWS = false;
+ IS_WINDOWS_VISTA = false;
+ IS_WINDOWS_7 = false;
+ IS_WINDOWS_8 = false;
+ IS_FREEBSD = false;
+ }
+
+ // arch i.e. x86, amd64
+ String osArch = System.getProperty("sun.arch.data.model");
+
+ if(osArch == null)
+ {
+ IS_32_BIT = true;
+ IS_64_BIT = false;
+ }
+ else if (osArch.indexOf("32") != -1)
+ {
+ IS_32_BIT = true;
+ IS_64_BIT = false;
+ }
+ else if (osArch.indexOf("64") != -1)
+ {
+ IS_32_BIT = false;
+ IS_64_BIT = true;
+ }
+ else
+ {
+ IS_32_BIT = false;
+ IS_64_BIT = false;
+ }
+
+ // OS && arch
+ IS_LINUX32 = IS_LINUX && IS_32_BIT;
+ IS_LINUX64 = IS_LINUX && IS_64_BIT;
+ IS_MAC32 = IS_MAC && IS_32_BIT;
+ IS_MAC64 = IS_MAC && IS_64_BIT;
+ IS_WINDOWS32 = IS_WINDOWS && IS_32_BIT;
+ IS_WINDOWS64 = IS_WINDOWS && IS_64_BIT;
+ }
+
+ /**
+ * Allows the extending of the OSUtils class but disallows
+ * initializing non-extended OSUtils instances.
+ */
+ protected OSUtils()
+ {
+ }
+}
diff --git a/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/ChatRoomDecorator.java b/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/ChatRoomDecorator.java
index acc30067a..0b574b51d 100644
--- a/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/ChatRoomDecorator.java
+++ b/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/ChatRoomDecorator.java
@@ -26,9 +26,9 @@ import org.jivesoftware.spark.component.RolloverButton;
import org.jivesoftware.spark.ui.ChatRoom;
import org.jivesoftware.spark.util.*;
import org.jivesoftware.spark.util.log.*;
+import org.jivesoftware.smack.*;
import org.jivesoftware.smack.packet.*;
-import org.jxmpp.jid.Jid;
-import org.jxmpp.jid.parts.Localpart;
+import org.jxmpp.util.XmppStringUtils;
import sun.misc.BASE64Decoder;
@@ -47,58 +47,56 @@ public class ChatRoomDecorator
try {
BASE64Decoder decoder = new BASE64Decoder();
- String imageString = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACZUlEQVR42o1TS09TQRg999mWgtTGQosiLEQhXVA0Me5swsYFRjBxoyatvwBi4g9g66p7F+2exIDvBYldGOIzbUwMGqw2Sr1YH+21Qsud2zvOTB9UkMQv+e6de2fOme+c+QaUUtCdjLF8TPdGpv7rc4wUnoEYL2F/X4VTKwuMxB8selmmWUawK5zKOpzSB5D8Mqy1e1n2K6qPTJn6iRnIB49B3Q9cL74GtSoirXeLIGt3YPuNiKIhXX2SicKxTT18RRDMdYLtjVeQJBlW7iH7qMHZLMIurGC7x0D/VVFtxLwtzVm5R/P62KU2QQP85Tlo7SdI4Sns9RUG3gBIFZRsQvJ0yCKYk4F5PuYEPlHy1wwk1S3A5P0D2MYLkBDKUJBykVBaK3eVi7ckUBU+veyf1ifOQeoKCAIRlDogqwsgH5cFWBnFIgYQZ1Omy3sd1bs3oP32Q3b7oY5OLmkj5yH3HAGrBHlOwHVD9QiwcxSp3ot0xn+GgmVyW7lZsgOgsrefqsOTJVf4clI+MMjNFwSptjhZ4c88c/oae483yePewaRP5h7IKuTuoA+6N85253PjfxPULbYglLC2mHeV+4mWP5U3U6gTVqXi7jxpPpdQW0fIPZCYBMkbzGqGAXNpKqrxIhmQmIBmAMrESTAZnURRTjAtjiN4qmGmXYXFB58yqDdX6R5mXt9hBu6DMnAaSiDcLoO3Mm1qzbK2zdKtbwlrdcGEopfqpZyQoBwaE02lHb/Q3qgZ+dZd2LHhx9tG48hajLVzCno3lN4hIXEXWEjovIn/yrP8JtL9Y3ZPBf8RQ03feJMN/wHiFFIvPgJ8vgAAAABJRU5ErkJggg==";
+ String imageString = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAKDSURBVDhPXVM7axVBFP5m7maN5gFyJTdNEEVDUt3C/yAIKkRSidjEUsTKRyrtLSyi/gArSRfFOiBqY5UqICaRYDCvm7177+7Ozj7G70wSSJzlmzk75/WdmTMqLwq3ubKCcGQEOgjgBgKoYACq0YBrKGh+rqrh6gKqqFGVBVCUKJMexqenoba2t937qSmcu3QRmk5aN7yz4tfQGrVzkOG4ermuUBPdzT+4v7wMHYYhMg3k1JkaSJktyQyigwPsdfYRRV0keY7EWpiyRFpWMGJbVQjPDCLQSiGJe9Bxn2lI0VUYao1jcmYGDTJJow6+v3mH860xlkSGcNBOI4oPyBGgv0bKmpIiR7+wPmP7ziyuP37EWi2utK/hdZKgZwyyNEWaW29r8hJK84w0p5yRLBWWNAVRt4vVL9+w+OIlXt27i6+LHzC78BbR7i5y6gUZfRTZ0531EDlrt5VDQWS5Qd9knmJIbP1cg7HG6wViK0lFHwiDHQqms8eZTGqHDg9tdH8XG/y/PDmJG/PP8XC8hR5Pv087cfxLaEq8KEbkT8lAHpTjuIuxdhvzH5dw8+kTzDWb2NneQR3oU3aHh8hFAuQ8WSugbEjxx6fPeHbrNhbmHqDb6bA3gkM9IbapBOCtkYA7OgPeLyHBMpvD8r4HKQtN9qDXHcPbUsd7lxKopGjJR8BGRZJkiPsxqhP7/0MS0R/K9mN3dXgUY7JxNKQMqXGIOGzk04P++E382lgnQ7avZK25e4yA/0JfgggLjxN6gfhI+Srd33PDzQtoycbR4NPwmY9XGbIyl88u2CKi1VWosq5db30N4dkh35q+tRRfI7tMwImJ6MpsruZKyMussgzhxAT+AQRKd557vsR7AAAAAElFTkSuQmCC";
byte[] imageByte = decoder.decodeBuffer(imageString);
ImageIcon ofmeetIcon = new ImageIcon(imageByte);
ofmeetButton = new RolloverButton(ofmeetIcon);
- ofmeetButton.setToolTipText(GraphicUtils.createToolTip("Openfire Meetings"));
- final Localpart roomId = room.getRoomJid().getLocalpart();
+ ofmeetButton.setToolTipText(GraphicUtils.createToolTip("Pade Meetings"));
+ final String roomId = getNode(room.getRoomname().toString());
final String sessionID = roomId + "-" + System.currentTimeMillis();
- final Localpart nickname = SparkManager.getSessionManager().getJID().getLocalpart();
+ final String nickname = getNode(XmppStringUtils.parseBareAddress(SparkManager.getSessionManager().getJID().toString()));
ofmeetButton.addActionListener( new ActionListener()
{
public void actionPerformed(ActionEvent event)
{
- String newUrl;
- Localpart newRoomId;
+ String newUrl, newRoomId;
if ("groupchat".equals(room.getChatType().toString()))
{
- newRoomId = roomId;
- newUrl = url + "/" + newRoomId;
- sendInvite(room.getRoomJid(), newUrl, Message.Type.groupchat);
+ newRoomId = roomId + "-" + sessionID;
+ newUrl = url + newRoomId;
+ plugin.handleClick(newUrl, room, newUrl, Message.Type.groupchat);
} else {
-
- newRoomId = Localpart.fromOrThrowUnchecked(sessionID);
- newUrl = url + "/" + newRoomId;
- sendInvite(room.getRoomJid(), newUrl, Message.Type.chat);
+ newRoomId = sessionID;
+ newUrl = url + newRoomId;
+ plugin.handleClick(newUrl, room, newUrl, Message.Type.chat);
}
-
- plugin.openUrl(newUrl, newRoomId);
}
});
room.getEditorBar().add(ofmeetButton);
} catch (Exception e) {
- Log.error("cannot create openfire meetings icon", e);
+ Log.error("cannot create pade meetings icon", e);
}
}
public void finished()
{
- Log.warning("ChatRoomDecorator: finished " + room.getRoomJid());
+ Log.warning("ChatRoomDecorator: finished " + room.getRoomname());
}
- private void sendInvite(Jid jid, String url, Message.Type type)
+ private String getNode(String jid)
{
- Message message2 = new Message();
- message2.setTo(jid);
- message2.setType(type);
- message2.setBody(url);
- room.sendMessage(message2);
+ String node = jid;
+ int pos = node.indexOf("@");
+
+ if (pos > -1)
+ node = jid.substring(0, pos);
+
+ return node;
}
}
diff --git a/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/SparkMeetPlugin.java b/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/SparkMeetPlugin.java
index 18631bae1..bb21b0ff3 100644
--- a/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/SparkMeetPlugin.java
+++ b/plugins/meet/src/main/java/org/jivesoftware/spark/plugin/ofmeet/SparkMeetPlugin.java
@@ -17,122 +17,97 @@
package org.jivesoftware.spark.plugin.ofmeet;
-import org.jivesoftware.Spark;
-import org.jivesoftware.smack.packet.Message;
-import org.jivesoftware.spark.SparkManager;
-import org.jivesoftware.spark.plugin.Plugin;
-import org.jivesoftware.spark.ui.ChatRoom;
-import org.jivesoftware.spark.ui.ChatRoomListener;
-import org.jivesoftware.spark.ui.GlobalMessageListener;
-import org.jivesoftware.spark.util.log.Log;
-import org.jxmpp.jid.DomainBareJid;
-import org.jxmpp.jid.EntityBareJid;
-import org.jxmpp.jid.impl.JidCreate;
-import org.jxmpp.jid.parts.Localpart;
-
-import javax.swing.*;
import java.awt.*;
-import java.awt.event.ActionEvent;
-import java.awt.event.ActionListener;
-import java.io.File;
-import java.io.FileInputStream;
-import java.net.URLEncoder;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Properties;
+import java.awt.event.*;
+import javax.swing.*;
+import java.util.*;
+import java.util.zip.*;
+import java.io.*;
+import java.net.*;
+import java.lang.reflect.*;
+
+
+import org.jivesoftware.Spark;
+import org.jivesoftware.spark.*;
+import org.jivesoftware.smack.packet.Message;
+import org.jivesoftware.spark.component.*;
+import org.jivesoftware.spark.component.browser.*;
+import org.jivesoftware.spark.plugin.*;
+import org.jivesoftware.spark.ui.rooms.*;
+import org.jivesoftware.spark.ui.*;
+import org.jivesoftware.spark.util.*;
+import org.jivesoftware.smack.*;
+import org.jivesoftware.spark.util.log.*;
+
+import org.jitsi.util.OSUtils;
+import de.mxro.process.*;
+import org.jxmpp.jid.parts.*;
public class SparkMeetPlugin implements Plugin, ChatRoomListener, GlobalMessageListener
{
private org.jivesoftware.spark.ChatManager chatManager;
-
- private String protocol = "https";
- private DomainBareJid server = null;
- private String port = "7443";
private String url = null;
- private int width = 1024;
- private int height = 768;
- private String path = "ofmeet";
- private static File pluginsettings = new File(Spark.getSparkUserHome() + "/ofmeet.properties");
- private Map decorators = new HashMap<>();
-
- private Browser browser = null;
- private JFrame frame = null;
+ private static File pluginsettings = new File(System.getProperty("user.home") + System.getProperty("file.separator") + "Spark" + System.getProperty("file.separator") + "ofmeet.properties");
+ private Map decorators = new HashMap();
+ private String electronExePath = null;
+ private String electronHomePath = null;
+ private XProcess electronThread = null;
private JPanel inviteAlert;
-
- public SparkMeetPlugin()
- {
-
- }
-
public void initialize()
{
+ checkNatives();
+
chatManager = SparkManager.getChatManager();
- server = SparkManager.getSessionManager().getServerAddress();
+ String server = SparkManager.getSessionManager().getServerAddress().toString();
+ String port = "7443";
+ url = "https://" + server + ":" + port + "/ofmeet/";
Properties props = new Properties();
if (pluginsettings.exists())
{
- Log.debug("ofmeet-info: Properties-file does exist= " + pluginsettings.getPath());
+ Log.warning("ofmeet-info: Properties-file does exist= " + pluginsettings.getPath());
try {
props.load(new FileInputStream(pluginsettings));
- if (props.getProperty("port") != null)
+ if (props.getProperty("url") != null)
{
- port = props.getProperty("port");
- Log.debug("ofmeet-info: ofmeet-port from properties-file is= " + port);
+ url = props.getProperty("url");
+ Log.warning("ofmeet-info: ofmeet url from properties-file is= " + url);
}
- if (props.getProperty("protocol") != null)
- {
- protocol = props.getProperty("protocol");
- Log.debug("ofmeet-info: ofmeet-protocol from properties-file is= " + protocol);
- }
-
- if (props.getProperty("server") != null)
- {
- server = JidCreate.domainBareFrom(props.getProperty("server"));
- Log.debug("ofmeet-info: ofmeet-server from properties-file is= " + server);
- }
-
- if (props.getProperty("path") != null)
- {
- path = props.getProperty("path");
- Log.debug("ofmeet-info: ofmeet-path from properties-file is= " + path);
- }
-
- if (props.getProperty("width") != null)
- {
- width = Integer.parseInt(props.getProperty("width"));
- Log.debug("ofmeet-info: ofmeet-width from properties-file is= " + width);
- }
-
- if (props.getProperty("height") != null)
- {
- height = Integer.parseInt(props.getProperty("height"));
- Log.debug("ofmeet-info: ofmeet-height from properties-file is= " + height);
- }
-
-
- } catch (Exception e) {
- System.err.println(e);
+ } catch (IOException ioe) {
+ Log.warning("ofmeet-Error:", ioe);
}
} else {
-
Log.warning("ofmeet-Error: Properties-file does not exist= " + pluginsettings.getPath() + ", using default " + url);
}
- url = "https://" + server + ":" + port + "/" + path;
-
chatManager.addChatRoomListener(this);
chatManager.addGlobalMessageListener(this);
+ }
+
+ public void shutdown()
+ {
+ try
+ {
+ Log.warning("shutdown");
+ chatManager.removeChatRoomListener(this);
+
+ if (electronThread != null) electronThread.destory();
+ electronThread = null;
+ }
+ catch(Exception e)
+ {
+ Log.warning("shutdown ", e);
+ }
}
@Override
@@ -141,9 +116,10 @@ public class SparkMeetPlugin implements Plugin, ChatRoomListener, GlobalMessageL
try {
Localpart roomId = room.getRoomJid().getLocalpart();
String body = message.getBody();
+ int pos = body.indexOf("https://");
- if ( body.startsWith("https://") && body.endsWith("/" + roomId) ) {
- showInvitationAlert(message.getBody(), room, roomId);
+ if ( pos > -1 && (body.contains("/" + roomId + "-") || body.contains("meeting")) ) {
+ showInvitationAlert(message.getBody().substring(pos), room, roomId);
}
@@ -153,214 +129,6 @@ public class SparkMeetPlugin implements Plugin, ChatRoomListener, GlobalMessageL
}
- @Override
- public void messageSent(ChatRoom room, Message message) {
-
- }
-
- private String getNode(String jid)
- {
- String node = jid;
- int pos = node.indexOf("@");
-
- if (pos > -1)
- node = jid.substring(0, pos);
-
- return node;
- }
-
- public void shutdown()
- {
- try
- {
- Log.debug("shutdown");
-
- chatManager.removeChatRoomListener(this);
- chatManager.removeGlobalMessageListener(this);
- }
- catch(Exception e)
- {
- Log.warning("shutdown ", e);
- }
- }
-
- public boolean canShutDown()
- {
- return true;
- }
-
- public void uninstall()
- {
-
- }
-
- // openUrl keep only one ofmeet window opened at any time
- // to close window, send hangup command and wait for 1 sec.
- // before disposing browser and jframe window
-
-
- public void openUrl(String url, CharSequence roomId)
- {
- String meetUrl = url;
-
- try {
- String username = URLEncoder.encode(SparkManager.getSessionManager().getUsername(), "UTF-8");
- String password = URLEncoder.encode(SparkManager.getSessionManager().getPassword(), "UTF-8");
-
- if (meetUrl.startsWith("https://"))
- {
- meetUrl = "https://" + username + ":" + password + "@" + url.substring(8);
- openRoom(meetUrl, roomId);
-
- } else Log.warning("openUrl: unexpected url " + meetUrl);
-
- } catch (Exception e) {
- Log.warning("Error with username/password: " + meetUrl);
- openRoom(meetUrl, roomId);
- }
- }
-
- private void openRoom(String roomUrl, CharSequence roomId)
- {
- try {
- if (browser != null)
- {
- ActionListener taskPerformer = new ActionListener()
- {
- public void actionPerformed(ActionEvent evt)
- {
- if (browser != null) browser.dispose();
- if (frame != null) frame.dispose();
-
- open(roomUrl, roomId);
- }
- };
-
- browser.executeJavaScript("APP.conference.hangup();");
-
- javax.swing.Timer timer = new javax.swing.Timer(1000 ,taskPerformer);
- timer.setRepeats(false);
- timer.start();
-
- } else open(roomUrl, roomId);
-
- } catch (Exception t) {
-
- Log.warning("openRoom " + roomUrl, t);
- }
- }
-
- private void open(String roomUrl, CharSequence roomId)
- {
- browser = new Browser();
- BrowserView view = new BrowserView(browser);
-
- frame = new JFrame();
- frame.add(view, BorderLayout.CENTER);
- frame.setSize(width, height);
- frame.setVisible(true);
- frame.setTitle("Openfire Meetings - " + roomId);
-
- frame.addWindowListener(new java.awt.event.WindowAdapter()
- {
- @Override public void windowClosing(java.awt.event.WindowEvent windowEvent)
- {
- close();
- }
- });
-
- browser.loadURL(roomUrl);
- }
-
- private void close()
- {
- ActionListener taskPerformer = new ActionListener()
- {
- public void actionPerformed(ActionEvent evt)
- {
- if (browser != null)
- {
- browser.dispose();
- browser = null;
- }
-
- if (frame != null)
- {
- frame.dispose();
- frame = null;
- }
- }
- };
-
- browser.executeJavaScript("APP.conference.hangup();");
-
- javax.swing.Timer timer = new javax.swing.Timer(1000 ,taskPerformer);
- timer.setRepeats(false);
- timer.start();
- }
-
- public void chatRoomLeft(ChatRoom chatroom)
- {
-
- }
-
- public void chatRoomClosed(ChatRoom chatroom)
- {
- Localpart roomId = chatroom.getRoomJid().getLocalpart();
-
- Log.debug("chatRoomClosed: " + roomId);
-
- if (decorators.containsKey(roomId))
- {
- ChatRoomDecorator decorator = decorators.remove(roomId);
- decorator.finished();
- decorator = null;
- }
-
- if (browser != null)
- {
- browser.dispose();
- browser = null;
- }
- }
-
- public void chatRoomActivated(ChatRoom chatroom)
- {
- EntityBareJid roomId = chatroom.getRoomJid();
-
- Log.debug("chatRoomActivated: " + roomId);
- }
-
- public void userHasJoined(ChatRoom room, String s)
- {
- EntityBareJid roomId = room.getRoomJid();
-
- Log.debug("userHasJoined: " + roomId + " " + s);
- }
-
- public void userHasLeft(ChatRoom room, String s)
- {
- EntityBareJid roomId = room.getRoomJid();
-
- Log.debug("userHasLeft: " + roomId + " " + s);
- }
-
- public void chatRoomOpened(final ChatRoom room)
- {
- EntityBareJid roomId = room.getRoomJid();
-
- Log.debug("chatRoomOpened: " + roomId);
-
- if (!decorators.containsKey(roomId))
- {
- decorators.put(roomId, new ChatRoomDecorator(room, url, this));
- }
- }
-
- /**
- * Display an alert that allows the user to accept or reject a meet
- * invitation.
- */
private void showInvitationAlert(final String meetUrl, final ChatRoom room, final CharSequence roomId)
{
// Got an offer to start a new meet. So, make sure that a chat is
@@ -389,11 +157,11 @@ public class SparkMeetPlugin implements Plugin, ChatRoomListener, GlobalMessageL
// Hide the response panel. TODO: make this work.
room.getTranscriptWindow().remove(inviteAlert);
inviteAlert.remove(1);
- inviteAlert.add(new JLabel("Joining audio/conference conference ..."), BorderLayout.CENTER);
+ inviteAlert.add(new JLabel("Meeting at " + meetUrl), BorderLayout.CENTER);
declineButton.setEnabled(false);
acceptButton.setEnabled(false);
- openUrl(meetUrl, roomId);
+ openURL(meetUrl);
}
});
buttonPanel.add(acceptButton);
@@ -417,4 +185,252 @@ public class SparkMeetPlugin implements Plugin, ChatRoomListener, GlobalMessageL
// Add the response panel to the transcript window.
room.getTranscriptWindow().addComponent(inviteAlert);
}
+
+ @Override
+ public void messageSent(ChatRoom room, Message message) {
+
+ }
+
+ public boolean canShutDown()
+ {
+ return true;
+ }
+
+ public void uninstall()
+ {
+
+ }
+
+ public void handleClick(String newUrl, ChatRoom room, String url, Message.Type type)
+ {
+ if (electronThread != null)
+ {
+ electronThread.destory();
+ electronThread = null;
+ return;
+ }
+
+ sendInvite(room, url, type);
+ openURL(newUrl);
+ }
+
+ public void openURL(String newUrl)
+ {
+ checkNatives();
+
+ try {
+ String username = URLEncoder.encode(SparkManager.getSessionManager().getUsername(), "UTF-8");
+ String password = URLEncoder.encode(SparkManager.getSessionManager().getPassword(), "UTF-8");
+
+ electronThread = Spawn.startProcess(electronExePath + " --ignore-certificate-errors " + newUrl, new File(electronHomePath), new ProcessListener() {
+
+ public void onOutputLine(final String line) {
+ System.out.println(line);
+ }
+
+ public void onProcessQuit(int code) {
+ electronThread = null;
+ }
+
+ public void onOutputClosed() {
+ System.out.println("process completed");
+ }
+
+ public void onErrorLine(final String line) {
+
+ if (!line.contains("Corrupt JPEG data"))
+ {
+ Log.warning("Electron error " + line);
+ }
+ }
+
+ public void onError(final Throwable t) {
+ Log.warning("Electron error", t);
+ }
+ });
+
+ } catch (Exception t) {
+
+ Log.warning("Error opening url " + newUrl, t);
+ }
+ }
+
+
+ public void chatRoomLeft(ChatRoom chatroom)
+ {
+ }
+
+ public void chatRoomClosed(ChatRoom chatroom)
+ {
+ String roomId = chatroom.getRoomname().toString();
+
+ Log.warning("chatRoomClosed: " + roomId);
+
+ if (decorators.containsKey(roomId))
+ {
+ ChatRoomDecorator decorator = decorators.remove(roomId);
+ decorator.finished();
+ decorator = null;
+ }
+
+ if (electronThread != null)
+ {
+ electronThread.destory();
+ electronThread = null;
+ return;
+ }
+ }
+
+ public void chatRoomActivated(ChatRoom chatroom)
+ {
+ String roomId = chatroom.getRoomname().toString();
+
+ Log.warning("chatRoomActivated: " + roomId);
+ }
+
+ public void userHasJoined(ChatRoom room, String s)
+ {
+ String roomId = room.getRoomname().toString();
+
+ Log.warning("userHasJoined: " + roomId + " " + s);
+ }
+
+ public void userHasLeft(ChatRoom room, String s)
+ {
+ String roomId = room.getRoomname().toString();
+
+ Log.warning("userHasLeft: " + roomId + " " + s);
+ }
+
+ public void chatRoomOpened(final ChatRoom room)
+ {
+ String roomId = room.getRoomname().toString();
+
+ Log.warning("chatRoomOpened: " + roomId);
+
+ if (roomId.indexOf('/') == -1)
+ {
+ decorators.put(roomId, new ChatRoomDecorator(room, url, this));
+ }
+ }
+
+ private void checkNatives()
+ {
+ Log.warning("checkNatives");
+
+ // Find the root path of the class that will be our plugin lib folder.
+ try
+ {
+ String nativeLibsJarPath = Spark.getSparkUserHome() + File.separator + "plugins" + File.separator + "meet" + File.separator + "lib";
+ File nativeLibFolder = new File(nativeLibsJarPath, "native");
+
+ electronHomePath = nativeLibsJarPath + File.separator + "native";
+ electronExePath = electronHomePath + File.separator + "electron";
+
+ if(!nativeLibFolder.exists())
+ {
+ nativeLibFolder.mkdir();
+
+ String jarFileSuffix = null;
+
+ if(OSUtils.IS_LINUX32)
+ {
+ jarFileSuffix = "-linux-ia32.zip";
+ }
+ else if(OSUtils.IS_LINUX64)
+ {
+ jarFileSuffix = "-linux-x64.zip";
+ }
+ else if(OSUtils.IS_WINDOWS32)
+ {
+ jarFileSuffix = "-win32-ia32.zip";
+ }
+ else if(OSUtils.IS_WINDOWS64)
+ {
+ jarFileSuffix = "-win32-x64.zip";
+ }
+ else if(OSUtils.IS_MAC)
+ {
+ jarFileSuffix = "-darwin-x64.zip";
+ }
+
+ InputStream inputStream = new URL("https://github.com/electron/electron/releases/download/v10.1.1/electron-v10.1.1" + jarFileSuffix).openStream();
+ ZipInputStream zipIn = new ZipInputStream(inputStream);
+ ZipEntry entry = zipIn.getNextEntry();
+
+ while (entry != null)
+ {
+ try
+ {
+ String filePath = electronHomePath + File.separator + entry.getName();
+
+ Log.warning("writing file..." + filePath);
+
+ if (!entry.isDirectory())
+ {
+ File file = new File(filePath);
+ file.setReadable(true, true);
+ file.setWritable(true, true);
+ file.setExecutable(true, true);
+
+ new File(file.getParent()).mkdirs();
+
+ extractFile(zipIn, filePath);
+ }
+ zipIn.closeEntry();
+ entry = zipIn.getNextEntry();
+ }
+ catch(Exception e) {
+ Log.error("Error", e);
+ }
+ }
+ zipIn.close();
+
+ Log.warning("Native lib folder created and natives extracted");
+ }
+ else {
+ Log.warning("Native lib folder already exist.");
+ }
+
+
+ String libPath = nativeLibFolder.getCanonicalPath();
+
+ if (!System.getProperty("java.library.path").contains(libPath))
+ {
+ String newLibPath = libPath + File.pathSeparator + System.getProperty("java.library.path");
+ System.setProperty("java.library.path", newLibPath);
+
+ // this will reload the new setting
+ Field fieldSysPath = ClassLoader.class.getDeclaredField("sys_paths");
+ fieldSysPath.setAccessible(true);
+ fieldSysPath.set(System.class.getClassLoader(), null);
+ }
+ }
+ catch (Exception e)
+ {
+ Log.warning(e.getMessage(), e);
+ }
+ }
+
+ private void extractFile(ZipInputStream zipIn, String filePath) throws IOException
+ {
+ BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(filePath));
+ byte[] bytesIn = new byte[4096];
+ int read = 0;
+
+ while ((read = zipIn.read(bytesIn)) != -1)
+ {
+ bos.write(bytesIn, 0, read);
+ }
+ bos.close();
+ }
+
+ private void sendInvite(ChatRoom room, String url, Message.Type type)
+ {
+ Message message2 = new Message();
+ message2.setTo(room.getRoomname().toString());
+ message2.setType(type);
+ message2.setBody(url);
+ room.sendMessage(message2);
+ }
}
diff --git a/plugins/meet/src/main/plugin-metadata/plugin.xml b/plugins/meet/src/main/plugin-metadata/plugin.xml
index d4ed4b601..31c55b0e2 100644
--- a/plugins/meet/src/main/plugin-metadata/plugin.xml
+++ b/plugins/meet/src/main/plugin-metadata/plugin.xml
@@ -2,7 +2,7 @@
${project.name}
${project.version}
${project.description}
- Ignite Realtime
+ Dele Olajide
http://igniterealtime.org
support@igniterealtime.org
org.jivesoftware.spark.plugin.ofmeet.SparkMeetPlugin
diff --git a/pom.xml b/pom.xml
index 61e5e43a1..f39675673 100644
--- a/pom.xml
+++ b/pom.xml
@@ -77,7 +77,7 @@
plugins/flashing
plugins/growl
-
+ plugins/meet
plugins/reversi
plugins/roar