Skip to content

Commit

Permalink
🎉 chore: init repo
Browse files Browse the repository at this point in the history
Signed-off-by: SimonShiki <sinangentoo@gmail.com>
  • Loading branch information
SimonShiki committed Sep 16, 2023
0 parents commit b8cc9e2
Show file tree
Hide file tree
Showing 31 changed files with 6,178 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-typescript",
"babel-preset-solid"
]
}
37 changes: 37 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"semi": "error",
"indent": ["error", 4],
"dot-notation": "error",
"block-scoped-var": "error",
"eqeqeq": "error",
"no-confusing-arrow": ["error"],
"no-else-return": "error",
"no-lonely-if": "error",
"no-useless-constructor": "error",
"no-useless-return": "error",
"no-var": "error",
"comma-spacing": "error",
"func-call-spacing": "error",
"dot-location": ["error", "property"],
"no-whitespace-before-property": "error",
"space-before-function-paren": "error",
"space-unary-ops": ["error", {
"words": true,
"nonwords": false
}]
}
}
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Mac OS
.DS_Store

# NPM
/node_modules
npm-*

# Yarn
yarn-error.log

# generated build files
/dist
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div align="center">

<img alt="logo" src="./assets/chibi.png">

# Chibi
#### Load scratch extension everywhere.

</div>

---

Chibi is a userscript which can load 3rd-party extensions in any Scratch-based editors (theoretically).
# ✨ Features
- [x] Load Scratch standard extensions
- [x] Unsandboxed extensions
- [x] TurboWarp Extension API (very small part)
- [ ] Fallback solution for visitors without script installation
- [ ] Load from editor

# 🔥 Usage
*I haven’t written a method to load extensions in the editor yet, 你先别急*

1. Install UserScript Manager like Tampermonkey or Greasymonkey.
2. Open [release](https://github.com/SimonShiki/chibi/releases), Then click one release to install.
3. Press 'F12' on your keyboard to open Developer Tools.
4. Input ``chibi.loader.load([extensionURL], [load mode, like 'unsandboxed'])'`` In your console, then enter to execute.
5. Your extension got loaded!

# ⚓ License
AGPL-3.0, see [LICENSE](./LICENSE).
Binary file added assets/chibi.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "chibi",
"displayName": "Chibi",
"version": "0.0.1",
"description": "Load scratch extension everywhere.",
"repository": "https://github.com/SimonShiki/chibi",
"author": "SimonShiki",
"private": true,
"scripts": {
"build": "webpack --color --bail",
"lint": "eslint ./src/ --ext .js,.ts",
"typecheck": "tsc --watch --noEmit"
},
"devDependencies": {
"@babel/core": "^7.22.17",
"@babel/preset-env": "^7.22.15",
"@babel/preset-typescript": "^7.22.15",
"@turbowarp/types": "^0.0.11",
"@typescript-eslint/eslint-plugin": "^6.7.0",
"@typescript-eslint/parser": "^6.7.0",
"babel-loader": "^9.1.3",
"babel-preset-solid": "^1.7.7",
"codingclip-worker-loader": "^3.0.9",
"eslint": "^8.49.0",
"mini-svg-data-uri": "^1.4.4",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-userscript": "^3.2.2"
},
"dependencies": {
"format-message": "^6.2.4",
"solid-js": "^1.7.11",
"typescript": "^5.2.2"
}
}
11 changes: 11 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// <reference path="node_modules/@turbowarp/types/index.d.ts" />
// <reference path="./loader/loader" />

declare interface Window {
chibi: {
version: string;
vm?: VM;
loader?: ChibiLoader;
registeredExtension: Record<string, string>;
}
}
4 changes: 4 additions & 0 deletions src/images.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.gif';
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { trap, inject } from './injector/inject';
import { log } from './util/log';

// @ts-expect-error defined in webpack define plugin
log(`Chibi ${__CHIBI_VERSION__}`);
await trap();
if (typeof window.chibi.vm !== 'undefined') {
inject(window.chibi.vm);
} else {
log(`Cannot find vm in this page, stop injecting.`);
}
101 changes: 101 additions & 0 deletions src/injector/inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/// <reference path="../global.d.ts" />
import {log, error} from '../util/log';
import { ChibiLoader } from '../loader/loader';
import type VM from 'scratch-vm';

const MAX_LISTENING_MS = 30 * 1000;

export function trap () {
window.chibi = {
// @ts-expect-error defined in webpack define plugin
version: __CHIBI_VERSION__,
registeredExtension: {}
};

log('Listening bind function...');
const oldBind = Function.prototype.bind;
return new Promise<void>(resolve => {
const timeoutId = setTimeout(() => {
log('Cannot find vm instance, stop listening.');
Function.prototype.bind = oldBind;
resolve();
}, MAX_LISTENING_MS);

Function.prototype.bind = function (...args) {
if (Function.prototype.bind === oldBind) {
return oldBind.apply(this, args);
} else if (
args[0] &&
Object.prototype.hasOwnProperty.call(args[0], "editingTarget") &&
Object.prototype.hasOwnProperty.call(args[0], "runtime")
) {
log('VM detected!');
window.chibi.vm = args[0];
Function.prototype.bind = oldBind;
clearTimeout(timeoutId);
resolve();
return oldBind.apply(this, args);
}
return oldBind.apply(this, args);
};
});
}

function stringify (obj) {
return JSON.stringify(obj, (_key, value) => {
if (typeof value === 'number' &&
(value === Infinity || value === -Infinity || isNaN(value))){
return 0;
}
return value;
});
}

export function inject (vm: VM) {
const loader = window.chibi.loader = new ChibiLoader(vm);
const originalLoadFunc = vm.extensionManager.loadExtensionURL;
vm.extensionManager.loadExtensionURL = async function (extensionURL: string, ...args: unknown[]) {
if (extensionURL in window.chibi.registeredExtension) {
const { url, env } = window.chibi.registeredExtension[extensionURL];
try {
if (confirm(`🤨 Project is trying to sideloading ${extensionURL} from ${url} in ${env} mode. Do you want to load?`)) {
await loader.load(url, env);
} else {
// @ts-expect-error internal hack
return originalLoadFunc.apply(vm.extensionManager, [extensionURL, ...args]);
}
} catch (e: unknown) {
error('Error occurred while sideloading extension. To avoid interrupting the loading process, we chose to ignore this error.', e);
}
} else {
// @ts-expect-error internal hack
return originalLoadFunc.apply(vm.extensionManager, [extensionURL, ...args]);
}
};

const originalToJSONFunc = vm.toJSON;
vm.toJSON = function (optTargetId: string, ...args: unknown[]) {
// @ts-expect-error internal hack
const json = originalToJSONFunc.apply(vm, optTargetId, ...args);
const obj = JSON.parse(json);
const [urls, envs] = window.chibi.loader.getLoadedInfo();
obj.extensionURLs = Object.assign({}, urls);
obj.extensionEnvs = Object.assign({}, envs);
return stringify(obj);
};

const originalDrserializeFunc = vm.deserializeProject;
vm.deserializeProject = function (projectJSON: Record<string, unknown>, ...args: unknown[]) {
if (typeof projectJSON.extensionURLs === 'object') {
for (const id in projectJSON.extensionURLs) {
window.chibi.registeredExtension[id] = {
url: projectJSON.extensionURLs[id],
env: typeof projectJSON.extensionEnvs === 'object' ?
projectJSON.extensionEnvs[id] : 'sandboxed'
};
}
}
// @ts-expect-error internal hack
return originalDrserializeFunc.apply(vm, [projectJSON, ...args]);
};
}
136 changes: 136 additions & 0 deletions src/loader/dispatch/central-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { SharedDispatch, DispatchCallMessage } from './shared-dispatch';
/**
* This class serves as the central broker for message dispatch. It expects to operate on the main thread / Window and
* it must be informed of any Worker threads which will participate in the messaging system. From any context in the
* messaging system, the dispatcher's "call" method can call any method on any "service" provided in any participating
* context. The dispatch system will forward function arguments and return values across worker boundaries as needed.
* @see {WorkerDispatch}
*/
class _CentralDispatch extends SharedDispatch {
services: Record<string, any>;
/**
* The constructor we will use to recognize workers.
* @type {Worker | null}
*/
workerClass: typeof Worker | null = (typeof Worker === 'undefined' ? null : Worker);
/**
* List of workers attached to this dispatcher.
* @type {Array}
*/
workers: Worker[] = [];
_onMessage!: (worker: Worker, event: MessageEvent) => void;
constructor () {
super();
/**
* Map of channel name to worker or local service provider.
* If the entry is a Worker, the service is provided by an object on that worker.
* Otherwise, the service is provided locally and methods on the service will be called directly.
* @see {setService}
* @type {object.<Worker|object>}
*/
this.services = {};
}
/**
* Synchronously call a particular method on a particular service provided locally.
* Calling this function on a remote service will fail.
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {*} - the return value of the service method.
*/
callSync (service: string, method: string, ...args: unknown[]) {
const {provider, isRemote} = this._getServiceProvider(service);
if (provider) {
if (isRemote) {
throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`);
}
return provider[method].apply(provider, args);
}
throw new Error(`Provider not found for service: ${service}`);
}
/**
* Synchronously set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
* @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'.
* @param {object} provider - a local object which provides this service.
*/
setServiceSync (service: string, provider: any) {
if (this.services.hasOwnProperty(service)) {
console.warn(`Central dispatch replacing existing service provider for ${service}`);
}
this.services[service] = provider;
}
/**
* Set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
* @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'.
* @param {object} provider - a local object which provides this service.
* @returns {Promise} - a promise which will resolve once the service is registered.
*/
setService (service: string, provider: any) {
/** Return a promise for consistency with {@link WorkerDispatch#setService} */
try {
this.setServiceSync(service, provider);
return Promise.resolve();
} catch (e) {
return Promise.reject(e);
}
}
/**
* Add a worker to the message dispatch system. The worker must implement a compatible message dispatch framework.
* The dispatcher will immediately attempt to "handshake" with the worker.
* @param {Worker} worker - the worker to add into the dispatch system.
*/
addWorker (worker: Worker) {
if (this.workers.indexOf(worker) === -1) {
this.workers.push(worker);
worker.onmessage = this._onMessage.bind(this, worker);
this._remoteCall(worker, 'dispatch', 'handshake').catch(e => {
console.error(`Could not handshake with worker: ${e}`);
});
} else {
console.warn('Central dispatch ignoring attempt to add duplicate worker');
}
}
/**
* Fetch the service provider object for a particular service name.
* @override
* @param {string} service - the name of the service to look up
* @returns {{provider:(object|Worker), isRemote:boolean}} - the means to contact the service, if found
* @protected
*/
_getServiceProvider (service: string) {
const provider = this.services[service];
return provider && {
provider,
isRemote: Boolean((this.workerClass && provider instanceof this.workerClass) || provider.isRemote)
};
}
/**
* Handle a call message sent to the dispatch service itself
* @override
* @param {Worker} worker - the worker which sent the message.
* @param {DispatchCallMessage} message - the message to be handled.
* @returns {Promise|undefined} - a promise for the results of this operation, if appropriate
* @protected
*/
_onDispatchMessage (worker: Worker, message: DispatchCallMessage) {
let promise;
switch (message.method) {
case 'setService':
if (!message.args) {
console.error('setService received empty argument');
break;
}
promise = this.setService(String(message.args[0]), worker);
break;
default:
console.error(`Central dispatch received message for unknown method: ${message.method}`);
}
return promise;
}
}

export type CentralDispatch = _CentralDispatch;

export const CentralDispatch = new _CentralDispatch();
3 changes: 3 additions & 0 deletions src/loader/dispatch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './shared-dispatch';
export * from './central-dispatch';
export * from './worker-dispatch';
Loading

0 comments on commit b8cc9e2

Please sign in to comment.