├── .gitattributes ├── .github ├── dependabot.yml ├── qqntim-plugin-management-page.png ├── qqntim-settings-page.png ├── screenshot.png ├── settings-entry.png └── workflows │ └── build.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── sdks │ ├── integrations.yml │ └── typescript │ ├── bin │ ├── tsc │ └── tsserver │ ├── lib │ ├── tsc.js │ ├── tsserver.js │ ├── tsserverlibrary.js │ └── typescript.js │ └── package.json ├── COPYING ├── COPYING.LESSER ├── DEVELOPMENT.md ├── MANUAL.md ├── README.md ├── build.ts ├── examples └── NT-API │ ├── consts.js │ ├── example.jpg │ ├── qqntim.json │ ├── renderer.js │ └── settings.js ├── package.json ├── publish ├── _ │ ├── install.ps1 │ └── uninstall.ps1 ├── install.cmd ├── install.sh ├── install_macos.sh ├── uninstall.cmd ├── uninstall.sh └── uninstall_macos.sh ├── rome.json ├── scripts ├── pack.ps1 ├── pack.sh ├── start.ps1 └── start.sh ├── src ├── builtins │ └── settings │ │ ├── package.json │ │ ├── publish │ │ ├── qqntim.json │ │ └── style.css │ │ ├── src │ │ ├── _renderer.tsx │ │ ├── exports │ │ │ ├── components.tsx │ │ │ └── index.ts │ │ ├── installer.ts │ │ ├── main.ts │ │ ├── nav.tsx │ │ ├── panel.tsx │ │ ├── qqntim-env.d.ts │ │ ├── renderer.ts │ │ └── utils │ │ │ ├── consts.ts │ │ │ ├── hooks.tsx │ │ │ ├── sep.ts │ │ │ └── utils.ts │ │ └── tsconfig.json ├── common │ ├── global.ts │ ├── ipc.ts │ ├── loader.ts │ ├── patch.ts │ ├── paths.ts │ ├── sep.ts │ ├── utils │ │ ├── console.ts │ │ ├── freePort.ts │ │ ├── ntVersion.ts │ │ └── process.ts │ ├── version.ts │ └── watch.ts ├── electron │ ├── README.txt │ └── package.json ├── main │ ├── api.ts │ ├── compatibility.ts │ ├── config.ts │ ├── debugger.ts │ ├── loader.ts │ ├── main.ts │ ├── patch.ts │ └── plugins.ts ├── qqntim-env.d.ts ├── renderer │ ├── api │ │ ├── app.ts │ │ ├── browserWindow.ts │ │ ├── dialog.ts │ │ ├── getVueId.ts │ │ ├── index.ts │ │ ├── nt │ │ │ ├── call.ts │ │ │ ├── constructor.ts │ │ │ ├── destructor.ts │ │ │ ├── index.ts │ │ │ ├── media.ts │ │ │ └── watcher.ts │ │ ├── waitForElement.ts │ │ └── windowLoadPromise.ts │ ├── debugger.ts │ ├── loader.ts │ ├── main.ts │ ├── patch.ts │ └── vueHelper.ts └── typings │ ├── COPYING │ ├── COPYING.LESSER │ ├── electron.d.ts │ ├── index.d.ts │ ├── package.json │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/qqntim-plugin-management-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlysoftBeta/QQNTim/5cd25d12b7a85529f512557ce544c26eddb2e9f4/.github/qqntim-plugin-management-page.png -------------------------------------------------------------------------------- /.github/qqntim-settings-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlysoftBeta/QQNTim/5cd25d12b7a85529f512557ce544c26eddb2e9f4/.github/qqntim-settings-page.png -------------------------------------------------------------------------------- /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlysoftBeta/QQNTim/5cd25d12b7a85529f512557ce544c26eddb2e9f4/.github/screenshot.png -------------------------------------------------------------------------------- /.github/settings-entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlysoftBeta/QQNTim/5cd25d12b7a85529f512557ce544c26eddb2e9f4/.github/settings-entry.png -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18.x 19 | 20 | - name: Setup Corepack 21 | run: corepack enable 22 | 23 | - name: Cache Yarn dependencies 24 | id: yarn-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: | 28 | .yarn/cache 29 | .yarn/unplugged 30 | .yarn/install-state.gz 31 | .pnp.cjs 32 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-yarn- 35 | 36 | - name: Install Yarn dependencies 37 | run: yarn install 38 | 39 | - name: Build 40 | run: yarn build:linux 41 | 42 | - name: Compress files 43 | run: | 44 | mkdir upload 45 | pushd dist 46 | tar -zcvf ../upload/qqntim-build.tar.gz . 47 | zip -r9 ../upload/qqntim-build.zip . 48 | popd 49 | 50 | - name: Upload artifact 51 | uses: actions/upload-artifact@v3 52 | with: 53 | name: build 54 | path: upload/* 55 | 56 | - name: Create release 57 | if: github.ref_type == 'tag' 58 | uses: softprops/action-gh-release@v1 59 | with: 60 | files: upload/* 61 | generate_release_notes: true 62 | tag_name: ${{ github.ref_name }} 63 | prerelease: ${{ endsWith(github.ref_name, 'alpha') }} 64 | draft: false 65 | name: ${{ github.ref_name }} 66 | 67 | - name: Setup .yarnrc.yml 68 | if: github.ref_type == 'tag' 69 | run: | 70 | yarn config set npmRegistryServer https://registry.npmjs.org 71 | yarn config set npmAlwaysAuth true 72 | yarn config set npmAuthToken $NPM_AUTH_TOKEN 73 | env: 74 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 75 | 76 | - name: Publish NPM Package 77 | if: github.ref_type == 'tag' 78 | run: yarn workspace @flysoftbeta/qqntim-typings npm publish 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | .yarn/* 126 | !.yarn/patches 127 | !.yarn/plugins 128 | !.yarn/releases 129 | !.yarn/sdks 130 | !.yarn/versions 131 | 132 | # Swap the comments on the following lines if you don't wish to use zero-installs 133 | # Documentation here: https://yarnpkg.com/features/zero-installs 134 | # !.yarn/cache 135 | .pnp.* 136 | 137 | test.sh -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["arcanis.vscode-zipfs"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.yarn": true, 4 | "**/.pnp.*": true 5 | }, 6 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 7 | "typescript.enablePromptUseWorkspaceTsdk": true 8 | } 9 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserver.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserver.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserver.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); 226 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserverlibrary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserverlibrary.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserverlibrary.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); 226 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/typescript.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/typescript.js your application uses 20 | module.exports = absRequire(`typescript/lib/typescript.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.0.4-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /COPYING.LESSER: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # 开发文档 2 | 3 | 此文档包含了插件开发文档(插件清单规范 v2.0)。 4 | 5 | **在开始之前,强烈建议你使用我们的[插件模板仓库](https://github.com/Flysoft-Studio/QQNTim-Plugin-Template)进行开发。** 模板仓库已经包含了 TypeScript 类型定义、自动构建及发布等内容,无需手动配置。 6 | 7 | ## 文件结构 8 | 9 | ### 数据文件夹 10 | 11 | 请查看 [使用手册-数据文件夹](MANUAL.md#数据文件夹) 查看默认的数据文件夹路径及其修改方式。 12 | 13 | 数据文件夹存放了所有插件和设置。 14 | 15 | 以下是一个结构示意图: 16 | 17 | ``` 18 | QQNTim 19 | ├─ plugins 20 | ├─ plugins-user 21 | └─ config.json 22 | ``` 23 | 24 | ### `plugins` 文件夹 25 | 26 | 存放所有插件的文件夹。在此文件夹下的插件**对所有账户都生效**。 27 | 28 | 以下是一个结构示意图: 29 | 30 | ``` 31 | plugins 32 | ├─ 插件1 (文件夹) 33 | │ ├─ qqntim.json 34 | │ ├─ renderer.js 35 | │ └─ style.css 36 | └─ 插件2 (文件夹) 37 | ├─ qqntim.json 38 | ├─ main.js 39 | ├─ renderer.js 40 | ├─ example.jpg 41 | └─ native.node 42 | ``` 43 | 44 | ### `plugins-user` 文件夹 45 | 46 | 存放指定账户插件的文件夹。在此文件夹下的插件**只对指定账户生效**。 47 | 48 | 以下是一个结构示意图: 49 | 50 | ``` 51 | plugins-user 52 | ├─ 10000 53 | │ ├─ 插件1 54 | │ └─ 插件2 55 | └─ 10001 56 | └─ ... 57 | ``` 58 | 59 | ### `config.json` 文件 60 | 61 | 存放 QQNTim 配置的文件。 62 | 63 | 一个示例配置文件如下所示: 64 | 65 | ```json 66 | { 67 | // (可选) 插件加载相关设置 68 | "plugins": { 69 | // 插件加载黑、白名单 70 | // (应指定插件的 ID,可以在插件的 qqntim.json 内的 id 栏查看) 71 | // ---------------------------------------------------- 72 | // (string[] 可选) 插件加载白名单 73 | "whitelist": [], 74 | // (string[] 可选) 插件加载黑名单 75 | "blacklist": ["mica", "mica-ui"] 76 | // ---------------------------------------------------- 77 | } 78 | } 79 | ``` 80 | 81 | ## 插件文件夹 82 | 83 | 插件文件夹存放了所有插件和设置。 84 | 85 | ### `qqntim.json` 文件 86 | 87 | 存放插件信息的清单文件,包含了插件的基本信息、插件加载条件、要注入的 [JS 脚本](#js-文件)等。 88 | 89 | 类型:[Manifest](#manifest) 90 | 91 | 一个完整的示例如下: 92 | 93 | ```json 94 | { 95 | "manifestVersion": "2.0", 96 | "id": "my-plugin", 97 | "name": "我的插件", 98 | "author": "Flysoft", 99 | "requirements": { 100 | "os": [ 101 | { 102 | "platform": "win32", 103 | "lte": "10.0.22621", 104 | "gte": "6.1.0" 105 | } 106 | ] 107 | }, 108 | "injections": [ 109 | { 110 | "type": "main", 111 | "script": "main.js" 112 | }, 113 | { 114 | "type": "renderer", 115 | "page": ["main", "chat"], 116 | "script": "main.js", 117 | "stylesheet": "style.css" 118 | } 119 | ] 120 | } 121 | ``` 122 | 123 | ### `*.js` 文件 124 | 125 | 脚本入口文件。 126 | 127 | #### 主进程脚本 128 | 129 | 该脚本必须使用 CommonJS 标准默认导出一个实现 [`QQNTim.Entry.Main`](src/typings/index.d.ts) 的类。 130 | 131 | 一个示例如下: 132 | 133 | ```typescript 134 | import { QQNTim } from "@flysoftbeta/qqntim-typings"; 135 | // 例子:QQNT 启动时显示一条 Hello world! 控制台信息 136 | // `qqntim` 内包含了很多实用的 API,可以帮助你对 QQNT 做出修改 137 | export default class Entry implements QQNTim.Entry.Main { 138 | constructor(qqntim: QQNTim.API.Main.API) { 139 | console.log("Hello world!", qqntim); 140 | } 141 | } 142 | ``` 143 | 144 | #### 渲染进程脚本 145 | 146 | 该脚本必须使用 CommonJS 标准默认导出一个实现 `QQNTim.Entry.Renderer` 的类。 147 | 148 | 一个示例如下: 149 | 150 | ```typescript 151 | import { QQNTim } from "@flysoftbeta/qqntim-typings"; 152 | // 例子:QQNT 启动时显示一条 Hello world! 控制台信息 153 | // `qqntim` 内包含了很多实用的 API,可以帮助你对 QQNT 做出修改 154 | export default class Entry implements QQNTim.Entry.Renderer { 155 | constructor(qqntim: QQNTim.API.Renderer.API) { 156 | console.log("Hello world!", qqntim); 157 | } 158 | } 159 | ``` 160 | 161 | ## 类型定义 162 | 163 | 请查看 QQNTim 的 [TypeScript 类型定义文件](src/typings/index.d.ts)。此类型定义可通过安装 NPM 包 `@flysoftbeta/qqntim-typings` 添加到你的项目中。 164 | -------------------------------------------------------------------------------- /MANUAL.md: -------------------------------------------------------------------------------- 1 | # 使用手册 2 | 3 | 此文档包含了使用教程。 4 | 5 | ## 已知支持的 QQNT 版本 6 | 7 | | 操作系统 | 最高版本 | 最低版本 | 8 | | -------- | -------- | -------- | 9 | | Windows | 9.9.1 | 9.8.0 | 10 | | Linux | 3.1.2 | 3.0.0 | 11 | | macOS | 6.9.17 (12118) | 6.9.12 (10951) | 12 | 13 | ## 安装 14 | 15 | **请务必仔细阅读本章节内容以避免不必要的麻烦!** 16 | 17 | QQNTim 支持 LiteLoader,但**不支持 BetterQQNT**。 18 | 19 | > ### LiteLoader 相关安装教程: 20 | > 21 | > #### 如果你已经安装了 LiteLoader,想安装 QQNTim: 22 | > 23 | > 1. **确保 LiteLoader 文件夹名称为 `LiteLoader` 或 `LiteLoaderQQNT`。否则 LiteLoader 将不会被加载!** 24 | > 25 | > 2. 到 `QQNT 根目录/resources/app` 下修改 `package.json`,将 `"LiteLoader"` 修改为 `"./app_launcher/index.js"`,之后再运行 QQNTim 的安装脚本即可。 26 | > 27 | > #### 如果你已经安装了 QQNTim,想安装 LiteLoader: 28 | > 29 | > 请遵循 [LiteLoader 官方安装教程](https://github.com/mo-jinran/LiteLoaderQQNT/blob/main/README.md#安装方法) 进行安装即可,但请注意以下两点: 30 | > 31 | > 1. **确保 LiteLoader 文件夹名称为 `LiteLoader` 或 `LiteLoaderQQNT`。否则 LiteLoader 将不会被加载!** 32 | > 33 | > 2. **不要按照教程中的方法修改 `package.json`!否则 QQNTim 将不会加载。你只需保持它原样即可。** 34 | 35 | 安装前请确保电脑上已经安装了 QQNT。 36 | 37 | 如果你正在使用的是 **[不支持的 QQNT 版本](#已知支持的-qqnt-版本)、发现 QQNTim 无法正常使用且希望申请适配**,请通过 [附录-显示终端日志](#显示终端日志) 中提供的方法收集日志,并[向我们**提交此问题**](https://github.com/Flysoft-Studio/QQNTim/issues)。 38 | 39 | 请先从 [Releases](https://github.com/Flysoft-Studio/QQNTim/releases) 中下载最新的版本(对于一般用户,建议下载 `qqntim-build.zip`),下载后,请确保你**解压了所有文件(必须包含 `_` 文件夹)**! 40 | 41 | 在 Windows 下,请运行 `install.cmd` 安装或运行 `uninstall.cmd` 卸载。 42 | 43 | 在 Linux 下,请在安装文件夹下运行: 44 | 45 | ```bash 46 | # 安装 47 | chmod +x ./install.sh 48 | ./install.sh 49 | # 卸载 50 | chmod +x ./uninstall.sh 51 | ./uninstall.sh 52 | ``` 53 | 在 macOS 下,打开 `终端.app` 执行以下操作: 54 | 55 | ```zsh 56 | # 安装 57 | chmod +x <----将 install_macos.sh 文件拖进终端窗口后按回车运行 58 | <----将 install_macos.sh 文件拖进终端窗口后按回车运行 59 | # 卸载 60 | chmod +x <----将 uninstall_macos.sh 文件拖进终端窗口后按回车运行 61 | <----将 uninstall_macos.sh 文件拖进终端窗口后按回车运行 62 | ``` 63 | 需要输入管理员密码鉴权,同时在弹出的通知中选择允许终端修改App,或前往 `系统设置->隐私与安全性->App管理` 中打开/添加 `终端` 来允许终端修改文件。 64 | 65 | 安装后,打开 "设置" 页面: 66 | 67 | ![设置入口](.github/settings-entry.png) 68 | 69 | 如果左侧菜单出现 "QQNTim 设置",即代表安装成功: 70 | 71 | ![QQNTim 设置页面](.github/qqntim-settings-page.png) 72 | 73 | 如果此项目并未按预期出现,则可能代表 QQNTim 安装没有成功,或 QQNTim 加载失败。请通过 [附录-显示终端日志](#显示终端日志) 中提供的方法收集日志,并[向我们**提交此问题**](https://github.com/Flysoft-Studio/QQNTim/issues)。 74 | 75 | ## 插件管理 76 | 77 | ### 获取插件 78 | 79 | **[Plugins Galaxy](https://github.com/FlysoftBeta/QQNTim-Plugins-Galaxy) 中拥有很多功能丰富的插件,欢迎下载。** 80 | 81 | 支持 QQNTim 3.0 及以上版本的插件: 82 | 83 | | 名称 | 说明 | 作者 | 84 | | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------- | 85 | | [QQNTim-Plugin-Markdown](https://github.com/Flysoft-Studio/QQNTim-Plugin-Markdown) | 显示 Markdown 消息/LaTeX 渲染插件 | [Flysoft](https://github.com/Flysoft-Studio) | 86 | | [QQNTim-Plugin-Wallpaper](https://github.com/Flysoft-Studio/QQNTim-Plugin-Wallpaper) | 自定义你的壁纸 | [Flysoft](https://github.com/Flysoft-Studio) | 87 | | [QQNTim-Plugin-No-Revoked-Messages](https://github.com/Flysoft-Studio/QQNTim-Plugin-No-Revoked-Messages) | 支持消息持久化保存的防撤回插件 | [Flysoft](https://github.com/Flysoft-Studio) | 88 | | [QQNTim-Plugins-Galaxy](https://github.com/FlysoftBeta/QQNTim-Plugins-Galaxy) | 插件商城(注意:只有 GaussianUI 插件支持 QQNTim 3.0) | [Flysoft](https://github.com/Flysoft-Studio) | 89 | 90 | 仅支持 QQNTim 2.2 及以下版本的插件: 91 | 92 | | 名称 | 说明 | 作者 | 93 | | ----------------------------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------- | 94 | | [QQNTim-Plugin-NTHttp](https://github.com/Rei1mu/QQNTim-Plugin-NTHttp) | WebSocket+Http 的通信实现 | [Rei1mu](https://github.com/Rei1mu) | 95 | | [QQNTim-Plugin-NoUpdate](https://github.com/Rei1mu/QQNTim-Plugin-NoUpdate) | 取消提示自动更新的烦人弹窗 | [Rei1mu](https://github.com/Rei1mu) | 96 | | [QQNTim-Plugins-Galaxy](https://github.com/FlysoftBeta/QQNTim-Plugins-Galaxy) | 插件商城(注意:壁纸插件不支持 QQNTim 2.2 及以下版本) | [Flysoft](https://github.com/Flysoft-Studio) | 97 | 98 | ### 安装插件 99 | 100 | 要安装插件,请准备一个插件**压缩包 (.zip) 或文件夹**。 101 | 102 | 结构如下图所示(每个插件结构各不相同,这里提供的结构图仅供参考): 103 | 104 | ``` 105 | 我的插件 106 | ├─ qqntim.json 107 | ├─ renderer.js 108 | ├─ style.css 109 | └─ ... 110 | ``` 111 | 112 | 打开 "设置" 页面,选中 "插件管理": 113 | 114 | ![插件管理页面](.github/qqntim-plugin-management-page.png) 115 | 116 | 如果你登录了多个 QQ 账户,且希望某个插件只对当前账户生效,那么请点击 "仅对账号 [你的 QQ 号] 生效的插件" 右侧的安装按钮。 117 | 118 | 如果你希望插件对所有账户生效,那么请点击 "对所有账号生效的插件" 右侧的安装按钮进行安装。 119 | 120 | **_注意:根据提示选择插件相关文件安装后,你可能需要重启 QQ 才能使这些插件开始生效。_** 121 | 122 | 安装插件后,如果插件没有按预期正常工作,请通过 [附录-显示终端日志](#显示终端日志) 中提供的方法收集日志,并向插件作者提交此问题。 123 | 124 | ### 管理现有插件 125 | 126 | 打开 "设置" 页面,选中 "插件管理": 127 | 128 | ![插件管理页面](.github/qqntim-plugin-management-page.png) 129 | 130 | 目前,你可以启用、禁用或删除插件。 131 | 132 | **_注意:修改设置之后需要点击 "保存并重启" 才能使设置生效。_** 133 | 134 | ## 数据文件夹 135 | 136 | 数据文件夹存放了所有插件和设置。 137 | 138 | 在 Windows 下,默认数据文件夹位于 `%UserProfile%/.qqntim`(例如:`C:/Users/[你的用户名]/.qqntim`)。 139 | 140 | 在 Linux 下,默认数据文件夹位于 `$HOME/.local/share/QQNTim`(例如:`/home/[你的用户名]/.local/share/QQNTim`,可能需要启用**显示隐藏文件**选项才能显示出来)。 141 | 142 | 你可以修改 `QQNTIM_HOME` 环境变量以修改数据文件夹的位置。 143 | 144 | ## 附录 145 | 146 | ### 显示终端日志 147 | 148 | #### 在 Windows 下显示终端日志 149 | 150 | 1. 桌面右键 QQ 图标,点击 "打开文件所在位置"。 151 | 2. 在出现的文件夹窗口内按住 `Shift` 右键,点击 "在此处打开命令窗口" 或 "在此处打开 PowerShell 窗口"。 152 | 3. 在弹出的控制台窗口内输入 ".\QQ.exe" 并按下 `Enter`。 153 | 4. 日志信息将会出现在控制台窗口内。 154 | 155 | #### 在 GNOME (Linux) 下显示终端日志 156 | 157 | 1. 按两次 `Super` 键,打开 "所有应用"。 158 | 2. 输入 "终端",并按下 `Enter`。 159 | 3. 在弹出的控制台窗口内输入 "linuxqq ; qq" 并按下 `Enter`。 160 | 4. 日志信息将会出现在控制台窗口内。 161 | 162 | #### 在 KDE (Linux) 下显示终端日志 163 | 164 | 1. 按一次次 `Super` 键,打开 "KDE 菜单"。 165 | 2. 输入 "Konsole",并按下 `Enter`。 166 | 3. 在弹出的控制台窗口内输入 "linuxqq ; qq" 并按下 `Enter`。 167 | 4. 日志信息将会出现在控制台窗口内。 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QQNT-Improved - PC 端 QQNT 插件管理器 2 | 3 | 此项目已废弃,我们正在全力开发全新 QQ 客户端——QPlugged,敬请期待。 4 | 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/bb8c6d1f5c2647ae38e8/maintainability)](https://codeclimate.com/github/Flysoft-Studio/QQNTim/maintainability) [![License](https://img.shields.io/github/license/FlysoftBeta/QQNTim)](https://github.com/Flysoft-Studio/QQNTim/blob/dev/COPYING.LESSER) [![Build](https://img.shields.io/github/actions/workflow/status/Flysoft-Studio/QQNTim/build.yml)](https://github.com/Flysoft-Studio/QQNTim/actions/workflows/build.yml) 6 | 7 | ![截图](.github/screenshot.png) 8 | 9 | 10 | 11 | QQNT-Improved (简称 QQNTim) 是一个给 QQNT 的插件管理器,目前处于 Alpha 版本阶段,支持 Windows,Linux 等平台 (macOS 未测试,不保证可用性)。 12 | 13 | 本程序**不提供任何担保**(包括但不限于使用导致的系统故障、封号等)。 14 | 15 | Telegram 群组:,欢迎加入。 16 | 17 | > QQNTim 可以与 **[LiteLoaderQQNT](https://github.com/mo-jinran/LiteLoaderQQNT)** 并存,快去试试吧! 18 | > 19 | > 请阅读 [使用手册-安装](MANUAL.md#安装) 查看如何安装。 20 | 21 | ## 安装与使用 22 | 23 | 请查看我们的[使用手册](MANUAL.md)。 24 | 25 | ## 插件开发 26 | 27 | 请查看我们的[开发文档](DEVELOPMENT.md)。 28 | 29 | ## 协议 30 | 31 | 本程序使用 GNU Lesser General Public License v3.0 or later 协议发行。 32 | 33 | 请在此源代码树下的 [COPYING](./COPYING) 和 [COPYING.LESSER](./COPYING.LESSER) 查看完整的协议。 34 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import { BuildOptions, build } from "esbuild"; 2 | import { copy, emptyDir, ensureDir, readdir, writeFile } from "fs-extra"; 3 | import { dirname, sep as s } from "path"; 4 | import { getAllLocators, getPackageInformation } from "pnpapi"; 5 | 6 | type Package = { 7 | packageLocation: string; 8 | packageDependencies: Map; 9 | }; 10 | type Packages = Record>; 11 | 12 | const unpackedPackages = ["chii"]; 13 | const junkFiles = [ 14 | ".d.ts", 15 | ".markdown", 16 | ".md", 17 | "/.eslintrc", 18 | "/.eslintrc.js", 19 | "/.prettierrc", 20 | "/.nycrc", 21 | ".yml", 22 | ".yaml", 23 | ".bak", 24 | "/.editorconfig", 25 | "/bower.json", 26 | "/.jscs.json", 27 | "/AUTHORS", 28 | "/LICENSE", 29 | "/License", 30 | "/yarn.lock", 31 | "/package-lock.json", 32 | ".map", 33 | ".debug.js", 34 | ".min.js", 35 | ".test.js", 36 | "/test/", 37 | "/bin/", 38 | "/tests/", 39 | "/.github/", 40 | ]; 41 | 42 | const isProduction = process.env["NODE_ENV"] == "production"; 43 | const commonOptions: Partial = { 44 | target: "node18", 45 | bundle: true, 46 | platform: "node", 47 | write: true, 48 | allowOverwrite: true, 49 | sourcemap: isProduction ? false : "inline", 50 | minify: isProduction, 51 | treeShaking: isProduction, 52 | }; 53 | 54 | async function buildBundles() { 55 | const buildPromise = Promise.all([ 56 | build({ 57 | ...commonOptions, 58 | entryPoints: [`src${s}main${s}main.ts`], 59 | outfile: `dist${s}_${s}qqntim.js`, 60 | external: ["electron", "./index.js", ...unpackedPackages], 61 | }), 62 | build({ 63 | ...commonOptions, 64 | entryPoints: [`src${s}renderer${s}main.ts`], 65 | outfile: `dist${s}_${s}qqntim-renderer.js`, 66 | external: ["electron", ...unpackedPackages], 67 | }), 68 | ]); 69 | 70 | return await buildPromise; 71 | } 72 | 73 | async function buildBuiltinPlugins() { 74 | const rootDir = `src${s}builtins`; 75 | const pluginsDir = await readdir(rootDir); 76 | await Promise.all( 77 | pluginsDir.map(async (dir) => { 78 | const pluginDir = `${rootDir}${s}${dir}`; 79 | const distDir = `dist${s}_${s}builtins${s}${dir}`; 80 | await ensureDir(pluginDir); 81 | await build({ 82 | ...commonOptions, 83 | entryPoints: [`${pluginDir}${s}src${s}main.ts`, `${pluginDir}${s}src${s}renderer.ts`], 84 | outdir: distDir, 85 | external: ["electron", "react", "react/jsx-runtime", "react-dom", "react-dom/client", "qqntim/main", "qqntim/renderer"], 86 | format: "cjs", 87 | }); 88 | await copy(`${pluginDir}${s}publish`, `${distDir}`); 89 | }), 90 | ); 91 | } 92 | 93 | async function prepareDistDir() { 94 | await emptyDir("dist"); 95 | await ensureDir(`dist${s}_`); 96 | await copy("publish", "dist"); 97 | } 98 | 99 | function collectDeps() { 100 | const packages: Packages = {}; 101 | getAllLocators().forEach((locator) => { 102 | if (!packages[locator.name]) packages[locator.name] = {}; 103 | packages[locator.name][locator.reference] = getPackageInformation(locator); 104 | }); 105 | return packages; 106 | } 107 | 108 | async function unpackPackage(packages: Packages, rootDir: string, name: string, reference?: string) { 109 | const item = packages[name]; 110 | if (!item) return; 111 | const location = item[reference ? reference : Object.keys(item)[0]]; 112 | const dir = `${rootDir}${s}node_modules${s}${name}`; 113 | await ensureDir(dir); 114 | await copy(location.packageLocation, dir, { 115 | filter: (src) => { 116 | for (const file of junkFiles) { 117 | if (src.includes(file)) return false; 118 | } 119 | return true; 120 | }, 121 | }); 122 | const promises: Promise[] = []; 123 | location.packageDependencies.forEach((depReference, depName) => { 124 | if (name == depName) return; 125 | promises.push(unpackPackage(packages, dir, depName, depReference)); 126 | }); 127 | await Promise.all(promises); 128 | } 129 | 130 | const packages = collectDeps(); 131 | prepareDistDir().then(() => Promise.all([buildBundles(), buildBuiltinPlugins(), Promise.all(unpackedPackages.map((unpackedPackage) => unpackPackage(packages, `dist${s}_`, unpackedPackage)))])); 132 | -------------------------------------------------------------------------------- /examples/NT-API/consts.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | id: "example-nt-api", 3 | defaults: { 4 | showAccountInfo: true, 5 | showHistoryMessages: true, 6 | historyMessageObject: "both", 7 | autoReply: true, 8 | testInputValue: "默认值", 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /examples/NT-API/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlysoftBeta/QQNTim/5cd25d12b7a85529f512557ce544c26eddb2e9f4/examples/NT-API/example.jpg -------------------------------------------------------------------------------- /examples/NT-API/qqntim.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": "3.0", 3 | "id": "example-nt-api", 4 | "name": "示例插件:NT API", 5 | "author": "Flysoft", 6 | "injections": [{ "type": "renderer", "script": "renderer.js", "page": "main" }, { "type": "renderer", "script": "settings.js", "page": "settings" }] 7 | } 8 | -------------------------------------------------------------------------------- /examples/NT-API/renderer.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const qqntim = require("qqntim/renderer"); 3 | const { id, defaults } = require("./consts"); 4 | 5 | module.exports.default = class Entry { 6 | constructor() { 7 | const config = qqntim.env.config.plugins?.config?.[id]; 8 | const showAccountInfo = config?.showAccountInfo != undefined ? config.showAccountInfo : defaults.showAccountInfo; 9 | const historyMessageObject = config?.historyMessageObject != undefined ? config.historyMessageObject : defaults.historyMessageObject; 10 | const showHistoryMessages = config?.showHistoryMessages != undefined ? config.showHistoryMessages : defaults.showHistoryMessages; 11 | const autoReply = config?.autoReply != undefined ? config.autoReply : defaults.autoReply; 12 | const testInputValue = config?.testInputValue != undefined ? config.testInputValue : defaults.testInputValue; 13 | 14 | //#region 示例:获取当前账号 15 | if (showAccountInfo) 16 | qqntim.nt.getAccountInfo().then((account) => { 17 | console.log("[Example-NT-API] 当前账号信息", account); 18 | }); 19 | //#endregion 20 | 21 | //#region 示例:获取好友和群的最近 20 条历史消息 22 | if (showHistoryMessages) { 23 | if (historyMessageObject == "friends" || historyMessageObject == "both") 24 | qqntim.nt.getFriendsList().then((list) => { 25 | console.log("[Example-NT-API] 好友列表", list); 26 | list.forEach((friend) => qqntim.nt.getPreviousMessages({ chatType: "friend", uid: friend.uid }, 20).then((messages) => qqntim.nt.getUserInfo(friend.uid).then((user) => console.log("[Example-NT-API] 好友", user, "的最近 20 条消息:", messages)))); 27 | }); 28 | if (historyMessageObject == "groups" || historyMessageObject == "both") 29 | qqntim.nt.getGroupsList().then((list) => { 30 | console.log("[Example-NT-API] 群组列表", list); 31 | list.forEach((group) => qqntim.nt.getPreviousMessages({ chatType: "group", uid: group.uid }, 20).then((messages) => console.log(`[Example-NT-API] 群组 ${group.name} (${group.uid}) 的最近 20 条消息:`, messages))); 32 | }); 33 | } 34 | //#endregion 35 | 36 | //#region 示例:自动回复 37 | if (autoReply) 38 | qqntim.nt.on("new-messages", (messages) => { 39 | console.log("[Example-NT-API] 收到新消息:", messages); 40 | messages.forEach((message) => { 41 | if (message.peer.chatType != "friend") return; 42 | message.allDownloadedPromise.then(() => { 43 | qqntim.nt 44 | .sendMessage(message.peer, [ 45 | { 46 | type: "text", 47 | content: "收到一条来自好友的消息:", 48 | }, 49 | ...message.elements, 50 | { 51 | type: "text", 52 | content: "(此消息两秒后自动撤回)\n示例图片:", 53 | }, 54 | // 附带一个插件目录下的 example.jpg 作为图片发送 55 | { 56 | type: "image", 57 | file: path.join(__dirname, "example.jpg"), 58 | }, 59 | ]) 60 | .then((id) => { 61 | setTimeout(() => { 62 | qqntim.nt.revokeMessage(message.peer, id); 63 | }, 2000); 64 | }); 65 | }); 66 | }); 67 | }); 68 | //#endregion 69 | 70 | console.log("[Example-NT-API] 测试消息:", testInputValue); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /examples/NT-API/settings.js: -------------------------------------------------------------------------------- 1 | const { Fragment, createElement } = require("react"); 2 | const { defineSettingsPanels } = require("qqntim-settings"); 3 | const { SettingsSection, SettingsBox, SettingsBoxItem, Switch, Input, Dropdown } = require("qqntim-settings/components"); 4 | const { id, defaults } = require("./consts"); 5 | 6 | function SettingsPanel({ config, setConfig }) { 7 | return createElement( 8 | Fragment, 9 | undefined, 10 | createElement( 11 | SettingsSection, 12 | { title: "插件设置" }, 13 | createElement( 14 | SettingsBox, 15 | undefined, 16 | createElement( 17 | SettingsBoxItem, 18 | { title: "显示账户信息", description: ["在控制台中显示当前登录的账户信息。"] }, 19 | createElement(Switch, { 20 | checked: config?.[id]?.showAccountInfo != undefined ? config?.[id]?.showAccountInfo : defaults.showAccountInfo, 21 | onToggle: (state) => 22 | setConfig((prev) => { 23 | return { 24 | ...(prev || {}), 25 | [id]: { 26 | ...(prev[id] || {}), 27 | showAccountInfo: state, 28 | }, 29 | }; 30 | }), 31 | }), 32 | ), 33 | createElement( 34 | SettingsBoxItem, 35 | { title: "显示历史消息", description: ["在控制台中显示历史 20 条消息。"] }, 36 | createElement(Switch, { 37 | checked: config?.[id]?.showHistoryMessages != undefined ? config?.[id]?.showHistoryMessages : defaults.showHistoryMessages, 38 | onToggle: (state) => 39 | setConfig((prev) => { 40 | return { 41 | ...(prev || {}), 42 | [id]: { 43 | ...(prev[id] || {}), 44 | showHistoryMessages: state, 45 | }, 46 | }; 47 | }), 48 | }), 49 | ), 50 | (config?.[id]?.showHistoryMessages != undefined ? config?.[id]?.showHistoryMessages : defaults.showHistoryMessages) && 51 | createElement( 52 | SettingsBoxItem, 53 | { title: "获取历史消息的对象" }, 54 | createElement(Dropdown, { 55 | items: [ 56 | ["friends", "好友"], 57 | ["groups", "群"], 58 | ["both", "好友和群"], 59 | ], 60 | selected: config?.[id]?.historyMessageObject != undefined ? config?.[id]?.historyMessageObject : defaults.historyMessageObject, 61 | onChange: (state) => 62 | setConfig((prev) => { 63 | return { 64 | ...(prev || {}), 65 | [id]: { 66 | ...(prev[id] || {}), 67 | historyMessageObject: state, 68 | }, 69 | }; 70 | }), 71 | width: "150px", 72 | }), 73 | ), 74 | createElement( 75 | SettingsBoxItem, 76 | { title: "自动回复", description: ["自动回复私聊消息。"] }, 77 | createElement(Switch, { 78 | checked: config?.[id]?.autoReply != undefined ? config?.[id]?.autoReply : defaults.autoReply, 79 | onToggle: (state) => 80 | setConfig((prev) => { 81 | return { 82 | ...(prev || {}), 83 | [id]: { 84 | ...(prev[id] || {}), 85 | autoReply: state, 86 | }, 87 | }; 88 | }), 89 | }), 90 | ), 91 | createElement( 92 | SettingsBoxItem, 93 | { title: "测试", description: ["测试输入框。"], isLast: true }, 94 | createElement(Input, { 95 | value: config?.[id]?.testInputValue != undefined ? config?.[id]?.testInputValue : defaults.testInputValue, 96 | onChange: (state) => 97 | setConfig((prev) => { 98 | return { 99 | ...(prev || {}), 100 | [id]: { 101 | ...(prev[id] || {}), 102 | testInputValue: state, 103 | }, 104 | }; 105 | }), 106 | }), 107 | ), 108 | ), 109 | ), 110 | ); 111 | } 112 | 113 | module.exports.default = class Entry { 114 | constructor() { 115 | defineSettingsPanels([ 116 | "Example-NT-API 设置", 117 | SettingsPanel, 118 | ``, 119 | ]); 120 | } 121 | }; 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qqnt-improved", 3 | "private": true, 4 | "version": "3.1.3", 5 | "license": "LGPL-3.0-or-later", 6 | "packageManager": "yarn@3.6.1", 7 | "workspaces": ["src/electron", "src/typings", "src/builtins/*"], 8 | "scripts": { 9 | "dev": "TS_NODE_FILES=1 TS_NODE_TRANSPILE_ONLY=1 NODE_ENV=development ts-node ./build.ts", 10 | "build:win": "TS_NODE_FILES=1 TS_NODE_TRANSPILE_ONLY=1 NODE_ENV=production ts-node ./build.ts && powershell -ExecutionPolicy Unrestricted -File ./scripts/pack.ps1", 11 | "build:linux": "TS_NODE_FILES=1 TS_NODE_TRANSPILE_ONLY=1 NODE_ENV=production ts-node ./build.ts && chmod +x ./scripts/pack.sh && ./scripts/pack.sh", 12 | "install:win": "QQNTIM_INSTALLER_NO_KILL_QQ=1 cmd /c start \"\" /wait cmd /c dist\\\\install.cmd", 13 | "install:linux": "chmod +x ./dist/install.sh && QQNTIM_INSTALLER_NO_KILL_QQ=1 ./dist/install.sh", 14 | "start:win": "powershell -ExecutionPolicy Unrestricted -File ./scripts/start.ps1", 15 | "start:linux": "chmod +x ./scripts/start.sh && ./scripts/start.sh", 16 | "lint": "tsc && rome check .", 17 | "lint:apply": "rome check . --apply", 18 | "lint:apply-unsafe": "rome check . --apply-unsafe", 19 | "format": "rome format . --write" 20 | }, 21 | "dependencies": { 22 | "@electron/remote": "^2.0.11", 23 | "@flysoftbeta/qqntim-typings": "workspace:*", 24 | "axios": "^1.5.0", 25 | "chii": "^1.9.0", 26 | "electron": "workspace:*", 27 | "fs-extra": "^11.1.1", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "semver": "^7.5.4", 31 | "supports-color": "^9.4.0" 32 | }, 33 | "devDependencies": { 34 | "@types/fs-extra": "^11.0.1", 35 | "@types/node": "^20.6.2", 36 | "@types/react": "^18.2.21", 37 | "@types/react-dom": "^18.2.7", 38 | "@types/semver": "^7.5.2", 39 | "@yarnpkg/sdks": "^3.0.0-rc.50", 40 | "esbuild": "^0.19.2", 41 | "rome": "12.1.3", 42 | "ts-node": "^10.9.1", 43 | "typed-emitter": "^2.1.0", 44 | "typescript": "^5.2.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /publish/_/install.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | $Host.UI.RawUI.WindowTitle = "QQNTim 安装程序 (PowerShell)" 4 | Set-Location (Split-Path -Parent $MyInvocation.MyCommand.Definition) 5 | $CD = (Get-Location).Path 6 | 7 | # 判断是否拥有管理员权限 8 | if (-Not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) { 9 | if ([int](Get-CimInstance -Class Win32_OperatingSystem | Select-Object -ExpandProperty BuildNumber) -ge 6000) { 10 | throw "权限不足。" 11 | } 12 | } 13 | 14 | # 从注册表获取 QQ 安装路径 15 | foreach ($RegistryPath in @("HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*")) { 16 | try { 17 | foreach ($Item in (Get-ItemProperty $RegistryPath)) { 18 | if ($Item.PSChildName -eq "QQ") { 19 | $QQInstallDir = (Split-Path -Parent $Item.UninstallString) 20 | break 21 | } 22 | } 23 | } 24 | catch {} 25 | } 26 | 27 | if (($null -eq $QQInstallDir) -or ((Test-Path $QQInstallDir) -eq $false)) { 28 | throw "未找到 QQNT 安装目录。" 29 | } 30 | 31 | $QQExecutableFile = "$QQInstallDir\QQ.exe" 32 | $QQExecutableBackupFile = "$QQInstallDir\QQ.exe.bak" 33 | $QQExecutableHashFile = "$QQInstallDir\QQ.exe.md5" 34 | $QQAppDir = "$QQInstallDir\resources\app" 35 | $QQAppLauncherDir = "$QQAppDir\app_launcher" 36 | $PackageJSONFile = "$QQAppDir\package.json" 37 | $QQNTimFlagFile = "$QQAppLauncherDir\qqntim-flag.txt" 38 | $SuccessFlagFile = "$env:TEMP\qqntim-install-successful.tmp" 39 | 40 | # 清理旧版文件,恢复被修改的入口文件 41 | if ((Test-Path "$QQAppLauncherDir\index.js.bak") -eq $true) { 42 | Write-Output "正在清理旧版 QQNTim……" 43 | Move-Item "$QQAppLauncherDir\index.js.bak" "$QQAppLauncherDir\index.js" -Force 44 | "" | Out-File $QQNTimFlagFile -Encoding UTF8 -Force 45 | } 46 | 47 | # 询问用户,如果存在旧版则不提示 48 | if ((Test-Path $QQNTimFlagFile) -eq $false) { 49 | if ((Read-Host "是否要安装 QQNTim (y/n)?") -notcontains "y") { 50 | throw "安装已被用户取消。" 51 | } 52 | } 53 | else { 54 | Write-Output "检测到已有安装,正在更新……" 55 | } 56 | 57 | if ($env:QQNTIM_INSTALLER_NO_KILL_QQ -ne "1") { 58 | Write-Output "正在关闭 QQ……" 59 | Stop-Process -Name QQ -ErrorAction SilentlyContinue 60 | } 61 | 62 | Write-Output "正在复制文件……" 63 | # 如果 node_modules 不存在或已经过期则执行复制 64 | if ((Test-Path .\node_modules.zip.md5) -eq $true -and (Test-Path .\node_modules.zip) -eq $true) { 65 | if ((Test-Path "$QQAppLauncherDir\node_modules.zip.md5") -eq $false -or (Get-Content "$QQAppLauncherDir\node_modules.zip.md5" -Encoding UTF8 -Force) -ne (Get-Content .\node_modules.zip.md5 -Encoding UTF8 -Force)) { 66 | $SourceZipPath = "$CD\node_modules.zip"; 67 | $DestinationDirPath = "$QQAppLauncherDir\node_modules" 68 | # 清空原有 node_modules 文件夹 69 | if ((Test-Path $DestinationDirPath) -eq $true) { 70 | Remove-Item $DestinationDirPath -Recurse -Force 71 | } 72 | New-Item $DestinationDirPath -ItemType Directory -Force | Out-Null 73 | try { 74 | # 回退 1 - 仅支持 .NET Framework 4.5 及以上 75 | Add-Type -AssemblyName System.IO.Compression.Filesystem 76 | [System.IO.Compression.ZipFile]::ExtractToDirectory($SourceZipPath, $DestinationDirPath) 77 | } 78 | catch { 79 | # 回退 2 - 使用系统 COM 复制 API 80 | $Shell = New-Object -ComObject Shell.Application 81 | $Shell.NameSpace($DestinationDirPath).CopyHere($Shell.NameSpace($SourceZipPath).Items()) 82 | } 83 | } 84 | Copy-Item ".\node_modules.zip.md5" $QQAppLauncherDir -Recurse -Force 85 | } 86 | elseif ((Test-Path .\node_modules) -eq $true) { 87 | Copy-Item ".\node_modules" $QQAppLauncherDir -Recurse -Force 88 | } 89 | Copy-Item ".\qqntim.js", ".\qqntim-renderer.js", ".\builtins" $QQAppLauncherDir -Recurse -Force 90 | 91 | Write-Output "正在修补 package.json……" 92 | # 使用 UTF-8 without BOM 进行保存 93 | $Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False 94 | [System.IO.File]::WriteAllLines($PackageJSONFile, ((Get-Content $PackageJSONFile -Encoding UTF8 -Force) -replace "./app_launcher/index.js", "./app_launcher/qqntim.js"), $Utf8NoBomEncoding) 95 | 96 | # For QQ 9.9.1+ 97 | # 如果 QQ.exe 未被修补或已被新安装覆盖则进行修补 98 | if ((Test-Path $QQExecutableHashFile) -eq $false -or (Get-Content $QQExecutableHashFile -Encoding UTF8 -Force) -replace "`r`n", "" -ne (Get-FileHash $QQExecutableFile -Algorithm MD5).Hash) { 99 | Write-Output "正在修补 QQ.exe,这可能需要一些时间……" 100 | Copy-Item $QQExecutableFile $QQExecutableBackupFile -Force 101 | # 引入 crypt32.dll 定义 102 | $Crypt32Def = @" 103 | [DllImport("Crypt32.dll", CharSet = CharSet.Auto, SetLastError = true)] 104 | public static extern bool CryptStringToBinary( 105 | string pszString, 106 | int cchString, 107 | int dwFlags, 108 | byte[] pbBinary, 109 | ref int pcbBinary, 110 | int pdwSkip, 111 | ref int pdwFlags 112 | ); 113 | [DllImport("Crypt32.dll", CharSet = CharSet.Auto, SetLastError = true)] 114 | public static extern bool CryptBinaryToString( 115 | byte[] pbBinary, 116 | int cbBinary, 117 | int dwFlags, 118 | StringBuilder pszString, 119 | ref int pcchString 120 | ); 121 | "@ 122 | Add-Type -MemberDefinition $Crypt32Def -Namespace PKI -Name Crypt32 -UsingNamespace "System.Text" 123 | $HexRawEncoding = 12 124 | 125 | $QQBin = [System.IO.File]::ReadAllBytes($QQExecutableFile) 126 | # Byte[] 转 Hex String 127 | $pcchString = 0 # Size 128 | if ([PKI.Crypt32]::CryptBinaryToString($QQBin, $QQBin.Length, $HexRawEncoding, $null, [ref]$pcchString)) { 129 | $QQHex = New-Object Text.StringBuilder $pcchString 130 | [void][PKI.Crypt32]::CryptBinaryToString($QQBin, $QQBin.Length, $HexRawEncoding, $QQHex, [ref]$pcchString) 131 | $PatchedQQHex = $QQHex.ToString() -replace "7061636b6167652e6a736f6e00696e6465782e6a73006c61756e636865722e6a73006c61756e636865722e6e6f646500", "696e6465782e6a730000000000696e6465782e6a73006c61756e636865722e6a73006c61756e636865722e6e6f646500" -replace "7061636b6167652e488d942400020000488902c742086a736f6e", "6c61756e63686572488d942400020000488902c742082e6a7300" 132 | # Hex String 转 Byte[] 133 | $pcbBinary = 0 # Size 134 | $pdwFlags = 0 135 | if ([PKI.Crypt32]::CryptStringToBinary($PatchedQQHex, $PatchedQQHex.Length, $HexRawEncoding, $null, [ref]$pcbBinary, 0, [ref]$pdwFlags)) { 136 | $PatchedQQBin = New-Object byte[] -ArgumentList $pcbBinary 137 | [void][PKI.Crypt32]::CryptStringToBinary($PatchedQQHex, $PatchedQQHex.Length, $HexRawEncoding, $PatchedQQBin, [ref]$pcbBinary, 0, [ref]$pdwFlags) 138 | 139 | # 写出文件 140 | [System.IO.File]::WriteAllBytes($QQExecutableFile, $PatchedQQBin) 141 | 142 | # 写出 MD5 143 | $QQFileHash = (Get-FileHash $QQExecutableFile -Algorithm MD5).Hash 144 | [System.IO.File]::WriteAllLines($QQExecutableHashFile, $QQFileHash, $Utf8NoBomEncoding) 145 | } 146 | else { 147 | throw $((New-Object ComponentModel.Win32Exception ([Runtime.InteropServices.Marshal]::GetLastWin32Error())).Message) 148 | } 149 | } 150 | else { 151 | throw $((New-Object ComponentModel.Win32Exception ([Runtime.InteropServices.Marshal]::GetLastWin32Error())).Message) 152 | } 153 | } 154 | else { 155 | Write-Output "QQ.exe 未更新,无需修补!" 156 | } 157 | 158 | if ((Test-Path $QQNTimFlagFile) -eq $false) { 159 | "" | Out-File $QQNTimFlagFile -Encoding UTF8 -Force 160 | } 161 | 162 | if ((Test-Path $SuccessFlagFile) -eq $false) { 163 | "" | Out-File $SuccessFlagFile -Encoding UTF8 -Force 164 | } 165 | 166 | if ($env:QQNTIM_INSTALLER_NO_DELAYED_EXIT -ne "1") { 167 | Write-Output "安装成功。安装程序将在 5 秒后自动退出。" 168 | Start-Sleep -Seconds 5 169 | } 170 | else { 171 | Write-Output "安装成功。" 172 | } -------------------------------------------------------------------------------- /publish/_/uninstall.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | $Host.UI.RawUI.WindowTitle = "QQNTim 卸载程序 (PowerShell)" 4 | Set-Location (Split-Path -Parent $MyInvocation.MyCommand.Definition) 5 | 6 | # 判断是否拥有管理员权限 7 | if (-Not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) { 8 | if ([int](Get-CimInstance -Class Win32_OperatingSystem | Select-Object -ExpandProperty BuildNumber) -ge 6000) { 9 | throw "权限不足。" 10 | } 11 | } 12 | 13 | # 从注册表获取 QQ 安装路径 14 | foreach ($RegistryPath in @("HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*")) { 15 | try { 16 | foreach ($Item in (Get-ItemProperty $RegistryPath)) { 17 | if ($Item.PSChildName -eq "QQ") { 18 | $QQInstallDir = (Split-Path -Parent $Item.UninstallString) 19 | break 20 | } 21 | } 22 | } 23 | catch {} 24 | } 25 | 26 | if (($null -eq $QQInstallDir) -or ((Test-Path $QQInstallDir) -eq $false)) { 27 | throw "未找到 QQNT 安装目录。" 28 | } 29 | 30 | $QQExecutableFile = "$QQInstallDir\QQ.exe" 31 | $QQExecutableBackupFile = "$QQInstallDir\QQ.exe.bak" 32 | $QQExecutableHashFile = "$QQInstallDir\QQ.exe.md5" 33 | $QQAppDir = "$QQInstallDir\resources\app" 34 | $QQAppLauncherDir = "$QQAppDir\app_launcher" 35 | $QQNTimFlagFile = "$QQAppLauncherDir\qqntim-flag.txt" 36 | $SuccessFlagFile = "$env:TEMP\qqntim-uninstall-successful.tmp" 37 | 38 | if ((Test-Path $QQNTimFlagFile) -eq $false) { 39 | throw "QQNTim 未被安装。" 40 | } 41 | 42 | if ((Read-Host "是否要卸载 QQNTim (y/n)?") -notcontains "y") { 43 | throw "卸载已被用户取消。" 44 | } 45 | 46 | if ((Read-Host "是否需要同时移除所有数据 (y/n)?") -contains "y") { 47 | Remove-Item "${env:UserProfile}\.qqntim" -Recurse -Force -ErrorAction SilentlyContinue 48 | } 49 | 50 | if ($env:QQNTIM_UNINSTALLER_NO_KILL_QQ -ne "1") { 51 | Write-Output "正在关闭 QQ……" 52 | Stop-Process -Name QQ -ErrorAction SilentlyContinue 53 | } 54 | 55 | Write-Output "正在移除文件……" 56 | if ((Test-Path "$QQAppLauncherDir\node_modules.zip.md5") -eq $true) { 57 | Remove-Item "$QQAppLauncherDir\node_modules.zip.md5" -Force 58 | } 59 | Remove-Item "$QQAppLauncherDir\qqntim.js", "$QQAppLauncherDir\qqntim-renderer.js", "$QQAppLauncherDir\node_modules", "$QQAppLauncherDir\builtins" -Recurse -Force 60 | 61 | Write-Output "正在还原 package.json……" 62 | $Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False 63 | [System.IO.File]::WriteAllLines("$QQAppDir\package.json", ((Get-Content "$QQAppDir\package.json" -Encoding UTF8 -Force) -replace "./app_launcher/qqntim.js", "./app_launcher/index.js"), $Utf8NoBomEncoding) 64 | 65 | Write-Output "正在还原 QQ.exe……" 66 | if ((Test-Path $QQExecutableHashFile) -eq $true) { 67 | Remove-Item $QQExecutableHashFile -Force 68 | } 69 | if ((Test-Path $QQExecutableBackupFile) -eq $true) { 70 | Remove-Item $QQExecutableFile -Force 71 | Move-Item $QQExecutableBackupFile $QQExecutableFile -Force 72 | } 73 | 74 | Remove-Item $QQNTimFlagFile -Force 75 | 76 | if ((Test-Path $SuccessFlagFile) -eq $false) { 77 | "" | Out-File $SuccessFlagFile -Encoding UTF8 -Force 78 | } 79 | 80 | if ($env:QQNTIM_UNINSTALLER_NO_DELAYED_EXIT -ne "1") { 81 | Write-Output "卸载成功。卸载程序将在 5 秒后自动退出。" 82 | Start-Sleep -Seconds 5 83 | } 84 | else { 85 | Write-Output "卸载成功。" 86 | } -------------------------------------------------------------------------------- /publish/install.cmd: -------------------------------------------------------------------------------- 1 | @setlocal enableextensions 2 | @echo off 3 | cd /d %~dp0_ 4 | color F0 5 | mode con cols=65 lines=16 6 | 7 | set PS_PREFIX=powershell -NoProfile -ExecutionPolicy Unrestricted 8 | 9 | "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system" >nul 2>nul 10 | if "%ERRORLEVEL%" neq "0" ( 11 | goto try_run_as 12 | ) else ( 13 | goto main 14 | ) 15 | goto:eof 16 | 17 | :try_run_as 18 | %PS_PREFIX% -WindowStyle Hidden -Command Start-Process -Wait -FilePath """%COMSPEC%""" -Verb RunAs -ArgumentList """/c""","""`"""%~f0`"""""" 19 | goto:eof 20 | 21 | :main 22 | set SUCCESS_FLAG=%TEMP%\qqntim-install-successful.tmp 23 | if exist "%SUCCESS_FLAG%" ( 24 | del /f /q "%SUCCESS_FLAG%" 25 | ) 26 | %PS_PREFIX% -File .\install.ps1 27 | if not exist "%SUCCESS_FLAG%" ( 28 | echo Installation error. If you believe this is an issue of QQNTim, please report it to us. 29 | pause >nul 2>nul 30 | ) 31 | goto:eof -------------------------------------------------------------------------------- /publish/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pushd "$( dirname "${BASH_SOURCE[0]}" )/_" > /dev/null 4 | 5 | # 判断是否拥有 root 权限 6 | if [ ! "$(whoami)" == "root" ]; then 7 | echo "正在提升权限……" 8 | popd > /dev/null 9 | sudo QQNTIM_INSTALLER_NO_KILL_QQ="$QQNTIM_INSTALLER_NO_KILL_QQ" QQNTIM_INSTALLER_NO_DELAYED_EXIT="$QQNTIM_INSTALLER_NO_DELAYED_EXIT" "${BASH_SOURCE[0]}" 10 | exit 0 11 | fi 12 | 13 | # 获取 QQ 安装路径 14 | qq_installation_dir=$( dirname $( readlink $( which qq || which linuxqq ) ) 2>/dev/null || echo "/var/lib/flatpak/app/com.qq.QQ/current/active/files/extra/QQ" ) 15 | 16 | if [ ! -d "$qq_installation_dir" ]; then 17 | echo "未找到 QQNT 安装目录。" 18 | fi 19 | 20 | qq_app_dir="$qq_installation_dir/resources/app" 21 | qq_applauncher_dir="$qq_app_dir/app_launcher" 22 | qqntim_flag_file="$qq_applauncher_dir/qqntim-flag.txt" 23 | 24 | # 询问用户,如果存在旧版则不提示 25 | if [ ! -f "$qqntim_flag_file" ]; then 26 | read -p "是否要安装 QQNTim (y/n)?" choice 27 | case $choice in 28 | y) ;; 29 | Y) ;; 30 | *) exit -1 ;; 31 | esac 32 | else 33 | echo "检测到已有安装,正在更新……" 34 | fi 35 | 36 | if [ "$QQNTIM_INSTALLER_NO_KILL_QQ" != "1" ]; then 37 | echo "正在关闭 QQ……" 38 | killall -w qq linuxqq > /dev/null 2>&1 39 | fi 40 | 41 | echo "正在复制文件……" 42 | 43 | if [ -f "./node_modules.zip.md5" -a -f "./node_modules.zip" ]; then 44 | diff "$qq_applauncher_dir/node_modules.zip.md5" "./node_modules.zip.md5" > /dev/null 2>&1 45 | [ $? != 0 ] && unzip -qo ./node_modules.zip -d "$qq_applauncher_dir/node_modules" 46 | cp -f ../node_modules.zip.md5 "$qq_applauncher_dir" 47 | elif [ -d "./node_modules" ]; then 48 | cp -rf ./node_modules "$qq_applauncher_dir" 49 | fi 50 | cp -rf ./qqntim.js ./qqntim-renderer.js ./builtins "$qq_applauncher_dir" 51 | 52 | echo "正在修补 package.json……" 53 | sed -i "s#\.\/app_launcher\/index\.js#\.\/app_launcher\/qqntim\.js#g" "$qq_app_dir/package.json" 54 | 55 | touch "$qqntim_flag_file" 56 | 57 | if [ "$QQNTIM_INSTALLER_NO_DELAYED_EXIT" != "1" ]; then 58 | echo "安装成功。安装程序将在 5 秒后自动退出。" 59 | sleep 5s 60 | else 61 | echo "安装成功。" 62 | fi 63 | 64 | exit 0 65 | -------------------------------------------------------------------------------- /publish/install_macos.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pushd "$( dirname "${BASH_SOURCE[0]}" )/_" > /dev/null 4 | 5 | # 判断是否拥有 root 权限 6 | if [ ! "$(whoami)" == "root" ]; then 7 | echo "正在提升权限……" 8 | popd > /dev/null 9 | sudo QQNTIM_INSTALLER_NO_KILL_QQ="$QQNTIM_INSTALLER_NO_KILL_QQ" QQNTIM_INSTALLER_NO_DELAYED_EXIT="$QQNTIM_INSTALLER_NO_DELAYED_EXIT" "${BASH_SOURCE[0]}" 10 | exit 0 11 | fi 12 | 13 | # 获取 QQ 安装路径 14 | qq_installation_dir="/Applications/QQ.app/Contents" 15 | 16 | if [ ! -d "$qq_installation_dir" ]; then 17 | echo "未找到 QQNT 安装目录。" 18 | fi 19 | 20 | qq_app_dir="$qq_installation_dir/Resources/app" 21 | qq_applauncher_dir="$qq_app_dir/app_launcher" 22 | qqntim_flag_file="$qq_applauncher_dir/qqntim-flag.txt" 23 | 24 | # 询问用户,如果存在旧版则不提示 25 | if [ ! -f "$qqntim_flag_file" ]; then 26 | read -p "是否要安装 QQNTim (y/n)?" choice 27 | case $choice in 28 | y) ;; 29 | Y) ;; 30 | *) exit -1 ;; 31 | esac 32 | else 33 | echo "检测到已有安装,正在更新……" 34 | fi 35 | 36 | if [ "$QQNTIM_INSTALLER_NO_KILL_QQ" != "1" ]; then 37 | echo "正在关闭 QQ……" 38 | pkill QQ > /dev/null 2>&1 39 | fi 40 | 41 | echo "正在复制文件……" 42 | 43 | # 清理安装目录 44 | if [ -d "$qq_applauncher_dir/node_modules" ]; then 45 | rm -rf 46 | else 47 | mkdir $qq_applauncher_dir/node_modules 48 | fi 49 | 50 | if [ -f "./node_modules.zip.md5" -a -f "./node_modules.zip" ]; then 51 | diff "$qq_applauncher_dir/node_modules.zip.md5" "./node_modules.zip.md5" > /dev/null 2>&1 52 | [ $? != 0 ] 53 | unzip -qo ./node_modules.zip -d "$qq_applauncher_dir/node_modules" 54 | cp -f ./node_modules.zip.md5 "$qq_applauncher_dir" 55 | elif [ -d "./node_modules" ]; then 56 | cp -rf ./node_modules "$qq_applauncher_dir" 57 | fi 58 | cp -rf ./qqntim.js ./qqntim-renderer.js ./builtins "$qq_applauncher_dir" 59 | 60 | echo "正在修补 package.json……" 61 | sed -i "" "s#\.\/app_launcher\/index\.js#\.\/app_launcher\/qqntim\.js#g" "$qq_app_dir/package.json" 62 | 63 | touch "$qqntim_flag_file" 64 | 65 | if [ "$QQNTIM_INSTALLER_NO_DELAYED_EXIT" != "1" ]; then 66 | echo "安装成功。安装程序将在 5 秒后自动退出。" 67 | sleep 5 68 | else 69 | echo "安装成功。" 70 | fi 71 | 72 | exit 0 73 | -------------------------------------------------------------------------------- /publish/uninstall.cmd: -------------------------------------------------------------------------------- 1 | @setlocal enableextensions 2 | @echo off 3 | cd /d %~dp0_ 4 | color F0 5 | mode con cols=65 lines=16 6 | 7 | set PS_PREFIX=powershell -NoProfile -ExecutionPolicy Unrestricted 8 | 9 | "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system" >nul 2>nul 10 | if "%ERRORLEVEL%" neq "0" ( 11 | goto try_run_as 12 | ) else ( 13 | goto main 14 | ) 15 | goto:eof 16 | 17 | :try_run_as 18 | %PS_PREFIX% -WindowStyle Hidden -Command Start-Process -Wait -FilePath """%COMSPEC%""" -Verb RunAs -ArgumentList """/c""","""`"""%~f0`"""""" 19 | goto:eof 20 | 21 | :main 22 | set SUCCESS_FLAG=%TEMP%\qqntim-uninstall-successful.tmp 23 | if exist "%SUCCESS_FLAG%" ( 24 | del /f /q "%SUCCESS_FLAG%" 25 | ) 26 | %PS_PREFIX% -File .\uninstall.ps1 27 | if not exist "%SUCCESS_FLAG%" ( 28 | echo Uninstallation error. If you believe this is an issue of QQNTim, please report it to us. 29 | pause >nul 2>nul 30 | ) 31 | goto:eof -------------------------------------------------------------------------------- /publish/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pushd "$( dirname "${BASH_SOURCE[0]}" )/_" > /dev/null 4 | 5 | # 判断是否拥有 root 权限 6 | if [ ! "$(whoami)" == "root" ]; then 7 | echo "正在提升权限……" 8 | popd > /dev/null 9 | sudo QQNTIM_UNINSTALLER_NO_KILL_QQ="$QQNTIM_UNINSTALLER_NO_KILL_QQ" QQNTIM_UNINSTALLER_NO_DELAYED_EXIT="$QQNTIM_UNINSTALLER_NO_DELAYED_EXIT" "${BASH_SOURCE[0]}" 10 | exit 0 11 | fi 12 | 13 | # 获取 QQ 安装路径 14 | qq_installation_dir=$( dirname $( readlink $( which qq || which linuxqq ) ) 2>/dev/null || echo "/var/lib/flatpak/app/com.qq.QQ/current/active/files/extra/QQ" ) 15 | 16 | if [ ! -d "$qq_installation_dir" ]; then 17 | echo "未找到 QQNT 安装目录。" 18 | fi 19 | 20 | qq_app_dir="$qq_installation_dir/resources/app" 21 | qq_applauncher_dir="$qq_app_dir/app_launcher" 22 | qqntim_flag_file="$qq_applauncher_dir/qqntim-flag.txt" 23 | 24 | if [ ! -f "$qqntim_flag_file" ]; then 25 | echo "QQNTim 未被安装。" 26 | exit -1 27 | fi 28 | 29 | read -p "是否要卸载 QQNTim (y/n)?" choice 30 | case $choice in 31 | y) ;; 32 | *) exit -1 ;; 33 | esac 34 | 35 | read -p "是否需要同时移除所有数据 (y/n)?" choice 36 | case $choice in 37 | y) rm -rf "$HOME/.local/share/QQNTim" ;; 38 | *) ;; 39 | esac 40 | 41 | if [ "$QQNTIM_UNINSTALLER_NO_KILL_QQ" != "1" ]; then 42 | echo "正在关闭 QQ……" 43 | killall -w qq linuxqq > /dev/null 2>&1 44 | fi 45 | 46 | echo "正在移除文件……" 47 | [ -f "$qq_applauncher_dir/node_modules.zip.md5" ] && rm -f "$qq_applauncher_dir/node_modules.zip.md5" 48 | rm -rf "$qq_applauncher_dir/qqntim.js" "$qq_applauncher_dir/qqntim-renderer.js" "$qq_applauncher_dir/node_modules" "$qq_applauncher_dir/builtins" 49 | 50 | echo "正在还原 package.json……" 51 | sed -i "s#\.\/app_launcher\/qqntim\.js#\.\/app_launcher\/index\.js#g" "$qq_app_dir/package.json" 52 | 53 | rm -f "$qqntim_flag_file" 54 | 55 | if [ "$QQNTIM_UNINSTALLER_NO_DELAYED_EXIT" != "1" ]; then 56 | echo "卸载成功。卸载程序将在 5 秒后自动退出。" 57 | sleep 5s 58 | else 59 | echo "卸载成功。" 60 | fi 61 | exit 0 62 | -------------------------------------------------------------------------------- /publish/uninstall_macos.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pushd "$( dirname "${BASH_SOURCE[0]}" )/_" > /dev/null 4 | 5 | # 判断是否拥有 root 权限 6 | if [ ! "$(whoami)" == "root" ]; then 7 | echo "正在提升权限……" 8 | popd > /dev/null 9 | sudo QQNTIM_UNINSTALLER_NO_KILL_QQ="$QQNTIM_UNINSTALLER_NO_KILL_QQ" QQNTIM_UNINSTALLER_NO_DELAYED_EXIT="$QQNTIM_UNINSTALLER_NO_DELAYED_EXIT" "${BASH_SOURCE[0]}" 10 | exit 0 11 | fi 12 | 13 | # 获取 QQ 安装路径 14 | qq_installation_dir="/Applications/QQ.app/Contents" 15 | 16 | if [ ! -d "$qq_installation_dir" ]; then 17 | echo "未找到 QQNT 安装目录。" 18 | fi 19 | 20 | qq_app_dir="$qq_installation_dir/Resources/app" 21 | qq_applauncher_dir="$qq_app_dir/app_launcher" 22 | qqntim_flag_file="$qq_applauncher_dir/qqntim-flag.txt" 23 | 24 | if [ ! -f "$qqntim_flag_file" ]; then 25 | echo "QQNTim 未被安装。" 26 | exit -1 27 | fi 28 | 29 | read -p "是否要卸载 QQNTim (y/n)?" choice 30 | case $choice in 31 | y) ;; 32 | *) exit -1 ;; 33 | esac 34 | 35 | read -p "是否需要同时移除所有数据 (y/n)?" choice 36 | case $choice in 37 | y) rm -rf "$HOME/.local/share/QQNTim" ;; 38 | *) ;; 39 | esac 40 | 41 | if [ "$QQNTIM_UNINSTALLER_NO_KILL_QQ" != "1" ]; then 42 | echo "正在关闭 QQ……" 43 | pkill QQ > /dev/null 2>&1 44 | fi 45 | 46 | echo "正在移除文件……" 47 | [ -f "$qq_applauncher_dir/node_modules.zip.md5" ] && rm -f "$qq_applauncher_dir/node_modules.zip.md5" 48 | rm -rf "$qq_applauncher_dir/qqntim.js" "$qq_applauncher_dir/qqntim-renderer.js" "$qq_applauncher_dir/node_modules" "$qq_applauncher_dir/builtins" 49 | 50 | echo "正在还原 package.json……" 51 | sed -i "" "s#\.\/app_launcher\/qqntim\.js#\.\/app_launcher\/index\.js#g" "$qq_app_dir/package.json" 52 | 53 | rm -f "$qqntim_flag_file" 54 | 55 | if [ "$QQNTIM_UNINSTALLER_NO_DELAYED_EXIT" != "1" ]; then 56 | echo "卸载成功。卸载程序将在 5 秒后自动退出。" 57 | sleep 5 58 | else 59 | echo "卸载成功。" 60 | fi 61 | exit 0 62 | -------------------------------------------------------------------------------- /rome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.rome.tools/schemas/12.1.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "ignore": [".pnp.cjs", ".pnp.loader.mjs", ".yarn/*", "dist/*", "src/typings/src/electron.d.ts"] 8 | }, 9 | "formatter": { 10 | "indentSize": 4, 11 | "indentStyle": "space", 12 | "lineWidth": 300 13 | }, 14 | "linter": { 15 | "enabled": true, 16 | "rules": { 17 | "recommended": true, 18 | "suspicious": { "noExplicitAny": "off", "noDoubleEquals": "off" }, 19 | "style": { "noParameterAssign": "off", "noNonNullAssertion": "off" }, 20 | "a11y": { "all": false } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scripts/pack.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | Set-Location ((Split-Path -Parent $MyInvocation.MyCommand.Definition) + "\..") 4 | $CD = (Get-Location).Path 5 | 6 | 7 | $SourceDirPath = "$CD\dist\_\node_modules" 8 | $DestinationZipPath = "$CD\dist\_\node_modules.zip" 9 | 10 | # 设置特定时间戳,确保两次构建结果 Hash 不变 11 | Get-ChildItem $SourceDirPath -Recurse | ForEach-Object { $_.LastWriteTimeUtc = "01/01/1970 08:00:00"; $_.LastAccessTimeUtc = "01/01/1970 08:00:00"; $_.CreationTimeUtc = "01/01/1970 08:00:00"; } 12 | 13 | # 打包 node_modules 14 | # 仅支持 .NET Framework 4.5 及以上 15 | Add-Type -AssemblyName System.IO.Compression.Filesystem 16 | [System.IO.Compression.ZipFile]::CreateFromDirectory($SourceDirPath, $DestinationZipPath) 17 | Remove-Item $SourceDirPath -Recurse -Force 18 | 19 | # 生成 MD5 校验和 20 | $Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $false 21 | [System.IO.File]::WriteAllLines($DestinationZipPath, (Get-FileHash "$DestinationZipPath.md5" -Algorithm MD5).Hash, $Utf8NoBomEncoding) 22 | -------------------------------------------------------------------------------- /scripts/pack.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$( dirname "${BASH_SOURCE[0]}" )/.." 4 | 5 | pushd ./dist/_/node_modules > /dev/null 6 | 7 | # 设置特定时间戳,确保两次构建结果 Hash 不变 8 | find . -exec touch -d @0 {} + 9 | 10 | # 打包 node_modules 11 | zip -Xyqr9 ../node_modules.zip . 12 | 13 | popd > /dev/null 14 | 15 | rm -rf ./dist/_/node_modules 16 | 17 | # 生成 MD5 校验和 18 | (md5sum -b ./dist/_/node_modules.zip | cut -d" " -f1) > ./dist/_/node_modules.zip.md5 -------------------------------------------------------------------------------- /scripts/start.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | Set-Location ((Split-Path -Parent $MyInvocation.MyCommand.Definition) + "\..") 4 | 5 | # 从注册表获取 QQ 安装路径 6 | foreach ($RegistryPath in @("HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*")) { 7 | try { 8 | foreach ($Item in (Get-ItemProperty $RegistryPath)) { 9 | if ($Item.PSChildName -eq "QQ") { 10 | $QQInstallDir = (Split-Path -Parent $Item.UninstallString) 11 | break 12 | } 13 | } 14 | } 15 | catch {} 16 | } 17 | 18 | if (($null -eq $QQInstallDir) -or ((Test-Path $QQInstallDir) -eq $false)) { 19 | throw "未找到 QQNT 安装目录。" 20 | } 21 | 22 | Start-Process "$QQInstallDir\QQ.exe" -Wait -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$( dirname "${BASH_SOURCE[0]}" )/.." 4 | 5 | ( qq || linuxqq || flatpak run com.qq.QQ ) 2>&1 | sed -e '/NODE_TLS_REJECT_UNAUTHORIZED/d' -e '/Gtk-Message/d' -e '/to show where the warning was created/d' -e '/gbm_wrapper\.cc/d' -e '/node_bindings\.cc/d' -e '/UnhandledPromiseRejectionWarning/d' -e '/\[BuglyManager\.cpp\]/d' -e '/\[NativeCrashHandler\.cpp\]/d' -e '/\[BuglyService\.cpp\]/d' -e '/\[HotUpdater\]/d' -e '/ERROR:CONSOLE/d' 6 | -------------------------------------------------------------------------------- /src/builtins/settings/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flysoftbeta/qqntim-plugin-settings", 3 | "private": true, 4 | "devDependencies": { 5 | "@flysoftbeta/qqntim-typings": "workspace:*", 6 | "@types/node": "^20.6.2", 7 | "@types/react": "^18.2.21", 8 | "@types/react-dom": "^18.2.7", 9 | "@types/unzipper": "^0.10.7" 10 | }, 11 | "dependencies": { 12 | "unzipper": "^0.10.14" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/builtins/settings/publish/qqntim.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": "3.0", 3 | "id": "builtin-settings-ui", 4 | "name": "设置", 5 | "author": "Flysoft", 6 | "injections": [ 7 | { 8 | "type": "renderer", 9 | "page": "settings", 10 | "stylesheet": "style.css", 11 | "script": "renderer.js" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/builtins/settings/publish/style.css: -------------------------------------------------------------------------------- 1 | .q-switch { 2 | position: relative; 3 | } 4 | 5 | .q-switch > input { 6 | position: absolute; 7 | left: 0; 8 | right: 0; 9 | top: 0; 10 | bottom: 0; 11 | appearance: none !important; 12 | margin: 0; 13 | z-index: 10; 14 | } 15 | 16 | .q-button { 17 | margin: 0 !important; 18 | } 19 | 20 | .qqntim-settings-panel-open 21 | .setting-main 22 | .setting-main__content:not(.qqntim-settings-panel), 23 | body:not(.qqntim-settings-panel-open) 24 | .setting-main 25 | .setting-main__content.qqntim-settings-panel { 26 | display: none !important; 27 | } 28 | 29 | .qqntim-settings-panel { 30 | padding: 20px 16px 70px !important; 31 | } 32 | 33 | .qqntim-settings-panel-section-header { 34 | display: flex; 35 | flex-direction: row; 36 | align-items: center; 37 | justify-content: space-between; 38 | padding: 8px; 39 | } 40 | 41 | .qqntim-settings-panel-section-header-title { 42 | font-size: min(var(--font_size_2), 18px) !important; 43 | } 44 | 45 | .qqntim-settings-panel-section-header-buttons { 46 | display: flex; 47 | flex-direction: row; 48 | align-items: center; 49 | gap: 5px; 50 | } 51 | 52 | .qqntim-settings-panel-section-content { 53 | border-radius: 5px; 54 | padding: 0 16px; 55 | background-color: var(--fg_white); 56 | } 57 | 58 | .qqntim-settings-panel-settings-versions { 59 | display: flex; 60 | flex-direction: row; 61 | justify-content: space-between; 62 | gap: 5px; 63 | padding: 16px 0; 64 | } 65 | 66 | .qqntim-settings-panel-settings-versions-item { 67 | display: flex; 68 | flex-direction: column; 69 | align-items: center; 70 | justify-content: center; 71 | gap: 5px; 72 | white-space: nowrap; 73 | flex: 10; 74 | } 75 | 76 | .qqntim-settings-panel-settings-versions-item > h3 { 77 | font-size: min(var(--font_size_3), 18px) !important; 78 | } 79 | 80 | .qqntim-settings-panel-settings-versions-item > div { 81 | font-size: min(var(--font_size_2), 16px) !important; 82 | color: var(--text_secondary_01); 83 | } 84 | 85 | .qqntim-settings-panel-box { 86 | display: flex; 87 | flex-direction: column; 88 | } 89 | 90 | .qqntim-settings-panel-box-item { 91 | display: flex; 92 | flex-direction: row; 93 | justify-content: space-between; 94 | align-items: center; 95 | width: 100%; 96 | min-height: 44px; 97 | padding: 8px 0; 98 | } 99 | 100 | .qqntim-settings-panel-box-item > span { 101 | display: flex; 102 | flex-direction: column; 103 | justify-content: center; 104 | } 105 | 106 | .qqntim-settings-panel-box-item > div { 107 | display: flex; 108 | flex-direction: row; 109 | align-items: center; 110 | gap: 5px; 111 | } 112 | 113 | .qqntim-settings-panel-box-item-title { 114 | font-size: min(var(--font_size_3), 18px) !important; 115 | } 116 | 117 | .qqntim-settings-panel-box-item-description { 118 | font-size: min(var(--font_size_2), 16px) !important; 119 | color: var(--text_secondary_01); 120 | } 121 | 122 | .qqntim-settings-panel-box-item:not(.qqntim-settings-panel-box-item-last) { 123 | border-bottom: 1px solid var(--divider_standard); 124 | } 125 | 126 | .qqntim-settings-panel-save { 127 | position: fixed; 128 | right: 20px; 129 | bottom: 20px; 130 | display: flex; 131 | flex-direction: row; 132 | align-items: center; 133 | gap: 10px; 134 | z-index: 100; 135 | } 136 | -------------------------------------------------------------------------------- /src/builtins/settings/src/_renderer.tsx: -------------------------------------------------------------------------------- 1 | import * as exports from "./exports"; 2 | import * as exportsComponents from "./exports/components"; 3 | import { Navigation, TabWithOtherTab } from "./nav"; 4 | import { Panel } from "./panel"; 5 | import { cl } from "./utils/consts"; 6 | import { defineModules, nt, utils } from "qqntim/renderer"; 7 | import { createRoot } from "react-dom/client"; 8 | 9 | export default class Entry implements QQNTim.Entry.Renderer { 10 | constructor() { 11 | defineModules({ "qqntim-settings": exports as typeof QQNTim.Settings, "qqntim-settings/components": exportsComponents as typeof QQNTim.SettingsComponents }); 12 | 13 | Promise.all([nt.getAccountInfo(), utils.waitForElement(".nav-bar:not(.qqntim-settings-nav)"), utils.waitForElement(".setting-main"), utils.waitForElement(`.setting-main .setting-main__content:not(.${cl.panel.c})`)]).then( 14 | ([account, nav, panel, panelContent]) => { 15 | if (!account) throw new Error("获取当前账户信息时失败"); 16 | 17 | const panelVueId = utils.getVueId(panelContent)!; 18 | const panelContainer = document.createElement("div"); 19 | panelContainer.classList.add("setting-main__content", cl.panel.c); 20 | panelContainer.setAttribute(panelVueId, ""); 21 | const panelRoot = createRoot(panelContainer); 22 | const renderPanel = (currentTab: TabWithOtherTab) => { 23 | panelRoot.render(); 24 | }; 25 | panel.appendChild(panelContainer); 26 | 27 | const navVueId = utils.getVueId(nav.firstElementChild as HTMLElement)!; 28 | const navContainer = document.createElement("div"); 29 | navContainer.classList.add(cl.nav.c); 30 | const navRoot = createRoot(navContainer); 31 | const renderNav = () => { 32 | navRoot.render(); 33 | }; 34 | nav.appendChild(navContainer); 35 | exports.setRenderNavFunction(renderNav); 36 | renderNav(); 37 | }, 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/builtins/settings/src/exports/components.tsx: -------------------------------------------------------------------------------- 1 | import { cl } from "../utils/consts"; 2 | import { Fragment, ReactNode, useState } from "react"; 3 | 4 | export function SettingsSection({ title, children, buttons }: { title: string; children: ReactNode; buttons?: ReactNode }) { 5 | return ( 6 |
7 | 8 |

{title}

9 | {!!buttons &&
{buttons}
} 10 |
11 |
{children}
12 |
13 | ); 14 | } 15 | 16 | export function SettingsBox({ children }: { children: ReactNode }) { 17 | return
{children}
; 18 | } 19 | 20 | export function SettingsBoxItem({ title, description, children, isLast = false }: { title: string; description?: string[]; children?: ReactNode; isLast?: boolean }) { 21 | return ( 22 | 41 | ); 42 | } 43 | 44 | export function Switch({ checked, onToggle }: { checked: boolean; onToggle: (checked: boolean) => void }) { 45 | return ( 46 |
onToggle(!checked)}> 47 | onToggle(event.target.checked)} /> 48 |
49 |
50 | ); 51 | } 52 | 53 | export function Input({ value, onChange, width }: { value: string; onChange: (value: string) => void; width }) { 54 | const [focus, setFocus] = useState(false); 55 | 56 | return ( 57 |
58 | onChange(event.target.value)} 63 | onFocus={() => setFocus(true)} 64 | onBlur={() => setFocus(false)} 65 | spellCheck={false} 66 | style={{ 67 | fontSize: "12px", 68 | height: "26px", 69 | lineHeight: "26px", 70 | padding: "0 32px 0 8px", 71 | border: "1px solid transparent", 72 | borderRadius: "4px", 73 | boxSizing: "border-box", 74 | ...(focus && { 75 | border: " 1px solid var(--brand_standard)", 76 | borderRadius: "4px", 77 | caretColor: "var(--brand_standard)", 78 | }), 79 | }} 80 | /> 81 |
82 | ); 83 | } 84 | 85 | export function Dropdown({ items, selected, onChange, width }: { items: [T, string][]; selected: T; onChange: (id: T) => void; width: string }) { 86 | const [open, setOpen] = useState(false); 87 | const [selectedId, selectedContent] = items.find(([id]) => id == selected)!; 88 | 89 | return ( 90 |
91 |
setOpen((prev) => !prev)}> 92 |
{selectedContent}
93 | 94 | 105 | 106 | 107 | 108 | 109 | 110 |
111 | {open && ( 112 |
113 |
    114 | {items.map(([id, content]) => { 115 | const isSelected = id == selectedId; 116 | return ( 117 |
  • { 121 | onChange(id); 122 | setOpen(false); 123 | }} 124 | > 125 | {content} 126 | {isSelected && ( 127 | 128 | 139 | 140 | 141 | 142 | 143 | 144 | )} 145 |
  • 146 | ); 147 | })} 148 |
149 |
150 | )} 151 |
152 | ); 153 | } 154 | 155 | export function Button({ onClick, primary, small, children }: { onClick: () => void; primary: boolean; small: boolean; children: ReactNode }) { 156 | return ( 157 | 160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /src/builtins/settings/src/exports/index.ts: -------------------------------------------------------------------------------- 1 | export const panels: [string, QQNTim.Settings.Panel, string | undefined][] = []; 2 | let renderNav: Function = () => undefined; 3 | 4 | export function defineSettingsPanels(...newSettingsPanels: [string, QQNTim.Settings.Panel, string | undefined][]) { 5 | panels.push(...newSettingsPanels); 6 | renderNav(); 7 | } 8 | 9 | export function setRenderNavFunction(newFunction: Function) { 10 | renderNav = newFunction; 11 | } 12 | -------------------------------------------------------------------------------- /src/builtins/settings/src/installer.ts: -------------------------------------------------------------------------------- 1 | import { s } from "./utils/sep"; 2 | import { randomUUID } from "crypto"; 3 | import * as path from "path"; 4 | import { allPlugins, app, dialog, env, modules } from "qqntim/renderer"; 5 | import { Extract } from "unzipper"; 6 | const { fs } = modules; 7 | 8 | export async function installZipPluginsForAccount(uin: string, requiresRestart: boolean) { 9 | const result = await dialog.openDialog({ title: "选择插件压缩包", properties: ["openFile"], filters: [{ name: "ZIP 压缩文件", extensions: ["zip"] }] }); 10 | if (result.canceled) return; 11 | const filePath = result.filePaths[0]; 12 | if (!(await fs.exists(filePath)) || !(await fs.stat(filePath)).isFile()) return; 13 | const tmpDir = `${process.platform == "win32" ? process.env["TEMP"] : "/tmp"}${s}${randomUUID()}`; 14 | 15 | try { 16 | await fs.ensureDir(tmpDir); 17 | await new Promise((resolve, reject) => 18 | fs 19 | .createReadStream(result.filePaths[0]) 20 | .pipe(Extract({ path: tmpDir })) 21 | .on("close", () => resolve()) 22 | .on("error", (error) => reject(error)), 23 | ); 24 | } catch (reason) { 25 | await dialog.alert("解压插件压缩包时出现错误:\n" + reason); 26 | return false; 27 | } 28 | 29 | return await installPluginsForAccount(uin, requiresRestart, tmpDir); 30 | } 31 | 32 | export async function installFolderPluginsForAccount(uin: string, requiresRestart: boolean) { 33 | const result = await dialog.openDialog({ title: "选择插件文件夹", properties: ["openDirectory"] }); 34 | if (result.canceled) return; 35 | const filePath = result.filePaths[0]; 36 | if (!(await fs.exists(filePath)) || !(await fs.stat(filePath)).isDirectory()) return false; 37 | 38 | return await installPluginsForAccount(uin, requiresRestart, filePath); 39 | } 40 | 41 | async function collectManifests(dir: string) { 42 | const getManifestFile = async (dir: string) => { 43 | const manifestFile = `${dir}${s}qqntim.json`; 44 | if ((await fs.exists(manifestFile)) && (await fs.stat(manifestFile)).isFile()) return manifestFile; 45 | }; 46 | 47 | let manifestFiles: string[] = []; 48 | const manifestFile = await getManifestFile(dir); 49 | if (!manifestFile) { 50 | const folders = await fs.readdir(dir); 51 | manifestFiles = ( 52 | await Promise.all( 53 | folders.map(async (folder) => { 54 | const folderPath = `${dir}${s}${folder}`; 55 | if ((await fs.exists(folderPath)) && (await fs.stat(folderPath)).isDirectory()) { 56 | return await getManifestFile(folderPath); 57 | } 58 | }), 59 | ) 60 | ).filter(Boolean) as string[]; 61 | } else manifestFiles = [manifestFile]; 62 | 63 | return manifestFiles; 64 | } 65 | 66 | async function installPluginsForAccount(uin: string, requiresRestart: boolean, dir: string) { 67 | const manifestFiles = await collectManifests(dir); 68 | if (manifestFiles.length == 0) { 69 | await dialog.alert("未在目标文件夹或文件夹搜索到任何插件。\n请联系插件作者以获得更多信息。"); 70 | return false; 71 | } 72 | 73 | let count = 0; 74 | for (const manifestFile of manifestFiles) { 75 | const manifest = fs.readJSONSync(manifestFile) as QQNTim.Manifest; 76 | const pluginDir = `${uin == "" ? env.path.pluginDir : `${env.path.pluginPerUserDir}${s}${uin}`}${s}${manifest.id}`; 77 | if (!(await dialog.confirm(`扫描到一个插件:\n\nID:${manifest.id}\n名称:${manifest.name}\n作者:${manifest.author || "未知"}\n说明:${manifest.description || "该插件没有提供说明。"}\n\n是否希望安装此插件?`))) continue; 78 | try { 79 | if (allPlugins[uin]?.[manifest.id]) await fs.rm(allPlugins[uin][manifest.id].dir); 80 | await fs.ensureDir(pluginDir); 81 | await fs.copy(path.dirname(manifestFile), pluginDir); 82 | count++; 83 | } catch (reason) { 84 | await dialog.alert("复制插件时出现错误:\n" + reason); 85 | } 86 | } 87 | 88 | await dialog.alert(`成功安装了 ${count} 个插件。${requiresRestart ? `\n\n点击 "确定" 将重启你的 QQ。` : ""}`); 89 | if (requiresRestart) app.relaunch(); 90 | return true; 91 | } 92 | 93 | export async function uninstallPlugin(requiresRestart: boolean, pluginDir: string) { 94 | if (!(await dialog.confirm("是否要卸载此插件?"))) return false; 95 | try { 96 | await fs.remove(pluginDir); 97 | await dialog.alert(`成功卸载此插件。${requiresRestart ? `\n\n点击 "确定" 将重启你的 QQ。` : ""}`); 98 | if (requiresRestart) app.relaunch(); 99 | return true; 100 | } catch (reason) { 101 | await dialog.alert(`卸载插件时出现错误:\n${reason}`); 102 | return false; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/builtins/settings/src/main.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlysoftBeta/QQNTim/5cd25d12b7a85529f512557ce544c26eddb2e9f4/src/builtins/settings/src/main.ts -------------------------------------------------------------------------------- /src/builtins/settings/src/nav.tsx: -------------------------------------------------------------------------------- 1 | import { panels } from "./exports"; 2 | import { cl } from "./utils/consts"; 3 | import { utils } from "qqntim/renderer"; 4 | import { Fragment, useEffect, useState } from "react"; 5 | 6 | interface OtherTab { 7 | key?: undefined; 8 | type?: undefined; 9 | title?: string; 10 | icon?: string; 11 | } 12 | 13 | interface PluginsManagerTab { 14 | key: string; 15 | type: "plugins-manager"; 16 | title: string; 17 | icon: string; 18 | } 19 | 20 | interface SettingsTab { 21 | key: string; 22 | type: "settings"; 23 | title: string; 24 | icon: string; 25 | } 26 | 27 | interface PluginTab { 28 | key: string; 29 | type: "plugin"; 30 | title: string; 31 | icon: string | undefined; 32 | node: QQNTim.Settings.Panel; 33 | } 34 | 35 | export type Tab = PluginsManagerTab | SettingsTab | PluginTab; 36 | export type TabWithOtherTab = Tab | OtherTab; 37 | 38 | export function Navigation({ vueId, onCurrentTabChange }: { vueId: string; onCurrentTabChange: (tab: TabWithOtherTab) => void }) { 39 | const [currentTab, setCurrentTab] = useState({}); 40 | useEffect(() => { 41 | utils.waitForElement(".nav-bar").then((element) => 42 | element.addEventListener("click", (event) => { 43 | const item = (event.target as HTMLElement).closest(".nav-item"); 44 | if (!item) return; 45 | if (item.classList.contains(cl.nav.item.c)) return; 46 | item.classList.toggle("nav-item-active", true); 47 | 48 | const title = item.firstElementChild?.nextElementSibling as HTMLElement; 49 | if (title) setCurrentTab({ title: title.innerText }); 50 | }), 51 | ); 52 | }, []); 53 | useEffect(() => { 54 | if (currentTab.type) utils.waitForElement(`.nav-item.nav-item-active:not(.${cl.nav.item.c})`).then((element) => element.classList.remove("nav-item-active")); 55 | onCurrentTabChange(currentTab); 56 | }, [currentTab]); 57 | 58 | const tabs: Tab[] = [ 59 | { 60 | key: "settings", 61 | type: "settings", 62 | title: "QQNTim 设置", 63 | icon: ``, 64 | }, 65 | { 66 | key: "plugins-manager", 67 | type: "plugins-manager", 68 | title: "插件管理", 69 | icon: ``, 70 | }, 71 | ...panels.map(([title, node, icon], idx): PluginTab => { 72 | return { 73 | key: `plugin-${idx}`, 74 | type: "plugin", 75 | title: title, 76 | icon: icon, 77 | node: node, 78 | }; 79 | }), 80 | ]; 81 | 82 | return ( 83 | 84 | {tabs.map((item) => ( 85 |
setCurrentTab(item)} {...{ [vueId]: "" }}> 86 | {!!item.icon && ( 87 | */ 90 | dangerouslySetInnerHTML={{ __html: item.icon }} 91 | style={{ 92 | width: "16px", 93 | height: "16px", 94 | marginRight: "8px", 95 | alignItems: "center", 96 | color: "inherit", 97 | display: "inline-flex", 98 | justifyContent: "center", 99 | }} 100 | /> 101 | )} 102 |
103 | {item.title} 104 |
105 |
106 | ))} 107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/builtins/settings/src/panel.tsx: -------------------------------------------------------------------------------- 1 | import { Button, SettingsBox, SettingsBoxItem, SettingsSection, Switch } from "./exports/components"; 2 | import { installFolderPluginsForAccount, installZipPluginsForAccount, uninstallPlugin } from "./installer"; 3 | import { TabWithOtherTab } from "./nav"; 4 | import { cl } from "./utils/consts"; 5 | import { enablePlugin, getPluginDescription, isInBlacklist, isInWhitelist, isPluginsExistent } from "./utils/utils"; 6 | import { shell } from "electron"; 7 | import { allPlugins, app, env, modules, utils } from "qqntim/renderer"; 8 | import { useEffect, useState } from "react"; 9 | const { fs } = modules; 10 | 11 | interface PanelProps { 12 | account: QQNTim.API.Renderer.NT.LoginAccount; 13 | config: Required; 14 | setConfig: React.Dispatch>>; 15 | } 16 | 17 | export function Panel({ currentTab, account }: { currentTab: TabWithOtherTab; account: QQNTim.API.Renderer.NT.LoginAccount }) { 18 | const [config, setConfig] = useState>(env.config); 19 | const [pluginsConfig, setPluginsConfig] = useState>(config.plugins.config || {}); 20 | 21 | const saveConfigAndRestart = () => { 22 | fs.writeJSONSync(env.path.configFile, config); 23 | app.relaunch(); 24 | }; 25 | 26 | const resetConfigAndRestart = () => { 27 | fs.writeJSONSync(env.path.configFile, {}); 28 | app.relaunch(); 29 | }; 30 | 31 | useEffect( 32 | () => 33 | setConfig((prev) => { 34 | return { ...prev, plugins: { ...prev.plugins, config: pluginsConfig } }; 35 | }), 36 | [pluginsConfig], 37 | ); 38 | 39 | useEffect(() => { 40 | document.body.classList.toggle(cl.panel.open.c, !!currentTab.type); 41 | utils.waitForElement(".setting-title").then((element) => { 42 | if (element.__VUE__?.[0]?.props?.title && currentTab.title) element.__VUE__[0].props.title = currentTab.title; 43 | }); 44 | }, [currentTab]); 45 | 46 | const panelProps: PanelProps = { 47 | account, 48 | config, 49 | setConfig, 50 | }; 51 | 52 | return ( 53 | <> 54 | {currentTab.type == "settings" ? : currentTab.type == "plugins-manager" ? : currentTab.type == "plugin" ? : null} 55 |
56 | 59 | 62 |
63 | 64 | ); 65 | } 66 | 67 | function SettingsPanel({ config, setConfig }: PanelProps) { 68 | return ( 69 | <> 70 | 71 |
72 | {[ 73 | ["QQNTim", process.versions.qqntim], 74 | ["QQNT", process.versions.qqnt], 75 | ["Electron", process.versions.electron], 76 | ["Node.js", process.versions.node], 77 | ["Chromium", process.versions.chrome], 78 | ["V8", process.versions.v8], 79 | ].map(([name, version]) => ( 80 |
81 |

{name}

82 |
{version}
83 |
84 | ))} 85 |
86 |
87 | 88 | 89 | {( 90 | [ 91 | [ 92 | "显示详细日志输出", 93 | "开启后,可以在控制台内查看到 IPC 通信、部分 Electron 对象的成员访问信息等。", 94 | config.verboseLogging, 95 | (state: boolean) => 96 | setConfig((prev) => { 97 | return { ...prev, verboseLogging: state }; 98 | }), 99 | ], 100 | [ 101 | "使用原版 DevTools", 102 | "使用 Chromium DevTools 而不是 chii DevTools (Windows 版 9.8.5 及以上不可用)。", 103 | config.useNativeDevTools, 104 | (state) => 105 | setConfig((prev) => { 106 | return { ...prev, useNativeDevTools: state }; 107 | }), 108 | ], 109 | [ 110 | "禁用兼容性处理", 111 | "禁用后,LiteLoader 可能将不能与 QQNTim 一起使用。", 112 | config.disableCompatibilityProcessing, 113 | (state) => 114 | setConfig((prev) => { 115 | return { ...prev, disableCompatibilityProcessing: state }; 116 | }), 117 | ], 118 | ] as [string, string, boolean, (state: boolean) => void][] 119 | ).map(([title, description, value, setValue], idx, array) => ( 120 | // rome-ignore lint/suspicious/noArrayIndexKey: 121 | 122 | 123 | 124 | ))} 125 | 126 | 127 | 128 | ); 129 | } 130 | 131 | function PluginsManagerPanel({ account, config, setConfig }: PanelProps) { 132 | const [existentPlugins, setExistentPlugins] = useState(isPluginsExistent()); 133 | 134 | return Array.from(new Set([...Object.keys(allPlugins), account.uin])) 135 | .sort() 136 | .map((uin: string) => { 137 | const plugins = allPlugins[uin] || {}; 138 | const requiresRestart = uin == account.uin || uin == ""; 139 | const isEmpty = Object.keys(plugins).length == 0; 140 | if (uin != account.uin && isEmpty) return; 141 | return ( 142 | 147 | 150 | 153 | 154 | } 155 | > 156 | 157 | {!isEmpty ? ( 158 | Object.keys(plugins) 159 | .sort() 160 | .map((id, idx, array) => { 161 | const plugin = plugins[id]; 162 | const inWhitelist = isInWhitelist(id, config.plugins.whitelist); 163 | const inBlacklist = isInBlacklist(id, config.plugins.blacklist); 164 | const description = getPluginDescription(plugin); 165 | 166 | if (!existentPlugins.includes(id)) return; 167 | return ( 168 | 169 | enablePlugin(setConfig, id, state, inWhitelist, inBlacklist)} /> 170 | 173 | 176 | 177 | ); 178 | }) 179 | ) : ( 180 | 181 | )} 182 | 183 | 184 | ); 185 | }); 186 | } 187 | -------------------------------------------------------------------------------- /src/builtins/settings/src/qqntim-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/builtins/settings/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import Entry from "./_renderer"; 2 | export default Entry; 3 | -------------------------------------------------------------------------------- /src/builtins/settings/src/utils/consts.ts: -------------------------------------------------------------------------------- 1 | export const cl = { 2 | nav: { 3 | c: "qqntim-settings-nav", 4 | item: { 5 | c: "qqntim-settings-nav-item", 6 | }, 7 | }, 8 | panel: { 9 | c: "qqntim-settings-panel", 10 | open: { 11 | c: "qqntim-settings-panel-open", 12 | }, 13 | settings: { 14 | versions: { 15 | c: "qqntim-settings-panel-settings-versions", 16 | item: { 17 | c: "qqntim-settings-panel-settings-versions-item", 18 | }, 19 | }, 20 | }, 21 | section: { 22 | c: "qqntim-settings-panel-section", 23 | header: { 24 | c: "qqntim-settings-panel-section-header", 25 | title: { 26 | c: "qqntim-settings-panel-section-header-title", 27 | }, 28 | buttons: { 29 | c: "qqntim-settings-panel-section-header-buttons", 30 | }, 31 | }, 32 | content: { 33 | c: "qqntim-settings-panel-section-content", 34 | }, 35 | }, 36 | box: { 37 | c: "qqntim-settings-panel-box", 38 | item: { 39 | c: "qqntim-settings-panel-box-item", 40 | last: { 41 | c: "qqntim-settings-panel-box-item-last", 42 | }, 43 | title: { 44 | c: "qqntim-settings-panel-box-item-title", 45 | }, 46 | description: { 47 | c: "qqntim-settings-panel-box-item-description", 48 | }, 49 | }, 50 | }, 51 | save: { 52 | c: "qqntim-settings-panel-save", 53 | }, 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /src/builtins/settings/src/utils/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | // Thanks https://learnersbucket.com/examples/interview/useprevious-hook-in-react/ 4 | export function usePrevious(value: T) { 5 | const ref = useRef(); 6 | 7 | useEffect(() => { 8 | ref.current = value; 9 | }, [value]); 10 | 11 | return ref.current; 12 | } 13 | -------------------------------------------------------------------------------- /src/builtins/settings/src/utils/sep.ts: -------------------------------------------------------------------------------- 1 | export { sep as s } from "path"; 2 | -------------------------------------------------------------------------------- /src/builtins/settings/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { allPlugins, modules } from "qqntim/renderer"; 2 | const { fs } = modules; 3 | 4 | export function isInWhitelist(id: string, whitelist?: string[]) { 5 | return !!(whitelist && !whitelist.includes(id)); 6 | } 7 | export function isInBlacklist(id: string, blacklist?: string[]) { 8 | return !!blacklist?.includes(id); 9 | } 10 | 11 | function addItemToArray(array: T[], item: T) { 12 | return [...array, item]; 13 | } 14 | function removeItemFromArray(array: T[], item: T) { 15 | return array.filter((value) => value != item); 16 | } 17 | export function enablePlugin(setConfig: React.Dispatch>>, id: string, enable: boolean, inWhitelist: boolean, inBlacklist: boolean) { 18 | setConfig((prev) => { 19 | let _config = prev; 20 | if (_config.plugins.whitelist && enable != inWhitelist) 21 | _config = { 22 | ..._config, 23 | plugins: { 24 | ..._config.plugins, 25 | whitelist: (enable ? addItemToArray : removeItemFromArray)(_config.plugins.whitelist!, id), 26 | }, 27 | }; 28 | else if (!_config.plugins.blacklist) _config.plugins.blacklist = []; 29 | if (_config.plugins.blacklist && enable == inBlacklist) 30 | _config = { 31 | ..._config, 32 | plugins: { 33 | ..._config.plugins, 34 | blacklist: (!enable ? addItemToArray : removeItemFromArray)(_config.plugins.blacklist!, id), 35 | }, 36 | }; 37 | return _config; 38 | }); 39 | } 40 | 41 | export function isPluginsExistent() { 42 | const ids: string[] = []; 43 | Object.keys(allPlugins).forEach((uin) => 44 | Object.keys(allPlugins[uin]).forEach((id) => { 45 | const plugin = allPlugins[uin][id]; 46 | if (fs.existsSync(plugin.dir) && fs.statSync(plugin.dir).isDirectory()) ids.push(id); 47 | }), 48 | ); 49 | return ids; 50 | } 51 | 52 | export function getPluginDescription(plugin: QQNTim.Plugin) { 53 | const versionText = [plugin.manifest.version, plugin.manifest.author].filter(Boolean).join(" - "); 54 | const warnText = [!plugin.meetRequirements && "当前环境不满足需求,未加载", plugin.manifest.manifestVersion != "3.0" && "插件使用了过时的插件标准,请提醒作者更新"].filter(Boolean).join("; "); 55 | const description = plugin.manifest.description || "该插件没有提供说明。"; 56 | return [versionText, warnText && `警告: ${warnText}。`, description].filter(Boolean); 57 | } 58 | -------------------------------------------------------------------------------- /src/builtins/settings/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "target": "ESNext", 7 | "resolveJsonModule": true, 8 | "strictNullChecks": true, 9 | "strictFunctionTypes": true, 10 | "noEmit": true 11 | }, 12 | "include": ["src"], 13 | "exclude": ["node_modules", "**/node_modules/*"] 14 | } 15 | -------------------------------------------------------------------------------- /src/common/global.ts: -------------------------------------------------------------------------------- 1 | export const env = {} as QQNTim.Environment; 2 | export const allPlugins = {} as QQNTim.Plugin.AllUsersPlugins; 3 | 4 | export const setEnv = (value: QQNTim.Environment) => Object.assign(env, value); 5 | export const setAllPlugins = (value: QQNTim.Plugin.AllUsersPlugins) => Object.assign(allPlugins, value); 6 | -------------------------------------------------------------------------------- /src/common/ipc.ts: -------------------------------------------------------------------------------- 1 | import { env } from "./global"; 2 | import { printObject } from "./utils/console"; 3 | import { isMainProcess } from "./utils/process"; 4 | 5 | const interruptIpcs: [QQNTim.IPC.InterruptFunction, QQNTim.IPC.InterruptIPCOptions | undefined][] = []; 6 | 7 | function interruptIpc(args: QQNTim.IPC.Args, direction: QQNTim.IPC.Direction, channel: string, sender?: Electron.WebContents) { 8 | for (const [func, options] of interruptIpcs) { 9 | if (options?.cmdName && (!args[1] || (args[1][0]?.cmdName != options?.cmdName && args[1][0] != options?.cmdName))) continue; 10 | if (options?.eventName && !args[0]?.eventName?.startsWith(`${options?.eventName}-`)) continue; 11 | if (options?.type && (!args[0] || args[0].type != options?.type)) continue; 12 | if (options?.direction && options?.direction != direction) continue; 13 | 14 | const ret = func(args, channel, sender); 15 | if (ret == false) return false; 16 | } 17 | 18 | return true; 19 | } 20 | 21 | export function handleIpc(args: QQNTim.IPC.Args, direction: QQNTim.IPC.Direction, channel: string, sender?: Electron.WebContents) { 22 | if (args[0]?.eventName?.startsWith("ns-LoggerApi-")) return false; 23 | return interruptIpc(args, direction, channel, sender); 24 | } 25 | 26 | export function addInterruptIpc(func: QQNTim.IPC.InterruptFunction, options?: QQNTim.IPC.InterruptIPCOptions) { 27 | interruptIpcs.push([func, options]); 28 | } 29 | 30 | export function watchIpc() { 31 | if (env.config.verboseLogging && (env.config.useNativeDevTools || (!env.config.useNativeDevTools && isMainProcess))) { 32 | (["in", "out"] as QQNTim.IPC.Direction[]).forEach((type) => { 33 | addInterruptIpc((args, channel, sender) => console.debug(`[!Watch:IPC?${type == "in" ? "In" : "Out"}${sender ? `:${sender.id.toString()}` : ""}] ${channel}`, printObject(args)), { direction: type }); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/common/loader.ts: -------------------------------------------------------------------------------- 1 | import { s } from "./sep"; 2 | 3 | const loadedPlugins: QQNTim.Plugin.LoadedPlugins = {}; 4 | 5 | function getUserPlugins(allPlugins: QQNTim.Plugin.AllUsersPlugins, uin: string) { 6 | const userPlugins = allPlugins[uin]; 7 | if (!userPlugins) { 8 | console.warn(`[!Loader] 账户 (${uin}) 没有插件,跳过加载`); 9 | return; 10 | } 11 | if (uin != "") console.log(`[!Loader] 正在为账户 (${uin}) 加载插件`); 12 | else console.log("[!Loader] 正在为所有账户加载插件"); 13 | return userPlugins; 14 | } 15 | 16 | export function loadPlugins(allPlugins: QQNTim.Plugin.AllUsersPlugins, uin: string, shouldInject: (injection: QQNTim.Plugin.Injection) => boolean, scripts: [QQNTim.Plugin, string][], stylesheets?: [QQNTim.Plugin, string][]) { 17 | const userPlugins = getUserPlugins(allPlugins, uin); 18 | if (!userPlugins) return false; 19 | 20 | for (const id in userPlugins) { 21 | if (loadedPlugins[id]) continue; 22 | const plugin = userPlugins[id]; 23 | if (!plugin.loaded) continue; 24 | loadedPlugins[id] = plugin; 25 | console.log(`[!Loader] 正在加载插件:${id}`); 26 | 27 | plugin.injections.forEach((injection) => { 28 | const rendererInjection = injection as QQNTim.Plugin.InjectionRenderer; 29 | if (!shouldInject(injection)) return; 30 | stylesheets && rendererInjection.stylesheet && stylesheets.push([plugin, `${plugin.dir}${s}${rendererInjection.stylesheet}`]); 31 | injection.script && scripts.push([plugin, `${plugin.dir}${s}${injection.script}`]); 32 | }); 33 | } 34 | 35 | return true; 36 | } 37 | -------------------------------------------------------------------------------- /src/common/patch.ts: -------------------------------------------------------------------------------- 1 | const modules: Record = {}; 2 | 3 | export function defineModules(newModules: Record) { 4 | for (const name in newModules) { 5 | if (modules[name]) throw new Error(`模块名已经被使用:${name}`); 6 | modules[name] = newModules[name]; 7 | } 8 | } 9 | 10 | export function getModule(name: string) { 11 | return modules[name]; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/paths.ts: -------------------------------------------------------------------------------- 1 | import { s } from "./sep"; 2 | 3 | export const dataDir = process.env["QQNTIM_HOME"] || (process.platform == "win32" ? `${process.env["UserProfile"]}${s}.qqntim` : `${process.env["HOME"]}${s}.local${s}share${s}QQNTim`); 4 | export const configFile = `${dataDir}${s}config.json`; 5 | export const pluginDir = `${dataDir}${s}plugins`; 6 | export const pluginPerUserDir = `${dataDir}${s}plugins-user`; 7 | -------------------------------------------------------------------------------- /src/common/sep.ts: -------------------------------------------------------------------------------- 1 | export { sep as s } from "path"; 2 | -------------------------------------------------------------------------------- /src/common/utils/console.ts: -------------------------------------------------------------------------------- 1 | import { isMainProcess } from "./process"; 2 | import supportsColor from "supports-color"; 3 | import { inspect } from "util"; 4 | 5 | export const hasColorSupport = !!supportsColor.stdout; 6 | 7 | export function printObject(object: any, enableColorSupport = isMainProcess && hasColorSupport) { 8 | return inspect(object, { 9 | compact: true, 10 | depth: null, 11 | showHidden: true, 12 | colors: enableColorSupport, 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/common/utils/freePort.ts: -------------------------------------------------------------------------------- 1 | import { AddressInfo, createServer } from "net"; 2 | 3 | export function findFreePort() { 4 | const server = createServer().listen(0); 5 | const { port } = server.address()! as AddressInfo; 6 | server.close(); 7 | return port; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/utils/ntVersion.ts: -------------------------------------------------------------------------------- 1 | import { s } from "../sep"; 2 | import { readJSONSync } from "fs-extra"; 3 | 4 | export function getCurrentNTVersion() { 5 | let version: string; 6 | if (process.platform == "win32") { 7 | version = readJSONSync(`${__dirname}${s}..${s}versions${s}config.json`)?.curVersion; 8 | } else if (process.platform == "linux" || process.platform == "darwin") { 9 | version = readJSONSync(`${__dirname}${s}..${s}package.json`)?.version; 10 | } else throw new Error(`unsupported platform: ${process.platform}`); 11 | if (!version) throw new Error("cannot determine QQNT version"); 12 | return version; 13 | } 14 | -------------------------------------------------------------------------------- /src/common/utils/process.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | 3 | export const isMainProcess = !!app as boolean; 4 | -------------------------------------------------------------------------------- /src/common/version.ts: -------------------------------------------------------------------------------- 1 | import { version } from "../../package.json"; 2 | import { getCurrentNTVersion } from "./utils/ntVersion"; 3 | 4 | export function mountVersion() { 5 | const ntVersion = getCurrentNTVersion(); 6 | Object.defineProperties(process.versions, { 7 | qqnt: { value: ntVersion, writable: false }, 8 | qqntim: { value: version, writable: false }, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/common/watch.ts: -------------------------------------------------------------------------------- 1 | import { env } from "./global"; 2 | import { printObject } from "./utils/console"; 3 | 4 | type Constructor = new (...args: any[]) => T; 5 | 6 | export function getter(scope: string | undefined, target: T, p: R) { 7 | if (p == "__qqntim_original_object") return target; 8 | if (typeof target[p] == "function") 9 | return (...argArray: any[]) => { 10 | const res = (target[p] as Function).apply(target, argArray); 11 | if (scope && env.config.verboseLogging) console.debug(`[!Watch:${scope}] 调用:${p as string}`, printObject(argArray), res != target ? printObject(res) : "[已隐藏]"); 12 | return res; 13 | }; 14 | else { 15 | const res = target[p]; 16 | if (scope && env.config.verboseLogging) console.debug(`[!Watch:${scope}] 获取:${p as string}`); 17 | return res; 18 | } 19 | } 20 | 21 | export function setter(scope: string | undefined, _: T, p: R, newValue: T[R]) { 22 | if (scope && env.config.verboseLogging) console.debug(`[!Watch:${scope}] 设置:${p as string}`, printObject(newValue)); 23 | return true; 24 | } 25 | 26 | export function construct>(scope: string | undefined, target: T, argArray: any[]) { 27 | if (scope && env.config.verboseLogging) console.debug(`[!Watch:${scope}] 构造新实例:`, printObject(argArray)); 28 | return new target(...argArray); 29 | } 30 | 31 | export function apply(target: T, thisArg: any, argArray: any[]) { 32 | return target.apply(thisArg, argArray); 33 | } 34 | -------------------------------------------------------------------------------- /src/electron/README.txt: -------------------------------------------------------------------------------- 1 | Silence is golden. 2 | -------------------------------------------------------------------------------- /src/electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron", 3 | "private": true, 4 | "version": "25.2.0" 5 | } 6 | -------------------------------------------------------------------------------- /src/main/api.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../common/global"; 2 | import { addInterruptIpc } from "../common/ipc"; 3 | import { defineModules } from "../common/patch"; 4 | import { mountVersion } from "../common/version"; 5 | import { addInterruptWindowArgs, addInterruptWindowCreation } from "./patch"; 6 | import { plugins } from "./plugins"; 7 | import * as fs from "fs-extra"; 8 | 9 | export let api: typeof QQNTim.API.Main; 10 | 11 | export function initAPI() { 12 | mountVersion(); 13 | 14 | api = { 15 | allPlugins: plugins, 16 | env: env, 17 | interrupt: { 18 | ipc: addInterruptIpc, 19 | windowCreation: addInterruptWindowCreation, 20 | windowArgs: addInterruptWindowArgs, 21 | }, 22 | defineModules: defineModules, 23 | modules: { 24 | fs: fs, 25 | }, 26 | }; 27 | 28 | defineModules({ "qqntim/main": api }); 29 | } 30 | -------------------------------------------------------------------------------- /src/main/compatibility.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../common/global"; 2 | import { existsSync } from "fs-extra"; 3 | import { resolve } from "path"; 4 | 5 | export function loadCustomLoaders() { 6 | if (env.config.disableCompatibilityProcessing) return; 7 | console.log("[!Compatibility] 正在进行兼容性处理"); 8 | env.config.pluginLoaders.map((loader: string) => { 9 | const path = resolve(__dirname, "..", loader); 10 | if (existsSync(path)) require(path); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/config.ts: -------------------------------------------------------------------------------- 1 | import { configFile, dataDir, pluginDir, pluginPerUserDir } from "../common/paths"; 2 | import * as fs from "fs-extra"; 3 | 4 | function toBoolean(item: boolean | undefined, env: string, defaultValue: boolean) { 5 | const envValue = process.env[env]; 6 | return envValue ? !!parseInt(envValue) : typeof item == "boolean" ? item : defaultValue; 7 | } 8 | 9 | function toStringArray(item: R, env: string, defaultValue: R) { 10 | const envValue = process.env[env]; 11 | return envValue ? envValue.split(";") : item && item instanceof Array ? item : defaultValue; 12 | } 13 | 14 | // function toNumberArray( 15 | // item: number[] | undefined, 16 | // env: string, 17 | // defaultValue: number[] | undefined, 18 | // isFloat = false 19 | // ) { 20 | // const envValue = process.env[env]; 21 | // return envValue 22 | // ? envValue 23 | // .split(";") 24 | // .map((value) => (isFloat ? parseFloat(value) : parseInt(value))) 25 | // : item instanceof Array 26 | // ? item 27 | // : defaultValue; 28 | // } 29 | 30 | export function getEnvironment(config: QQNTim.Configuration): QQNTim.Environment { 31 | return { 32 | config: { 33 | plugins: { 34 | whitelist: toStringArray(config.plugins?.whitelist, "QQNTIM_PLUGINS_WHITELIST", undefined), 35 | blacklist: toStringArray(config.plugins?.blacklist, "QQNTIM_PLUGINS_BLACKLIST", undefined), 36 | config: config.plugins?.config || {}, 37 | }, 38 | pluginLoaders: toStringArray(config.pluginLoaders, "QQNTIM_PLUGIN_LOADER", ["LiteLoader", "LiteLoaderQQNT"]), 39 | verboseLogging: toBoolean(config.verboseLogging, "QQNTIM_VERBOSE_LOGGING", false), 40 | useNativeDevTools: toBoolean(config.useNativeDevTools, "QQNTIM_USE_NATIVE_DEVTOOLS", false), 41 | disableCompatibilityProcessing: toBoolean(config.disableCompatibilityProcessing, "QQNTIM_NO_COMPATIBILITY_PROCESSING", false), 42 | }, 43 | path: { 44 | dataDir, 45 | configFile, 46 | pluginDir, 47 | pluginPerUserDir, 48 | }, 49 | }; 50 | } 51 | 52 | function prepareDataDir() { 53 | fs.ensureDirSync(dataDir); 54 | fs.ensureDirSync(pluginDir); 55 | fs.ensureDirSync(pluginPerUserDir); 56 | if (!fs.existsSync(configFile)) fs.writeJSONSync(configFile, {}); 57 | } 58 | 59 | export function loadConfig() { 60 | prepareDataDir(); 61 | const config = fs.readJSONSync(configFile) || {}; 62 | return getEnvironment(config); 63 | } 64 | -------------------------------------------------------------------------------- /src/main/debugger.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../common/global"; 2 | import { findFreePort } from "../common/utils/freePort"; 3 | import axios from "axios"; 4 | import { start } from "chii/server"; 5 | import { BrowserWindow } from "electron"; 6 | 7 | export let debuggerHost = ""; 8 | export let debuggerPort = -1; 9 | export let debuggerOrigin = ""; 10 | 11 | export async function initDebugger() { 12 | if (!env.config.useNativeDevTools) { 13 | debuggerPort = findFreePort(); 14 | debuggerHost = "127.0.0.1"; 15 | debuggerOrigin = `http://${debuggerHost}:${debuggerPort}`; 16 | 17 | console.log(`[!Debugger] 正在启动 chii 调试器:${debuggerOrigin}`); 18 | await start({ 19 | host: debuggerHost, 20 | port: debuggerPort, 21 | useHttps: false, 22 | }); 23 | } 24 | } 25 | 26 | async function listChiiTargets() { 27 | const res = await axios.get(`${debuggerOrigin}/targets`); 28 | return (res.data.targets as any[]).map((target) => [target.title, target.id]); 29 | } 30 | 31 | export async function createDebuggerWindow(debuggerId: string, win: BrowserWindow) { 32 | const targets = await listChiiTargets(); 33 | for (const [id, target] of targets) { 34 | if (id == debuggerId) { 35 | const url = `${debuggerOrigin}/front_end/chii_app.html?ws=${debuggerHost}:${debuggerPort}/client/${(Math.random() * 6).toString()}?target=${target}`; 36 | 37 | console.log(`[!Debugger] 正在打开 chii 调试器窗口:${url}`); 38 | 39 | const debuggerWin = new BrowserWindow({ 40 | width: 800, 41 | height: 600, 42 | show: true, 43 | webPreferences: { 44 | webSecurity: false, 45 | allowRunningInsecureContent: true, 46 | devTools: false, 47 | nodeIntegration: false, 48 | nodeIntegrationInSubFrames: false, 49 | contextIsolation: true, 50 | }, 51 | }); 52 | 53 | debuggerWin.webContents.loadURL(url); 54 | 55 | win.on("closed", () => debuggerWin.destroy()); 56 | 57 | break; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/loader.ts: -------------------------------------------------------------------------------- 1 | import { loadPlugins } from "../common/loader"; 2 | 3 | let scripts: [QQNTim.Plugin, string][] = []; 4 | 5 | function shouldInject(injection: QQNTim.Plugin.Injection) { 6 | return injection.type == "main"; 7 | } 8 | 9 | export function applyPlugins(allPlugins: QQNTim.Plugin.AllUsersPlugins, uin = "") { 10 | loadPlugins(allPlugins, uin, shouldInject, scripts); 11 | applyScripts(); 12 | 13 | return true; 14 | } 15 | 16 | function applyScripts() { 17 | scripts = scripts.filter(([plugin, script]) => { 18 | try { 19 | const mod = require(script); 20 | if (mod) new ((mod.default || mod) as typeof QQNTim.Entry.Main)(); 21 | return false; 22 | } catch (reason) { 23 | console.error(`[!Loader] 运行此插件脚本时出现意外错误:${script},请联系插件作者 (${plugin.manifest.author}) 解决`); 24 | console.error(reason); 25 | } 26 | return true; 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) Flysoft. 4 | */ 5 | 6 | import { setAllPlugins, setEnv } from "../common/global"; 7 | import { watchIpc } from "../common/ipc"; 8 | import { initAPI } from "./api"; 9 | import { loadCustomLoaders } from "./compatibility"; 10 | import { loadConfig } from "./config"; 11 | import { initDebugger } from "./debugger"; 12 | import { applyPlugins } from "./loader"; 13 | import { patchModuleLoader } from "./patch"; 14 | import { collectPlugins, plugins } from "./plugins"; 15 | 16 | setEnv(loadConfig()); 17 | initDebugger(); 18 | patchModuleLoader(); 19 | watchIpc(); 20 | loadCustomLoaders(); 21 | collectPlugins(); 22 | setAllPlugins(plugins); 23 | initAPI(); 24 | applyPlugins(plugins); 25 | console.log("[!Main] QQNTim 加载成功"); 26 | require("./index.js"); 27 | -------------------------------------------------------------------------------- /src/main/patch.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../common/global"; 2 | import { handleIpc } from "../common/ipc"; 3 | import { defineModules, getModule } from "../common/patch"; 4 | import { s } from "../common/sep"; 5 | import { hasColorSupport } from "../common/utils/console"; 6 | import { apply, construct, getter, setter } from "../common/watch"; 7 | import { createDebuggerWindow, debuggerOrigin } from "./debugger"; 8 | import { applyPlugins } from "./loader"; 9 | import { plugins } from "./plugins"; 10 | import { enable, initialize } from "@electron/remote/main"; 11 | import { BrowserWindow, Menu, MenuItem, app, dialog, ipcMain } from "electron"; 12 | import { Module } from "module"; 13 | 14 | const interruptWindowArgs: QQNTim.WindowCreation.InterruptArgsFunction[] = []; 15 | const interruptWindowCreation: QQNTim.WindowCreation.InterruptFunction[] = []; 16 | 17 | ipcMain.on("___!boot", (event) => { 18 | if (!event.returnValue) event.returnValue = { enabled: false }; 19 | }); 20 | 21 | ipcMain.on("___!log", (event, level, ...args) => { 22 | console[{ 0: "debug", 1: "log", 2: "info", 3: "warn", 4: "error" }[level] || "log"](`[!Renderer:Log:${event.sender.id}]`, ...args); 23 | }); 24 | 25 | ipcMain.handle("___!dialog", (event, method: string, options: object) => dialog[method](BrowserWindow.fromWebContents(event.sender), options)); 26 | 27 | function patchBrowserWindow() { 28 | const windowMenu: Electron.MenuItem[] = [ 29 | new MenuItem({ 30 | label: "刷新", 31 | role: "reload", 32 | accelerator: "F5", 33 | }), 34 | new MenuItem({ 35 | label: "开发者工具", 36 | accelerator: "F12", 37 | ...(env.config.useNativeDevTools 38 | ? { role: "toggleDevTools" } 39 | : { 40 | click: (_, win) => { 41 | if (!win) return; 42 | const debuggerId = win.webContents.id.toString(); 43 | createDebuggerWindow(debuggerId, win); 44 | }, 45 | }), 46 | }), 47 | ]; 48 | return new Proxy(BrowserWindow, { 49 | apply(target, thisArg, argArray) { 50 | return apply(target, thisArg, argArray); 51 | }, 52 | get(target, p) { 53 | return getter(undefined, target, p as any); 54 | }, 55 | set(target, p, newValue) { 56 | return setter(undefined, target, p as any, newValue); 57 | }, 58 | construct(target, [options]: [Electron.BrowserWindowConstructorOptions]) { 59 | let patchedArgs: Electron.BrowserWindowConstructorOptions = { 60 | ...options, 61 | webPreferences: { 62 | ...options.webPreferences, 63 | preload: `${__dirname}${s}qqntim-renderer.js`, 64 | webSecurity: false, 65 | allowRunningInsecureContent: true, 66 | nodeIntegration: true, 67 | nodeIntegrationInSubFrames: true, 68 | contextIsolation: false, 69 | devTools: env.config.useNativeDevTools, 70 | sandbox: false, 71 | }, 72 | }; 73 | interruptWindowArgs.forEach((func) => { 74 | patchedArgs = func(patchedArgs); 75 | }); 76 | const win = construct("BrowserWindow", target, [patchedArgs]) as BrowserWindow; 77 | 78 | const webContentsId = win.webContents.id.toString(); 79 | 80 | let thirdpartyPreloads: string[] = win.webContents.session.getPreloads(); 81 | win.webContents.session.setPreloads([]); 82 | enable(win.webContents); 83 | 84 | const session = new Proxy(win.webContents.session, { 85 | get(target, p) { 86 | const res = getter(undefined, target, p as any); 87 | if (p == "setPreloads") 88 | return (newPreloads: string[]) => { 89 | thirdpartyPreloads = newPreloads; 90 | }; 91 | return res; 92 | }, 93 | set(target, p, newValue) { 94 | return setter(undefined, target, p as any, newValue); 95 | }, 96 | }); 97 | const webContents = new Proxy(win.webContents, { 98 | get(target, p) { 99 | const res = getter(undefined, target, p as any); 100 | if (p == "session") return session; 101 | return res; 102 | }, 103 | set(target, p, newValue) { 104 | return setter(undefined, target, p as any, newValue); 105 | }, 106 | }); 107 | 108 | const send = win.webContents.send; 109 | win.webContents.send = (channel: string, ...args: QQNTim.IPC.Args) => { 110 | handleIpc(args, "out", channel); 111 | return send.call(win.webContents, channel, ...args); 112 | }; 113 | win.webContents.on("ipc-message", (_, channel, ...args) => { 114 | if (!handleIpc(args as any, "in", channel, win.webContents)) throw new Error("QQNTim 已强行中断了一条 IPC 消息"); 115 | if (channel == "___!apply_plugins") applyPlugins(plugins, args[0] as string); 116 | }); 117 | win.webContents.on("ipc-message-sync", (event, channel, ...args) => { 118 | handleIpc(args as any, "in", channel, win.webContents); 119 | if (channel == "___!boot") { 120 | event.returnValue = { 121 | enabled: true, 122 | preload: Array.from(new Set([...thirdpartyPreloads, options.webPreferences?.preload].filter(Boolean))), 123 | debuggerOrigin: !env.config.useNativeDevTools && debuggerOrigin, 124 | webContentsId: webContentsId, 125 | plugins: plugins, 126 | env: env, 127 | hasColorSupport: hasColorSupport, 128 | }; 129 | } else if (channel == "___!browserwindow_api") { 130 | event.returnValue = win[args[0][0]](...args[0][1]); 131 | } else if (channel == "___!app_api") { 132 | event.returnValue = app[args[0][0]](...args[0][1]); 133 | } 134 | }); 135 | 136 | const setMenu = win.setMenu; 137 | win.setMenu = (menu) => { 138 | const patchedMenu = Menu.buildFromTemplate([...(menu?.items || []), ...windowMenu]); 139 | return setMenu.call(win, patchedMenu); 140 | }; 141 | win.setMenu(null); 142 | 143 | return new Proxy(win, { 144 | get(target, p) { 145 | const res = getter(undefined, target, p as any); 146 | if (p == "webContents") return webContents; 147 | return res; 148 | }, 149 | set(target, p, newValue) { 150 | return setter(undefined, target, p as any, newValue); 151 | }, 152 | }); 153 | }, 154 | }); 155 | } 156 | 157 | export function addInterruptWindowCreation(func: QQNTim.WindowCreation.InterruptFunction) { 158 | interruptWindowCreation.push(func); 159 | } 160 | 161 | export function addInterruptWindowArgs(func: QQNTim.WindowCreation.InterruptArgsFunction) { 162 | interruptWindowArgs.push(func); 163 | } 164 | 165 | export function patchModuleLoader() { 166 | // 阻止 Electron 默认菜单生成 167 | Menu.setApplicationMenu(null); 168 | initialize(); 169 | 170 | const patchedElectron: typeof Electron.CrossProcessExports = { 171 | ...require("electron"), 172 | BrowserWindow: patchBrowserWindow(), 173 | }; 174 | 175 | defineModules({ electron: patchedElectron }); 176 | 177 | const loadBackend = (Module as any)._load; 178 | (Module as any)._load = (request: string, parent: NodeModule, isMain: boolean) => { 179 | return getModule(request) || loadBackend(request, parent, isMain); 180 | }; 181 | } 182 | -------------------------------------------------------------------------------- /src/main/plugins.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../common/global"; 2 | import { s } from "../common/sep"; 3 | import * as fs from "fs-extra"; 4 | import * as os from "os"; 5 | import * as semver from "semver"; 6 | 7 | const supportedManifestVersions: QQNTim.Manifest.ManifestVersion[] = ["3.0"]; 8 | export const plugins: QQNTim.Plugin.AllUsersPlugins = {}; 9 | 10 | function isPluginEnabled(manifest: QQNTim.Manifest) { 11 | if (env.config.plugins.whitelist) return env.config.plugins.whitelist.includes(manifest.id); 12 | else if (env.config.plugins.blacklist) return !env.config.plugins.blacklist.includes(manifest.id); 13 | else return true; 14 | } 15 | 16 | function isPluginRequirementsMet(manifest: QQNTim.Manifest) { 17 | if (manifest.requirements?.os) { 18 | let meetRequirements = false; 19 | const osRelease = os.release(); 20 | for (const item of manifest.requirements.os) { 21 | if (item.platform != process.platform) continue; 22 | if (item.lte && !semver.lte(item.lte, osRelease)) continue; 23 | if (item.lt && !semver.lt(item.lt, osRelease)) continue; 24 | if (item.gte && !semver.gte(item.gte, osRelease)) continue; 25 | if (item.gt && !semver.gt(item.gt, osRelease)) continue; 26 | if (item.eq && !semver.eq(item.eq, osRelease)) continue; 27 | meetRequirements = true; 28 | break; 29 | } 30 | if (!meetRequirements) { 31 | return false; 32 | } 33 | } 34 | 35 | return true; 36 | } 37 | 38 | export function parsePlugin(dir: string) { 39 | try { 40 | const manifestFile = `${dir}${s}qqntim.json`; 41 | if (!fs.existsSync(manifestFile)) return null; 42 | const manifest = fs.readJSONSync(manifestFile) as QQNTim.Manifest; 43 | if (!manifest.manifestVersion) manifest.manifestVersion = supportedManifestVersions[0]; 44 | else if (!supportedManifestVersions.includes(manifest.manifestVersion)) throw new TypeError(`此插件包含一个无效的清单版本:${manifest.manifestVersion},支持的版本有:${supportedManifestVersions.join(", ")}`); 45 | 46 | const meetRequirements = isPluginRequirementsMet(manifest); 47 | const enabled = isPluginEnabled(manifest); 48 | const loaded = meetRequirements && enabled; 49 | if (!meetRequirements) console.error(`[!Plugins] 跳过加载插件:${manifest.id}(当前环境不满足要求)`); 50 | else if (!enabled) console.error(`[!Plugins] 跳过加载插件:${manifest.id}(插件已被禁用)`); 51 | 52 | return { 53 | enabled: enabled, 54 | meetRequirements: meetRequirements, 55 | loaded: loaded, 56 | id: manifest.id, 57 | dir: dir, 58 | injections: manifest.injections.map((injection) => { 59 | return injection.type == "main" 60 | ? { ...injection } 61 | : { 62 | ...injection, 63 | pattern: injection.pattern && new RegExp(injection.pattern), 64 | }; 65 | }), 66 | manifest: manifest, 67 | } as QQNTim.Plugin; 68 | } catch (reason) { 69 | console.error("[!Plugins] 解析插件时出现意外错误:", dir); 70 | console.error(reason); 71 | return null; 72 | } 73 | } 74 | 75 | function collectPluginsFromDir(baseDir: string, uin = "") { 76 | const folders = fs.readdirSync(baseDir); 77 | if (!plugins[uin]) plugins[uin] = {}; 78 | folders.forEach((folder) => { 79 | const folderPath = `${baseDir}${s}${folder}`; 80 | if (fs.existsSync(folderPath) && fs.statSync(folderPath).isDirectory()) { 81 | const plugin = parsePlugin(folderPath); 82 | if (!plugin) return; 83 | if (plugins[uin][plugin.id]) return; 84 | plugins[uin][plugin.id] = plugin; 85 | } 86 | }); 87 | } 88 | 89 | export function collectPlugins() { 90 | collectPluginsFromDir(`${__dirname}${s}builtins`); 91 | collectPluginsFromDir(env.path.pluginDir); 92 | const folders = fs.readdirSync(env.path.pluginPerUserDir); 93 | folders.forEach((folder) => { 94 | const folderPath = `${env.path.pluginPerUserDir}${s}${folder}`; 95 | if (fs.statSync(folderPath).isDirectory()) { 96 | collectPluginsFromDir(folderPath, folder); 97 | } 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /src/qqntim-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/renderer/api/app.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | 3 | class AppAPI implements QQNTim.API.Renderer.AppAPI { 4 | relaunch() { 5 | ipcRenderer.sendSync("___!app_api", ["relaunch", []]); 6 | this.quit(); 7 | } 8 | 9 | quit() { 10 | ipcRenderer.sendSync("___!app_api", ["quit", []]); 11 | } 12 | 13 | exit() { 14 | ipcRenderer.sendSync("___!app_api", ["exit", []]); 15 | } 16 | } 17 | 18 | export const appApi = new AppAPI(); 19 | -------------------------------------------------------------------------------- /src/renderer/api/browserWindow.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | 3 | class BrowserWindowAPI implements QQNTim.API.Renderer.BrowserWindowAPI { 4 | setSize(width: number, height: number) { 5 | ipcRenderer.sendSync( 6 | "___!browserwindow_api", 7 | { 8 | eventName: "QQNTIM_BROWSERWINDOW_API", 9 | }, 10 | ["setSize", [width, height]], 11 | ); 12 | } 13 | 14 | setMinimumSize(width: number, height: number) { 15 | ipcRenderer.sendSync( 16 | "___!browserwindow_api", 17 | { 18 | eventName: "QQNTIM_BROWSERWINDOW_API", 19 | }, 20 | ["setMinimumSize", [width, height]], 21 | ); 22 | } 23 | } 24 | 25 | export const browserWindowApi = new BrowserWindowAPI(); 26 | -------------------------------------------------------------------------------- /src/renderer/api/dialog.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | 3 | class DialogAPI implements QQNTim.API.Renderer.DialogAPI { 4 | async confirm(message = "") { 5 | const res = await this.messageBox({ message, buttons: ["确定", "取消"], defaultId: 0, type: "question" }); 6 | return res.response == 0; 7 | } 8 | 9 | async alert(message = "") { 10 | await this.messageBox({ message, buttons: ["确定"], defaultId: 0, type: "info" }); 11 | return; 12 | } 13 | 14 | messageBox(options: Electron.MessageBoxOptions): Promise { 15 | return ipcRenderer.invoke("___!dialog", "showMessageBox", options); 16 | } 17 | 18 | openDialog(options: Electron.OpenDialogOptions): Promise { 19 | return ipcRenderer.invoke("___!dialog", "showOpenDialog", options); 20 | } 21 | 22 | saveDialog(options: Electron.SaveDialogOptions): Promise { 23 | return ipcRenderer.invoke("___!dialog", "showSaveDialog", options); 24 | } 25 | } 26 | 27 | export const dialogApi = new DialogAPI(); 28 | -------------------------------------------------------------------------------- /src/renderer/api/getVueId.ts: -------------------------------------------------------------------------------- 1 | export function getVueId(element: HTMLElement) { 2 | let vueId: string | undefined; 3 | 4 | for (const item in element.dataset) { 5 | if (item.startsWith("v")) 6 | vueId = `data-${item 7 | .split("") 8 | .map((item) => { 9 | const low = item.toLocaleLowerCase(); 10 | if (low != item) return `-${low}`; 11 | else return low; 12 | }) 13 | .join("")}`; 14 | } 15 | 16 | return vueId; 17 | } 18 | -------------------------------------------------------------------------------- /src/renderer/api/index.ts: -------------------------------------------------------------------------------- 1 | import { allPlugins, env } from "../../common/global"; 2 | import { addInterruptIpc } from "../../common/ipc"; 3 | import { defineModules } from "../../common/patch"; 4 | import { mountVersion } from "../../common/version"; 5 | import { appApi } from "./app"; 6 | import { browserWindowApi } from "./browserWindow"; 7 | import { dialogApi } from "./dialog"; 8 | import { getVueId } from "./getVueId"; 9 | import { nt } from "./nt"; 10 | import { ntCall } from "./nt/call"; 11 | import { waitForElement } from "./waitForElement"; 12 | import { windowLoadPromise } from "./windowLoadPromise"; 13 | import * as fs from "fs-extra"; 14 | 15 | export let api: typeof QQNTim.API.Renderer; 16 | 17 | export function initAPI() { 18 | mountVersion(); 19 | nt.init(); 20 | 21 | api = { 22 | allPlugins: allPlugins, 23 | env: env, 24 | interrupt: { 25 | ipc: addInterruptIpc, 26 | }, 27 | nt: nt, 28 | browserWindow: browserWindowApi, 29 | app: appApi, 30 | dialog: dialogApi, 31 | modules: { 32 | fs: fs, 33 | }, 34 | defineModules: defineModules, 35 | utils: { 36 | waitForElement: waitForElement, 37 | getVueId: getVueId, 38 | ntCall: ntCall, 39 | }, 40 | windowLoadPromise: windowLoadPromise, 41 | }; 42 | 43 | defineModules({ "qqntim/renderer": api }); 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/api/nt/call.ts: -------------------------------------------------------------------------------- 1 | import { addInterruptIpc } from "../../../common/ipc"; 2 | import { webContentsId } from "../../main"; 3 | import { randomUUID } from "crypto"; 4 | import { ipcRenderer } from "electron"; 5 | 6 | class NTCallError extends Error { 7 | public code: number; 8 | public message: string; 9 | constructor(code: number, message: string) { 10 | super(); 11 | this.code = code; 12 | this.message = message; 13 | } 14 | } 15 | 16 | const pendingCallbacks: Record = {}; 17 | 18 | addInterruptIpc( 19 | (args) => { 20 | const id = args[0].callbackId; 21 | if (pendingCallbacks[id]) { 22 | pendingCallbacks[id](args); 23 | delete pendingCallbacks[id]; 24 | return false; 25 | } 26 | }, 27 | { 28 | direction: "in", 29 | }, 30 | ); 31 | 32 | export function ntCall(eventName: string, cmdName: string, args: any[], isRegister = false) { 33 | return new Promise((resolve, reject) => { 34 | const uuid = randomUUID(); 35 | pendingCallbacks[uuid] = (args: QQNTim.IPC.Args) => { 36 | if (args[1] && args[1].result != undefined && args[1].result != 0) reject(new NTCallError(args[1].result, args[1].errMsg)); 37 | else resolve(args[1]); 38 | }; 39 | ipcRenderer.send( 40 | `IPC_UP_${webContentsId}`, 41 | { 42 | type: "request", 43 | callbackId: uuid, 44 | eventName: `${eventName}-${webContentsId}${isRegister ? "-register" : ""}`, 45 | }, 46 | [cmdName, ...args], 47 | ); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/renderer/api/nt/constructor.ts: -------------------------------------------------------------------------------- 1 | import { ntMedia } from "./media"; 2 | 3 | export function constructTextElement(ele: any): QQNTim.API.Renderer.NT.MessageElementText { 4 | return { 5 | type: "text", 6 | content: ele.textElement.content, 7 | raw: ele, 8 | }; 9 | } 10 | 11 | export function constructImageElement(ele: any, msg: any): QQNTim.API.Renderer.NT.MessageElementImage { 12 | return { 13 | type: "image", 14 | file: ele.picElement.sourcePath, 15 | downloadedPromise: ntMedia.downloadMedia(msg.msgId, ele.elementId, msg.peerUid, msg.chatType, ele.picElement.thumbPath.get(0), ele.picElement.sourcePath), 16 | raw: ele, 17 | }; 18 | } 19 | export function constructFaceElement(ele: any): QQNTim.API.Renderer.NT.MessageElementFace { 20 | return { 21 | type: "face", 22 | faceIndex: ele.faceElement.faceIndex, 23 | faceType: ele.faceElement.faceType == 1 ? "normal" : ele.faceElement.faceType == 2 ? "normal-extended" : ele.faceElement.faceType == 3 ? "super" : ele.faceElement.faceType, 24 | faceSuperIndex: ele.faceElement.stickerId && parseInt(ele.faceElement.stickerId), 25 | raw: ele, 26 | }; 27 | } 28 | export function constructRawElement(ele: any): QQNTim.API.Renderer.NT.MessageElementRaw { 29 | return { 30 | type: "raw", 31 | raw: ele, 32 | }; 33 | } 34 | export function constructMessage(msg: any): QQNTim.API.Renderer.NT.Message { 35 | const downloadedPromises: Promise[] = []; 36 | const elements = (msg.elements as any[]).map((ele): QQNTim.API.Renderer.NT.MessageElement => { 37 | if (ele.elementType == 1) return constructTextElement(ele); 38 | else if (ele.elementType == 2) { 39 | const element = constructImageElement(ele, msg); 40 | downloadedPromises.push(element.downloadedPromise); 41 | return element; 42 | } else if (ele.elementType == 6) return constructFaceElement(ele); 43 | else return constructRawElement(ele); 44 | }); 45 | return { 46 | allDownloadedPromise: Promise.all(downloadedPromises), 47 | peer: { 48 | uid: msg.peerUid, 49 | name: msg.peerName, 50 | chatType: msg.chatType == 1 ? "friend" : msg.chatType == 2 ? "group" : "others", 51 | }, 52 | sender: { 53 | uid: msg.senderUid, 54 | memberName: msg.sendMemberName || msg.sendNickName, 55 | nickName: msg.sendNickName, 56 | }, 57 | elements: elements, 58 | raw: msg, 59 | }; 60 | } 61 | export function constructUser(user: any): QQNTim.API.Renderer.NT.User { 62 | return { 63 | uid: user.uid, 64 | qid: user.qid, 65 | uin: user.uin, 66 | avatarUrl: user.avatarUrl, 67 | nickName: user.nick, 68 | bio: user.longNick, 69 | sex: { 1: "male", 2: "female", 255: "unset", 0: "unset" }[user.sex] || "others", 70 | raw: user, 71 | }; 72 | } 73 | export function constructGroup(group: any): QQNTim.API.Renderer.NT.Group { 74 | return { 75 | uid: group.groupCode, 76 | avatarUrl: group.avatarUrl, 77 | name: group.groupName, 78 | role: { 4: "master", 3: "moderator", 2: "member" }[group.memberRole] || "others", 79 | maxMembers: group.maxMember, 80 | members: group.memberCount, 81 | raw: group, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/renderer/api/nt/destructor.ts: -------------------------------------------------------------------------------- 1 | export function destructTextElement(element: QQNTim.API.Renderer.NT.MessageElementText) { 2 | return { 3 | elementType: 1, 4 | elementId: "", 5 | textElement: { 6 | content: element.content, 7 | atType: 0, 8 | atUid: "", 9 | atTinyId: "", 10 | atNtUid: "", 11 | }, 12 | }; 13 | } 14 | 15 | export function destructImageElement(element: QQNTim.API.Renderer.NT.MessageElementImage, picElement: any) { 16 | return { 17 | elementType: 2, 18 | elementId: "", 19 | picElement: picElement, 20 | }; 21 | } 22 | 23 | export function destructFaceElement(element: QQNTim.API.Renderer.NT.MessageElementFace) { 24 | return { 25 | elementType: 6, 26 | elementId: "", 27 | faceElement: { 28 | faceIndex: element.faceIndex, 29 | faceType: element.faceType == "normal" ? 1 : element.faceType == "normal-extended" ? 2 : element.faceType == "super" ? 3 : element.faceType, 30 | ...((element.faceType == "super" || element.faceType == 3) && { 31 | packId: "1", 32 | stickerId: (element.faceSuperIndex || "0").toString(), 33 | stickerType: 1, 34 | sourceType: 1, 35 | resultId: "", 36 | superisedId: "", 37 | randomType: 1, 38 | }), 39 | }, 40 | }; 41 | } 42 | 43 | export function destructRawElement(element: QQNTim.API.Renderer.NT.MessageElementRaw) { 44 | return element.raw; 45 | } 46 | 47 | export function destructPeer(peer: QQNTim.API.Renderer.NT.Peer) { 48 | return { 49 | chatType: peer.chatType == "friend" ? 1 : peer.chatType == "group" ? 2 : 1, 50 | peerUid: peer.uid, 51 | guildId: "", 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/renderer/api/nt/index.ts: -------------------------------------------------------------------------------- 1 | import { addInterruptIpc } from "../../../common/ipc"; 2 | import { ntCall } from "./call"; 3 | import { constructGroup, constructMessage, constructUser } from "./constructor"; 4 | import { destructFaceElement, destructImageElement, destructPeer, destructRawElement, destructTextElement } from "./destructor"; 5 | import { ntMedia } from "./media"; 6 | import { NTWatcher } from "./watcher"; 7 | import { EventEmitter } from "events"; 8 | 9 | const NTEventEmitter = EventEmitter as new () => QQNTim.API.Renderer.NT.EventEmitter; 10 | class NT extends NTEventEmitter implements QQNTim.API.Renderer.NT { 11 | private sentMessageWatcher: NTWatcher; 12 | private profileChangeWatcher: NTWatcher; 13 | private friendsList: QQNTim.API.Renderer.NT.User[] = []; 14 | private groupsList: QQNTim.API.Renderer.NT.Group[] = []; 15 | 16 | public init() { 17 | this.listenNewMessages(); 18 | this.listenContactListChange(); 19 | ntMedia.init(); 20 | this.sentMessageWatcher = new NTWatcher((args) => args?.[1]?.[0]?.payload?.msgRecord?.peerUid, "ns-ntApi", "nodeIKernelMsgListener/onAddSendMsg", "in", "request"); 21 | this.profileChangeWatcher = new NTWatcher((args) => args?.[1]?.[0]?.payload?.profiles?.keys()?.next()?.value, "ns-ntApi", "nodeIKernelProfileListener/onProfileSimpleChanged", "in", "request"); 22 | } 23 | 24 | private listenNewMessages() { 25 | addInterruptIpc( 26 | (args) => { 27 | const messages = (args?.[1]?.[0]?.payload?.msgList as any[]).map((msg): QQNTim.API.Renderer.NT.Message => constructMessage(msg)); 28 | this.emit("new-messages", messages); 29 | }, 30 | { eventName: "ns-ntApi", cmdName: "nodeIKernelMsgListener/onRecvMsg", direction: "in", type: "request" }, 31 | ); 32 | } 33 | 34 | private listenContactListChange() { 35 | addInterruptIpc( 36 | (args) => { 37 | this.friendsList = []; 38 | ((args?.[1]?.[0]?.payload?.data || []) as any[]).forEach((category) => this.friendsList.push(...((category?.buddyList || []) as any[]).map((friend) => constructUser(friend)))); 39 | this.emit("friends-list-updated", this.friendsList); 40 | }, 41 | { eventName: "ns-ntApi", cmdName: "nodeIKernelBuddyListener/onBuddyListChange", direction: "in", type: "request" }, 42 | ); 43 | addInterruptIpc( 44 | (args) => { 45 | this.groupsList = ((args[1]?.[0]?.payload?.groupList || []) as any[]).map((group) => constructGroup(group)); 46 | this.emit("groups-list-updated", this.groupsList); 47 | }, 48 | { eventName: "ns-ntApi", cmdName: "nodeIKernelGroupListener/onGroupListUpdate", direction: "in", type: "request" }, 49 | ); 50 | } 51 | 52 | async getAccountInfo(): Promise { 53 | return await ntCall("ns-BusinessApi", "fetchAuthData", []).then((data) => { 54 | if (!data) return; 55 | return { uid: data.uid, uin: data.uin } as QQNTim.API.Renderer.NT.LoginAccount; 56 | }); 57 | } 58 | 59 | async getUserInfo(uid: string): Promise { 60 | ntCall("ns-ntApi", "nodeIKernelProfileService/getUserDetailInfo", [{ uid: uid }, undefined]); 61 | return await this.profileChangeWatcher.wait(uid).then((args) => constructUser(args?.[1]?.[0]?.payload?.profiles?.get(uid))); 62 | } 63 | 64 | async revokeMessage(peer: QQNTim.API.Renderer.NT.Peer, message: string) { 65 | await ntCall("ns-ntApi", "nodeIKernelMsgService/recallMsg", [ 66 | { 67 | peer: destructPeer(peer), 68 | msgIds: [message], 69 | }, 70 | ]); 71 | } 72 | 73 | async sendMessage(peer: QQNTim.API.Renderer.NT.Peer, elements: QQNTim.API.Renderer.NT.MessageElement[]) { 74 | ntCall("ns-ntApi", "nodeIKernelMsgService/sendMsg", [ 75 | { 76 | msgId: "0", 77 | peer: destructPeer(peer), 78 | msgElements: await Promise.all( 79 | elements.map(async (element) => { 80 | if (element.type == "text") return destructTextElement(element); 81 | else if (element.type == "image") return destructImageElement(element, await ntMedia.prepareImageElement(element.file)); 82 | else if (element.type == "face") return destructFaceElement(element); 83 | else if (element.type == "raw") return destructRawElement(element); 84 | else return null; 85 | }), 86 | ), 87 | }, 88 | null, 89 | ]); 90 | return await this.sentMessageWatcher.wait(peer.uid).then((args) => args?.[1]?.[0]?.payload?.msgRecord?.msgId); 91 | } 92 | 93 | async getFriendsList(forced: boolean) { 94 | ntCall("ns-ntApi", "nodeIKernelBuddyService/getBuddyList", [{ force_update: forced }, undefined]); 95 | return await new Promise((resolve) => { 96 | this.once("friends-list-updated", (list) => resolve(list)); 97 | }); 98 | } 99 | 100 | async getGroupsList(forced: boolean) { 101 | ntCall("ns-ntApi", "nodeIKernelGroupService/getGroupList", [{ forceFetch: forced }, undefined]); 102 | return await new Promise((resolve) => { 103 | this.once("groups-list-updated", (list) => resolve(list)); 104 | }); 105 | } 106 | 107 | async getPreviousMessages(peer: QQNTim.API.Renderer.NT.Peer, count = 20, startMsgId = "0") { 108 | try { 109 | const msgs = await ntCall("ns-ntApi", "nodeIKernelMsgService/getMsgsIncludeSelf", [ 110 | { 111 | peer: destructPeer(peer), 112 | msgId: startMsgId, 113 | cnt: count, 114 | queryOrder: true, 115 | }, 116 | undefined, 117 | ]); 118 | const messages = (msgs.msgList as any[]).map((msg) => constructMessage(msg)); 119 | return messages; 120 | } catch { 121 | return []; 122 | } 123 | } 124 | } 125 | 126 | export const nt = new NT(); 127 | -------------------------------------------------------------------------------- /src/renderer/api/nt/media.ts: -------------------------------------------------------------------------------- 1 | import { ntCall } from "./call"; 2 | import { NTWatcher } from "./watcher"; 3 | import { exists } from "fs-extra"; 4 | 5 | class NTMedia { 6 | private mediaDownloadWatcher: NTWatcher; 7 | private registerEventsPromise: Promise; 8 | public init() { 9 | this.mediaDownloadWatcher = new NTWatcher((args) => args[1][0].payload?.notifyInfo?.msgElementId, "ns-ntApi", "nodeIKernelMsgListener/onRichMediaDownloadComplete", "in", "request"); 10 | this.registerEventsPromise = ntCall("ns-ntApi", "nodeIKernelMsgListener/onRichMediaDownloadComplete", [], true); 11 | } 12 | public async prepareImageElement(file: string) { 13 | const type = await ntCall("ns-fsApi", "getFileType", [file]); 14 | const md5 = await ntCall("ns-fsApi", "getFileMd5", [file]); 15 | const fileName = `${md5}.${type.ext}`; 16 | const filePath = await ntCall("ns-ntApi", "nodeIKernelMsgService/getRichMediaFilePath", [ 17 | { 18 | md5HexStr: md5, 19 | fileName: fileName, 20 | elementType: 2, 21 | elementSubType: 0, 22 | thumbSize: 0, 23 | needCreate: true, 24 | fileType: 1, 25 | }, 26 | ]); 27 | await ntCall("ns-fsApi", "copyFile", [{ fromPath: file, toPath: filePath }]); 28 | const imageSize = await ntCall("ns-fsApi", "getImageSizeFromPath", [file]); 29 | const fileSize = await ntCall("ns-fsApi", "getFileSize", [file]); 30 | return { 31 | md5HexStr: md5, 32 | fileSize: fileSize, 33 | picWidth: imageSize.width, 34 | picHeight: imageSize.height, 35 | fileName: fileName, 36 | sourcePath: filePath, 37 | original: true, 38 | picType: 1001, 39 | picSubType: 0, 40 | fileUuid: "", 41 | fileSubId: "", 42 | thumbFileSize: 0, 43 | summary: "", 44 | }; 45 | } 46 | public async downloadMedia(msgId: string, elementId: string, peerUid: string, chatType: number, filePath: string, originalFilePath: string) { 47 | if (await exists(originalFilePath)) return; 48 | await this.registerEventsPromise; 49 | ntCall("ns-ntApi", "nodeIKernelMsgService/downloadRichMedia", [ 50 | { 51 | getReq: { 52 | msgId: msgId, 53 | chatType: chatType, 54 | peerUid: peerUid, 55 | elementId: elementId, 56 | thumbSize: 0, 57 | downloadType: 2, 58 | filePath: filePath, 59 | }, 60 | }, 61 | undefined, 62 | ]); 63 | return await this.mediaDownloadWatcher.wait(elementId).then(() => undefined); 64 | } 65 | } 66 | 67 | export const ntMedia = new NTMedia(); 68 | -------------------------------------------------------------------------------- /src/renderer/api/nt/watcher.ts: -------------------------------------------------------------------------------- 1 | import { addInterruptIpc } from "../../../common/ipc"; 2 | 3 | export class NTWatcher { 4 | private pendingList = {} as Record; 5 | constructor(getId: (args: QQNTim.IPC.Args) => T, eventName: string, cmdName: string, direction?: QQNTim.IPC.Direction, type?: QQNTim.IPC.Type) { 6 | addInterruptIpc( 7 | (args) => { 8 | const id = getId(args); 9 | if (this.pendingList[id]) { 10 | this.pendingList[id](args); 11 | delete this.pendingList[id]; 12 | return false; 13 | } 14 | }, 15 | { type: type, eventName: eventName, cmdName: cmdName, direction: direction }, 16 | ); 17 | } 18 | wait(id: T) { 19 | return new Promise>((resolve) => { 20 | this.pendingList[id] = (args: QQNTim.IPC.Args) => { 21 | resolve(args); 22 | }; 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/api/waitForElement.ts: -------------------------------------------------------------------------------- 1 | import { windowLoadPromise } from "./windowLoadPromise"; 2 | 3 | let waitForElementSelectors: [string, (element: Element) => void][] = []; 4 | 5 | windowLoadPromise.then(() => 6 | new MutationObserver(() => refreshStatus()).observe(document.documentElement, { 7 | childList: true, 8 | subtree: true, 9 | }), 10 | ); 11 | 12 | export function refreshStatus() { 13 | waitForElementSelectors = waitForElementSelectors.filter(([selector, callback]) => { 14 | const element = document.querySelector(selector); 15 | element && callback(element); 16 | return !element; 17 | }); 18 | } 19 | 20 | export function waitForElement(selector: string) { 21 | return new Promise((resolve) => { 22 | waitForElementSelectors.push([ 23 | selector, 24 | (element) => { 25 | resolve(element as T); 26 | }, 27 | ]); 28 | refreshStatus(); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/api/windowLoadPromise.ts: -------------------------------------------------------------------------------- 1 | export const windowLoadPromise = new Promise((resolve) => window.addEventListener("load", () => resolve())); 2 | -------------------------------------------------------------------------------- /src/renderer/debugger.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../common/global"; 2 | import { debuggerOrigin, webContentsId } from "./main"; 3 | 4 | export function attachDebugger() { 5 | if (!env.config.useNativeDevTools) 6 | window.addEventListener("DOMContentLoaded", () => { 7 | // 将标题临时改为当前 WebContents ID 用于标识此窗口 8 | const oldTitle = document.title; 9 | document.title = webContentsId; 10 | window.addEventListener("load", () => { 11 | document.title = oldTitle == "" ? "QQ" : oldTitle; 12 | }); 13 | 14 | const scriptTag = document.createElement("script"); 15 | scriptTag.src = `${debuggerOrigin}/target.js`; 16 | document.head.appendChild(scriptTag); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/loader.ts: -------------------------------------------------------------------------------- 1 | import { loadPlugins } from "../common/loader"; 2 | import { windowLoadPromise } from "./api/windowLoadPromise"; 3 | import { ipcRenderer } from "electron"; 4 | import * as fs from "fs-extra"; 5 | 6 | let scripts: [QQNTim.Plugin, string][] = []; 7 | const stylesheets: [QQNTim.Plugin, string][] = []; 8 | 9 | function detectCurrentPage(): QQNTim.Manifest.PageWithAbout { 10 | const url = window.location.href; 11 | for (const [keyword, name] of [ 12 | ["login", "login"], 13 | ["main", "main"], 14 | ["chat", "chat"], 15 | ["setting", "settings"], 16 | ["about", "about"], 17 | ] as [string, QQNTim.Manifest.PageWithAbout][]) { 18 | if (url.includes(keyword)) return name; 19 | } 20 | return "others"; 21 | } 22 | 23 | function shouldInject(injection: QQNTim.Plugin.Injection, page: QQNTim.Manifest.Page) { 24 | return injection.type == "renderer" && (!injection.pattern || injection.pattern.test(window.location.href)) && (!injection.page || injection.page.includes(page)); 25 | } 26 | 27 | export function applyPlugins(allPlugins: QQNTim.Plugin.AllUsersPlugins, uin = "") { 28 | const page = detectCurrentPage(); 29 | if (page == "about") return false; 30 | 31 | loadPlugins(allPlugins, uin, (injection) => shouldInject(injection, page), scripts, stylesheets); 32 | applyScripts(); 33 | 34 | windowLoadPromise.then(() => applyStylesheets()); 35 | 36 | if (uin != "") ipcRenderer.send("___!apply_plugins", uin); 37 | 38 | return true; 39 | } 40 | 41 | function applyStylesheets() { 42 | console.log("[!Loader] 正在注入 CSS", stylesheets); 43 | 44 | let element: HTMLStyleElement = document.querySelector("#qqntim_injected_styles")!; 45 | if (element) element.remove(); 46 | 47 | element = document.createElement("style"); 48 | element.id = "qqntim_injected_styles"; 49 | element.innerHTML = stylesheets.map(([plugin, stylesheet]) => `/* ${plugin.manifest.id.replaceAll("/", "-")} - ${stylesheet.replaceAll("/", "-")} */\n${fs.readFileSync(stylesheet).toString()}`).join("\n"); 50 | document.body.appendChild(element); 51 | } 52 | 53 | function applyScripts() { 54 | scripts = scripts.filter(([plugin, script]) => { 55 | try { 56 | const mod = require(script); 57 | if (mod) { 58 | const entry = new ((mod.default || mod) as typeof QQNTim.Entry.Renderer)(); 59 | windowLoadPromise.then(() => entry.onWindowLoaded?.()); 60 | } 61 | return false; 62 | } catch (reason) { 63 | console.error(`[!Loader] 运行此插件脚本时出现意外错误:${script},请联系插件作者 (${plugin.manifest.author}) 解决`); 64 | console.error(reason); 65 | } 66 | return true; 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/main.ts: -------------------------------------------------------------------------------- 1 | import { setAllPlugins, setEnv } from "../common/global"; 2 | import { watchIpc } from "../common/ipc"; 3 | import { initAPI } from "./api"; 4 | import { nt } from "./api/nt"; 5 | import { attachDebugger } from "./debugger"; 6 | import { applyPlugins } from "./loader"; 7 | import { patchLogger, patchModuleLoader } from "./patch"; 8 | import { hookVue3 } from "./vueHelper"; 9 | import { ipcRenderer } from "electron"; 10 | 11 | export const { enabled, preload, debuggerOrigin, webContentsId, plugins, env, hasColorSupport } = ipcRenderer.sendSync("___!boot"); 12 | 13 | patchModuleLoader(); 14 | if (enabled) { 15 | setEnv(env); 16 | setAllPlugins(plugins); 17 | patchLogger(); 18 | watchIpc(); 19 | hookVue3(); 20 | attachDebugger(); 21 | initAPI(); 22 | 23 | const timer = setInterval(() => { 24 | if (window.location.href.includes("blank")) return; 25 | clearInterval(timer); 26 | applyPlugins(plugins); 27 | nt.getAccountInfo().then((account) => { 28 | if (!account) return; 29 | const uin = account.uin; 30 | applyPlugins(plugins, uin); 31 | }); 32 | console.log("[!Main] QQNTim 加载成功"); 33 | }, 1); 34 | } 35 | 36 | preload.forEach((item: string) => require(item)); 37 | -------------------------------------------------------------------------------- /src/renderer/patch.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../common/global"; 2 | import { handleIpc } from "../common/ipc"; 3 | import { defineModules, getModule } from "../common/patch"; 4 | import { printObject } from "../common/utils/console"; 5 | import { getter, setter } from "../common/watch"; 6 | import { hasColorSupport } from "./main"; 7 | import { contextBridge, ipcRenderer } from "electron"; 8 | import { Module } from "module"; 9 | import * as React from "react"; 10 | import * as ReactDOM from "react-dom"; 11 | import * as ReactDOMClient from "react-dom/client"; 12 | import * as ReactJSXRuntime from "react/jsx-runtime"; 13 | 14 | function patchIpcRenderer() { 15 | return new Proxy(ipcRenderer, { 16 | get(target, p) { 17 | if (p == "on") 18 | return (channel: string, listener: (event: any, ...args: any[]) => void) => { 19 | target.on(channel, (event: any, ...args: QQNTim.IPC.Args) => { 20 | if (handleIpc(args, "in", channel)) listener(event, ...args); 21 | }); 22 | }; 23 | else if (p == "send") 24 | return (channel: string, ...args: QQNTim.IPC.Args) => { 25 | if (handleIpc(args, "out", channel)) target.send(channel, ...args); 26 | }; 27 | else if (p == "sendSync") 28 | return (channel: string, ...args: QQNTim.IPC.Args) => { 29 | if (handleIpc(args, "out", channel)) return target.sendSync(channel, ...args); 30 | }; 31 | return getter(undefined, target, p as any); 32 | }, 33 | set(target, p, newValue) { 34 | return setter(undefined, target, p as any, newValue); 35 | }, 36 | }); 37 | } 38 | 39 | function patchContextBridge() { 40 | return new Proxy(contextBridge, { 41 | get(target, p) { 42 | if (p == "exposeInMainWorld") 43 | return (apiKey: string, api: any) => { 44 | window[apiKey] = api; 45 | }; 46 | return getter(undefined, target, p as any); 47 | }, 48 | set(target, p, newValue) { 49 | return setter(undefined, target, p as any, newValue); 50 | }, 51 | }); 52 | } 53 | 54 | export function patchModuleLoader() { 55 | const patchedElectron: typeof Electron.CrossProcessExports = { 56 | ...require("electron"), 57 | ipcRenderer: patchIpcRenderer(), 58 | contextBridge: patchContextBridge(), 59 | }; 60 | 61 | defineModules({ electron: patchedElectron, react: React, "react/jsx-runtime": ReactJSXRuntime, "react-dom": ReactDOM, "react-dom/client": ReactDOMClient }); 62 | 63 | const loadBackend = (Module as any)._load; 64 | (Module as any)._load = (request: string, parent: NodeModule, isMain: boolean) => { 65 | // 重写模块加载以隐藏 `vm` 模块弃用提示 66 | if (request == "vm") request = "node:vm"; 67 | return getModule(request) || loadBackend(request, parent, isMain); 68 | }; 69 | } 70 | 71 | export function patchLogger() { 72 | if (env.config.useNativeDevTools) return; 73 | const log = (level: number, ...args: any[]) => { 74 | const serializedArgs: any[] = []; 75 | for (const arg of args) { 76 | serializedArgs.push(typeof arg == "string" ? arg : printObject(arg, hasColorSupport)); 77 | } 78 | ipcRenderer.send("___!log", level, ...serializedArgs); 79 | }; 80 | ( 81 | [ 82 | ["debug", 0], 83 | ["log", 1], 84 | ["info", 2], 85 | ["warn", 3], 86 | ["error", 4], 87 | ] as [string, number][] 88 | ).forEach(([method, level]) => { 89 | console[method] = (...args: any[]) => log(level, ...args); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /src/renderer/vueHelper.ts: -------------------------------------------------------------------------------- 1 | interface Component { 2 | vnode: { 3 | el: VueElement; 4 | component: Component; 5 | }; 6 | bum: Function[]; 7 | uid: number; 8 | } 9 | 10 | interface VueElement extends HTMLElement { 11 | __VUE__?: Component[]; 12 | } 13 | 14 | // Modified from https://greasyfork.org/zh-CN/scripts/449444-hook-vue3-app 15 | // Thanks to DreamNya & Cesaryuan ;) 16 | 17 | const elements = new WeakMap(); 18 | (window as any).__VUE_ELEMENTS__ = elements; 19 | 20 | function watchComponentUnmount(component: Component) { 21 | if (!component.bum) component.bum = []; 22 | component.bum.push(() => { 23 | const element = component.vnode.el; 24 | if (element) { 25 | const components = elements.get(element); 26 | if (components?.length == 1) elements.delete(element); 27 | else components?.splice(components.indexOf(component)); 28 | if (element.__VUE__?.length == 1) element.__VUE__ = undefined; 29 | else element.__VUE__?.splice(element.__VUE__.indexOf(component)); 30 | } 31 | }); 32 | } 33 | 34 | function watchComponentMount(component: Component) { 35 | let value: HTMLElement; 36 | Object.defineProperty(component.vnode, "el", { 37 | get() { 38 | return value; 39 | }, 40 | set(newValue) { 41 | value = newValue; 42 | if (value) recordComponent(component); 43 | }, 44 | }); 45 | } 46 | 47 | function recordComponent(component: Component) { 48 | let element = component.vnode.el; 49 | while (!(element instanceof HTMLElement)) element = (element as VueElement).parentElement!; 50 | 51 | // Expose component to element's __VUE__ property 52 | if (element.__VUE__) element.__VUE__.push(component); 53 | else element.__VUE__ = [component]; 54 | 55 | // Add class to element 56 | element.classList.add("vue-component"); 57 | 58 | // Map element to components 59 | const components = elements.get(element); 60 | if (components) components.push(component); 61 | else elements.set(element, [component]); 62 | 63 | watchComponentUnmount(component); 64 | } 65 | 66 | export function hookVue3() { 67 | window.Proxy = new Proxy(window.Proxy, { 68 | construct(target, [proxyTarget, proxyHandler]) { 69 | const component = proxyTarget?._ as Component; 70 | if (component?.uid >= 0) { 71 | const element = component.vnode.el; 72 | if (element) recordComponent(component); 73 | else watchComponentMount(component); 74 | } 75 | return new target(proxyTarget, proxyHandler); 76 | }, 77 | }); 78 | 79 | console.log("[!VueHelper] 输入 `__VUE_ELEMENTS__` 查看所有已挂载的 Vue 组件"); 80 | } 81 | -------------------------------------------------------------------------------- /src/typings/COPYING.LESSER: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/typings/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flysoftbeta/qqntim-typings", 3 | "version": "3.1.3", 4 | "license": "LGPL-3.0-or-later", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "dependencies": { 9 | "@types/fs-extra": "^11.0.1", 10 | "@types/node": "^20.6.2", 11 | "@types/react": "^18.2.21", 12 | "@types/react-dom": "^18.2.7", 13 | "typed-emitter": "^2.1.0" 14 | }, 15 | "devDependencies": { 16 | "typescript": "^5.2.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/typings/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "moduleResolution": "Node", 5 | "target": "ESNext", 6 | "resolveJsonModule": true, 7 | "strictNullChecks": true, 8 | "strictFunctionTypes": true, 9 | "noEmit": true 10 | }, 11 | "include": ["index.d.ts","electron.d.ts"], 12 | "exclude": ["node_modules", "**/node_modules/*"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "target": "ESNext", 7 | "resolveJsonModule": true, 8 | "strictNullChecks": true, 9 | "strictFunctionTypes": true, 10 | "noEmit": true 11 | }, 12 | "include": ["src"], 13 | "exclude": ["node_modules", "**/node_modules/*","src/typings"] 14 | } 15 | --------------------------------------------------------------------------------