1.1.18 • Published 2 years ago

@tzuleger/svelte-emscripten v1.1.18

Weekly downloads
-
License
(MIT OR Apache-2....
Repository
github
Last release
2 years ago

svelte-emscripten

Allows a User to easily import an Emscripten WebAssembly module into their code using a component.

Below is an example of how this is used.

Be sure to include the following flags in your Emscripten compiler command: emcc -s MODULARIZE=1 -s ENVIRONMENT='web' or if you're using C++ em++ -s MODULARIZE=1 -s ENVIRONMENT='web'

Usage

<script>
    import Emscripten, { intFunction } from "@tzuleger/svelte-emscripten";

    /** @type {import("@tzuleger/svelte-emscripten").WASMFunctionDetails<?>} */
    var functions = {
        foo: intFunction(),
        bar: intFunction("actuallyNotBar") // C/C++ defined function named "actuallyNotBar"
    };

    /** @type {import("@tzuleger/svelte-emscripten").WASMOptions} */
    var options = {
        preInitFunctions: [
            () => console.log("Initializing WebAssembly...")
        ]
    }
    
    /** @type {{js: string, wasm: string}} */
    var files = {
        js: a.out.js,
        wasm: a.out.wasm
    }

    function run({ detail }) {
        const module = detail.module;
        /** @type {import("@tzuleger/svelte-emscripten").ExportedWASMFunctions} */
        const { main, foo, bar } = detail.functions;

        console.log(detail.mainCalled); // Prints "true" since prop {autorun} was provided.
        foo(); // Calls the C/C++ function: "foo"
        bar(); // Calls the C/C++ function: "actuallyNotBar"
    }
</script>
<Emscripten autorun {functions} {options} {files} on:runtimeInitialized={run}/>

Props

Prop NameDescriptionDefault
autorunCalls the C/C++ main function as soon as Runtime is initialized.false
filesCustom name (and path if applicable) for the generated WebAssembly files generated by Emscripten.{ js: a.out.js, wasm: a.out.wasm }
functionsDictionary of WASMFunctionDetails that are exported functions from your C/C++ code.{}
constructorsDictionary of WASMFunctionDetails that are exported object constructors from your C/C++ code.{}
optionsSee @type WASMOptions for more information.{ preInitFunctions: [] }

Functions

These functions are helpers in creating Functions for your {functions} prop. It is recently discovered that argTypes does not need to be provided in order for the behavior to work as intended. It is being kept in case the User would like to keep the code more readable.

The name of these functions does not need to be provided, but if the Key in the {functions} prop dictionary is not the same as the real name in the C/C++ code, then the real name of the function must be provided.

Function NameDescriptionArgumentsReturn Type
objectConstructorReturns a WASMFunctionDetails object that is used in the Module instantiation to create appropriate WASMFunction (for object constructors only) objects.name?: string, argTypes?: string[]{ name?: string, argTypes?: string[] }
voidFunctionReturns a WASMFunctionDetails object that is used in the Module instantiation to create appropriate WASMFunction (return type void) objects.name?: string, argTypes?: string[]{ name?: string, argTypes?: string[] }
intFunctionReturns a WASMFunctionDetails object that is used in the Module instantiation to create appropriate WASMFunction (return type int) objects.name?: string, argTypes?: string[]{ name?: string, type: "int", argTypes?: string[] }
stringFunctionReturns a WASMFunctionDetails object that is used in the Module instantiation to create appropriate WASMFunction (return type string) objects.name?: string, argTypes?: string[]{ name?: string, type: "string", argTypes?: string[] }
arrayFunctionReturns a WASMFunctionDetails object that is used in the Module instantiation to create appropriate WASMFunction (return type Array\) objects.name?: string, argTypes?: string[]{ name?: string, type: "array", argTypes?: string[] }

Events

Event NameDescriptionDetail
runtimeInitializedCalled when the Module has finished loading and is ready for usage.{ module: EmscriptenModule, functions: Record\<string, WASMFunction\<?>>, calledMain: boolean }
exitNot available{ }

runtimeInitialized

When the Module has finished loading and initializing, control is given back to the User. When that control is given back, this event will be called. This is also the only place where you can access your WebAssembly module. (through the Detail)

Detail Variable NameDescription
module: EmscriptenModule(Note: EmscriptenModule isn't an actual type, but the best verbiage to describe this object) The finished Emscripten Module that is retrieved from the JS glue code generated by Emscripten's compiler.
functions: Record\<string, WASMFunction\<?>>All of the functions that were specified by the {functions} prop.
calledMainboolean to determine whether main was called or not.

Types

WASMOptions

Defined by TypeScript as:

interface WASMOptions {
    preInitFunctions?: (() => void)[];
}

WASMFunction

Defined by TypeScript as:

type WASMFunction = ((...args: any[]) => T);

This is primarily used to interact with your C/C++ functions directly instead of calling them via ccall or Module._{myFunction}.

WASMFunctionDetails

Defined by TypeScript as:

interface WASMFunctionDetails {
    name?: string;
    type?: string;
    argTypes?: string[];
}

This is what is required to store in your WASMFunctions dictionary when creating your Emscripten component. The easiest way to create this Object is to reference the CreateWASMFunctionDetails functions.

WASMFunctions

@type Record\<string, WASMFunctionDetails>

Emscripten exports appropriate Functions to the Module by referencing them by names of "_{functionName}" or by calling "ccall('{functionName}')" on them.

Providing WASMFunctions in the {functions} prop makes it so you can confidently interact with these functions as if they are just more JS functions you wrote.

NOTE: To properly use this feature, you must include "cwrap" in your EXPORTED_RUNTIME_METHODS compiler setting when compiling.

Here is an example:

example.c:

#include <stdio.h>

EMSCRIPTEN_KEEPALIVE
int main() {
    printf("Hello World!\n");
}

EMSCRIPTEN_KEEPALIVE
int foo() {
    return 12;
}

void bar(char * s, int x, int y) {
    printf(s);
    printf("%d + %d = %d\n", x, y, (x+y));
}

EMSCRIPTEN_KEEPALIVE
int add(int x, int y) {
    return x + y;
}

EMSCRIPTEN_KEEPALIVE
char * getHello() {
    return "Hello";
}

EMSCRIPTEN_KEEPALIVE
char * getWorld() {
    return "World";
}

routes/example.svelte:

<script>
import { intFunction, stringFunction, voidFunction } from "@tzuleger/svelte-emscripten";
var Module;

/** @type {Record<string, import('@tzuleger/svelte-emscripten').WASMFunctionDetails<?>>}
var functions = {
    get12: intFunction("foo"), // Example of using a Pseudoname
    printAndAdd: voidFunction("bar", ["string", "int", "int"]), // Example of using a Pseudoname
    add: intFunction(["int", "int"]), // Example of using the real name (from C/C++ code)
    getHello: stringFunction(),
    getWorld: stringFunction()
}

// using {functions} prop to interact with exported C functions.
function example1({ detail }) {
    // unpack event detail
    const module = detail.module;
    // if main is to be used, then "callMain" must be added to the "EXPORTED_RUNTIME_METHODS" compiler setting.
    const { main, get12, printAndAdd, add, getHello, getWorld } = detail.functions;

    let helloWorld = `${getHello() + getWorld()}!\n`;
    printAndAdd(helloWorld, get12(), 15);
    console.log(`40 + 50 = ${add(40, 50)}`);
}

// using module to interact with exported C functions
function example2({ detail }) {
    // unpack event detail
    const module = detail.module;

    let helloWorld = `${module._getHello() + module._getWorld()}!\n`;
    printAndAdd(helloWorld, module._get12(), 15);
    console.log(`40 + 50 = ${add(40, 50)}`);

    if(!module.mainCalled) {
        module.callMain();
    }
}
</script>

<!-- Example 1 -->
<Emscripten autorun {functions} on:runtimeInitialized={example1}>
<!-- Example 2 -->
<Emscripten on:runtimeInitialized={example2}>

output of Example 1:

Hello World!
HelloWorld!
12 + 15 = 27
40 + 50 = 90

output of Example 2:

HelloWorld!
12 + 15 = 27
40 + 50 = 90
Hello World!

Fetching JS or WASM files from external (or internal) API

Although, it is recommended to keep the Emscripten generated JS and WASM file in your $static folder, it may be possible that you would want an external API to handle the file exchange. This can be useful for situations like keeping your Website online while still having the opportunity to update a WebAssembly application. So long as the JavaScript on your host site does not need to be changed, this will accomplish just that.

Below is an excerpt of code that can be used in Svelte-Kit to grab files from a Svelte-Kit API.

// WARNING: This endpoint is NOT PROTECTED UNDER THE WRONG CIRCUMSTANCES. 
// If you do not specify a path and assume that the file they are looking for is at the root of the directory,
// then the User can set the path themselves.
// Example:
// example.com/api/appfiles?name=C:/Users/JohnDoe/Desktop/bankpassword.txt
// then the User WILL get that file and have all access to it.
// To protect against this, set the Path yourself in the endpoint and check to make sure that the File name passed does not hold a path.

import fs from 'fs';

/** @type {any} */
const MIME_TYPES = {
    js: "text/javascript;charset=UTF-8",
    wasm: "text/plain"
}

// Route: /api/emscripten
/** @type {import('@sveltejs/kit').RequestHandler} */
export async function GET({ url, params, locals }) {
    console.log(url.searchParams);
    if(!url.searchParams.has('name')) {
        return new Response(null, {
            status: 400,
        });
    }
    // These next 3 lines of code will ensure that the User only accesses the folder: "src/wasm".
    const path = "src/wasm/";
    const filenameSplit = /** @type {string} */ (url.searchParams.get('name')).split('/');
    const filename = filenameSplit[filenameSplit.length-1];
    const split = filename.split('.');
    const type = MIME_TYPES[split[split.length-1]];
    let file;
    try {
        file = fs.readFileSync(`${path}${path.endsWith('/') ? '' : '/'}${filename}`);
    } catch(err) {
        return new Response(file, {
            status: 404
        });
    }
    return new Response(file, {
        status: 200,
        headers: {
            "Content-Type": type,
            "Content-Disposition": `attachment; filename=${filename}`
        }
    });
}

If using an internal API. Use something like the below {files} prop.

var files = {
    js: "/api/emscripten?name=a.out.js",
    wasm: "/api/emscripten?name=a.out.wasm"
}

If using an external API. Use something like the below {files} prop.

var files {
    js: "http://cdn.example.com/api/emscripten/js",
    wasm: "http://cdn.example.com/api/emscripten/wasm"
}

Examples

Below are some examples on how this can be used.

Calculator (Beginner)

This Calculator simulates a traditional calculator using +, -, *, /, !, and ^.
All computation will be run via WebAssembly while the svelte file will handle all of the UI.

This example uses the following:

File Structure

[src]
    [lib]
    [routes]
        [examples]
            - calculator.svelte
[wasm]
    calculator.c
[static]
    calculator.js
    calculator.wasm

calculator.c

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
    return a + b;
}

EMSCRIPTEN_KEEPALIVE
int subtract(int a, int b) {
    return a - b;
}

EMSCRIPTEN_KEEPALIVE
int multiply(int a, int b) {
    return a * b;
}

EMSCRIPTEN_KEEPALIVE
int divide(int a, int b) {
    return a / b;
}

EMSCRIPTEN_KEEPALIVE
int square(int a) {
    return a * a;
}

EMSCRIPTEN_KEEPALIVE
int factorial(int n) {
    if(n <= 1) {
        return n;
    }
    return n * factorial(n-1);
}

Emscripten CLI Command

Executed in $static folder on Command Line

emcc ../src/wasm/calculator.c -s MODULARIZE=1 -s ENVIRONMENT='web' -s EXPORTED_RUNTIME_METHODS="['cwrap', 'ccall']" -o calculator.js

Calculator.svelte

<script>
//@ts-nocheck
import Emscripten, { intFunction } from '$lib/Emscripten.svelte';

var options = {
    preInitFunctions: [
        () => {
            calculatorEnabled = false;
        }
    ]
}

var files = {
    js: "/calculator.js",
    wasm: "/calculator.wasm"
}

var functions = {
    add: intFunction(["int", "int"]),
    sub: intFunction("subtract", ["int", "int"]),
    mul: intFunction("multiply", ["int", "int"]),
    div: intFunction("divide", ["int", "int"]),
    square: intFunction(["int"]),
    factorial: intFunction(["int"]),
};

var calculatorEnabled = false;
var x = "", y = "", result = "";
var fn;

var layout = [
    7, 8, 9, "+", 
    4, 5, 6, "-", 
    1, 2, 3, "*", 
    "^", 0, "!", "/"
]
var action = " : ";
var actions; 

function store(act) {
    const compute = () => {
        calculatorEnabled = false;
        result = fn(x,y);
        x = "";
        y = "";
        action = " : "
        calculatorEnabled = true;
    }
    if(typeof(act) === "number") {
        y = y + act;
    } else {
        if(act === "=") {
            compute();
            return;
        }
        action = ` ${act} `;
        x = y;
        y = "";
        fn = actions[act];
    }
    if(["!", "^"].includes(act)) {
        compute();
    }
}

function run({ detail }) {
    const { add, sub, mul, div, square, factorial } = detail.functions;
    actions = {
        "+": add,
        "-": sub,
        "*": mul,
        "/": div,
        "^": square,
        "!": factorial
    }
    calculatorEnabled = true;
}
</script>

<Emscripten {functions} {options} {files} on:runtimeInitialized={run}/>
<input disabled type="number" bind:value={x}>{action}<input disabled type="number" bind:value={y}>
<div>
    {#each layout as n}
        <button disabled={!calculatorEnabled} on:click={() => store(n)}>{n}</button>
    {/each}
</div>
Result: <input disabled type="number" bind:value={result}>
<button disabled={!calculatorEnabled} on:click={() => store("=")}>Calculate!</button>

<style>
div {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 20px;
    width: 400px;
}

div button {
    width: 80px;
    height: 80px;
}
</style>

C++ Embinded Custom Map (Advanced)

In this example, we will create a Map in C++ that has basic functions of your traditional map. Editing this map will also trigger reactivity in Svelte. Finally, we will embind this map to the WASM Module so the runtimeInitialized event will have a constructor for Map.

This example uses the following:

File Structure

[src]
    [lib]
    [routes]
        [examples]
            map.svelte
[wasm]
    modals.h
    modals.cpp
[static]
    map.js
    map.wasm

modals.h

#include <emscripten/emscripten.h>
#include <emscripten/bind.h>
#include <functional>
#include <string>
#include <iostream>
#include <vector>

#ifndef __MODALS_H__
#define __MODALS_H__

class Equatable {
    virtual bool operator==(Equatable) {
        return false;
    }
};

template <typename T>
struct Node {
    Node * next;
    T * data;
};

template <typename T, typename R>
struct KeyValuePair : public Equatable {
    inline KeyValuePair<T, R>& operator=(KeyValuePair<T, R> kvp) {
        key = kvp.key;
        value = kvp.value;
        return *this;
    }
    inline bool operator==(KeyValuePair<T, R> kvp) {
        return kvp.key == key;
    }
    T key;
    R value;
};

template <typename T>
class List {
    public:
        Node<T> * head;
        List(); // Creates a new List.
        ~List(); // Destroy list
        void push(T *); // Pushes a new value onto the list
        T * pop(); // Pops the first value from the List and returns that value.
        T * get(T *); // Gets the (first) specified value from the list (T must have operator== overload)
        void remove(T *); // Removes the (first) specified value from the list (T must have operator== overload)
        bool exists(T *); // Checks if the specified value exists in the list (T must have operator== overload)
    private:
        int size;
};

template <typename T>
class ListIterator {
    public:
        ListIterator(List<T> *); // Constructs a new iterator that iterates over the specified list.
        T * next(); // Returns the next value to grab.
        bool hasNext(); // Checks if the iterator has another value to grab.
    private:
        List<T> * list;
        Node<T> * current;
};

template <typename T, typename R>
class Map {
    public:
        Map(int); // Creates new Map with an allocated number of lists specified by the input.
        ~Map(); // Destroy map
        void put(T, R); // Adds a new key value pair into the Map
        void del(T); // Deletes the key value pair from the Map specified by the key.
        R get(T); // Returns item value from Map (if exists)
        bool has(T); // Checks if item exists in Map.
        std::map<T,R> all(); // Gets all key value pairs in the Map.
    private:
        List<KeyValuePair<T, R>> ** lists;
        int size;
};

#endif

modals.cpp

#include "./modals.h"

template <typename T>
List<T> :: List() {
    this->head = NULL;
    this->size = 0;
}

template <typename T>
List<T> :: ~List() {
    Node<T> * node = NULL;
    while((node = node->next)) {
        delete node->data;
        delete node;
        node = NULL;
    }
}

template <typename T>
void List<T> :: push(T * t) {
    Node<T> * node = new Node<T>;

    node->data = t;
    node->next = this->head;
    this->head = node;
    this->size++;
}

template <typename T>
T * List<T> :: pop() {
    if(this->size <= 0) return NULL;
    Node<T> * node = this->head;
    this->head = node->next;
    T * t = node->data;
    delete node;
    this->size--;
    return t;
}

template <typename T>
T * List<T> :: get(T * t) {
    Node<T> * node = this->head;
    while(node && (((unsigned long) node) != 0x63736d65)) {
        T data = *node->data;
        if(data == *t) {
            return node->data;
        }
        node = node->next;
    }
    return NULL;
}

template <typename T>
void List<T> :: remove(T * t) {
    std::cout << "List->remove" << std::endl;
    Node<T> * node = this->head;
    if(*(node->data) == *t) {
        delete node;
        this->head = NULL;
        this->size--;
        return;
    }
    while(node->next) {
        if(*(node->next->data) == *t) {
            Node<T> * toRemove = node->next;
            node->next = node->next->next;
            delete toRemove;
            this->size--;
            return;
        }
        node = node->next;
    }
}

template <typename T>
bool List<T> :: exists(T * t) {
    return get(t) != NULL;
}

template<typename T>
ListIterator<T> :: ListIterator(List<T> * list) {
    this->list = list;
    this->current = list->head;
}

template<typename T>
T * ListIterator<T> :: next() {
    if(!this->current) {
        return NULL;
    }
    T * t = this->current->data;
    this->current = this->current->next;
    return t;
}

template<typename T>
bool ListIterator<T> :: hasNext() {
    return this->current != NULL;
}

template <typename T, typename R>
Map<T, R> :: Map(int size) {
    this->lists = new List<KeyValuePair<T, R>> *[size];

    for(int i = 0; i < size; ++i) {
        this->lists[i] = new List<KeyValuePair<T, R>>();
    }

    this->size = size;
}

template <typename T, typename R>
Map<T, R> :: ~Map() {
    for(int i = 0; i < size; ++i) {
        delete this->lists[i];
    }
}

template <typename T, typename R>
void Map<T, R> :: put(T t, R r) {
    unsigned long hash = std::hash<T>()(t);
    
    KeyValuePair<T, R> * kvp = new KeyValuePair<T, R>;
    kvp->key = t;
    kvp->value = r;

    this->lists[hash % this->size]->push(kvp);
}

template <typename T, typename R>
void Map<T, R> :: del(T t) {
    unsigned long hash = std::hash<T>()(t);
    
    KeyValuePair<T, R> * kvp = new KeyValuePair<T, R>;
    kvp->key = t;
    kvp->value = NULL;

    this->lists[hash % this->size]->remove(kvp);
}

template <typename T, typename R>
R Map<T, R> :: get(T t) {
    unsigned long hash = std::hash<T>()(t);
    
    KeyValuePair<T, R> * kvp = new KeyValuePair<T, R>;
    kvp->key = t;
    R val = this->lists[hash % this->size]->get(kvp)->value;
    delete kvp;
    return val;
}

template <typename T, typename R>
bool Map<T, R> :: has(T t) {
    unsigned long hash = std::hash<T>()(t);
    
    KeyValuePair<T, R> * kvp = new KeyValuePair<T, R>;
    kvp->key = t;
    bool exists = this->lists[hash % this->size]->exists(kvp);
    delete kvp;
    return exists;
}

template <typename T, typename R>
std::map<T, R> Map<T, R> :: all() {
    std::map<T, R> m;
    int x = 0;
    for(int i = 0; i < this->size; ++i) {
        ListIterator<KeyValuePair<T,R>> it(this->lists[i]);
        while(it.hasNext() && x++ < 100) {
            KeyValuePair<T,R> * kvp = it.next();
            m.insert(std::pair<T, R>(kvp->key, kvp->value));
        }
    }
    return m;
}

Emscripten Embind

EMSCRIPTEN_BINDINGS(Map)
{
    // Unfortunately (and understandably so), Embind doesn't allow
    // for binding of raw Generic classes, so we must explicitly specify the types.
    // Embind multiple Map generic types like so
    emscripten::class_<Map<std::string, int>>("Map_string_int")
        .constructor<int>()
        .function("put", &Map<std::string, int>::put)
        .function("del", &Map<std::string, int>::del)
        .function("get", &Map<std::string, int>::get)
        .function("has", &Map<std::string, int>::has)
        .function("all", &Map<std::string, int>::all);
    emscripten::register_map<std::string, int>("map<string, int>");
    emscripten::register_vector<std::string>("vector<string>");
};

Emscripten CLI Command

Executed in $static folder on Command Line

em++ ../src/wasm/modals.cpp -s MODULARIZE=1 -s ENVIRONMENT='web' -s EXPORTED_RUNTIME_METHODS="['cwrap']" --bind -o map.js

map.svelte

<script>
//@ts-nocheck
import Emscripten, { objectConstructor } from "$lib/Emscripten.svelte";

const files = {
    js: "/map.js",
    wasm: "/map.wasm"
}

const constructors = {
    Map: objectConstructor("Map_string_int", ["int"])
};

var map;
var kvps = [];
var key = "";
var val = "";

function run({ detail }) {
    const { Map } = detail.constructors;
    map = new Map(25);
}

// Event Handlers.
const mapEvents = {
    put() {
        map.put(key, parseInt(val));
        map = map; // Reassign so Svelte Reactivity can take its course.
    },
    del() {
        map.del(key);
        map = map; // Reassign so Svelte Reactivity can take its course.
    }
}

$: {
    if(map) {
        kvps = [];
        const m = map.all();
        const mKeys = m.keys();
        for(let i = 0; i < mKeys.size(); ++i) {
            kvps = [...kvps, {
                id: i,
                key: mKeys.get(i),
                val: m.get(mKeys.get(i))
            }]
        }
    }
}

$: keyExists = map?.has(key);
$: val = map?.get(key);
$: console.log(map);
</script>

<Emscripten {files} {constructors} on:runtimeInitialized={run}/>

<div style="width:400px;">
    <table class="table table-striped table-hover">
        <thead>
            <tr>
                <td>Id</td>
                <td>Key</td>
                <td><img alt="=>" src="/arrow-right.svg"/></td>
                <td class="text-right">Value</td>
            </tr>
        </thead>
        <tbody>
            {#each kvps as kvp}
            <tr on:click={() => { key = kvp.key; val = kvp.val;}}>
                <td>{kvp.id}</td>
                <td>{kvp.key}</td>
                <td><img alt="=>" src="/arrow-right.svg"/></td>
                <td class="text-right">{kvp.val}</td>
            </tr>
            {/each}
        </tbody>
        <tfoot>
            <tr>
                <td>
                    <button class="btn btn-primary" on:click={mapEvents.put}>{keyExists ? "Edit" : "Insert"}</button>
                    <button class="btn btn-danger" on:click={mapEvents.del} disabled={!keyExists}>Delete</button>
                </td>
                <td><input 
                    class:good={keyExists} 
                    class:bad={!keyExists} 
                    bind:value={key}/> </td>
                <td><img alt="=>" src="/arrow-right.svg"/></td>
                <td><input bind:value={val}/></td>
            </tr>
        </tfoot>
    </table>
</div>

<style>
    button {
        width: 100%;
    }

    .good {
        background-color: #80ff8080;
    }

    .bad {
        background-color: #ff808080;
    }

    tbody tr {
        cursor: pointer;
    }
</style>

Creating a project

If you're seeing this, you've probably already done this step. Congrats!

# create a new project in the current directory
npm init svelte

# create a new project in my-app
npm init svelte my-app

Developing

Once you've created a project and installed dependencies with npm install (or pnpm install or yarn), start a development server:

npm run dev

# or start the server and open the app in a new browser tab
npm run dev -- --open

Building

To create a production version of your app:

npm run build

You can preview the production build with npm run preview.

To deploy your app, you may need to install an adapter for your target environment.

1.1.18

2 years ago

1.1.1

2 years ago

1.1.0

2 years ago

1.1.9

2 years ago

1.1.8

2 years ago

1.1.7

2 years ago

1.1.6

2 years ago

1.1.5

2 years ago

1.1.4

2 years ago

1.1.3

2 years ago

1.1.2

2 years ago

1.1.12

2 years ago

1.1.11

2 years ago

1.1.10

2 years ago

1.1.16

2 years ago

1.1.15

2 years ago

1.1.14

2 years ago

1.1.13

2 years ago

1.1.17

2 years ago

1.0.4

2 years ago

1.0.3

2 years ago

1.0.2

2 years ago

1.0.1

2 years ago

1.0.0

2 years ago

0.0.5

2 years ago

0.0.4

2 years ago

0.0.3

2 years ago

0.0.2

2 years ago

0.0.1

2 years ago