Skip to content

Plugin system

LancerComet edited this page Feb 3, 2024 · 3 revisions

Background

Some people suggest me to embed a plugin system in Rulia, which is like what Tachiyomi does.

Since all plugins for Tachiyomi are targetted for the Android devices and written in Kotlin, Rulia cannot bring them in, so I have to design a new plugin format from scratch.

Plugin system was introduced in 0.11.0.

Using plugins

You can download the plugin files (.zip) and install them through Rulia app. Just click few times and you are ready to go.

But every single plugin has its own design. Maybe you have to ask plugin authors for help.

Check out plugin list for installing plugins.

Building plugins

Design

For the simplest complexity, I would like to make all plugins in pure JavaScript since many people have experience in. A plugin comes with the file structure:

my-plugin
  |- index.js                     # Plugin code implementation.
  |- package.json                 # Metadata for plugin.
  |- icon.png/jpg/gif/jfif/webp   # Icon image for plugin.
  |- README.md                    # A readme document. It is not necessary.

package.json

package.json contains metadata for plugin:

{
  // Name of your plugin. Acts like an ID.
  "name": "@rulia/my-plugin",

  // Display name.
  "title": "My first plugin",

  // Description. Let users know what you are going to do.
  "description": "I give this plugin for you. Keep it safe.",

  // Version.
  "version": "1.0.0",

  // Author name.
  "author": "LancerComet",

  // Tags. It can be used for searching if provided.
  "tags": ["Rulia", "Manga"], 

  // Homepage. User can visit it if provided.
  "homepage": "https://github.com/LancerComet/RuliaReader",

  // You can ask users to provide something for you by defining this field.
  // Please read "User Config" section in following document.
  "userConfig": { ... }
}

index.js

In index.js, there are four functions declared:

/**
 * Get manga list for manga list page.
 * This function will be invoked by Rulia in the manga list page.
 *
 * @param {number} page Page number. This arg will be passed from Rulia.
 * @param {string} keyword Search keyword. It will empty when user doesn't provide it.
 */
async function getMangaList (page: number, keyword: string) {
  const result = {
    list: [
      'https://some-site.com/manga-1',
      'https://some-site.com/manga-2',
      ...
    ]
  }

  // Call this function to send data back to Rulia.
  Rulia.endWithResult(result)
}

/**
 * Get data of a single manga.
 * This function will be invoked by Rulia when user clicks
 * a certain manga in the manga list page.
 *
 * @param {string} dataPageUrl This url is from the function "getMangaList".
 */
async function getMangaData (dataPageUrl: string) {
  // The result would be like:
  const result = {
    title: 'Some manga title',
    description: 'Some description',
    coverUrl: 'https://some-url.jpg',
    episodeList: string[]
  }

  Rulia.endWithResult(result)
}

/**
 * Get image urls of all images from a single episode.
 *
 * @param {string} chapterUrl This url is from the result of the function 'getMangaData'.
 */
async function getChapterImageList (chapterUrl: string) {
  const result = [
    { url: 'https://some-manga.com/01.jpg', x: 1200, y: 1800 },
    { url: 'https://some-manga.com/02.jpg', x: 1200, y: 1800 },
    ...
  ]

  Rulia.endWithResult(result)
}

/**
 * This function will be invoked when Rulia is going to download a image.
 *
 * Since some websites require special verification before downloading images,
 * you may need to implement these verification logics within this method.
 * If the target website doesn't need special logic, you can just directly
 * return the parameter 'url'.
 *
 * @param {string} url This url is from the result of the function 'getMangaEpisodeImageUrls'
 */
async function getImageUrl (url = '') {
  // If you don't need to implement some verification logics just return it.
  return url

  // If you need to.
  // An example of verification logics implementation here.
  const imageToken = await getImageToken(url)
  const fullUrl = url + '?token=' + imageToken
  Rulia.endWithResult(fullUrl)
}

You don't need to export these functions in ESModule or CommonJS way, just declare them in the form of function xxx () {}.

So you might find out that there will be some new pages in Rulia:

  • A page to display a list that contains all manga. This page uses the function getMangaList to fill the list.
  • A page to display the information and chapter list of a single manga. This page uses the function getMangaData to get the information and chapter list of a single manga. Then user can jump to the reading page from this page.

In the reading page, the function getChapterImageList will be invoked to get a complete list of the urls of all images. Then reader will use this list to download manga images. Before it starts to download an image, the function getImageUrl will be invoked and the url will be passed into this function as the argument "url". The reason why getImageUrl exists is that some sites require certain verification before downloading every single image, so you may need to implement these verification logics within this method. If you don't need to, you can just directly return the argument "url".

After you finish your plugin, you need to pack all files into a zip and throw it into Rulia. Then there will be a new entry in Rulia for your plugin.

Icon

You can place a icon.jpg/jfif/png/gif/webp as the icon image.

README.md

You can make a readme file for users. It is not necessary, but it will be helpful if you provide it.

User Config

Sometime you need users to specify something for you, like "where is your server location", or "what's your account". To meet this needs, you can define some config fields in your plugin.

Let's say you are making a plugin that connects to a self-hosted server, thus users need to tell you where their servers are. To do this, add this following code to your package.json:

{
  ...
  "userConfig": {
    "serverUrl": {
      "displayName": "Server URL",
      "description": "Please tell me where your server is, like: http://localhost:3000."
    }
  }
}

A new field called userConfig has been added, then the following things will happen:

  • User can fill these fields in Rulia. In this example, users can provide their serverUrl.
  • You can get these values by invoking window.Rulia.getUserConfig() in your plugin:
    const userConfig = window.Rulia.getUserConfig() ?? {}
    const serverUrl = userConfig['serverUrl']  // Now you have what you want.

Packing

Just pack these files into a zip:

my-plugin.zip
  |- index.js      # Must have.
  |- package.json  # Must have.
  |- icon.jpg      # Must have.
  |- README.md

Then distribute the zip to users. Done.

Typings

I would like to provide a npm package named @rulia/types for you to make the developing process easier. It provides the type definition of Rulia's built-in functions for the Rulia plugins. Most editors can recogonize it and give you hint when coding.

Check out this package right here.

If you don't know what it is, just ignore it. Sorry for the inconvenience.

How Rulia identifies plugins

Rulia identifies different plugins using the sha1 of the plugin file. Therefore, technically, the same version of a plugin can coexist as long as the sha1 of the zip file is different.

How about using tools like Webpack/Vite/TypeScript/... ?

It is totally okay to bring frontend toolchain to your plugin project. It is no matter how you organize your source code, just bundle them into a single index.js file.

Functions from Rulia

Several functions are injected into JS runtime and available for plugins:

window.Rulia = {
  /**
   * APIs available for Rulia plugins.
   */
  Rulia: {
    /**
     * End execution of current plugin context without returning any data.
     */
    end: () => void

    /**
     * End execution of current plugin context with some data returned.
     *
     * @param payload Data passed to Rulia.
     */
    endWithResult: (payload: any) => void

    /**
     * End execution of current plugin context with raising an exception.
     *
     * @param errorMsg Error message.
     */
    endWithException: (errorMsg: string) => void

    /**
     * Make an app toast.
     * You can use this function to tell user something if you need to.
     *
     * @param message Toast message.
     */
    appToast: (message: string) => void

    /**
     * Make an http request.
     *
     * @example
     * // 1. Make a get request.
     * // ===========================================
     * const payload = new URLSearchParams()
     * payload.append('region', 'japan')
     * payload.append('keyword', 'school')
     *
     * const rawResponse = await window.Rulia.httpRequest({
     *   url: 'https://example.com/v1/comic-list',
     *   method: 'GET',
     *   payload: payload.toString()  // 'region=japan&keyword=school'
     * })
     *
     * // If your response is a JSON.
     * const response = JSON.parse(rawResponse)
     *
     * @example
     * // 2. Make a post request with "application/json".
     * // ===========================================
     * const payload = {
     *   name: 'John Smith',
     *   age: 100
     * }
     * const rawResponse = await window.Rulia.httpRequest({
     *   url: 'https://example.com/v1/add-user',
     *   method: 'POST',
     *   payload: JSON.stringify(payload),
     *   contentType: 'application/json'
     * })
     *
     * // If your response is some kind of customized string, just use it.
     * const response = rawResponse
     *
     * @example
     * // 3. Make a post request with "application/x-www-form-urlencoded".
     * // ===========================================
     * const payload = new URLSearchParams()
     * payload.append('name', 'John Smith')
     * payload.append('age', 100)
     *
     * const rawResposne = await window.Rulia.httpRequest({
     *   url: 'https://example.com/v1/add-user',
     *   method: 'POST',
     *   payload: payload.toString(),
     *   contentType: 'application/x-www-form-urlencoded'
     * })
     *
     * @example
     * // 4. Make a post request with your custom type.
     * // ===========================================
     * const rawResponse = await window.Rulia.httpRequest({
     *   url: 'https://example.com/some/api/requires/xml',
     *   method: 'POST',
     *   payload: '<user><name>John Smith</name><age>100</age></user>',  // Let's say the server requires a piece of XML.
     *   contentType: 'application/must-be-written-in-this-way'  // The required content type by the server.
     * })
     *
     * // For an example, the sever responses with a YAML string.
     * const myYAML = parseYAML(rawResponse)
     */
    httpRequest: (params: {
      /**
       * Request URL.
       */
      url: string

      /**
       * Http method.
       */
      method: string

      /**
       * Request data.
       * It only accetps string, you have to serialize it yourself.
       * Check the example above to see how to do this.
       */
      payload?: string

      /**
       * Content type of the payload.
       * It has to match the payload that you send to server.
       */
      contentType?: string

      /**
       * Timeout for the request.
       * This parameter is available from 0.15.0.
       */
      timeout?: number
    }) => Promise<string>

    /**
     * Get app version. Maybe you need it.
     *
     * @returns {string} Version string.
     */
    getAppVersion: () => string

    /**
     * Get user config of the plugin.
     * User config fields are defined in the section "userConfig" in the package.json.
     */
    getUserConfig: () => Record<string, string>

    /**
     * This local storage API just acts exactly the same as the one in browser.
     * Your data will be saved by Rulia. 
     * Available from 0.15.0.
     */
    localStorage: {
      getItem: (key: string) => string | undefined
      setItem: (key: string, value: string) => void
    },

    /**
     * This session storage API just acts exactly the same as the one in browser.
     * Your data will be lost after user close the Rulia.
     * Available from 0.15.0.
     */
    sessionStorage: {
      getItem: (key: string) => string | undefined
      setItem: (key: string, value: string) => void
    }
  }
}