Skip to content

skiptools/skip-miniapp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SkipMiniApp

A Skip framework for loading and running W3C MiniApp packages on iOS and Android. Implements the dual-thread execution model (Logic Layer + View Layer), WeChat-compatible navigation APIs, and a reactive data-binding view system powered by Alpine.js.

Experimental: This package is under active development. APIs may change without notice.

Architecture Overview

SkipMiniApp follows the dual-thread model used by WeChat Mini Programs: application logic runs in an isolated JavaScript context (no DOM access), while the view layer renders HTML templates in a WebView (no host API access). Communication between the two threads flows through a structured bridge.

graph TB
    subgraph "Host App (SwiftUI)"
        HV[MiniAppHostView]
        TV[TabView]
        NS[NavigationStack]
    end

    subgraph "Logic Layer (JSContext)"
        RT[MiniAppRuntime]
        AJ[app.js]
        PJ[page.js]
        MOD[Modules: fs, network, i18n, nav, log]
    end

    subgraph "View Layer (WebView)"
        AL[Alpine.js CSP]
        HT[page.html + CSS]
        BD[Reactive Data Store]
    end

    HV --> TV --> NS
    NS --> |"hosts"| AL
    RT --> |"setData(patch)"| BD
    AL --> |"events"| RT
    PJ --> |"skip.nav.navigateTo()"| MOD
    MOD --> |"pendingAction"| HV
Loading

Dual-Thread Model

The separation between Logic and View layers provides security isolation and performance predictability, matching the W3C MiniApp Architecture model:

Layer Runs In Can Access Cannot Access
Logic Layer JSContext (JavaScriptCore) Host APIs (skip.fs, skip.net.fetch, skip.nav.navigateTo, etc.), page data via this.data and this.setData() DOM, document, window, any HTML
View Layer WKWebView (iOS) / WebView (Android) Alpine.js reactive store, HTML templates, CSS, user event dispatch Host APIs, file system, network, navigation

Data flows one way: Logic → View via setData() patches. User interactions flow back as named events.

sequenceDiagram
    participant User
    participant View as View Layer (WebView)
    participant Bridge as Native Bridge
    participant Logic as Logic Layer (JSContext)

    Logic->>Bridge: setData({ count: 1 })
    Bridge->>View: __miniappSetData(patch)
    View->>View: Alpine.store('page').count = 1
    View->>View: DOM updates reactively

    User->>View: tap button
    View->>Bridge: postMessage({ handler: 'onTap', detail: {} })
    Bridge->>Logic: dispatchEvent('onTap', event)
    Logic->>Logic: this.data.count += 1
    Logic->>Bridge: setData({ count: 2 })
    Bridge->>View: __miniappSetData({ count: 2 })
Loading

W3C MiniApp Specifications

This framework implements portions of the following W3C specifications:

Specification Status Coverage
MiniApp Packaging Complete ZIP-based .ma packages, resource layout, manifest location
MiniApp Manifest Complete All top-level members, window config, icons, permissions, widgets
MiniApp Lifecycle Complete Global + per-page state machines, all lifecycle callbacks
MiniApp Addressing Complete miniapp:// URI scheme with version, path, query, fragment

Package Structure

Following the W3C MiniApp Packaging specification, a .ma package is a ZIP archive with this layout:

my-app.ma/
├── manifest.json          # App metadata (W3C MiniApp Manifest)
├── app.js                 # Logic Layer entry point (App lifecycle)
├── app.css                # Global styles
├── icons/                 # Tab bar and app icons (SVG)
│   ├── home.svg
│   ├── list.svg
│   └── profile.svg
├── pages/
│   ├── home/
│   │   ├── home.html      # View template (Alpine.js directives)
│   │   ├── home.js        # Page logic (Page lifecycle + handlers)
│   │   └── home.css       # Page styles
│   ├── detail/
│   │   ├── detail.html
│   │   ├── detail.js
│   │   └── detail.css
│   └── ...
└── i18n/                  # Internationalization
    ├── en.json
    ├── fr.json
    └── zh-CN.json

Manifest (manifest.json)

Implements the MiniApp Manifest specification:

{
    "app_id": "com.example.myapp",
    "name": "My App",
    "short_name": "App",
    "description": "A sample MiniApp",
    "lang": "en",
    "icons": [{ "src": "icon.png", "sizes": "48x48" }],
    "version": { "code": 1, "name": "1.0.0" },
    "platform_version": { "min_code": 1 },
    "pages": [
        "pages/home/home",
        "pages/list/list",
        "pages/detail/detail"
    ],
    "window": {
        "navigation_bar_title_text": "My App",
        "background_color": "#ffffff",
        "navigation_style": "custom"
    },
    "tab_bar": {
        "tabs": [
            { "page": "pages/home/home", "text": "tab.home", "icon": "icons/home.svg" },
            { "page": "pages/list/list", "text": "tab.list", "icon": "icons/list.svg" }
        ]
    }
}

Key manifest members per the W3C spec:

Member Spec Reference Description
app_id appId Unique reverse-domain identifier
name name Full display name
version version Integer code + human-readable name
pages pages Ordered list of page routes
window window Navigation bar, background, orientation
icons icons App icons with sizes
widgets widgets Widget entry points
req_permissions reqPermissions Requested system permissions
tab_bar WeChat extension Tab bar configuration (2-5 tabs with SVG icons)

Lifecycle

Global App Lifecycle

Implements the W3C MiniApp Global Lifecycle:

stateDiagram-v2
    [*] --> Launched: App({onLaunch})
    Launched --> Shown: onShow
    Shown --> Hidden: onHide (app backgrounded)
    Hidden --> Shown: onShow (app foregrounded)
    Shown --> Error: onError
    Hidden --> Error: onError
    Shown --> [*]: unload
    Hidden --> [*]: unload
    Error --> [*]: unload
Loading

Registration (app.js):

App({
    onLaunch: function(options) {
        // App initialized — options.path, options.query available
    },
    onShow: function() {
        // App became visible
    },
    onHide: function() {
        // App went to background
    },
    onError: function(message) {
        // Unhandled error
    }
});

Page Lifecycle

Implements the W3C MiniApp Page Lifecycle:

stateDiagram-v2
    [*] --> Loaded: Page JS evaluated, onLoad(options)
    Loaded --> Ready: First render complete, onReady
    Ready --> Shown: onShow
    Shown --> Hidden: onHide (navigated away or tab switch)
    Hidden --> Shown: onShow (navigated back or tab switch)
    Hidden --> Unloaded: onUnload (popped from stack)
    Shown --> Unloaded: onUnload (popped from stack)
    Unloaded --> [*]
Loading

Registration (pages/home/home.js):

Page({
    data: {
        count: 0,
        items: []
    },

    onLoad: function(options) {
        // Page created — options.query available
        skip.nav.setNavigationBarTitle({ title: 'nav.home' });
        this.setData({ count: 1 });
    },
    onShow: function() { /* Page became visible */ },
    onReady: function() { /* First render complete */ },
    onHide: function() { /* Page hidden (still in stack) */ },
    onUnload: function() { /* Page destroyed (popped) */ },

    // Custom event handlers (called from View Layer)
    onTapButton: function(event) {
        this.setData({ count: this.data.count + 1 });
    }
});

Each page in the navigation stack has its own WebView instance. When a page is popped, its WebView and all state are destroyed (matching WeChat behavior). Tab root pages persist across tab switches.

Navigation

Five WeChat-compatible navigation APIs, plus setNavigationBarTitle:

API Behavior
skip.nav.navigateTo({ url, query }) Push page onto current tab's stack (max 10 levels)
skip.nav.navigateBack({ delta }) Pop delta pages (default 1, never pops root)
skip.nav.redirectTo({ url }) Replace current page (no new stack entry)
skip.nav.reLaunch({ url }) Clear all stacks, reset to single page
skip.nav.switchTab({ url }) Switch to the tab whose root matches url
skip.nav.setNavigationBarTitle({ title }) Set the navigation bar title (supports i18n keys)

Navigation is managed by real SwiftUI NavigationStack (per-tab) with TabView when the manifest defines a tab_bar. Each pushed page gets its own isolated WebView.

View Layer (Alpine.js)

Instead of WeChat's WXML templating language, pages use standard HTML with Alpine.js (CSP build) for reactive rendering:

<body x-data="page">
    <h1 x-text="store.count"></h1>
    <button @click="onTapButton">Increment</button>

    <ul>
        <template x-for="item in store.items">
            <li x-text="item.name"></li>
        </template>
    </ul>

    <p x-text="$t('greeting.hello')"></p>
</body>

Available Alpine features:

  • x-data="page" — connects to the page's reactive data store
  • x-text, x-show, x-if, x-for — reactive rendering
  • @click="handlerName" — dispatches events to the Logic Layer
  • x-model="store.key" — two-way binding (syncs back to Logic Layer)
  • $t('key', params) — i18n translation magic

Modules

The runtime is extensible via MiniAppModule. Built-in modules:

Module Namespace Description
Navigation skip.nav.navigateTo() etc. 5 WeChat navigation APIs + title
Network skip.net.fetch(url, options) Promise-based HTTP (GET/POST/PUT/DELETE)
File System skip.fs.root OPFS-style sandboxed storage (per-app isolation)
I18n skip.i18n.t(key) Translations, number/date formatting, plurals
Logging skip.log(...) Structured logging with host capture

File System API

// Write a file
var dir = skip.fs.root.getDirectoryHandle('notes', { create: true });
var file = dir.getFileHandle('todo.txt', { create: true });
file.write('Buy groceries');

// Read it back
var content = file.read(); // "Buy groceries"

// List entries
var entries = skip.fs.root.entries(); // [{name, kind}]

// Remove
skip.fs.root.removeEntry('notes', { recursive: true });

I18n API

// Simple translation
var greeting = skip.i18n.t('hello.world');

// With parameters
var msg = skip.i18n.t('items.count', { count: 5 });
// Template: "You have {count} items" → "You have 5 items"

// Number formatting
var price = skip.i18n.n(1234.5, { style: 'currency', currency: 'USD' });

// Plural forms
var label = skip.i18n.plural(count, { one: '# item', other: '# items' });

Locale resolution chain: device locale → region default → language code → manifest lang"en".

Modules

The package contains two Swift modules:

Module Role
SkipMiniAppModel Platform-independent: manifest parsing, package reading/building, JavaScript runtime, lifecycle state machines, all Logic Layer modules
SkipMiniApp SwiftUI view layer: MiniAppHostView, MiniAppPageView, Alpine.js bridge, SVG icon rendering, WebView management

Usage

import SkipMiniApp

// Present a MiniApp in a full-screen cover
MiniAppHostView(
    directoryURL: miniAppURL,
    namespace: "skip",
    modules: [
        .fileSystem(baseDirectory: storageDir),
        .network,
        .i18n,
        .navigation,
        .logging(onLog: { entry in print(entry.message) })
    ],
    onDismiss: { dismiss() }
)

The onDismiss closure adds a close button (upper-right) to every page in the navigation stack.

Building

swift build    # Swift compilation
swift test     # Swift tests + Kotlin transpilation + JVM tests

This project uses the Skip plugin to transpile Swift to Kotlin for Android. The swift test command verifies both platforms.

License

This software is licensed under the Mozilla Public License 2.0.

About

Skip support for W3C MiniApps

Resources

License

Contributing

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors

Languages