diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..efd08cf
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+# Auto detect text files and perform LF normalization
+* text=auto
+*.lockb binary diff=lockb
diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml
new file mode 100644
index 0000000..0bab191
--- /dev/null
+++ b/.github/workflows/pages-deploy.yml
@@ -0,0 +1,47 @@
+name: Deploy to Pages
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - master
+ paths:
+ - 'src/**'
+ - 'public/**'
+ - 'index.html'
+ - 'package.json'
+ - 'vite.config.ts'
+ - '.github/workflows/pages-deploy.yml'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ - name: Install Dependencies
+ run: bun i
+ - name: Build
+ run: bun build-only
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v1
+ with:
+ path: './dist'
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ concurrency:
+ group: 'pages'
+ cancel-in-progress: true
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8ee54e8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..9d353cc
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "printWidth": 140,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "arrowParens": "avoid",
+ "useTabs": false,
+ "tabWidth": 2,
+ "semi": true
+}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..a7cea0b
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["Vue.volar"]
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..515d60a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 神代綺凛
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b131aaa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,13 @@
+# MAA Resource Updater
+
+无需依赖任何第三方软件,纯靠浏览器工作的 [MAA 资源文件](https://github.com/arkntools/maa-resource-updater) 更新器
+
+仅支持:Chrome ≥ 86, Edge ≥ 86, Opera ≥ 72
+
+## 原理
+
+[isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) + [File System API](https://developer.mozilla.org/zh-CN/docs/Web/API/File_System_API)
+
+## 首次使用 Tips
+
+如果你本地的资源已经最新,那么可以直接点击“仅 clone / pull”,后续有更新时再用增量更新即可
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..0e291b7
Binary files /dev/null and b/bun.lockb differ
diff --git a/env.d.ts b/env.d.ts
new file mode 100644
index 0000000..e516e2c
--- /dev/null
+++ b/env.d.ts
@@ -0,0 +1,4 @@
+///
+///
+///
+///
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..562de1c
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ MAA Resource Updater
+
+
+
+
+
+
diff --git a/node/env.d.ts b/node/env.d.ts
new file mode 100644
index 0000000..752bd78
--- /dev/null
+++ b/node/env.d.ts
@@ -0,0 +1,3 @@
+declare module '@isomorphic-git/cors-proxy/middleware' {
+ export default function corsProxy(): any;
+}
diff --git a/node/proxy.ts b/node/proxy.ts
new file mode 100644
index 0000000..7b2fc40
--- /dev/null
+++ b/node/proxy.ts
@@ -0,0 +1,16 @@
+import { createServer } from 'http';
+import Express from 'express';
+import gitCorsProxy from '@isomorphic-git/cors-proxy/middleware';
+
+export const startProxy = () => {
+ try {
+ const app = Express();
+ app.use(gitCorsProxy());
+ app.on('error', console.error);
+ const server = createServer(app);
+ server.on('error', () => {});
+ server.listen(9999, () => {
+ console.log('Git CORS proxy started');
+ });
+ } catch {}
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a56b56a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "maa-resource-updater",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "run-p type-check \"build-only {@}\" --",
+ "preview": "vite preview",
+ "build-only": "vite build",
+ "type-check": "vue-tsc --build --force"
+ },
+ "dependencies": {
+ "@isomorphic-git/lightning-fs": "^4.6.0",
+ "@vueuse/core": "^11.0.0",
+ "buffer": "^6.0.3",
+ "isomorphic-git": "^1.27.1",
+ "sass": "^1.77.8",
+ "vue": "^3.4.38"
+ },
+ "devDependencies": {
+ "@isomorphic-git/cors-proxy": "^2.7.1",
+ "@tsconfig/node18": "^18.2.4",
+ "@types/express": "^4.17.21",
+ "@types/node": "^18.19.44",
+ "@types/wicg-file-system-access": "^2023.10.5",
+ "@vitejs/plugin-vue": "^5.1.2",
+ "@vue/tsconfig": "^0.5.1",
+ "express": "^4.19.2",
+ "npm-run-all2": "^6.2.2",
+ "typescript": "^5.5.4",
+ "vite": "^5.4.1",
+ "vite-plugin-comlink": "^5.0.1",
+ "vue-tsc": "^2.0.29"
+ }
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..3187611
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 0000000..8d71783
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,216 @@
+
+ MAA Resource Updater
+
+ 无需依赖任何第三方软件,纯靠浏览器工作的
+ MAA 资源文件 更新器
+
+ 仅支持:Chrome ≥ 86, Edge ≥ 86, Opera ≥ 72
+
+ GitHub: arkntools/maa-resource-updater
+
+ 首次使用 Tips:如果你本地的资源已经最新,那么可以直接点击“仅 clone / pull”,后续有更新时再用增量更新即可
+
+
+
+
{{ gitProgress.desc }}
+
+ {{ errorText }}
+
+
+
+
+
diff --git a/src/assets/main.css b/src/assets/main.css
new file mode 100644
index 0000000..d21088a
--- /dev/null
+++ b/src/assets/main.css
@@ -0,0 +1,43 @@
+html,
+body {
+ height: 100%;
+ width: 100%;
+ margin: 0;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+#app {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: 632px;
+ padding: 16px;
+ box-sizing: border-box;
+}
+
+h1,
+p {
+ margin-top: 0;
+}
+pre {
+ white-space: pre-wrap;
+}
+button {
+ user-select: none;
+}
+a,
+a:visited {
+ color: #0969da;
+}
+a {
+ text-decoration: none;
+}
+
+.text-center {
+ text-align: center;
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..b05b45f
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,6 @@
+import './assets/main.css';
+
+import { createApp } from 'vue';
+import App from './App.vue';
+
+createApp(App).mount('#app');
diff --git a/src/utils/fileSystem.ts b/src/utils/fileSystem.ts
new file mode 100644
index 0000000..4b2146b
--- /dev/null
+++ b/src/utils/fileSystem.ts
@@ -0,0 +1,32 @@
+import { shallowRef } from 'vue';
+
+export const useDirectoryPicker = () => {
+ const dirHandle = shallowRef();
+
+ const pickDir = async (checkFunc?: (handle: FileSystemDirectoryHandle) => Promise) => {
+ const handle = await window.showDirectoryPicker({
+ id: 'maa-root',
+ mode: 'readwrite',
+ startIn: 'desktop',
+ });
+ if (checkFunc && !(await checkFunc(handle))) return;
+ dirHandle.value = handle;
+ };
+
+ return { dirHandle, pickDir };
+};
+
+const checkDirExist = async (rootHandle: FileSystemDirectoryHandle, name: string) => {
+ try {
+ await rootHandle.getDirectoryHandle(name, { create: false });
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const checkIsMAARoot = async (dirHandle: FileSystemDirectoryHandle) => {
+ if (!(await checkDirExist(dirHandle, 'cache'))) return false;
+ if (!(await checkDirExist(dirHandle, 'resource'))) return false;
+ return true;
+};
diff --git a/src/utils/git.ts b/src/utils/git.ts
new file mode 100644
index 0000000..dbd6a1d
--- /dev/null
+++ b/src/utils/git.ts
@@ -0,0 +1,5 @@
+import type { Git } from '@/workers/git';
+
+const worker = new ComlinkWorker(new URL('../workers/git.js', import.meta.url));
+
+export const createGitClient = (...args: ConstructorParameters) => new worker.Git(...args);
diff --git a/src/workers/git.ts b/src/workers/git.ts
new file mode 100644
index 0000000..ef83d33
--- /dev/null
+++ b/src/workers/git.ts
@@ -0,0 +1,211 @@
+import LightningFS, { type PromisifiedFS } from '@isomorphic-git/lightning-fs';
+import http from 'isomorphic-git/http/web';
+import git, { TREE, WORKDIR, type WalkerEntry } from 'isomorphic-git';
+import { Buffer } from 'buffer/';
+
+(globalThis as any).Buffer = Buffer;
+
+const ignoreWalkSet = new Set(['.git', '.gitignore', 'LICENSE', 'README.md']);
+
+const getFilename = (path: string) => path.split('/').pop()!;
+
+interface WalkResult {
+ path: string;
+ name: string;
+ type: 'blob' | 'tree';
+ entry: WalkerEntry;
+ children?: WalkResult[];
+}
+
+export interface GitProgress {
+ value: number;
+ desc: string;
+}
+
+export interface GitCommit {
+ message: string;
+ sha1: string;
+}
+
+export type OnGitProgress = (progress: GitProgress) => void;
+
+export type OnGitCommitsUpdate = (commits: GitCommit[]) => void;
+
+export class Git {
+ private readonly fs: PromisifiedFS;
+ private readonly commonOptions: Parameters<(typeof git)['clone']>[0] & Parameters<(typeof git)['pull']>[0];
+
+ constructor(
+ private readonly url: string,
+ private readonly onProgress: OnGitProgress,
+ private readonly onCommitsUpdate: OnGitCommitsUpdate,
+ ) {
+ this.fs = new LightningFS(url).promises;
+ this.commonOptions = {
+ fs: this.fs,
+ http,
+ dir: '/',
+ corsProxy: import.meta.env.DEV ? 'http://127.0.0.1:9999' : 'https://mashir0-mrugcp.hf.space',
+ url,
+ singleBranch: true,
+ depth: 1,
+ author: {
+ name: '',
+ email: '',
+ },
+ onProgress: ({ phase, loaded, total }) => {
+ this.onProgress({
+ value: total ? loaded / total : 0,
+ desc: total ? `${phase} (${loaded}/${total})` : `${phase} (${loaded})`,
+ });
+ },
+ };
+ this.emitUpdateCommits();
+ }
+
+ async update() {
+ let type = '';
+ if (await this.getHeadCommit()) {
+ console.log('git pull');
+ await git.pull(this.commonOptions);
+ type = 'pull';
+ } else {
+ console.log('git clone');
+ await git.clone(this.commonOptions);
+ type = 'clone';
+ }
+ await this.emitUpdateCommits();
+ return type;
+ }
+
+ clear() {
+ return new Promise(resolve => {
+ const req = indexedDB.deleteDatabase(this.url);
+ req.onsuccess = () => {
+ this.onCommitsUpdate([]);
+ resolve(true);
+ };
+ req.onerror = () => {
+ resolve(false);
+ };
+ });
+ }
+
+ async copyToFileSystem(root: FileSystemDirectoryHandle) {
+ this.onProgress({ value: 0, desc: 'Processing files' });
+ let total = 0;
+ const result: WalkResult = await git.walk({
+ fs: this.fs,
+ dir: '/',
+ trees: [WORKDIR()],
+ map: async (path, [entry]) => {
+ if (!entry) return null;
+ const name = getFilename(path);
+ if (ignoreWalkSet.has(name)) return null;
+ const type = await entry.type();
+ if (type === 'blob') total++;
+ return {
+ path,
+ name,
+ type,
+ entry,
+ };
+ },
+ reduce: this.walkReduce,
+ });
+ await this.copyDir(root, result, { cur: 0, total });
+ return { total };
+ }
+
+ async copyToFileSystemIncremental(root: FileSystemDirectoryHandle, startCommit: string) {
+ this.onProgress({ value: 0, desc: 'Processing files' });
+ let total = 0;
+ const headCommit = await this.getHeadCommit();
+ if (headCommit === startCommit) return { total };
+ const paths: string[] = [];
+ const result: WalkResult = await git.walk({
+ fs: this.fs,
+ dir: '/',
+ trees: [TREE({ ref: 'HEAD' }), TREE({ ref: startCommit })],
+ map: async (path, [entry, pastEntry]) => {
+ if (!entry) return null;
+ const name = getFilename(path);
+ if (ignoreWalkSet.has(name)) return null;
+ const type = await entry.type();
+ if (type === 'blob') {
+ if (pastEntry) {
+ const oid = await entry.oid();
+ const pastOid = await pastEntry.oid();
+ if (oid === pastOid) return null;
+ }
+ total++;
+ paths.push(path);
+ }
+ return {
+ path,
+ name,
+ type,
+ entry,
+ };
+ },
+ reduce: this.walkReduce,
+ });
+ await this.copyDir(root, result, { cur: 0, total });
+ return { total };
+ }
+
+ async getHeadCommit() {
+ try {
+ const logs = await git.log({ fs: this.fs, dir: '/', depth: 1 });
+ return logs[0]?.commit.tree || null;
+ } catch {
+ return null;
+ }
+ }
+
+ async getCommitList() {
+ try {
+ const logs = await git.log({ fs: this.fs, dir: '/' });
+ return logs.map(({ commit: { message, tree } }): GitCommit => ({ message, sha1: tree }));
+ } catch {
+ return [];
+ }
+ }
+
+ private async walkReduce(parent: WalkResult, children: WalkResult[]) {
+ if (parent.type === 'blob') return parent;
+ return Object.assign(parent, { children: children.filter(({ type, children }) => !(type === 'tree' && !children?.length)) });
+ }
+
+ private async copyDir(parentHandler: FileSystemDirectoryHandle, parentResult: WalkResult, state: { cur: number; total: number }) {
+ if (!parentResult.children) return;
+ await Promise.all(
+ parentResult.children.map(async result => {
+ try {
+ if (result.children) {
+ const dirHandler = await parentHandler.getDirectoryHandle(result.name, { create: true });
+ await this.copyDir(dirHandler, result, state);
+ } else {
+ const content = await result.entry.content();
+ if (!content) return;
+ const fileHandler = await parentHandler.getFileHandle(result.name, { create: true });
+ const writable = await fileHandler.createWritable();
+ await writable.write(content);
+ await writable.close();
+ state.cur++;
+ this.onProgress({
+ value: state.total === 0 ? 1 : state.cur / state.total,
+ desc: `Write (${state.cur}/${state.total}): ${result.path}`,
+ });
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ }),
+ );
+ }
+
+ private async emitUpdateCommits() {
+ this.onCommitsUpdate(await this.getCommitList());
+ }
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..491e093
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,13 @@
+{
+ "extends": "@vue/tsconfig/tsconfig.dom.json",
+ "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+ "exclude": ["src/**/__tests__/*"],
+ "compilerOptions": {
+ "composite": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..66b5e57
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ },
+ {
+ "path": "./tsconfig.app.json"
+ }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..80cd7c8
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@tsconfig/node18/tsconfig.json",
+ "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*", "node/**/*"],
+ "compilerOptions": {
+ "composite": true,
+ "noEmit": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "types": ["node"]
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..096b0d1
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,21 @@
+import { fileURLToPath, URL } from 'node:url';
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+import { comlink } from 'vite-plugin-comlink';
+import { startProxy } from './node/proxy';
+
+// https://vitejs.dev/config/
+export default defineConfig(({ command }) => {
+ if (command === 'serve') startProxy();
+ return {
+ plugins: [comlink(), vue()],
+ worker: {
+ plugins: () => [comlink()],
+ },
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ };
+});