Introduction

Moosync lets you create your own extensions to extent the functionality for the main app. Extensions are web-assembly modules that can be loaded at runtime.

Restrictions

Web assembly is platform independant and has a few restrictions:

  • Limited access to the file system
  • Limited access to the network
  • No support for threads (tracking wasi-threads)
  • No support for native libraries

Development

Moosync uses extism under the hood to load web assembly modules. Any host language supported by extism can be used to write extensions. Currently we only provide support for

  • Rust
  • Golang
  • Javascript / Typescript
  • Python

Getting started

Extensions follow a very specific layout.

Creating a new extension

Manifest

Create a new file called package.json. This will be the manifest for your extension containing all details and permissions about it.

A sample manifest looks like

{
  "name": "moosync.sample.extension",
  "version": "0.0.1",
  "icon": "assets/icon.svg",
  "extensionEntry": "ext.wasm",
  "moosyncExtension": true,
  "displayName": "My extension",
  "permissions": {
    "hosts": [
      "*.google.com",
      "google.com"
    ],
    "paths": {
      "{ENV_1}": "/",
    }
  }
}

Fields

  • name: A unique identifier for your extension.
  • version: The version of your extension in semver format.
  • icon: The icon of your extension. The path is relative to package.json.
  • extensionEntry: The path to the compiled WASM file. The path is relative to package.json.
  • moosyncExtension: This must be set to true for the extension to be loaded.
  • displayName: The name of your extension that will be displayed in the UI.
  • permissions: The permissions that your extension needs to run.
    • hosts: The hosts (URLs) that your extension needs to access.

    • paths: The paths that your extension needs to access. You can use environment variables in the path. The environment variable must be wrapped in {}. For example, {ENV_1} will be replaced with the value of ENV_1 environment variable.

      Keys in the paths object is the location in the user's filesystem. Values in the paths object is where the actual directory will be accessible in the extension. Eg.

      "paths": {
        "/test": "/"
      }
      

      To access /test/file.txt in the extension, you can use the path /file.txt.

Prerequisites

Moosync extensions are built and packaged using Bazel.

Installing Bazel

Please follow the official Bazel installation guide to install Bazel (via Bazelisk) on your system:

Verify your installation by running:

bazel --version

Writing your first extension

1. Workspace Setup

In your workspace root, create or update MODULE.bazel to depend on extensions_sdk:

module(
    name = "my_extensions",
    version = "1.0.0",
)

bazel_dep(name = "extensions_sdk", version = "1.0.0")

2. Generating Boilerplate

The easiest way to get started is by using the built-in scaffolding tool. This generates both the Bazel BUILD file and the starter code for your extension:

bazel run @extensions_sdk//tools:scaffold -- --lang rust --name my_rust_ext

3. Extension Rules & Implementation

Generated BUILD Rule

The scaffolding tool generates a BUILD file using rust_extension:

load("@extensions_sdk//:defs.bzl", "rust_extension")

package(default_visibility = ["//visibility:public"])

rust_extension(
    name = "rust_sample",
    srcs = ["src/lib.rs"],
    package_name = "moosync.sample.rust",
    display_name = "Sample Extension (Rust)",
    version = "0.0.1",
)

Implementation

Extensions are represented by the Extension trait. You need to register your extension through the init function:

#![allow(unused)]
fn main() {
struct SampleExtension;

impl Extension for SampleExtension {}
impl Accounts for SampleExtension {}
impl DatabaseEvents for SampleExtension {}
impl PlayerEvents for SampleExtension {}
impl PreferenceEvents for SampleExtension {}
impl ContextMenu for SampleExtension {}

#[unsafe(no_mangle)]
pub extern "C" fn init() {
    info!("Initializing SampleExtension");
    register_extension(Box::new(SampleExtension)).unwrap();
    info!("Initialized SampleExtension");
}
}

Build

bazel build //my_rust_ext:my_rust_ext

Required implementations

All extensions must return ProviderScopes. These scopes determine which events are sent to your extension.

Lets consider the search scope for this example. Adding the search scope would cause the main app to request search results from your extension.

#![allow(unused)]
fn main() {
impl Provider for SampleExtension {
    fn search(&self, req: RequestedSearchResultRequest) -> api::MoosyncResult<SearchResult> {
        info!("Search requested for query: {}", req.query);

        let url = format!("https://api.spotify.com/v1/search?q={}&type=track", req.query);
        let resp = moosync_edk::http::get(&url, None)?;
        if resp.is_success() {
            info!("Search API response: {}", resp.text().unwrap_or_default());
        }

        Ok(SearchResult {
            songs: vec![],
            artists: vec![],
            playlists: vec![],
            albums: vec![],
            genres: vec![],
        })
    }
}
}

Using the API

The extension development kit provides APIs to interact with the main app and perform external network operations.

Fetching App State

For this example lets consider the getCurrentSong API. getCurrentSong returns the actively playing song.

#![allow(unused)]
fn main() {
impl Provider for SampleExtension {
    fn scrobble(&self, _req: ScrobbleRequest) -> api::MoosyncResult<()> {
        let song = get_current_song()?;
        if let Some(title) = song.and_then(|s| s.song).and_then(|i| i.title) {
            info!("Currently playing song: {}", title);
        }
        Ok(())
    }
}
}

Making HTTP Requests

Extensions can make outgoing HTTP requests through the host runner using the SDK's HTTP APIs. Both single requests and batch/parallel requests are supported.

Permissions: To communicate with external servers, specify allowed hosts in your extension manifest (package.json) under "allowed_hosts" (for example, ["api.spotify.com", "*.last.fm"]).

#![allow(unused)]
fn main() {
pub fn search_and_fetch_details(query: &str) -> api::MoosyncResult<()> {
    use moosync_edk::http::{self, HttpRequest};

    let url = format!("https://api.spotify.com/v1/search?q={}&type=track", query);
    let resp = http::get(&url, None)?;
    if resp.is_success() {
        info!("Search response: {}", resp.text().unwrap_or_default());
    }

    let post_req = HttpRequest::post("https://api.spotify.com/v1/playlists")
        .header("Authorization", "Bearer token123")
        .body(r#"{"name":"My Playlist"}"#.as_bytes().to_vec())
        .timeout_ms(5000);
    let post_resp = http::request(&post_req)?;
    info!("Create playlist status: {}", post_resp.status_code);

    let track_urls = vec![
        "https://api.spotify.com/v1/tracks/1",
        "https://api.spotify.com/v1/tracks/2",
    ];
    let responses = http::batch_get(&track_urls, None)?;
    for r in responses {
        info!("Track status: {}", r.status_code);
    }
    Ok(())
}
}

Full extension example

Here is a complete extension example combining registration, provider capabilities, and API usage:

#![allow(unused)]
#![no_main]

fn main() {
use moosync_edk::{
    RequestedSearchResultRequest, ScrobbleRequest, SearchResult,
    api::{
        self, Accounts, ContextMenu, DatabaseEvents, Extension, PlayerEvents, PreferenceEvents,
        Provider, extension_api::get_current_song,
    },
    handler::register_extension,
    info,
};

// ANCHOR: first_extension
struct SampleExtension;

impl Extension for SampleExtension {}
impl Accounts for SampleExtension {}
impl DatabaseEvents for SampleExtension {}
impl PlayerEvents for SampleExtension {}
impl PreferenceEvents for SampleExtension {}
impl ContextMenu for SampleExtension {}

#[unsafe(no_mangle)]
pub extern "C" fn init() {
    info!("Initializing SampleExtension");
    register_extension(Box::new(SampleExtension)).unwrap();
    info!("Initialized SampleExtension");
}
// ANCHOR_END: first_extension

impl Provider for SampleExtension {
// ANCHOR: provider
    fn search(&self, req: RequestedSearchResultRequest) -> api::MoosyncResult<SearchResult> {
        info!("Search requested for query: {}", req.query);

        let url = format!("https://api.spotify.com/v1/search?q={}&type=track", req.query);
        let resp = moosync_edk::http::get(&url, None)?;
        if resp.is_success() {
            info!("Search API response: {}", resp.text().unwrap_or_default());
        }

        Ok(SearchResult {
            songs: vec![],
            artists: vec![],
            playlists: vec![],
            albums: vec![],
            genres: vec![],
        })
    }
// ANCHOR_END: provider

// ANCHOR: api_usage
    fn scrobble(&self, _req: ScrobbleRequest) -> api::MoosyncResult<()> {
        let song = get_current_song()?;
        if let Some(title) = song.and_then(|s| s.song).and_then(|i| i.title) {
            info!("Currently playing song: {}", title);
        }
        Ok(())
    }
// ANCHOR_END: api_usage
}

// ANCHOR: http_usage
pub fn search_and_fetch_details(query: &str) -> api::MoosyncResult<()> {
    use moosync_edk::http::{self, HttpRequest};

    let url = format!("https://api.spotify.com/v1/search?q={}&type=track", query);
    let resp = http::get(&url, None)?;
    if resp.is_success() {
        info!("Search response: {}", resp.text().unwrap_or_default());
    }

    let post_req = HttpRequest::post("https://api.spotify.com/v1/playlists")
        .header("Authorization", "Bearer token123")
        .body(r#"{"name":"My Playlist"}"#.as_bytes().to_vec())
        .timeout_ms(5000);
    let post_resp = http::request(&post_req)?;
    info!("Create playlist status: {}", post_resp.status_code);

    let track_urls = vec![
        "https://api.spotify.com/v1/tracks/1",
        "https://api.spotify.com/v1/tracks/2",
    ];
    let responses = http::batch_get(&track_urls, None)?;
    for r in responses {
        info!("Track status: {}", r.status_code);
    }
    Ok(())
}
// ANCHOR_END: http_usage
}

Language API Documentation

API reference documentation for each supported language: