Sunday, January 16, 2011

ZeroConf/Bonjour in LiveCode with JmDNS

In the last few posts, we examined how LiveCode scripts can delegate work to Java classes using the shell function. Even though this generally works quite fast and reliably, the downside of this approach is that a new Java process is started every time.

In this post, we'll use process communication to start a helper process, interact with it, and finally close it when we're done. Our use case: ZeroConf / Bonjour registration and discovery of services. The JmDNS project offers a pure-Java implementation of Multicast DNS and DNS Service Discovery technologies.

LiveCode process communication revolves around opening a process (a.k.a. command-line interface application), writing data, reading data, and finally closing said process. But we'll get to that later; let's start by writing a LiveCodeJmDNSHelper class in Java. First download a copy of the JmDNS library - I picked version 3.0 as I was building this on an older Mac which can't run Java 6.

In the .zip file, you'll find a file 'jmdns.jar' which is all you need for the rest of this example. Copy it into your Eclipse project, add it to the Build path, and use the following code for the LiveCodeJmDNSHelper.java file:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;

import javax.jmdns.JmDNS;
import javax.jmdns.ServiceInfo;


public class LiveCodeJmDNSHelper {

private final static String EXIT_COMMAND = "exit";
private final static String LIST_COMMAND = "list";
private final static String REGISTER_COMMAND = "register";
private final static String UNREGISTER_COMMAND = "unregister";

private JmDNS jmdns;
private Map<String, ServiceInfo> regMap;

public LiveCodeJmDNSHelper() {
super();
}

public void start() throws IOException {
this.jmdns = JmDNS.create();
this.regMap = new HashMap<String, ServiceInfo>();
String commandLine = ""; // the last received command line
System.out.println("LiveCodeJmDNSHelper started. Type the command you wish to execute (type 'exit' to stop):");
final InputStreamReader isr = new InputStreamReader(System.in);
final BufferedReader bir = new BufferedReader(isr);

while(!EXIT_COMMAND.equals(commandLine)) {
commandLine = bir.readLine();
final String[] commandParts = commandLine.split(" ");
final String commandName = commandParts[0];
if (EXIT_COMMAND.equalsIgnoreCase(commandName)) {
System.out.println("LiveCodeHelper is exiting");
System.exit(0);
} else if (LIST_COMMAND.equalsIgnoreCase(commandName)) {
handleListCommand(commandParts);
} else if (REGISTER_COMMAND.equalsIgnoreCase(commandName)) {
handleRegisterCommand(commandParts);
} else if (UNREGISTER_COMMAND.equalsIgnoreCase(commandName)) {
handleUnregisterCommand(commandParts);
} else {
System.out.println("Unrecognized command: " + commandLine);
}
}
}

private void handleListCommand(final String[] commandParts) {
final String svcType = commandParts[1];
final ServiceInfo[] svcInfos = this.jmdns.list(svcType);
for (ServiceInfo svcInfo : svcInfos) {
System.out.println(svcInfo);
}
System.out.println(".");
}

private void handleRegisterCommand(final String[] commandParts) {
final String svcAlias = commandParts[1];
final String svcType = commandParts[2];
final String svcName = commandParts[3];
final int svcPort = Integer.parseInt(commandParts[4]);
final String svcText = commandParts[5];
ServiceInfo svcInfo = ServiceInfo.create(svcType, svcName, svcPort, svcText);
try {
this.jmdns.registerService(svcInfo);
this.regMap.put(svcAlias, svcInfo);
System.out.println("Registered service '" + svcAlias + "' as: " + svcInfo);
} catch (IOException e) {
System.out.println("Failed to register service '" + svcAlias + "'");
}
}

private void handleUnregisterCommand(final String[] commandParts) {
final String svcAlias = commandParts[1];
final ServiceInfo svcInfo = this.regMap.remove(svcAlias);
if (svcInfo != null) {
this.jmdns.unregisterService(svcInfo);
System.out.println("Unregistered service '" + svcAlias + "'");
} else {
System.out.println("Failed to unregister service '" + svcAlias + "'");
}
}

public static void main(String[] args) throws IOException {
final LiveCodeJmDNSHelper helper = new LiveCodeJmDNSHelper();
helper.start();
}

}


The start() method goes into an infinite loop, waiting for a line of text as its input, parsing the command and executing it. You can 'list' the ServiceInfo data for a given service type, 'register' your own services, and 'unregister' those services again. All nice and dandy, but how do we talk to it from LiveCode? Let's start by creating a new stack.



So we have two buttons at the top, to Start and Stop the helper process. Then we have a button to Register a service of our own, with fields for the Alias, Type, Name, Port and Text. Next there is a button to Unregister our own service, given an Alias. Finally, there is a button to List all the services of a specific Type in a scrolling field below. With the user interface laid out, we can put the following code into the stack script:
local sProcess

on JmDNS_StartHelper
if sProcess is empty then
local tClassPath
if the platform is "Win32" then
put ".;jmdns.jar" into tClassPath
else
put ".:jmdns.jar" into tClassPath
end if
local tDefaultFolder, tStackFolder
put JMDNS_StackFolder() into tStackFolder
put the defaultFolder into tDefaultFolder
set the defaultFolder to tStackFolder
put "java -cp" && tClassPath && "LiveCodeJmDNSHelper" into sProcess
open process sProcess for update
set the defaultFolder to tDefaultFolder
--> make sure we're speaking with the right helper
read from process sProcess for 1 line
if it begins with "LiveCodeJmDNSHelper started." then
--> ready for interaction
else
close process sProcess
put empty into sProcess
end if
end if
end JmDNS_StartHelper

function JmDNS_GetList pServiceType
local tJmDNSList
write ("list" && pServiceType) & return to process sProcess
repeat forever
read from process sProcess for 1 line
if it begins with "." then exit repeat
put it after tJmDNSList
end repeat
delete char -1 of tJmDNSList
return tJmDNSList
end JmDNS_GetList

command JmDNS_RegisterService pAlias, pType, pName, pPort, pText
write ("register" && pAlias && pType && pName && pPort && pText) & return to process sProcess
read from process sProcess for 1 line
return it
end JmDNS_RegisterService

command JmDNS_UnregisterService pAlias
write ("unregister" && pAlias) & return to process sProcess
read from process sProcess for 1 line
return it
end JmDNS_UnregisterService

command JmDNS_StopHelper
write ("exit") & return to process sProcess
close process sProcess
put empty into sProcess
end JmDNS_StopHelper

function JmDNS_StackFolder
local tStackFolder
put the effective filename of this stack into tStackFolder
set the itemDelimiter to slash
delete item -1 of tStackFolder
return tStackFolder
end JmDNS_StackFolder

The script for the Start button is easy enough:
on mouseUp
JmDNS_StartHelper
disable button "Start"
enable button "Stop"
end mouseUp

And the script of the Stop button is equally trivial:
on mouseUp
JmDNS_StopHelper
enable button "Start"
disable button "Stop"
end mouseUp

The script of the Register button isn't too complicated either:
on mouseUp
JmDNS_RegisterService field "Alias", field "Type", field "Name", field "Port", field "Text"
answer the result
end mouseUp

Neither is the script of the Unregister button:
on mouseUp
ask "Unregister which alias?" with field "Alias"
if it is empty then exit mouseUp
JmDNS_UnregisterService it
answer the result
end mouseUp

Nor the script of the List button:
on mouseUp
ask "List services of which type?" with field "Type"
if it is empty then exit mouseUp
put JMDNS_GetList(it) into field "List"
end mouseUp


Save the stack, and copy jmdns.jar as well as the compiled LiveCodeJmDNSHelper.class into the same directory where you saved the stack. Click the Start button, Register a service of your own, click the List button to make sure it shows up, click the Unregister button to remove it again, and then click the List button to make sure it's gone. If you want, you can launch some of the Java samples that come with JmDNS to see that both Java and LiveCode recognize the same services.

For the purists: no, I didn't do proper error checking or exception handling in this example. Because it is just that: an example. Never promised it was production-ready, did I? ;-)
Download it here.