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 @@ + + + + + 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)), + }, + }, + }; +});