diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6f598a5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml, yaml}] +indent_size = 4 + +[*.{less, css}] +indent_size = 4 + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2bc0b86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,11 @@ +--- + +name: 🐞 Bug report +about: Create a report to help us improve +title: "[Bug] the title of bug report" +labels: bug +assignees: '' + +--- + +#### Describe the bug diff --git a/.github/ISSUE_TEMPLATE/help_wanted.md b/.github/ISSUE_TEMPLATE/help_wanted.md new file mode 100644 index 0000000..6fba797 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/help_wanted.md @@ -0,0 +1,10 @@ +--- +name: 🥺 Help wanted +about: Confuse about the use of electron-vue-vite +title: "[Help] the title of help wanted report" +labels: help wanted +assignees: '' + +--- + +#### Describe the problem you confuse diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c533dfb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ + + +### Description + + + +### What is the purpose of this pull request? + +- [ ] Bug fix +- [ ] New Feature +- [ ] Documentation update +- [ ] Other diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fe250eb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "monthly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d3b53e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + pull_request_target: + branches: + - main + +permissions: + pull-requests: write + +jobs: + job1: + name: Check Not Allowed File Changes + runs-on: ubuntu-latest + outputs: + markdown_change: ${{ steps.filter_markdown.outputs.change }} + markdown_files: ${{ steps.filter_markdown.outputs.change_files }} + steps: + + - name: Check Not Allowed File Changes + uses: dorny/paths-filter@v2 + id: filter_not_allowed + with: + list-files: json + filters: | + change: + - 'package-lock.json' + - 'yarn.lock' + - 'pnpm-lock.yaml' + + # ref: https://github.com/github/docs/blob/main/.github/workflows/triage-unallowed-contributions.yml + - name: Comment About Changes We Can't Accept + if: ${{ steps.filter_not_allowed.outputs.change == 'true' }} + uses: actions/github-script@v6 + with: + script: | + let workflowFailMessage = "It looks like you've modified some files that we can't accept as contributions." + try { + const badFilesArr = [ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + ] + const badFiles = badFilesArr.join('\n- ') + const reviewMessage = `👋 Hey there spelunker. It looks like you've modified some files that we can't accept as contributions. The complete list of files we can't accept are:\n- ${badFiles}\n\nYou'll need to revert all of the files you changed in that list using [GitHub Desktop](https://docs.github.com/en/free-pro-team@latest/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/reverting-a-commit) or \`git checkout origin/main \`. Once you get those files reverted, we can continue with the review process. :octocat:\n\nMore discussion:\n- https://github.com/electron-vite/electron-vite-vue/issues/192` + createdComment = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.number, + body: reviewMessage, + }) + workflowFailMessage = `${workflowFailMessage} Please see ${createdComment.data.html_url} for details.` + } catch(err) { + console.log("Error creating comment.", err) + } + core.setFailed(workflowFailMessage) + + - name: Check Not Linted Markdown + if: ${{ always() }} + uses: dorny/paths-filter@v2 + id: filter_markdown + with: + list-files: shell + filters: | + change: + - added|modified: '*.md' + + + job2: + name: Lint Markdown + runs-on: ubuntu-latest + needs: job1 + if: ${{ always() && needs.job1.outputs.markdown_change == 'true' }} + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Lint markdown + run: npx markdownlint-cli ${{ needs.job1.outputs.markdown_files }} --ignore node_modules \ No newline at end of file diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml new file mode 100644 index 0000000..50c1061 --- /dev/null +++ b/.github/workflows/main-build.yml @@ -0,0 +1,130 @@ +name: Build + +on: + push: + branches: + - main + +jobs: + build: + runs-on: self-hosted + # runs-on: ${{ matrix.os }} + + strategy: + matrix: + include: +# - os: ubuntu-latest +# arch: [arm64, amd64] +# - os: macos-latest +# arch: [arm64, amd64] + - os: windows-latest + arch: [arm64, amd64] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build Prepare (macOS) + if: runner.os == 'macOS' + run: | + brew install python-setuptools + + - name: Cert Prepare (macOS) + if: runner.os == 'macOS' + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + echo "find-identity" + security find-identity -p codesigning + echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security list-keychains -s build.keychain + security set-keychain-settings -t 3600 -u build.keychain + security unlock-keychain -p "" build.keychain + echo "find-identity" + security find-identity -v -p codesigning build.keychain + echo "find-identity" + security find-identity -p codesigning + echo "set-key-partition-list" + security set-key-partition-list -S apple-tool:,apple: -s -k "" -l "FocusAnyKey" -t private build.keychain + echo "find-certificate" + security find-certificate -a -c "FocusAnyKey" -p + echo "export" + security export -k build.keychain -t certs -f x509 -p -o certificate.cer + echo "add-trusted-cert" + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certificate.cer + echo "find-identity" + security find-identity -p codesigning + + - name: Install Dependencies + run: npm install + + - name: Build Release Files + run: npm run build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set Build Name ( Linux / macOS ) + if: runner.os == 'Linux' || runner.os == 'macOS' + run: | + DIST_FILE_NAME=${{ runner.os }}-${{ runner.arch }}-v$(date +%Y%m%d_%H%M%S)-${RANDOM} + echo ::add-mask::$DIST_FILE_NAME + echo DIST_FILE_NAME=$DIST_FILE_NAME >> $GITHUB_ENV + + - name: Set Build Name ( Windows ) + if: runner.os == 'Windows' + shell: pwsh + run: | + $randomNumber = Get-Random -Minimum 10000 -Maximum 99999 + $DIST_FILE_NAME = "Windows-X64-v$(Get-Date -Format 'yyyyMMdd_HHmmss')-$randomNumber" + Write-Host "::add-mask::$DIST_FILE_NAME" + echo "DIST_FILE_NAME=$DIST_FILE_NAME" >> $env:GITHUB_ENV + + - name: Upload + uses: modstart/github-oss-action@master + with: + title: ${{ github.event.head_commit.message }} + key-id: ${{ secrets.OSS_KEY_ID }} + key-secret: ${{ secrets.OSS_KEY_SECRET }} + region: ${{ secrets.OSS_REGION }} + bucket: ${{ secrets.OSS_BUCKET }} + callback: ${{ secrets.OSS_CALLBACK }} + assets: | + dist-release/*.exe:focusany/focusany-${{ env.DIST_FILE_NAME }}/ + dist-release/*.dmg:focusany/focusany-${{ env.DIST_FILE_NAME }}/ + dist-release/*.AppImage:focusany/focusany-${{ env.DIST_FILE_NAME }}/ + dist-release/*.deb:focusany/focusany-${{ env.DIST_FILE_NAME }}/ + +# - name: Upload Artifact Windows +# if: runner.os == 'Windows' +# uses: actions/upload-artifact@v4 +# with: +# name: windows-artifact +# path: | +# dist-release/*.exe +# +# - name: Upload Artifact Macos +# if: runner.os == 'macOS' +# uses: actions/upload-artifact@v4 +# with: +# name: macos-artifact +# path: | +# dist-release/*.dmg +# +# - name: Upload Artifact Linux +# if: runner.os == 'Linux' +# uses: actions/upload-artifact@v4 +# with: +# name: linux-artifact +# path: | +# dist-release/*.AppImage +# dist-release/*.deb + + diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..cc8197b --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,86 @@ +name: Build + +on: + push: + tags: + - v*.*.* + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + include: +# - os: ubuntu-latest +# arch: [arm64, amd64] + - os: macos-latest + arch: [arm64, amd64] + - os: windows-latest + arch: [arm64, amd64] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build Prepare (macOS) + if: runner.os == 'macOS' + run: | + brew install python-setuptools + + - name: Cert Prepare (macOS) + if: runner.os == 'macOS' + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + echo "find-identity" + security find-identity -p codesigning + echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security list-keychains -s build.keychain + security set-keychain-settings -t 3600 -u build.keychain + security unlock-keychain -p "" build.keychain + echo "find-identity" + security find-identity -v -p codesigning build.keychain + echo "find-identity" + security find-identity -p codesigning + echo "set-key-partition-list" + security set-key-partition-list -S apple-tool:,apple: -s -k "" -l "FocusAnyKey" -t private build.keychain + echo "find-certificate" + security find-certificate -a -c "FocusAnyKey" -p + echo "export" + security export -k build.keychain -t certs -f x509 -p -o certificate.cer + echo "add-trusted-cert" + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certificate.cer + echo "find-identity" + security find-identity -p codesigning + + - name: Install Dependencies + run: npm install + + - name: Build Release Files + run: npm run build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Release Assets + uses: softprops/action-gh-release@v2 + with: + draft: false + prerelease: false + fail_on_unmatched_files: false + files: | + dist-release/*.exe + dist-release/*.dmg + dist-release/*.AppImage + dist-release/*.deb + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70b6e03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +/dist +/dist-ssr +/dist-electron +/dist-release +*.local + +# Editor directories and files +.vscode/.debug.env +.idea/ +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# lockfile +package-lock.json +pnpm-lock.yaml +yarn.lock +database.db + +src/lang/source-use.json +/focusany-plugin-* +/data +/data-* diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..61b14f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +# For electron-builder +# https://github.com/electron-userland/electron-builder/issues/6289#issuecomment-1042620422 +shamefully-hoist=true + +# For China 🇨🇳 developers +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..570d97a --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b6e84b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ + + +build_and_install: + npm run build; + rm -rfv /Applications/FocusAny.app; + cp -a ./dist-release/mac-arm64/FocusAny.app /Applications diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e1d634 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# FocusAny + +![](./screenshots/cn/home.png) + +`FocusAny` 是一个桌面工具条系统,支持市场插件、本地插件的一键启动,快速扩展功能,提高工作效率。 + +## 功能特性 + +- 功能设置:呼出快捷键设置、开机启动 +- 插件管理:支持插件安装、卸载、启用、禁用等操作 +- 指令管理:支持内置和插件指令快速一览和启用、禁用、打开等操作 +- 文件快速启动:支持文件快速启动,快速抵达目标文件 +- 快捷键启动:支持全局快捷键启动,快速启动软件 +- 数据中心:支持文件导出同步、WebDav文件同步 +- 暗黑模式:支持暗黑模式,保护眼睛 + +## 安装使用 + +- 访问 [https://focusany.com](https://focusany.com) 下载 对应系统 安装包,一键安装即可 + +## 技术栈 + +- `electron` +- `vue3` +- `typescript` + +## 本地运行开发 + +> 仅在 node 20 测试过 + +```shell +# 安装依赖 +npm install +# 调试运行 +npm run dev +# 打包 +npm run build +``` + +## 加入交流群 + + + + + + + + + + + + + + +
微信群QQ群
+ + + +
+ +## 本程序中使用到了以下开源项目,特此感谢 + +- Electron +- Vue3 +- TypeScript + +## License + +AGPL-3.0 diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..f85a151 --- /dev/null +++ b/changelog.md @@ -0,0 +1,4 @@ + +## v0.0.1 + +- 优化:第一个正式版本发布 diff --git a/electron-builder.json5 b/electron-builder.json5 new file mode 100644 index 0000000..7ce3f8c --- /dev/null +++ b/electron-builder.json5 @@ -0,0 +1,140 @@ +// @see https://www.electron.build/configuration/configuration +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "FocusAny", + "asar": true, + "npmRebuild": true, + "productName": "FocusAnyPro", + "directories": { + "output": "dist-release", + "buildResources": "electron/resources/build" + }, + "afterPack": "./scripts/build_optimize.cjs", + "files": [ + "dist", + "dist-electron" + ], + "extraResources": [ + { + "from": "node_modules/ffmpeg-static", + "to": "bin/ffmpeg", + } + ], + "win": { + icon: "electron/resources/build/logo.ico", + "target": [ + { + "target": "nsis", + "arch": [ + "x64", + "arm64" + ] + }, + ], + "artifactName": "${productName}-${version}-win-${arch}.${ext}", + "extraResources": [ + { + "from": "electron/resources/extra", + "to": "extra", + "filter": [ + "common", + "win" + ] + } + ] + }, + "nsis": { + "artifactName": "${productName}-${version}-win-setup-${arch}.${ext}", + "shortcutName": "${productName}", + "uninstallDisplayName": "${productName}", + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "deleteAppDataOnUninstall": false + }, + "portable": { + "artifactName": "${productName}-${version}-win-portable-${arch}.${ext}", + "requestExecutionLevel": "user" + }, + "appx": { + "identityName": "FocusAny", + "publisher": "CN=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXX", + "publisherDisplayName": "FocusAny", + "languages": [ + "zh-CN", + "en-US", + "zh-TW" + ] + }, + "mac": { + "icon": "logo.icns", + "target": [ + { + "target": "dmg", + // "arch": [ + // "x64", + // "arm64" + // ] + } + ], + "artifactName": "${productName}-${version}-mac-${arch}.${ext}", + "extraResources": [ + { + "from": "electron/resources/extra", + "to": "extra", + "filter": [ + "common", + "mac" + ] + } + ], + "x64ArchFiles": "Contents/Resources/extra/**/*", + "entitlementsInherit": "./release/entitlements.mac.plist", + "entitlements": "./release/entitlements.mac.plist", + "extendInfo": { + "NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.", + "NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder.", + "NSAccessibilityUsageDescription": "Application requests access to the user's Accessibility features.", + }, + "type": "development", + "notarize": false, + "darkModeSupport": false, + "hardenedRuntime": true, + "gatekeeperAssess": false, + "identity": "FocusAnyKey", + }, + "linux": { + "icon": "logo.icns", + "desktop": "logo", + "maintainer": "FocusAny", + "category": "Utility", + "target": [ + { + "target": "AppImage", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "deb", + "arch": [ + "x64", + "arm64" + ] + } + ], + "artifactName": "${productName}-${version}-linux-${arch}.${ext}", + "extraResources": [ + { + "from": "electron/resources/extra", + "to": "extra", + "filter": [ + "common", + "linux" + ] + } + ] + }, +// "afterSign": "./scripts/notarize.cjs", +} diff --git a/electron/config/common.ts b/electron/config/common.ts new file mode 100644 index 0000000..affffd1 --- /dev/null +++ b/electron/config/common.ts @@ -0,0 +1,14 @@ +export const CommonConfig = { + darkModeEnable: true, + dbSystem: 'system', + dbConfigId: 'config', + dbDisabledActionMatchId: 'disabledActionMatch', + dbPinActionId: 'pinAction', + dbFileId: 'file', + dbLaunchId: 'launch', + dbCustomActionId: 'customAction', + dbHistoryActionId: 'historyAction', + dbPluginConfigId: 'pluginConfig', + dbPluginIdPrefix: 'plugin', + dbPluginStorageIdPrefix: 'storage', +} diff --git a/electron/config/contextMenu.ts b/electron/config/contextMenu.ts new file mode 100644 index 0000000..7d01cbb --- /dev/null +++ b/electron/config/contextMenu.ts @@ -0,0 +1,19 @@ +import contextMenu from 'electron-context-menu'; + + +const init = () => { + contextMenu({ + showSaveImageAs: false, + showCopyLink: false, + showCopyImage: false, + showSelectAll: false, + showInspectElement: false, + showSearchWithGoogle: false, + showLookUpSelection: false, + }); +} + + +export const ConfigContextMenu = { + init +} diff --git a/electron/config/icon.ts b/electron/config/icon.ts new file mode 100644 index 0000000..2402dcc --- /dev/null +++ b/electron/config/icon.ts @@ -0,0 +1,9 @@ +import {buildResolve, extraResolve} from "../lib/env"; + +export const logoPath = buildResolve('logo.png') +export const icoLogoPath = buildResolve('logo.ico') +export const icnsLogoPath = buildResolve('logo.icns') + +export const trayPath = process.platform === 'darwin' + ? extraResolve('mac/tray/iconTemplate.png') + : extraResolve('common/tray/icon.png') diff --git a/electron/config/lang.ts b/electron/config/lang.ts new file mode 100644 index 0000000..bc9d7b6 --- /dev/null +++ b/electron/config/lang.ts @@ -0,0 +1,78 @@ +import source from "./../../src/lang/source.json"; +import enUS from "./../../src/lang/en-US.json"; +import zhCN from "./../../src/lang/zh-CN.json"; +import {isDev} from "../lib/env"; +import lang from "../mapi/lang/main"; +import {ConfigMain} from "../mapi/config/main"; + +export const defaultLocale = 'zh-CN' + +let locale = defaultLocale + +export const langMessageList = [ + { + name: 'en-US', + label: 'English', + messages: enUS + }, + { + name: 'zh-CN', + label: '简体中文', + messages: zhCN + }, +] + +const buildMessages = (): any => { + let messages = {} + for (let m of langMessageList) { + let msgList = {} + for (let k in source) { + const v = source[k] + if (m.messages[v]) { + msgList[k] = m.messages[v] + } + } + messages[m.name] = msgList + } + return messages +} + + +let messages = buildMessages() + +export const t = (text: string, param: object | null = null) => { + if (messages[locale]) { + if (messages[locale][text]) { + if (param) { + return messages[locale][text].replace(/\{(\w+)\}/g, function (match, key) { + return param[key] ? param[key] : match; + }); + } + return messages[locale][text] + } + } + if (isDev) { + console.warn('key not found, writing', locale, text, messages) + lang.writeSourceKey(text).then(() => { + console.info('writeSourceKey.success', locale, text) + }).catch((e) => { + console.error('writeSourceKey.error', locale, text, e) + }) + lang.writeSourceKeyUse(text).then(() => { + }) + } + return text; +} + +const readyAsync = async () => { + locale = await ConfigMain.get('lang', defaultLocale) +} + +const getLocale = () => { + return locale +} + +export const ConfigLang = { + readyAsync, + getLocale, +} diff --git a/electron/config/menu.ts b/electron/config/menu.ts new file mode 100644 index 0000000..8094d58 --- /dev/null +++ b/electron/config/menu.ts @@ -0,0 +1,89 @@ +import {app, Menu} from "electron"; +import {isDev, isMac} from "../lib/env"; +import {t} from "./lang"; +import {PageAbout} from "../page/about"; + +let contextMenu: Electron.Menu; + +const ready = () => { + const menuTemplate: Electron.MenuItemConstructorOptions[] = []; + if (isMac) { + menuTemplate.push( + { + label: app.name, + submenu: [ + {label: `${t('关于')}${app.name}`, role: "about"}, + {type: "separator"}, + // { + // label: t("设置"), + // click: () => { + // createSettingWindow(); + // }, + // accelerator: "CmdOrCtrl+,", + // }, + // {type: "separator"}, + {label: t('服务'), role: "services"}, + {type: "separator"}, + {label: `${t("隐藏")} ${app.name}`, role: "hide"}, + {label: t("隐藏其他"), role: "hideOthers"}, + {label: t("全部显示"), role: "unhide"}, + {type: "separator"}, + {label: t('退出'), role: "quit"}, + ], + }, + ) + } + menuTemplate.push({ + label: t('编辑'), + submenu: [ + {label: t('撤销'), accelerator: "CmdOrCtrl+Z", role: "undo"}, + {label: t('重做'), accelerator: "Shift+CmdOrCtrl+Z", role: "redo"}, + {type: "separator"}, + {label: t('剪切'), accelerator: "CmdOrCtrl+X", role: "cut"}, + {label: t('复制'), accelerator: "CmdOrCtrl+C", role: "copy"}, + {label: t('粘贴'), accelerator: "CmdOrCtrl+V", role: "paste"}, + {label: t('全选'), accelerator: "CmdOrCtrl+A", role: "selectAll"} + ] + }) + if (isDev) { + menuTemplate.push({ + label: t("视图"), + submenu: [ + {label: t("重新加载"), role: "reload"}, + {label: t("强制重载"), role: "forceReload"}, + {label: t("开发者工具"), role: "toggleDevTools"}, + {type: "separator"}, + {label: t("实际大小"), role: "resetZoom", accelerator: ""}, + {label: t("放大"), role: "zoomIn"}, + {label: t("缩小"), role: "zoomOut"}, + {type: "separator"}, + {label: t("全屏"), role: "togglefullscreen"}, + ], + }) + } + // menuTemplate.push({ + // label: t("帮助"), + // role: "help", + // submenu: [ + // // { + // // label: t("教程帮助"), + // // click: () => { + // // createHelpWindow(); + // // }, + // // }, + // // {type: "separator"}, + // // { + // // label: t("关于"), + // // click: () => { + // // PageAbout.open().then() + // // }, + // // }, + // ], + // }) + const menu = Menu.buildFromTemplate(menuTemplate); + Menu.setApplicationMenu(menu); +} + +export const ConfigMenu = { + ready +} diff --git a/electron/config/tray.ts b/electron/config/tray.ts new file mode 100644 index 0000000..38eab71 --- /dev/null +++ b/electron/config/tray.ts @@ -0,0 +1,96 @@ +import {app, Menu, shell, Tray} from 'electron' +import {trayPath} from "./icon"; +import {AppRuntime} from "../mapi/env"; +import {AppConfig} from "../../src/config"; +import {t} from "./lang"; +import {isMac, isWin} from "../lib/env"; +import {AppsMain} from "../mapi/app/main"; + +let tray = null + +const showApp = () => { + if (isMac) { + app.dock.show() + } + AppRuntime.mainWindow.show() +} + +const hideApp = () => { + if (isMac) { + app.dock.hide() + } + AppRuntime.mainWindow.hide() +} + +const quitApp = () => { + app.quit() +} + +const ready = () => { + tray = new Tray(trayPath) + + tray.setToolTip(AppConfig.name) + + if (isWin) { + tray.on('click', () => { + showApp() + }) + } + + const contextMenu = Menu.buildFromTemplate([ + { + label: t('显示主界面'), + click: () => { + showApp() + }, + }, + { + label: t('新手指引'), + click: () => { + AppsMain.windowOpen('guide').then() + }, + }, + { + label: t('访问官网'), + click: () => { + shell.openExternal(AppConfig.website) + }, + }, + {type: 'separator'}, + { + label: t('重启'), + click: () => { + app.relaunch() + quitApp() + }, + }, + { + label: t('退出'), + click: () => { + quitApp() + }, + }, + {type: 'separator'}, + { + label: t('关于'), + click: () => { + AppsMain.windowOpen('about').then() + }, + }, + ]) + + tray.setContextMenu(contextMenu) +} + +const show = () => { + + if (tray) { + tray.destroy() + tray = null + } +} + + +export const ConfigTray = { + ready +} diff --git a/electron/config/window.ts b/electron/config/window.ts new file mode 100644 index 0000000..4ba11d4 --- /dev/null +++ b/electron/config/window.ts @@ -0,0 +1,21 @@ +export const WindowConfig = { + alwaysOpenDevTools: true, + minWidth: 800, + minHeight: 60, + initWidth: 800, + initHeight: 60, + mainHeight: 60, + mainWidth: 800, + mainMaxHeight: 600, + pluginWidth: 800, + pluginHeight: 500, + aboutWidth: 500, + aboutHeight: 400, + guideWidth: 800, + guideHeight: 540, + setupWidth: 800, + setupHeight: 540, + fastPanelWidth: 260, + fastPanelHeight: 500, + detachWindowTitleHeight: 40, +} diff --git a/electron/declarations/electron.d.ts b/electron/declarations/electron.d.ts new file mode 100644 index 0000000..766ea69 --- /dev/null +++ b/electron/declarations/electron.d.ts @@ -0,0 +1,7 @@ +declare module 'electron' { + interface BrowserView { + _window?: any; + _plugin?: any; + } +} + diff --git a/electron/declarations/svg.d.ts b/electron/declarations/svg.d.ts new file mode 100644 index 0000000..44350b3 --- /dev/null +++ b/electron/declarations/svg.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: string; + export default content; +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts new file mode 100644 index 0000000..2939f91 --- /dev/null +++ b/electron/electron-env.d.ts @@ -0,0 +1,23 @@ +/// +/// + +declare namespace NodeJS { + interface ProcessEnv { + /** + * The built directory structure + * + * ```tree + * ├─┬ dist-electron + * │ ├─┬ main + * │ │ └── index.js > Electron-Main + * │ └─┬ preload + * │ └── index.mjs > Preload-Scripts + * ├─┬ dist + * │ └── index.html > Electron-Renderer + * ``` + */ + APP_ROOT: string + /** /dist/ or /public/ */ + VITE_PUBLIC: string + } +} diff --git a/electron/lib/api.ts b/electron/lib/api.ts new file mode 100644 index 0000000..c22869a --- /dev/null +++ b/electron/lib/api.ts @@ -0,0 +1,19 @@ +import {AppConfig} from "../../src/config"; +import Apps from "../mapi/app"; + +export type ResultType = { + code: boolean, + msg: string, + data: T +} + +export const post = async (url: string, data: any) => { + return await fetch(url, { + method: 'POST', + headers: { + 'User-Agent': Apps.getUserAgent(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) +} diff --git a/electron/lib/devtools.ts b/electron/lib/devtools.ts new file mode 100644 index 0000000..2d59046 --- /dev/null +++ b/electron/lib/devtools.ts @@ -0,0 +1,96 @@ +import {BrowserView, BrowserWindow, screen} from "electron"; +import {isDev} from "./env"; +import {WindowConfig} from "../config/window"; + +export const DevToolsManager = { + enable: true, + rowCount: 4, + colCount: 3, + windows: new Map(), + setEnable(enable: boolean) { + DevToolsManager.enable = enable + }, + getWindow(win: BrowserWindow | BrowserView) { + return this.windows.get(win); + }, + getOrCreateWindow(name: string, win: BrowserWindow | BrowserView) { + if (this.windows.has(win)) { + return this.windows.get(win); + } + const {x, y, width, height} = this.getDisplayPosition() + // console.log('DevToolsManager', name, {x, y, width, height}) + const devtools = new BrowserWindow({ + show: true, + x, + y, + width, + height, + title: name, + }); + // console.log('DevToolsManager', name, {x, y}) + win.webContents.setDevToolsWebContents(devtools.webContents); + win.webContents.on('destroyed', () => { + // console.log('DevToolsManager', 'destroyed', name) + devtools.destroy() + this.windows.delete(win) + }) + devtools.webContents.on('dom-ready', () => { + setTimeout(() => { + if (!devtools.isDestroyed()) { + devtools.setTitle(name) + } + }, 1000) + }) + this.windows.set(win, devtools); + return devtools; + }, + getLargestDisplay(): Electron.Display { + const displays = screen.getAllDisplays(); + return displays.reduce((max, display) => { + const {width, height} = display.size; + const maxResolution = max.size.width * max.size.height; + const currentResolution = width * height; + return currentResolution > maxResolution ? display : max; + }); + }, + getDisplayPosition(): { + x: number, y: number, + width: number, height: number + } { + const display = this.getLargestDisplay() + const {x, y, width, height} = display.workArea; + // console.log('DevToolsManager', 'getDisplayPosition', {x, y, width, height}) + if (width < 1300) { + this.rowCount = 3 + this.colCount = 2 + } + const itemWidth = Math.floor(width / this.rowCount); + const itemHeight = Math.floor(height / this.colCount); + const maxRow = Math.floor(width / itemWidth); + const row = this.windows.size % maxRow; + const col = Math.floor(this.windows.size / maxRow); + return { + x: x + row * itemWidth, + y: y + col * itemHeight, + width: itemWidth, + height: itemHeight, + } + }, + register(name: string, win: BrowserWindow | BrowserView) { + if (!isDev || !DevToolsManager.enable) { + return + } + this.getOrCreateWindow(name, win); + }, + autoShow(win: BrowserWindow | BrowserView) { + if (!isDev || !DevToolsManager.enable) { + return + } + if (WindowConfig.alwaysOpenDevTools) { + win.webContents.openDevTools({ + mode: 'detach', + activate: false, + }) + } + } +} diff --git a/electron/lib/env-main.ts b/electron/lib/env-main.ts new file mode 100644 index 0000000..28b30f0 --- /dev/null +++ b/electron/lib/env-main.ts @@ -0,0 +1,48 @@ +import url, {fileURLToPath} from "node:url"; +import {BrowserView, BrowserWindow} from "electron"; +import {isPackaged} from "./env"; +import path, {join} from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +process.env.APP_ROOT = path.join(__dirname, '../..') + +export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') +export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') +export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL + +process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL + ? path.join(process.env.APP_ROOT, 'public') + : RENDERER_DIST + +export const preloadDefault = path.join(MAIN_DIST, 'preload/index.cjs') + +export const preloadPluginDefault = path.join(MAIN_DIST, 'preload-plugin/plugin.cjs') + +export const rendererLoadPath = (window: BrowserWindow | BrowserView, fileName: string) => { + if (!isPackaged && process.env.VITE_DEV_SERVER_URL) { + const x = new url.URL(rendererDistPath(fileName)); + if (window instanceof BrowserView) { + window.webContents.loadURL(x.toString()); + } else { + window.loadURL(x.toString()); + } + } else { + if (window instanceof BrowserView) { + window.webContents.loadFile(rendererDistPath(fileName)); + } else { + window.loadFile(rendererDistPath(fileName)); + } + } +} + +export const rendererDistPath = (fileName: string) => { + if (!isPackaged && process.env.VITE_DEV_SERVER_URL) { + return `${process.env.VITE_DEV_SERVER_URL.replace(/\/+$/, '')}/${fileName}`; + } + return join(RENDERER_DIST, fileName); +} + +export const rendererIsUrl = (url: string) => { + return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://'); +} diff --git a/electron/lib/env.ts b/electron/lib/env.ts new file mode 100644 index 0000000..9720e44 --- /dev/null +++ b/electron/lib/env.ts @@ -0,0 +1,80 @@ +import {resolve} from "node:path"; +import os from "os"; +import {execSync} from "child_process"; + +export const isPackaged = ['true'].includes(process.env.IS_PACKAGED) + +export const isDev = !isPackaged + +export const isWin = process.platform === 'win32' + +export const isMac = process.platform === 'darwin' + +export const isLinux = process.platform === 'linux' + +export const isMain = process.type === 'browser' + +export const isRender = process.type === 'renderer' + +export const platformName = (): 'win' | 'osx' | 'linux' | null => { + if (isWin) return 'win' + if (isMac) return 'osx' + if (isLinux) return 'linux' + return null +} + +let platformVersionCache: string | null = null +export const platformVersion = () => { + if (null === platformVersionCache) { + if (isWin) { + platformVersionCache = execSync('wmic os get Version').toString().split('\n')[1].trim() + } else if (isMac) { + platformVersionCache = execSync('sw_vers -productVersion').toString().trim() + } else if (isLinux) { + platformVersionCache = execSync('cat /etc/os-release | grep VERSION_ID').toString().split('=')[1].trim().replace(/"/g, '') + } else { + platformVersionCache = '' + } + } + return platformVersionCache +} + +export const platformArch = (): 'x86' | 'arm64' | null => { + switch (os.arch()) { + case 'x64': + return 'x86' + case 'arm64': + return 'arm64' + } + return null +} + +let platformUUIDCache: string | null = null +export const platformUUID = () => { + if (null === platformUUIDCache) { + if (isWin) { + platformUUIDCache = execSync('wmic csproduct get UUID').toString().split('\n')[1].trim() + } else if (isMac) { + platformUUIDCache = execSync('system_profiler SPHardwareDataType | grep UUID').toString().split(': ')[1].trim() + } else if (isLinux) { + platformUUIDCache = execSync('cat /sys/class/dmi/id/product_uuid').toString().trim() + } else { + platformUUIDCache = '' + } + } + return platformUUIDCache +} + +export const buildResolve = (value: string) => { + return resolve(`electron/resources/build/${value}`) +} + +export const binResolve = (value: string) => { + return resolve(process.resourcesPath, 'bin', value) +} + +export const extraResolve = (filePath: string) => { + const basePath = isPackaged ? process.resourcesPath : 'electron/resources' + return resolve(basePath, 'extra', filePath) +} + diff --git a/electron/lib/hooks.ts b/electron/lib/hooks.ts new file mode 100644 index 0000000..c002f3d --- /dev/null +++ b/electron/lib/hooks.ts @@ -0,0 +1,17 @@ +import {BrowserWindow} from "electron"; + +type HookType = never + | 'Show' + | 'Hide' + +export const executeHooks = async (win: BrowserWindow, hook: HookType, data?: any) => { + const evalJs = ` + if(window.__page && window.__page.hooks && typeof window.__page.hooks.on${hook} === 'function' ) { + try { + window.__page.hooks.on${hook}(${JSON.stringify(data)}); + } catch(e) { + console.log('executeHooks.on${hook}.error', e); + } + }`; + return win.webContents?.executeJavaScript(evalJs); +} diff --git a/electron/lib/permission.ts b/electron/lib/permission.ts new file mode 100644 index 0000000..ac482b8 --- /dev/null +++ b/electron/lib/permission.ts @@ -0,0 +1,42 @@ +import {isMac} from "./env"; + +let nodeMacPermissions = null +if (isMac) { + (async () => { + try { + nodeMacPermissions = await import('node-mac-permissions'); + nodeMacPermissions = nodeMacPermissions.default; + // console.log('nodeMacPermissions',nodeMacPermissions); + } catch (e) { + } + })() +} + +export const Permissions = { + async checkAccessibilityAccess(): Promise { + return new Promise((resolve, reject) => { + if (isMac) { + const status = nodeMacPermissions.getAuthStatus('accessibility'); + resolve(status === 'authorized') + } else { + resolve(true); + } + }) + }, + async askAccessibilityAccess() { + nodeMacPermissions.askForAccessibilityAccess() + }, + async checkScreenCaptureAccess(): Promise { + return new Promise((resolve, reject) => { + if (isMac) { + const status = nodeMacPermissions.getAuthStatus('screen'); + resolve(status === 'authorized') + } else { + resolve(true); + } + }) + }, + async askScreenCaptureAccess() { + nodeMacPermissions.askForScreenCaptureAccess(true) + }, +} diff --git a/electron/lib/pinyin-util.ts b/electron/lib/pinyin-util.ts new file mode 100644 index 0000000..5b5b2f0 --- /dev/null +++ b/electron/lib/pinyin-util.ts @@ -0,0 +1,23 @@ +import PinyinMatch from 'pinyin-match'; + +export const PinyinUtil = { + match(input, keywords) { + const index = PinyinMatch.match(input, keywords) + let inputMark = input + let similarity = 0 + if (index) { + const indexStart = index[0] + const indexEnd = index[1] + inputMark = input.substring(0, indexStart) + '' + input.substring(indexStart, indexEnd + 1) + '' + input.substring(indexEnd + 1) + similarity = (indexEnd - indexStart + 1) / input.length + } + return { + matched: !!index, + inputMark, + similarity + } + }, + mark(text) { + return `${text}` + } +} diff --git a/electron/lib/process.ts b/electron/lib/process.ts new file mode 100644 index 0000000..a65e1e1 --- /dev/null +++ b/electron/lib/process.ts @@ -0,0 +1,21 @@ +/** 在主进程中获取关键信息存储到环境变量中,从而在预加载脚本中及渲染进程中使用 */ +import {app} from 'electron' + +/** 注意: app.isPackaged 可能被被某些方法改变所以请将该文件放到 main.js 必须位于非依赖项的顶部 */ +import fixPath from 'fix-path' + +if (process.platform === 'darwin') { + fixPath() +} + +process.env.IS_PACKAGED = String(app.isPackaged) + +process.env.DESKTOP_PATH = app.getPath('desktop') + +process.env.CWD = process.cwd() + +export const isDummy = false + + + + diff --git a/electron/lib/util.ts b/electron/lib/util.ts new file mode 100644 index 0000000..931fd76 --- /dev/null +++ b/electron/lib/util.ts @@ -0,0 +1,417 @@ +import {Base64} from "js-base64"; +import * as crypto from "node:crypto"; +import dayjs from "dayjs"; +import fs from "node:fs"; +import Showdown from "showdown" +import iconvLite from "iconv-lite"; +import chardet from "chardet"; +import {Iconv} from "iconv" + +export const sleep = (time = 1000) => { + return new Promise((resolve) => { + setTimeout(() => resolve(true), time) + }) +} + +export const EncodeUtil = { + base64Encode(str: string) { + return Base64.encode(str) + }, + base64Decode(str: string) { + return Base64.decode(str) + }, + md5(str: string) { + return crypto.createHash('md5').update(str).digest('hex') + }, + aesEncode(str: string, key: string) { + const cipher = crypto.createCipheriv('aes-128-ecb', key, '') + let crypted = cipher.update(str, 'utf8', 'base64') + crypted += cipher.final('base64') + return crypted + }, + aesDecode(str: string, key: string) { + const decipher = crypto.createDecipheriv('aes-128-ecb', key, '') + let dec = decipher.update(str, 'base64', 'utf8') + dec += decipher.final('utf8') + return dec + }, +} + +export const IconvUtil = { + convert(str: string, from: string, to: string) { + to = to || 'utf8' + const fromEncoding = chardet.detect(Buffer.from(str)) + return iconvLite.decode(Buffer.from(str), fromEncoding).toString() + }, + bufferToUtf8(buffer: Buffer) { + const encoding = chardet.detect(buffer) + // console.log('bufferToUtf8.encoding', encoding) + if ('ISO-2022-CN' === encoding) { + const iconvInstance = new Iconv('ISO-2022-CN', 'UTF-8//TRANSLIT//IGNORE'); + return iconvInstance.convert(buffer).toString() + } + return iconvLite.decode(buffer, encoding).toString() + }, + detect(buffer: Uint8Array) { + // detect str encoding + return chardet.detect(buffer) + } +} + +export const StrUtil = { + randomString(len: number = 32) { + const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + let result = '' + for (let i = len; i > 0; --i) { + result += chars[Math.floor(Math.random() * chars.length)] + } + return result + }, + uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) + }, + hashCode(str: string, length: number = 8) { + let hash = 0 + if (str.length === 0) return hash + '' + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash + } + let result = Math.abs(hash).toString(16) + if (result.length < length) { + result = '0'.repeat(length - result.length) + result + } + return result + }, + hashCodeWithDuplicateCheck(str: string, check: string[], length: number = 8) { + let code = this.hashCode(str, length) + while (check.includes(code)) { + code = this.uuid().substring(0, length) + } + return code + }, + bigIntegerId() { + return [ + Date.now(), + (Math.floor(Math.random() * 1000000) + '').padStart(6, '0') + ].join('') + } +} + +export const TimeUtil = { + timestampInMs() { + return Date.now() + }, + timestamp() { + return Math.floor(Date.now() / 1000) + }, + format(time: number, format: string = 'YYYY-MM-DD HH:mm:ss') { + return dayjs(time).format(format) + }, + formatDate(time: number) { + return dayjs(time).format('YYYY-MM-DD') + }, + dateString() { + return dayjs().format('YYYYMMDD') + }, + datetimeString() { + return dayjs().format('YYYYMMDD_HHmmss_SSS') + }, + timestampDayStart(msTimestamp?: number) { + let date = msTimestamp ? new Date(msTimestamp) : new Date() + date.setHours(0, 0, 0, 0) + return Math.floor(date.getTime() / 1000) + }, + replacePattern(text: string) { + // @ts-ignore + return text.replaceAll('{year}', dayjs().format('YYYY')) + .replaceAll('{month}', dayjs().format('MM')) + .replaceAll('{day}', dayjs().format('DD')) + .replaceAll('{hour}', dayjs().format('HH')) + .replaceAll('{minute}', dayjs().format('mm')) + .replaceAll('{second}', dayjs().format('ss')) + } +} + + +export const FileUtil = { + streamToBase64(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks = [] + stream.on('data', (chunk) => { + chunks.push(chunk) + }) + stream.on('end', () => { + const buffer = Buffer.concat(chunks) + resolve(buffer.toString('base64')) + }) + stream.on('error', (error) => { + reject(error) + }) + }) + }, + bufferToBase64(buffer: Buffer) { + let binary = ''; + let bytes = new Uint8Array(buffer); + let len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return EncodeUtil.base64Encode(binary) + }, + base64ToBuffer(base64: string): Buffer { + if (base64.startsWith('data:')) { + base64 = base64.split('base64,')[1] + } + return Buffer.from(base64, 'base64') + }, + formatSize(size: number) { + if (size < 1024) { + return size + 'B' + } else if (size < 1024 * 1024) { + return (size / 1024).toFixed(2) + 'KB' + } else if (size < 1024 * 1024 * 1024) { + return (size / 1024 / 1024).toFixed(2) + 'MB' + } else { + return (size / 1024 / 1024 / 1024).toFixed(2) + 'GB' + } + }, + async md5(filePath: string) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5') + const stream = fs.createReadStream(filePath) + stream.on('data', (data) => { + hash.update(data) + }) + stream.on('end', () => { + resolve(hash.digest('hex')) + }) + stream.on('error', (error) => { + reject(error) + }) + }) + } +} + +export const JsonUtil = { + stringifyOrdered(obj: any) { + return JSON.stringify(obj, Object.keys(obj).sort(), 4) + }, + stringifyValueOrdered(obj: any) { + const sortedData = Object.fromEntries( + Object.entries(obj).sort(([, a], [, b]) => { + // @ts-ignore + return a as any - b as any + }) + ); + return JSON.stringify(sortedData, null, 4) + } +} + +export const ImportUtil = { + async loadCommonJs(cjsPath: string) { + const md5 = await FileUtil.md5(cjsPath) + const backend = await import(/* @vite-ignore */ `${cjsPath}?t=${md5}`) + // console.log('loadCommonJs', `${cjsPath}?t=${md5}`) + return backend.default + } +} + +export const MemoryCacheUtil = { + pool: {} as { + [key: string]: { + value: any, + expire: number, + } + }, + _gc() { + const now = TimeUtil.timestamp() + for (const key in this.pool) { + if (this.pool[key].expire < now) { + delete this.pool[key] + } + } + }, + async remember(key: string, callback: () => Promise, ttl: number = 3600) { + if (this.pool[key] && this.pool[key].expire > TimeUtil.timestamp()) { + return this.pool[key].value + } + const value = await callback() + this.pool[key] = { + value, + expire: TimeUtil.timestamp() + ttl, + } + this._gc() + return value + }, + get(key: string) { + if (this.pool[key] && this.pool[key].expire > TimeUtil.timestamp()) { + return this.pool[key].value + } + this._gc() + return null + }, + set(key: string, value: any, ttl: number = 60) { + this.pool[key] = { + value, + expire: TimeUtil.timestamp() + ttl, + } + this._gc() + }, + forget(key: string) { + delete this.pool[key] + } +} + + +export const ShellUtil = { + quotaPath(p: string) { + return `"${p}"` + }, + parseCommandArgs(command: string) { + let args = [] + let arg = '' + let quote = '' + let escape = false + for (let i = 0; i < command.length; i++) { + const c = command[i] + if (escape) { + arg += c + escape = false + continue + } + if ('\\' === c) { + escape = true + arg += c + continue + } + if ('' === quote && (' ' === c || '\t' === c)) { + if (arg) { + args.push(arg) + arg = '' + } + continue + } + if ('' === quote && ('"' === c || "'" === c)) { + quote = c + arg += c + continue + } + if ('"' === quote && '"' === c) { + quote = '' + arg += c + continue + } + if ("'" === quote && "'" === c) { + quote = '' + arg += c + continue + } + arg += c + } + if (arg) { + args.push(arg) + } + return args + } +} + + +export const VersionUtil = { + /** + * 检测版本是否匹配 + * @param v string + * @param match string 如 * 或 >=1.0.0 或 >1.0.0 或 <1.0.0 或 <=1.0.0 或 1.0.0 + */ + match(v: string, match: string) { + if (match === '*') { + return true + } + if (match.startsWith('>=') && this.ge(v, match.substring(2))) { + return true + } + if (match.startsWith('>') && this.gt(v, match.substring(1))) { + return true + } + if (match.startsWith('<=') && this.le(v, match.substring(2))) { + return true + } + if (match.startsWith('<') && this.lt(v, match.substring(1))) { + return true + } + return this.eq(v, match) + }, + compare(v1: string, v2: string) { + const v1Arr = v1.split('.') + const v2Arr = v2.split('.') + for (let i = 0; i < v1Arr.length; i++) { + const v1Num = parseInt(v1Arr[i]) + const v2Num = parseInt(v2Arr[i]) + if (v1Num > v2Num) { + return 1 + } else if (v1Num < v2Num) { + return -1 + } + } + return 0 + }, + gt(v1: string, v2: string) { + return VersionUtil.compare(v1, v2) > 0 + }, + ge(v1: string, v2: string) { + return VersionUtil.compare(v1, v2) >= 0 + }, + lt(v1: string, v2: string) { + return VersionUtil.compare(v1, v2) < 0 + }, + le: (v1: string, v2: string) => { + return VersionUtil.compare(v1, v2) <= 0 + }, + eq: (v1: string, v2: string) => { + return VersionUtil.compare(v1, v2) === 0 + } +} + + +export const UIUtil = { + sizeToPx(size: string, sizeFull: number) { + if (/^\d+$/.test(size)) { + // 纯数字 + return parseInt(size) + } else if (size.endsWith('%')) { + // 百分比 + let result = Math.floor((sizeFull * parseInt(size) / 100)) + result = Math.min(result, sizeFull) + return result + } else { + throw 'UnsupportSizeString' + } + } +} + +export const ReUtil = { + match(regex: string, text: string) { + if ('' === regex || null === regex) { + return false + } + if (regex.startsWith('/')) { + const index = regex.lastIndexOf('/') + const source = regex.slice(1, index) + const flags = regex.slice(index + 1) + return (new RegExp(source, flags)).test(text) + } + return (new RegExp(regex)).test(text) + } +} + +const converter = new Showdown.Converter() +export const MarkdownUtil = { + toHtml(markdown: string): string { + return converter.makeHtml(markdown) + }, +} diff --git a/electron/main/fastPanel.ts b/electron/main/fastPanel.ts new file mode 100644 index 0000000..4b8a311 --- /dev/null +++ b/electron/main/fastPanel.ts @@ -0,0 +1,79 @@ +import {icnsLogoPath, icoLogoPath, logoPath} from "../config/icon"; +import {AppRuntime} from "../mapi/env"; +import {AppConfig} from "../../src/config"; +import {isPackaged} from "../lib/env"; +import {WindowConfig} from "../config/window"; +import {preloadDefault, RENDERER_DIST, rendererLoadPath, VITE_DEV_SERVER_URL} from "../lib/env-main"; +import * as remoteMain from "@electron/remote/main"; +import {Page} from "../page"; +import {BrowserWindow} from "electron"; +import path from "node:path"; +import {executeHooks} from "../mapi/manager/lib/hooks"; +import {DevToolsManager} from "../lib/devtools"; + +export const FastPanelMain = { + init() { + const fastPanelHtml = path.join(RENDERER_DIST, 'page/fastPanel.html') + let icon = logoPath + if (process.platform === 'win32') { + icon = icoLogoPath + } else if (process.platform === 'darwin') { + icon = icnsLogoPath + } + AppRuntime.fastPanelWindow = new BrowserWindow({ + show: false, + title: AppConfig.name, + ...(!isPackaged ? {icon} : {}), + frame: false, + transparent: false, + hasShadow: true, + center: true, + useContentSize: true, + minWidth: WindowConfig.fastPanelWidth, + minHeight: WindowConfig.fastPanelHeight, + width: WindowConfig.fastPanelWidth, + height: WindowConfig.fastPanelHeight, + skipTaskbar: true, + resizable: false, + maximizable: false, + backgroundColor: '#f1f5f9', + alwaysOnTop: true, + webPreferences: { + preload: preloadDefault, + // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production + nodeIntegration: true, + contextIsolation: false, + sandbox: false, + webSecurity: false, + webviewTag: true, + }, + }) + + AppRuntime.fastPanelWindow.on('closed', () => { + AppRuntime.fastPanelWindow = null + }) + AppRuntime.fastPanelWindow.on('show', async () => { + await executeHooks(AppRuntime.fastPanelWindow, 'Show') + }); + AppRuntime.fastPanelWindow.on('hide', async () => { + await executeHooks(AppRuntime.fastPanelWindow, 'Hide') + }); + AppRuntime.fastPanelWindow.on('blur', async () => { + AppRuntime.fastPanelWindow.hide() + }) + + rendererLoadPath(AppRuntime.fastPanelWindow, 'page/fastPanel.html') + + remoteMain.enable(AppRuntime.fastPanelWindow.webContents) + + AppRuntime.fastPanelWindow.webContents.on('did-finish-load', () => { + Page.ready('fastPanel') + DevToolsManager.autoShow(AppRuntime.fastPanelWindow) + }) + DevToolsManager.register('FastPanel', AppRuntime.fastPanelWindow) + // AppRuntime.fastPanelWindow.webContents.setWindowOpenHandler(({url}) => { + // if (url.startsWith('https:')) shell.openExternal(url) + // return {action: 'deny'} + // }) + }, +} diff --git a/electron/main/index.ts b/electron/main/index.ts new file mode 100644 index 0000000..5eb432b --- /dev/null +++ b/electron/main/index.ts @@ -0,0 +1,196 @@ +import {app, BrowserWindow, desktopCapturer, session, shell} from 'electron' +import {optimizer} from '@electron-toolkit/utils' + +/** process.js 必须位于非依赖项的顶部 */ +import {isDummy} from "../lib/process"; +import * as remoteMain from '@electron/remote/main'; + +import {AppEnv, AppRuntime} from "../mapi/env"; +import {MAPI} from '../mapi/main'; + +import {WindowConfig} from "../config/window"; +import {AppConfig} from "../../src/config"; +import Log from "../mapi/log/main"; +import {ConfigMenu} from "../config/menu"; +import {ConfigLang} from "../config/lang"; +import {ConfigContextMenu} from "../config/contextMenu"; +import {preloadDefault, rendererLoadPath} from "../lib/env-main"; +import {Page} from "../page"; +import {ConfigTray} from "../config/tray"; +import {icnsLogoPath, icoLogoPath, logoPath} from "../config/icon"; +import {isMac, isPackaged} from "../lib/env"; +import {FastPanelMain} from "./fastPanel"; +import {executeHooks} from "../mapi/manager/lib/hooks"; +import {AppPosition} from "../mapi/app/lib/position"; +import {DevToolsManager} from "../lib/devtools"; +import {AppsMain} from "../mapi/app/main"; + +const isDummyNew = isDummy + +if (process.env['ELECTRON_ENV_PROD']) { + DevToolsManager.setEnable(false) +} + +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); +}); + +process.on('unhandledRejection', (reason) => { + console.error('Unhandled Rejection:', reason); +}); + +// Set application name for Windows 10+ notifications +if (process.platform === 'win32') app.setAppUserModelId(app.getName()) + +if (!app.requestSingleInstanceLock()) { + app.quit() + process.exit(0) +} + +app.disableHardwareAcceleration() +// app.setAccessibilitySupportEnabled(true) + +AppEnv.appRoot = process.env.APP_ROOT +AppEnv.appData = app.getPath('appData') +AppEnv.userData = app.getPath('userData') +AppEnv.isInit = true + +MAPI.init() +ConfigContextMenu.init() + +Log.info('Starting') +Log.info('LaunchInfo', { + isPackaged, + appRoot: AppEnv.appRoot, + appData: AppEnv.appData, + userData: AppEnv.userData, +}) + +async function createWindow() { + let icon = logoPath + if (process.platform === 'win32') { + icon = icoLogoPath + } else if (process.platform === 'darwin') { + icon = icnsLogoPath + } + const {x: wx, y: wy} = AppPosition.get('main', (screenX, screenY, screenWidth, screenHeight) => { + // console.log('calculator', {screenX, screenY, screenWidth, screenHeight}); + return { + x: screenX + screenWidth / 2 - WindowConfig.mainWidth / 2, + y: screenY + screenHeight / 8, + } + }) + AppRuntime.mainWindow = new BrowserWindow({ + show: true, + title: AppConfig.name, + ...(!isPackaged ? {icon} : {}), + frame: false, + transparent: false, + hasShadow: true, + // center: true, + x: wx, + y: wy, + useContentSize: true, + minWidth: WindowConfig.mainWidth, + minHeight: WindowConfig.mainHeight, + width: WindowConfig.mainWidth, + height: WindowConfig.mainHeight, + skipTaskbar: true, + resizable: false, + maximizable: false, + backgroundColor: await AppsMain.defaultDarkModeBackgroundColor(), + webPreferences: { + preload: preloadDefault, + // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production + nodeIntegration: true, + webSecurity: false, + webviewTag: true, + // Consider using contextBridge.exposeInMainWorld + // Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation + contextIsolation: false, + // sandbox: false, + }, + }) + + AppRuntime.mainWindow.on('closed', () => { + AppRuntime.mainWindow = null + }) + AppRuntime.mainWindow.on('show', async () => { + await executeHooks(AppRuntime.mainWindow, 'Show') + }); + AppRuntime.mainWindow.on('hide', async () => { + await executeHooks(AppRuntime.mainWindow, 'Hide') + }); + + rendererLoadPath(AppRuntime.mainWindow, 'index.html') + + remoteMain.enable(AppRuntime.mainWindow.webContents) + AppRuntime.mainWindow.webContents.on('did-finish-load', () => { + Page.ready('main') + DevToolsManager.autoShow(AppRuntime.mainWindow) + }) + AppRuntime.mainWindow.webContents.setWindowOpenHandler(({url}) => { + if (url.startsWith('https://') || url.startsWith('http://')) { + shell.openExternal(url) + } + return {action: 'deny'} + }) + DevToolsManager.register('Main', AppRuntime.mainWindow) + + FastPanelMain.init() +} + +app.whenReady() + .then(() => { + remoteMain.initialize() + session.defaultSession.setDisplayMediaRequestHandler((request, callback) => { + desktopCapturer.getSources({types: ['screen']}).then((sources) => { + // Grant access to the first screen found. + callback({video: sources[0], audio: 'loopback'}) + }) + }) + }) + .then(ConfigLang.readyAsync) + .then(() => { + if (isMac) { + app.dock.hide() + } + MAPI.ready() + ConfigMenu.ready() + ConfigTray.ready() + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + createWindow().then() + }) + +app.on('will-quit', () => { + MAPI.destroy() +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() +}) + +app.on('second-instance', () => { + if (AppRuntime.mainWindow) { + if (AppRuntime.mainWindow.isMinimized()) { + AppRuntime.mainWindow.restore() + } + AppRuntime.mainWindow.show() + AppRuntime.mainWindow.focus() + } +}) + +app.on('activate', () => { + const allWindows = BrowserWindow.getAllWindows() + if (allWindows.length) { + if (!AppRuntime.mainWindow.isVisible()) { + AppRuntime.mainWindow.show() + } + AppRuntime.mainWindow.focus() + } else { + createWindow().then() + } +}) + diff --git a/electron/mapi/app/index.ts b/electron/mapi/app/index.ts new file mode 100644 index 0000000..61a5b27 --- /dev/null +++ b/electron/mapi/app/index.ts @@ -0,0 +1,268 @@ +import util from "node:util"; +import net from "node:net"; +import {exec as _exec, spawn} from "node:child_process"; +import {isLinux, isMac, isWin, platformArch, platformName, platformUUID, platformVersion} from "../../lib/env"; +import {Log} from "../log/index"; +import iconv from "iconv-lite"; +import {ShellUtil, StrUtil} from "../../lib/util"; +import {AppConfig} from "../../../src/config"; + +const exec = util.promisify(_exec) + +const outputStringConvert = (outputEncoding: 'utf8' | 'cp936', data: any) => { + if (!data) { + return '' + } + if (outputEncoding === 'utf8') { + return data.toString() + } + // convert outputEncoding(cp936) to utf8 + return iconv.decode(Buffer.from(data, 'binary'), outputEncoding) +} + +const shell = async (command: string, option?: { + cwd?: string, + outputEncoding?: string, +}) => { + option = Object.assign({ + cwd: process.cwd(), + outputEncoding: isWin ? 'cp936' : 'utf8', + }, option) + const result = await exec(command, { + env: {...process.env}, + shell: true, + encoding: 'binary', + cwd: option['cwd'], + } as any) + return { + stdout: outputStringConvert(option.outputEncoding as any, result.stdout), + stderr: outputStringConvert(option.outputEncoding as any, result.stderr), + } +} + +const spawnShell = async (command: string | string[], option: { + stdout?: (data: string, process: any) => void, + stderr?: (data: string, process: any) => void, + success?: (process: any) => void, + error?: (msg: string, exitCode: number, process: any) => void, + cwd?: string, + outputEncoding?: string, + env?: Record, +} | null = null): Promise<{ + stop: () => void, + send: (data: any) => void, + result: () => Promise +}> => { + option = Object.assign({ + cwd: process.cwd(), + outputEncoding: isWin ? 'cp936' : 'utf8', + env: {}, + }, option) + let commandEntry = '', args = [] + if (Array.isArray(command)) { + commandEntry = command[0] + args = command.slice(1) + } else { + args = ShellUtil.parseCommandArgs(command) + commandEntry = args.shift() as string + } + Log.info('App.spawnShell', { + commandEntry, args, option: { + cwd: option['cwd'], + outputEncoding: option['outputEncoding'], + } + }) + const spawnProcess = spawn(commandEntry, args, { + env: {...process.env, ...option.env}, + cwd: option['cwd'], + shell: true, + encoding: 'binary', + } as any) + let end = false + let isSuccess = false + let exitCode = -1 + const stdoutList: string[] = [] + const stderrList: string[] = [] + const outputStringConvert = (data: any) => { + if (option.outputEncoding === 'utf8') { + return data.toString() + } + // convert outputEncoding(cp936) to utf8 + return iconv.decode(Buffer.from(data, 'binary'), option.outputEncoding) + } + spawnProcess.stdout?.on('data', (data) => { + // console.log('App.spawnShell.stdout', data) + let dataString = outputStringConvert(data) + Log.info('App.spawnShell.stdout', dataString) + stdoutList.push(dataString) + option.stdout?.(dataString, spawnProcess) + }) + spawnProcess.stderr?.on('data', (data) => { + // console.log('App.spawnShell.stderr', data) + let dataString = outputStringConvert(data) + Log.info('App.spawnShell.stderr', dataString) + stderrList.push(dataString) + option.stderr?.(dataString, spawnProcess) + }) + spawnProcess.on('exit', (code) => { + // console.log('App.spawnShell.exit', code) + Log.info('App.spawnShell.exit', JSON.stringify(code)) + exitCode = code + if (isWin) { + if (0 === code || 1 === code) { + isSuccess = true + } + } else { + if (null === code || 0 === code) { + isSuccess = true + } + } + if (isSuccess) { + option.success?.(spawnProcess) + } else { + option.error?.(`command ${command} failed with code ${code}`, exitCode, spawnProcess) + } + end = true + }) + spawnProcess.on('error', (err) => { + // console.log('App.spawnShell.error', err) + Log.info('App.spawnShell.error', err) + option.error?.(err.toString(), -1, spawnProcess) + end = true + }) + return { + stop: () => { + Log.info('App.spawnShell.stop') + if (isWin) { + _exec(`taskkill /pid ${spawnProcess.pid} /T /F`, { + encoding: 'binary' + }, (err, stdout, stderr) => { + if (stdout) { + stdout = outputStringConvert(stdout) + } + if (stderr) { + stderr = outputStringConvert(stderr) + } + Log.info('App.spawnShell.stop.taskkill', JSON.parse(JSON.stringify({err, stdout, stderr}))) + }) + } else { + spawnProcess.kill('SIGINT') + } + }, + send: (data) => { + Log.info('App.spawnShell.send', data) + spawnProcess.stdin.write(data) + }, + result: async (): Promise => { + if (end) { + return stdoutList.join('') + stderrList.join('') + } + return new Promise((resolve, reject) => { + spawnProcess.on('exit', (code) => { + const watchEnd = () => { + setTimeout(() => { + if (!end) { + watchEnd() + return + } + if (isSuccess) { + resolve(stdoutList.join('') + stderrList.join('')) + } else { + reject(`command ${command} failed with code ${exitCode}`) + } + }, 10) + } + watchEnd() + }) + }) + } + } +} + +const availablePortLock: { + [port: number]: { + lockKey: string, + lockTime: number, + }, +} = {} + +/** + * 获取一个可用的端口 + * @param start 开始的端口 + * @param lockKey 锁定的key,避免其他进程获取,默认会创建一个随机的key + * @param lockTime 锁定时间,避免在本次获取后未启动服务导致其他进程重复获取 + */ +const availablePort = async (start: number, lockKey?: string, lockTime?: number): Promise => { + lockKey = lockKey || StrUtil.randomString(8) + lockTime = lockTime || 60 + // expire lock + const now = Date.now() + for (const port in availablePortLock) { + const lockInfo = availablePortLock[port] + if (lockInfo.lockTime < now) { + delete availablePortLock[port] + } + } + for (let i = start; i < 65535; i++) { + const available = await isPortAvailable(i, '0.0.0.0') + const availableLocal = await isPortAvailable(i, '127.0.0.1') + // console.log('isPortAvailable', i, available, availableLocal) + if (available && availableLocal) { + const lockInfo = availablePortLock[i] + if (lockInfo) { + if (lockInfo.lockKey === lockKey) { + return i + } else { + // other lockKey lock the port + continue + } + } + availablePortLock[i] = { + lockKey, + lockTime: Date.now() + lockTime * 1000, + } + return i + } + } + throw new Error('no available port') +} + + +const isPortAvailable = async (port: number, host?: string): Promise => { + return new Promise((resolve) => { + const server = net.createServer() + server.listen(port, host) + server.on('listening', () => { + server.close() + resolve(true) + }) + server.on('error', () => { + resolve(false) + }) + }) +} + +const fixExecutable = async (executable: string) => { + if (isMac || isLinux) { + // chmod +x executable + await shell(`chmod +x "${executable}"`) + } +} + +const getUserAgent = () => { + let param = [] + param.push(`AppOpen/${AppConfig.name}/${AppConfig.version}`) + param.push(`Platform/${platformName()}/${platformArch()}/${platformVersion()}/${platformUUID()}`) + return param.join(' ') +} + +export const Apps = { + shell, + spawnShell, + availablePort, + isPortAvailable, + fixExecutable, + getUserAgent, +} + +export default Apps diff --git a/electron/mapi/app/lib/position.ts b/electron/mapi/app/lib/position.ts new file mode 100644 index 0000000..fbf37a6 --- /dev/null +++ b/electron/mapi/app/lib/position.ts @@ -0,0 +1,93 @@ +import {screen} from "electron"; + +type PositionCache = { + x: 0, + y: 0, + screenWidth: 0, + screenHeight: 0, + id: -1, +} + +export const AppPosition = { + caches: {} as Record, + getCache(name: string): PositionCache { + if (!this.caches[name]) { + this.caches[name] = { + x: 0, + y: 0, + screenWidth: 0, + screenHeight: 0, + id: -1, + } + } + return this.caches[name]; + }, + get(name: string, calculator?: (screenX: number, screenY: number, screenWidth: number, screenHeight: number) => { + x: number, + y: number + }): { + x: number; + y: number, + } { + const cache = this.getCache(name) + const {x, y} = screen.getCursorScreenPoint(); + const currentDisplay = screen.getDisplayNearestPoint({x, y}); + if (cache.id !== currentDisplay.id) { + cache.id = currentDisplay.id; + cache.screenWidth = currentDisplay.workArea.width; + cache.screenHeight = currentDisplay.workArea.height; + if (!calculator) { + calculator = ( + screenX: number, + screenY: number, + screenWidth: number, + screenHeight: number + ) => { + // console.log('calculator', {screenX, screenY, screenWidth, screenHeight}); + return { + x: screenX + screenWidth / 10, + y: screenY + screenHeight / 10, + } + } + } + const res = calculator( + currentDisplay.workArea.x, + currentDisplay.workArea.y, + cache.screenWidth, + cache.screenHeight + ); + cache.x = parseInt(String(res.x)); + cache.y = parseInt(String(res.y)); + } + return { + x: cache.x, + y: cache.y, + }; + }, + set(name: string, x: number, y: number): void { + const cache = this.getCache(name) + cache.x = x; + cache.y = y; + }, + getContextMenuPosition(boxWidth: number, boxHeight: number): { + x: number; + y: number, + } { + const {x, y} = screen.getCursorScreenPoint(); + const currentDisplay = screen.getDisplayNearestPoint({x, y}); + let resultX = x; + let resultY = y; + if (currentDisplay.workArea.width - x < boxWidth) { + resultX = currentDisplay.workArea.width - boxWidth; + } + if (currentDisplay.workArea.height - y < boxHeight) { + resultY = currentDisplay.workArea.height - boxHeight + } + return { + x: resultX, + y: resultY, + } + }, +}; + + diff --git a/electron/mapi/app/main.ts b/electron/mapi/app/main.ts new file mode 100644 index 0000000..3958a88 --- /dev/null +++ b/electron/mapi/app/main.ts @@ -0,0 +1,284 @@ +import {app, BrowserWindow, ipcMain, screen, shell, clipboard, nativeImage, nativeTheme} from "electron"; +import {WindowConfig} from "../../config/window"; +import {AppRuntime} from "../env"; +import {isMac} from "../../lib/env"; +import {AppPosition} from "./lib/position"; +import {Events} from "../event/main"; +import {ConfigMain} from "../config/main"; +import {CommonConfig} from "../../config/common"; +import {preloadDefault} from "../../lib/env-main"; +import {Page} from "../../page"; +import {makeToast} from "./toast"; +import {SetupMain} from "./setup"; + + +const getWindowByName = (name?: string) => { + if (!name || 'main' === name) { + return AppRuntime.mainWindow + } + if ('fastPanel' === name) { + return AppRuntime.fastPanelWindow + } + return AppRuntime.windows[name] +} + +const getCurrentWindow = (window, e) => { + let originWindow = BrowserWindow.fromWebContents(e.sender); + // if (originWindow !== window) originWindow = detachInstance.getWindow(); + return originWindow; +} + + +const quit = () => { + app.quit() +} + +ipcMain.handle('app:quit', () => { + quit() +}) + +const restart = () => { + app.relaunch() +} + +ipcMain.handle('app:restart', () => { + restart() +}) + +const windowMin = (name?: string) => { + getWindowByName(name)?.minimize() +} + +const windowMax = (name?: string) => { + const win = getWindowByName(name) + if (!win) { + return + } + if (win.isFullScreen()) { + win.setFullScreen(false) + win.unmaximize() + win.center() + } else if (win.isMaximized()) { + win.unmaximize() + win.center() + } else { + win.setMinimumSize(WindowConfig.minWidth, WindowConfig.minHeight) + win.maximize() + } +} + +const windowSetSize = (name: string | null, width: number, height: number, option?: { + includeMinimumSize: boolean, + center: boolean +}) => { + width = parseInt(String(width)) + height = parseInt(String(height)) + // console.log('windowSetSize', name, width, height, option) + const win = getWindowByName(name) + if (!win) { + return + } + option = Object.assign({ + includeMinimumSize: true, + center: true + }, option) + if (option.includeMinimumSize) { + win.setMinimumSize(width, height) + } + win.setSize(width, height) + if (option.center) { + win.center() + } +} + +ipcMain.handle('app:openExternalWeb', (event, url: string) => { + return shell.openExternal(url) +}) + +ipcMain.handle('app:getPreload', (event) => { + let preload = preloadDefault + if (!preload.startsWith('file://')) { + preload = `file://${preload}` + } + return preload +}) + +ipcMain.handle('window:min', (event, name: string) => { + windowMin(name) +}) +ipcMain.handle('window:max', (event, name: string) => { + windowMax(name) +}) +ipcMain.handle('window:setSize', (event, name: string | null, width: number, height: number, option?: { + includeMinimumSize: boolean, + center: boolean +}) => { + windowSetSize(name, width, height, option) +}) + +ipcMain.handle('window:close', (event, name: string) => { + getWindowByName(name)?.close() +}) + +const windowOpen = async (name: string, option?: { + singleton?: boolean, +}) => { + name = name || 'main' + const win = getWindowByName(name) + if (win) { + win.show() + return + } + return Page.open(name, option) +} + +ipcMain.handle('window:open', (event, name: string, option: any) => { + return windowOpen(name, option) +}) + +ipcMain.handle('window:hide', (event, name: string) => { + getWindowByName(name)?.hide() + if (isMac) { + app.dock.hide() + } +}) + +ipcMain.handle('window:move', (event, name: string | null, data: { + mouseX: number, + mouseY: number, + width: number, + height: number +}) => { + const {x, y} = screen.getCursorScreenPoint(); + const originWindow = getWindowByName(name); + if (!originWindow) return; + originWindow.setBounds({x: x - data.mouseX, y: y - data.mouseY, width: data.width, height: data.height}); + AppPosition.set(name, x - data.mouseX, y - data.mouseY); +}) + + +const getClipboardText = () => { + return clipboard.readText('clipboard') +} + +ipcMain.handle('app:getClipboardText', (event) => { + return getClipboardText() +}) + +const setClipboardText = (text: string) => { + clipboard.writeText(text, 'clipboard') +} + +ipcMain.handle('app:setClipboardText', (event, text: string) => { + setClipboardText(text) +}) + +const getClipboardImage = () => { + const image = clipboard.readImage('clipboard') + return image.isEmpty() ? '' : image.toDataURL() +} + +ipcMain.handle('app:getClipboardImage', (event) => { + return getClipboardImage() +}) + +const setClipboardImage = (image: string) => { + const img = nativeImage.createFromDataURL(image) + clipboard.writeImage(img, 'clipboard') +} + +ipcMain.handle('app:setClipboardImage', (event, image: string) => { + setClipboardImage(image) +}) + +const isDarkMode = () => { + if (!CommonConfig.darkModeEnable) { + return false; + } + return nativeTheme.shouldUseDarkColors +} + +const shouldDarkMode = async () => { + if (!CommonConfig.darkModeEnable) { + return false; + } + const darkMode = (await ConfigMain.get('darkMode')) || 'auto' + if ('dark' === darkMode) { + return true + } else if ('light' === darkMode) { + return false + } else if ('auto' === darkMode) { + return isDarkMode() + } + return false +} + +const defaultDarkModeBackgroundColor = async () => { + if (await shouldDarkMode()) { + return '#17171A' + } + return '#FFFFFF' +} + +nativeTheme.on('updated', () => { + Events.broadcast('DarkModeChange', {isDarkMode: isDarkMode()}) +}) + +ipcMain.handle('app:isDarkMode', () => { + return isDarkMode() +}) + +const getCurrentScreenDisplay = () => { + const screenPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(screenPoint); + return { + bounds: display.bounds, + workArea: display.workArea, + } +} + +const toast = (msg: string, options?: { + duration?: number, + status?: 'success' | 'error' | 'info' +}) => { + return makeToast(msg, options) +} + +ipcMain.handle('app:toast', (event, msg: string, option?: any) => { + return toast(msg, option) +}) + +ipcMain.handle('app:setupList', async () => { + return SetupMain.list() +}) + +ipcMain.handle('app:setupOpen', async (event, name: string) => { + return SetupMain.open(name) +}) + + +const setupIsOk = async () => { + return SetupMain.isOk() +} + +ipcMain.handle('app:setupIsOk', async () => { + return setupIsOk() +}) + +export default { + quit +} + +export const AppsMain = { + shouldDarkMode, + defaultDarkModeBackgroundColor, + getWindowByName, + getClipboardText, + setClipboardText, + getClipboardImage, + setClipboardImage, + getCurrentScreenDisplay, + toast, + setupIsOk, + windowOpen, +} diff --git a/electron/mapi/app/render.ts b/electron/mapi/app/render.ts new file mode 100644 index 0000000..0f4b5da --- /dev/null +++ b/electron/mapi/app/render.ts @@ -0,0 +1,146 @@ +import {ipcRenderer} from "electron"; +import {resolve} from "node:path"; +import {isPackaged, platformName, platformArch} from "../../lib/env"; +import {AppEnv, waitAppEnvReady} from "../env"; +import appIndex from "./index"; + +const isDarkMode = async () => { + return ipcRenderer.invoke('app:isDarkMode') +} + +const quit = () => { + return ipcRenderer.invoke('app:quit') +} + +const restart = () => { + return ipcRenderer.invoke('app:restart') +} + +const isPlatform = (name: 'win' | 'osx' | 'linux') => { + return platformName() === name +} + +const windowMin = (name?: string) => { + return ipcRenderer.invoke('window:min', name) +} + +const windowMax = (name?: string) => { + return ipcRenderer.invoke('window:max', name) +} + +const windowSetSize = (name: string | null, width: number, height: number, option?: { + includeMinimumSize: boolean, + center: boolean +}) => { + return ipcRenderer.invoke('window:setSize', name, width, height, option) +} + +const windowOpen = (name: string, option: any) => { + return ipcRenderer.invoke('window:open', name, option) +} + +const windowHide = (name: string) => { + return ipcRenderer.invoke('window:hide', name) +} + +const windowClose = (name: string) => { + return ipcRenderer.invoke('window:close', name) +} + +const windowMove = (name: string | null, data: { mouseX: number, mouseY: number, width: number, height: number }) => { + return ipcRenderer.invoke('window:move', name, data) +} + +const openExternalWeb = (url: string) => { + return ipcRenderer.invoke('app:openExternalWeb', url) +} + +const getPreload = async () => { + return ipcRenderer.invoke('app:getPreload') +} + +const resourcePathResolve = async (filePath: string) => { + await waitAppEnvReady() + const basePath = isPackaged ? process.resourcesPath : AppEnv.appRoot + return resolve(basePath, filePath) +} + +const extraPathResolve = async (filePath: string) => { + await waitAppEnvReady() + const basePath = isPackaged ? process.resourcesPath : 'electron/resources' + return resolve(basePath, 'extra', filePath) +} + +const appEnv = async () => { + await waitAppEnvReady() + return AppEnv +} + +const getClipboardText = () => { + return ipcRenderer.invoke('app:getClipboardText') +} + +const setClipboardText = (text: string) => { + return ipcRenderer.invoke('app:setClipboardText', text) +} + +const getClipboardImage = () => { + return ipcRenderer.invoke('app:getClipboardImage') +} + +const setClipboardImage = (image: string) => { + return ipcRenderer.invoke('app:setClipboardImage', image) +} + +const toast = (msg: string, option?: any) => { + return ipcRenderer.invoke('app:toast', msg, option) +} + +const setupList = () => { + return ipcRenderer.invoke('app:setupList') +} + +const setupOpen = (name: string) => { + return ipcRenderer.invoke('app:setupOpen', name) +} + +const setupIsOk = async () => { + return ipcRenderer.invoke('app:setupIsOk') +} + +export const AppsRender = { + isDarkMode, + resourcePathResolve, + extraPathResolve, + platformName, + platformArch, + isPlatform, + quit, + restart, + windowMin, + windowMax, + windowSetSize, + windowOpen, + windowHide, + windowClose, + windowMove, + openExternalWeb, + getPreload, + appEnv, + getClipboardText, + setClipboardText, + getClipboardImage, + setClipboardImage, + toast, + setupList, + setupOpen, + setupIsOk, + shell: appIndex.shell, + spawnShell: appIndex.spawnShell, + availablePort: appIndex.availablePort, + fixExecutable: appIndex.fixExecutable, + getUserAgent: appIndex.getUserAgent, +} + +export default AppsRender + diff --git a/electron/mapi/app/setup.ts b/electron/mapi/app/setup.ts new file mode 100644 index 0000000..dede5a9 --- /dev/null +++ b/electron/mapi/app/setup.ts @@ -0,0 +1,52 @@ +import {Permissions} from "../../lib/permission"; +import {rendererDistPath} from "../../lib/env-main"; + +export const SetupMain = { + async isOk() { + if (!await Permissions.checkAccessibilityAccess()) { + return false + } + if (!await Permissions.checkScreenCaptureAccess()) { + return false + } + return true + }, + async list() { + return [ + { + name: 'accessibility', + title: '辅助功能', + status: (await Permissions.checkAccessibilityAccess()) ? 'success' : 'fail', + desc: '系统运行需要依赖辅助功能,请打开设置,找到辅助功能,开启本软件的辅助功能。', + steps: [ + { + title: '打开 设置 → 隐私与安全性 → 辅助功能,开启本软件', + image: rendererDistPath('setup/accessibility.png') + }, + ] + }, + { + name: 'screen', + title: '屏幕录制', + status: (await Permissions.checkScreenCaptureAccess()) ? 'success' : 'fail', + desc: '系统运行需要依赖屏幕录制,请打开设置,找到屏幕录制,开启本软件的屏幕录制权限。', + steps: [ + { + title: '打开 设置 → 隐私与安全性 → 录屏与系统录音,开启本软件', + image: rendererDistPath('setup/screen.png') + }, + ] + } + ] + }, + async open(name: string) { + switch (name) { + case 'accessibility': + Permissions.askAccessibilityAccess().then() + break + case 'screen': + Permissions.askScreenCaptureAccess().then() + break + } + } +} diff --git a/electron/mapi/app/toast.ts b/electron/mapi/app/toast.ts new file mode 100644 index 0000000..5a6adf0 --- /dev/null +++ b/electron/mapi/app/toast.ts @@ -0,0 +1,124 @@ +import {BrowserWindow, screen} from "electron"; +import {AppsMain} from "./main"; + +const icons = { + success: '', + error: '', + info:'', +} +let win = null +let winCloseTimer = null + +export const makeToast = (msg: string, options?: { + duration?: number, + status?: 'success' | 'error' | 'info' +}) => { + + if (win) { + win.close() + clearTimeout(winCloseTimer) + win = null + winCloseTimer = null + } + + options = Object.assign({ + status: 'info', + duration: 0 + }, options) + + if (options.duration === 0) { + options.duration = Math.max(msg.length * 400, 3000) + } + // console.log('options', options) + + const display = AppsMain.getCurrentScreenDisplay() + // console.log('xxxx', primaryDisplay); + const width = display.workArea.width + const height = 60 + const icon = icons[options.status] || icons.success + + win = new BrowserWindow({ + height, + width, + x: 0, + y: 0, + modal: false, + frame: false, + alwaysOnTop: true, + // opacity: 0.9, + center: false, + transparent: true, + hasShadow: false, + show: false, + focusable: false, + skipTaskbar: true, + }) + const htmlContent = ` + + + + + + +
+
${icon}${msg}
+
+ + +`; + + const encodedHTML = encodeURIComponent(htmlContent); + win.loadURL(`data:text/html;charset=UTF-8,${encodedHTML}`); + win.on('ready-to-show', async () => { + const width = Math.ceil(await win.webContents.executeJavaScript(`(()=>{ + const message = document.getElementById('message'); + const width = message.scrollWidth; + return width; + })()`)) + win.setSize(width + 20, height) + const x = display.workArea.x + (display.workArea.width / 2) - ((width + 20) / 2) + const y = display.workArea.y + (display.workArea.height * 2 / 3) + win.setPosition(Math.floor(x), Math.floor(y)) + win.show() + // win.webContents.openDevTools({ + // mode: 'detach' + // }) + }) + winCloseTimer = setTimeout(() => { + win.close() + clearTimeout(winCloseTimer) + win = null + winCloseTimer = null + }, options.duration) +} diff --git a/electron/mapi/config/main.ts b/electron/mapi/config/main.ts new file mode 100644 index 0000000..644a9ba --- /dev/null +++ b/electron/mapi/config/main.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import {AppEnv} from "../env"; +import fs from "node:fs"; +import {dialog, ipcMain} from "electron"; +import {Events} from "../event/main"; + +let data = null + +const configPath = () => { + return path.join(AppEnv.userData, 'config.json') +} + +const load = () => { + try { + let json = fs.readFileSync(configPath()).toString() + json = JSON.parse(json) + data = json || {} + } catch (e) { + data = {} + } +} + +const loadIfNeed = () => { + if (data === null) { + load() + } +} + +const save = () => { + fs.writeFileSync(configPath(), JSON.stringify(data, null, 4)) +} + +const all = async () => { + loadIfNeed() + return data +} + +const get = async (key: string, defaultValue: any = null) => { + loadIfNeed() + if (!(key in data)) { + data[key] = defaultValue + save() + } + return data[key] +} + +const set = async (key: string, value: any) => { + loadIfNeed() + data[key] = value + save() +} +ipcMain.handle('config:all', async (_) => { + return await all() +}) +ipcMain.handle('config:get', async (_, key: string, defaultValue: any = null) => { + return await get(key, defaultValue) +}) +ipcMain.handle('config:set', async (_, key: string, value: any) => { + const res = await set(key, value) + Events.broadcast('ConfigChange', {key, value}) + return res +}) + +export default { + all, + get, + set, +} + +export const ConfigMain = { + all, + get, + set +} diff --git a/electron/mapi/config/render.ts b/electron/mapi/config/render.ts new file mode 100644 index 0000000..1721320 --- /dev/null +++ b/electron/mapi/config/render.ts @@ -0,0 +1,22 @@ +import {ipcRenderer} from "electron"; + +let data = null + + +const all = async () => { + return ipcRenderer.invoke('config:all') +} + +const get = async (key: string, defaultValue: any = null) => { + return ipcRenderer.invoke('config:get', key, defaultValue) +} + +const set = async (key: string, value: any) => { + return ipcRenderer.invoke('config:set', key, value) +} + +export default { + all, + get, + set, +} diff --git a/electron/mapi/db/db.ts b/electron/mapi/db/db.ts new file mode 100644 index 0000000..e69de29 diff --git a/electron/mapi/db/main.ts b/electron/mapi/db/main.ts new file mode 100644 index 0000000..cbac6a0 --- /dev/null +++ b/electron/mapi/db/main.ts @@ -0,0 +1,157 @@ +import sqlite3, {Database} from 'sqlite3'; +import path from "node:path"; +import migration from './migration'; +import {AppEnv} from "../env"; +import {Log} from "../log/main"; +import {ipcMain} from "electron"; + +let dbPath: string | null = null +let dbConn: Database | null = null; +let dbSuccess = false; + +const db = { + _check() { + if (!dbSuccess) { + throw 'DBNotInitialized' + } + }, + async execute(sql: string, params: any = []): Promise { + db._check() + return new Promise((resolve, reject) => { + dbConn.prepare(sql).run(...params, function (err) { + if (err) { + reject(err); + } else { + resolve(undefined); + } + }); + }); + }, + async insert(sql: string, params: any = []): Promise { + db._check() + return new Promise((resolve, reject) => { + dbConn.prepare(sql).run(...params, function (err) { + if (err) { + reject(err); + } else { + resolve(this.lastID); + } + }); + }); + }, + async first(sql: string, params: any = []): Promise { + db._check() + return new Promise((resolve, reject) => { + dbConn.prepare(sql).get(...params, (err, row) => { + if (err) { + reject(err); + } else { + resolve(row); + } + }); + }); + }, + async select(sql: string, params: any = []): Promise { + db._check() + return new Promise((resolve, reject) => { + dbConn.prepare(sql).all(...params, (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }); + }); + }, + async update(sql: string, params: any = []): Promise { + db._check() + return new Promise((resolve, reject) => { + dbConn.prepare(sql).run(...params, function (err) { + if (err) { + reject(err); + } else { + resolve(this.changes); + } + }); + }); + }, + async delete(sql: string, params: any = []): Promise { + db._check() + return new Promise((resolve, reject) => { + dbConn.prepare(sql).run(...params, function (err) { + if (err) { + reject(err); + } else { + resolve(this.changes); + } + }); + }); + }, +} + + +const migrate = async () => { + await db.execute(`CREATE TABLE IF NOT EXISTS migrate + ( + id + INTEGER + PRIMARY + KEY, + version + INTEGER + )`); + for (const version of migration.versions) { + const result = await db.first(`SELECT * + FROM migrate + WHERE version = ?`, [version.version]); + if (!result) { + Log.info(`Migrating to version ${version.version}`); + await version.up(db); + await db.execute(`INSERT INTO migrate (version) + VALUES (?)`, [version.version]); + } + } +} + +const init = () => { + dbPath = path.join(AppEnv.userData, 'database.db') + dbConn = new sqlite3.Database(dbPath, (err) => { + if (err) { + Log.error('DBConnect SQLite database failed:', err.message); + } else { + dbSuccess = true; + migrate().then() + } + }); +} + +ipcMain.handle('db:execute', (event, sql: string, params: any) => { + return db.execute(sql, params); +}) +ipcMain.handle('db:insert', (event, sql: string, params: any) => { + return db.insert(sql, params); +}) +ipcMain.handle('db:first', (event, sql: string, params: any) => { + return db.first(sql, params); +}) +ipcMain.handle('db:select', (event, sql: string, params: any) => { + return db.select(sql, params); +}) +ipcMain.handle('db:update', (event, sql: string, params: any) => { + return db.update(sql, params); +}) +ipcMain.handle('db:delete', (event, sql: string, params: any) => { + return db.delete(sql, params); +}) + +export const DBMain = { + init, + execute: db.execute, + insert: db.insert, + first: db.first, + select: db.select, + update: db.update, + delete: db.delete +} + +export default DBMain diff --git a/electron/mapi/db/migration.ts b/electron/mapi/db/migration.ts new file mode 100644 index 0000000..baa4e96 --- /dev/null +++ b/electron/mapi/db/migration.ts @@ -0,0 +1,33 @@ +const versions = [ + { + version: 0, + up: async (db: DB) => { + // await db.execute(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`); + // console.log('db.insert', await db.insert(`INSERT INTO users (name, email) VALUES (?, ?)`,['Alice', 'alice@example.com'])); + // console.log('db.select', await db.select(`SELECT * FROM users`)); + // console.log('db.first', await db.first(`SELECT * FROM users`)); + } + }, + { + version: 1, + up: async (db: DB) => { + await db.execute(`CREATE TABLE IF NOT EXISTS kvdb_data + ( + id TEXT PRIMARY KEY, + cloudVersion INTEGER, + version INTEGER, + isDeleted INTEGER, + name TEXT + )`); + await db.execute(`CREATE INDEX IF NOT EXISTS idx_kvdb_data_name + ON kvdb_data (name) + `); + } + }, +] + +export default { + versions, +} + + diff --git a/electron/mapi/db/render.ts b/electron/mapi/db/render.ts new file mode 100644 index 0000000..10235db --- /dev/null +++ b/electron/mapi/db/render.ts @@ -0,0 +1,40 @@ +import {ipcRenderer} from "electron"; + + +const init = () => { + +} + +const execute = async (sql: string, params: any = []) => { + return ipcRenderer.invoke('db:execute', sql, params) +} + +const insert = async (sql: string, params: any = []) => { + return ipcRenderer.invoke('db:insert', sql, params) +} + +const first = async (sql: string, params: any = []) => { + return ipcRenderer.invoke('db:first', sql, params) +} + +const select = async (sql: string, params: any = []) => { + return ipcRenderer.invoke('db:select', sql, params) +} + +const update = async (sql: string, params: any = []) => { + return ipcRenderer.invoke('db:update', sql, params) +} + +const deletes = async (sql: string, params: any = []) => { + return ipcRenderer.invoke('db:delete', sql, params) +} + +export default { + init, + execute, + insert, + first, + select, + update, + delete: deletes +} diff --git a/electron/mapi/db/type.d.ts b/electron/mapi/db/type.d.ts new file mode 100644 index 0000000..6d7c596 --- /dev/null +++ b/electron/mapi/db/type.d.ts @@ -0,0 +1,8 @@ +type DB = { + execute(sql: string, params?: any): Promise; + insert(sql: string, params?: any): Promise; + first(sql: string, params?: any): Promise; + select(sql: string, params?: any): Promise; + update(sql: string, params?: any): Promise; + delete(sql: string, params?: any): Promise; +} diff --git a/electron/mapi/env.ts b/electron/mapi/env.ts new file mode 100644 index 0000000..f0e3662 --- /dev/null +++ b/electron/mapi/env.ts @@ -0,0 +1,23 @@ +import {BrowserWindow} from "electron"; + +export const AppEnv = { + isInit: false, + appRoot: null as string, + appData: null as string, + userData: null as string, +} + +export const AppRuntime = { + splashWindow: null as BrowserWindow, + mainWindow: null as BrowserWindow, + fastPanelWindow: null as BrowserWindow, + windows: {} as Record, +} + +export const waitAppEnvReady = async () => { + while (!AppEnv.isInit) { + await new Promise(resolve => { + setTimeout(resolve, 1000) + }) + } +} diff --git a/electron/mapi/event/main.ts b/electron/mapi/event/main.ts new file mode 100644 index 0000000..a0cdedb --- /dev/null +++ b/electron/mapi/event/main.ts @@ -0,0 +1,177 @@ +import {AppRuntime} from "../env"; +import {ipcMain} from "electron"; +import {StrUtil} from "../../lib/util"; +import {ManagerWindow} from "../manager/window"; + + +const init = () => { + +} + +type NameType = 'main' | 'fastPanel' | string +type EventType = 'APP_READY' | 'CALL_PAGE' | 'CHANNEL' | 'BROADCAST' +type BroadcastType = 'ConfigChange' | 'UserChange' | 'DarkModeChange' | 'HotkeyWatch' | 'Notice' + +const broadcast = (type: BroadcastType, data: any, option?: { + limit?: boolean, + scopes?: string[] +}) => { + data = data || {} + option = Object.assign({ + limit: false, + scopes: [] + }, option) + if (!option.limit || option.scopes.includes('main')) { + send('main', 'BROADCAST', {type, data}) + } + if (!option.limit || option.scopes.includes('pages')) { + for (let name in AppRuntime.windows) { + send(name, 'BROADCAST', {type, data}) + } + } + if (!option.limit || option.scopes.includes('fastPanel')) { + send('fastPanel', 'BROADCAST', {type, data}) + } + if (!option.limit || option.scopes.includes('views')) { + for (const view of ManagerWindow.listBrowserViews()) { + view.webContents.send('MAIN_PROCESS_MESSAGE', { + id: StrUtil.randomString(32), + type: 'BROADCAST', + data: {type, data} + }) + } + } + if (!option.limit || option.scopes.includes('detachWindows')) { + for (const win of ManagerWindow.listDetachWindows()) { + win.webContents.send('MAIN_PROCESS_MESSAGE', { + id: StrUtil.randomString(32), + type: 'BROADCAST', + data: {type, data} + }) + } + } +} + +const sendRaw = (webContents: any, type: EventType, data: any = {}, id?: string): boolean => { + id = id || StrUtil.randomString(32) + const payload = {id, type, data} + webContents.send('MAIN_PROCESS_MESSAGE', payload) + return true +} + +const send = (name: NameType, type: EventType, data: any = {}, id?: string): boolean => { + id = id || StrUtil.randomString(32) + const payload = {id, type, data} + if (name === 'main') { + if (!AppRuntime.mainWindow) { + return false + } + // console.log('send', payload) + AppRuntime.mainWindow?.webContents.send('MAIN_PROCESS_MESSAGE', payload) + } else if (name === 'fastPanel') { + if (!AppRuntime.fastPanelWindow) { + return false + } + AppRuntime.fastPanelWindow?.webContents.send('MAIN_PROCESS_MESSAGE', payload) + } else { + if (!AppRuntime.windows[name]) { + return false + } + AppRuntime.windows[name]?.webContents.send('MAIN_PROCESS_MESSAGE', payload) + } + return true +} + +ipcMain.handle('event:send', async (_, name: NameType, type: EventType, data: any) => { + send(name, type, data) +}) + +const callPage = async (name: string, type: string, data: any, option?: { + waitReadyTimeout?: number, + timeout?: number +}) => { + option = Object.assign({ + waitReadyTimeout: 10 * 1000, + timeout: 10 * 1000 + }, option) + return new Promise((resolve, reject) => { + const id = StrUtil.randomString(32) + const timer = setTimeout(() => { + ipcMain.removeListener(listenerKey, listener) + resolve({code: -1, msg: 'timeout'}) + }, option.timeout) + const listener = (_, result) => { + clearTimeout(timer) + resolve(result) + return true + } + const listenerKey = 'event:callPage:' + id + ipcMain.once(listenerKey, listener) + const payload = { + type, + data, + option: { + waitReadyTimeout: option.waitReadyTimeout + } + } + if (!send(name, 'CALL_PAGE', payload, id)) { + clearTimeout(timer) + ipcMain.removeListener(listenerKey, listener) + resolve({code: -1, msg: 'send failed'}) + } + }) +} + +ipcMain.handle('event:callPage', async (_, name: string, type: string, data: any, option?: {}) => { + return callPage(name, type, data, option) +}) + +let onChannelIsListen = false +let channelOnCallback = {} + +const sendChannel = (channel: string, data: any) => { + send('main', 'CHANNEL', {channel, data}) +} + +const onChannel = (channel: string, callback: (data: any) => void) => { + if (!channelOnCallback[channel]) { + channelOnCallback[channel] = [] + } + channelOnCallback[channel].push(callback) + if (!onChannelIsListen) { + onChannelIsListen = true + ipcMain.handle('event:channelSend', (event, channel_, data) => { + if (channelOnCallback[channel_]) { + channelOnCallback[channel_].forEach((callback: (data: any) => void) => { + callback(data) + }) + } + }) + } +} + +const offChannel = (channel: string, callback: (data: any) => void) => { + if (channelOnCallback[channel]) { + channelOnCallback[channel] = channelOnCallback[channel].filter((item: (data: any) => void) => { + return item !== callback + }) + } + if (channelOnCallback[channel].length === 0) { + delete channelOnCallback[channel] + } +} + +export default { + init, + send +} + +export const Events = { + broadcast, + send, + sendRaw, + sendChannel, + callPage, + onChannel, + offChannel, +} diff --git a/electron/mapi/event/render.ts b/electron/mapi/event/render.ts new file mode 100644 index 0000000..9c07ed9 --- /dev/null +++ b/electron/mapi/event/render.ts @@ -0,0 +1,24 @@ +import {ipcRenderer} from "electron"; + +const init = () => { + +} + +const send = (name: string, type: string, data: any = {}) => { + return ipcRenderer.invoke('event:send', name, type, data).then() +} + +const callPage = async (name: string, type: string, data: any, option: any) => { + return ipcRenderer.invoke('event:callPage', name, type, data, option) +} + +const channelSend = async (channel: string, data: any) => { + return ipcRenderer.invoke('event:channelSend', channel, data) +} + +export default { + init, + send, + callPage, + channelSend, +} diff --git a/electron/mapi/ffmpeg/render.ts b/electron/mapi/ffmpeg/render.ts new file mode 100644 index 0000000..7f58bc3 --- /dev/null +++ b/electron/mapi/ffmpeg/render.ts @@ -0,0 +1,27 @@ +import ffmpegPath from "ffmpeg-static"; +import {Apps} from "../app"; +import {binResolve, isPackaged} from "../../lib/env"; + +const getBinPath = () => { + if (isPackaged) { + return binResolve('ffmpeg/ffmpeg') + } + return ffmpegPath +} + +const version = async () => { + const controller = await Apps.spawnShell(`${getBinPath()} -version`) + const text = await controller.result() + const match = text.match(/ffmpeg version ([\d.]+)/) + return match ? match[1] : '' +} + +const run = async (args: string[]) => { + const controller = await Apps.spawnShell(`${getBinPath()} ${args.join(' ')}`) + return await controller.result() +} + +export default { + version, + run, +} diff --git a/electron/mapi/file/index.ts b/electron/mapi/file/index.ts new file mode 100644 index 0000000..86cebf9 --- /dev/null +++ b/electron/mapi/file/index.ts @@ -0,0 +1,543 @@ +import path from "node:path"; +import {AppEnv, waitAppEnvReady} from "../env"; +import fs from "node:fs"; +import {StrUtil, TimeUtil} from "../../lib/util"; +import Apps from "../app"; +import {Readable} from "node:stream"; + +const nodePath = path + +const root = () => { + return path.join(AppEnv.userData, 'data') +} + +const absolutePath = (path: string) => { + return `ABS://${path}` +} + +const fullPath = async (path: string) => { + await waitAppEnvReady() + if (path.startsWith('ABS://')) { + return path.replace(/^ABS:\/\//, '') + } + return nodePath.join(root(), path) +} + +const exists = async (path: string, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + return new Promise((resolve, reject) => { + fs.stat(fp, (err, stat) => { + if (err) { + resolve(false) + } else { + resolve(true) + } + }) + }) +} + +const isDirectory = async (path: string, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (!fs.existsSync(fp)) { + return false + } + return fs.statSync(fp).isDirectory() +} + +const mkdir = async (path: string, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (!fs.existsSync(fp)) { + fs.mkdirSync(fp, {recursive: true}) + } +} + +const list = async (path: string, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (!fs.existsSync(fp)) { + return [] + } + const files = fs.readdirSync(fp) + return files.map(file => { + const stat = fs.statSync(nodePath.join(fp, file)) + let f = { + name: file, + pathname: nodePath.join(fp, file), + isDirectory: stat.isDirectory(), + size: stat.size, + lastModified: stat.mtimeMs, + } + return f + }) +} + +const listAll = async (path: string, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (!fs.existsSync(fp)) { + return [] + } + const listDirectory = (path: string, basePath: string = '') => { + let files = [] + const list = fs.readdirSync(path) + for (let file of list) { + const stat = fs.statSync(nodePath.join(path, file)) + let fPath = nodePath.join(basePath, file) + fPath = fPath.replace(/\\/g, '/') + let f = { + name: file, + path: fPath, + isDirectory: stat.isDirectory(), + size: stat.size, + lastModified: stat.mtimeMs, + } + if (f.isDirectory) { + files = files.concat(listDirectory(nodePath.join(path, file), f.path)) + continue + } + files.push(f) + } + return files + } + return listDirectory(fp) +} + +const write = async (path: string, data: any, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + const fullPathDir = nodePath.dirname(fp) + if (!fs.existsSync(fullPathDir)) { + fs.mkdirSync(fullPathDir, {recursive: true}) + } + if (typeof data === 'string') { + data = { + content: data, + } + } + const f = fs.openSync(fp, 'w') + fs.writeSync(f, data.content) + fs.closeSync(f) +} + +const writeBuffer = async (path: string, data: any, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + const fullPathDir = nodePath.dirname(fp) + if (!fs.existsSync(fullPathDir)) { + fs.mkdirSync(fullPathDir, {recursive: true}) + } + const f = fs.openSync(fp, 'w') + fs.writeSync(f, data) + fs.closeSync(f) +} + +const read = async (path: string, option?: { + isFullPath?: boolean, + encoding?: string, +}) => { + option = Object.assign({ + isFullPath: false, + encoding: 'utf8' + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (!fs.existsSync(fp)) { + return null + } + const f = fs.openSync(fp, 'r') + const content = fs.readFileSync(f, { + encoding: option.encoding as BufferEncoding + }) + fs.closeSync(f) + return content +} + +const readBuffer = async (path: string, option?: { isFullPath?: boolean, }): Promise => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (!fs.existsSync(fp)) { + return null + } + return new Promise((resolve, reject) => { + fs.readFile(fp, (err, data) => { + if (err) { + reject(err) + return + } + resolve(data) + }) + }) +} + +const deletes = async (path: string, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (!await exists(fp, { + isFullPath: true + })) { + return + } + return new Promise((resolve, reject) => { + fs.stat(fp, (err, stat) => { + if (err) { + reject(err) + return + } + if (stat.isDirectory()) { + fs.rmdir(fp, {recursive: true}, (err) => { + if (err) { + reject(err) + return + } + resolve(undefined) + }) + } else { + fs.unlink(fp, (err) => { + if (err) { + reject(err) + return + } + resolve(undefined) + }) + } + }) + }) +} +const rename = async (pathOld: string, pathNew: string, option?: { + isFullPath?: boolean, + overwrite?: boolean +}) => { + option = Object.assign({ + isFullPath: false, + overwrite: false, + }, option) + let fullPathOld = pathOld + let fullPathNew = pathNew + if (!option.isFullPath) { + fullPathOld = await fullPath(pathOld) + fullPathNew = await fullPath(pathNew) + } + if (!fs.existsSync(fullPathOld)) { + throw new Error(`FileNotFound:${fullPathOld}`) + } + if (fs.existsSync(fullPathNew)) { + if (!option.overwrite) { + throw new Error(`FileAlreadyExists:${fullPathNew}`) + } + fs.unlinkSync(fullPathNew) + } + const dir = nodePath.dirname(fullPathNew) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true}) + } + fs.renameSync(fullPathOld, fullPathNew) +} + +const copy = async (pathOld: string, pathNew: string, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fullPathOld = pathOld + let fullPathNew = pathNew + if (!option.isFullPath) { + fullPathOld = await fullPath(pathOld) + fullPathNew = await fullPath(pathNew) + } + if (!fs.existsSync(fullPathOld)) { + throw new Error(`FileNotFound:${fullPathOld}`) + } + if (fs.existsSync(fullPathNew)) { + throw new Error(`FileAlreadyExists:${fullPathNew}`) + } + // console.log('copy', fullPathOld, fullPathNew) + const dir = nodePath.dirname(fullPathNew) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true}) + } + fs.copyFileSync(fullPathOld, fullPathNew) +} + +const tempRoot = async () => { + await waitAppEnvReady() + const tempDir = path.join(AppEnv.userData, 'temp') + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, {recursive: true}) + } + return tempDir +} + +const temp = async (ext: string = 'tmp', prefix: string = 'file') => { + const root = await tempRoot() + const p = [ + prefix, + TimeUtil.timestampInMs(), + StrUtil.randomString(32), + ].join('_') + return path.join(root, `${p}.${ext}`) +} + +const tempDir = async (prefix: string = 'dir') => { + const root = await tempRoot() + const p = [ + prefix, + TimeUtil.timestampInMs(), + StrUtil.randomString(32), + ].join('_') + const dir = path.join(root, p) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, {recursive: true}) + } + return dir +} + +const watchText = async (path: string, callback: (data: {}) => void, option?: { + isFullPath?: boolean, + limit?: number, +}): Promise<{ + stop: Function, +}> => { + if (!path) { + throw new Error('path is empty') + } + option = Object.assign({ + isFullPath: false, + limit: 0, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + let watcher = null + let fd = null + let isFirstReading = true + let firstReadingLines = [] + const watchFileExists = () => { + if (fs.existsSync(fp)) { + watcher = null + watchFileContent() + return + } + watcher = setTimeout(() => { + watchFileExists() + }, 1000) + } + const watchFileContent = () => { + const CHUNK_SIZE = 16 * 1024; + const fd = fs.openSync(fp, 'r') + let position = 0 + let lineNumber = 0 + let content = '' + const parseContentLine = () => { + while (true) { + const index = content.indexOf('\n') + if (index < 0) { + break + } + const line = content.substring(0, index) + content = content.substring(index + 1) + const lineItem = { + num: lineNumber++, + text: line, + } + if (option.limit > 0 && isFirstReading) { + // 限制显示模式并且是第一次读取,暂时先不回调 + firstReadingLines.push(lineItem) + while (firstReadingLines.length >= option.limit) { + firstReadingLines.shift() + } + } else { + callback(lineItem) + } + // console.log('watchText.line', line, content) + } + } + const readChunk = () => { + const buf = new Buffer(CHUNK_SIZE); + const bytesRead = fs.readSync(fd, buf, 0, CHUNK_SIZE, position) + position += bytesRead + content += buf.toString('utf8', 0, bytesRead) + parseContentLine() + if (bytesRead < CHUNK_SIZE) { + isFirstReading = false + if (firstReadingLines.length > 0) { + firstReadingLines.forEach((lineItem) => { + callback(lineItem) + }) + firstReadingLines = [] + } + watcher = setTimeout(readChunk, 1000); + } else { + readChunk() + } + } + readChunk() + } + watchFileExists() + const stop = () => { + // console.log('watchText stop', fp) + if (fd) { + fs.closeSync(fd) + } + if (watcher) { + clearTimeout(watcher) + } + } + // console.log('watchText', fp) + return { + stop, + } +} + +let appendTextPathCached = null +let appendTextStreamCached = null + +const appendText = async (path: string, data: any, option?: { isFullPath?: boolean, }) => { + option = Object.assign({ + isFullPath: false, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + if (path !== appendTextPathCached) { + appendTextPathCached = path + if (appendTextStreamCached) { + appendTextStreamCached.end() + appendTextStreamCached = null + } + const fullPathDir = nodePath.dirname(fp) + if (!fs.existsSync(fullPathDir)) { + fs.mkdirSync(fullPathDir, {recursive: true}) + } + appendTextStreamCached = fs.createWriteStream(fp, {flags: 'a'}) + } + appendTextStreamCached.write(data) +} + +const download = async (url: string, path: string, option?: { + isFullPath?: boolean, + progress?: (percent: number, total: number) => void, +}) => { + option = Object.assign({ + isFullPath: false, + progress: null, + }, option) + let fp = path + if (!option.isFullPath) { + fp = await fullPath(path) + } + const fullPathDir = nodePath.dirname(fp) + if (!fs.existsSync(fullPathDir)) { + fs.mkdirSync(fullPathDir, {recursive: true}) + } + const res = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': Apps.getUserAgent() + }, + }) + if (!res.ok) { + throw new Error(`DownloadError:${url}`) + } + + const contentLength = res.headers.get('content-length'); + const totalSize = contentLength ? parseInt(contentLength, 10) : null; + let downloaded = 0; + + // @ts-ignore + const readableStream = Readable.fromWeb(res.body); + const fileStream = fs.createWriteStream(fp) + return new Promise((resolve, reject) => { + readableStream + .on('data', (chunk) => { + // console.log('download.data', chunk.length) + downloaded += chunk.length; + if (totalSize) { + option.progress && option.progress(downloaded / totalSize, totalSize) + } + fileStream.write(chunk) + }) + .on('end', () => { + // console.log('download.end') + fileStream.end(); + resolve(undefined) + }) + .on('error', (err) => { + // console.log('download.error', err) + fileStream.close() + reject(err) + }) + }) +} + +export default { + fullPath, + absolutePath, + exists, + isDirectory, + mkdir, + list, + listAll, + write, + writeBuffer, + read, + readBuffer, + deletes, + rename, + copy, + temp, + tempDir, + watchText, + appendText, + download, +} diff --git a/electron/mapi/file/main.ts b/electron/mapi/file/main.ts new file mode 100644 index 0000000..8589f58 --- /dev/null +++ b/electron/mapi/file/main.ts @@ -0,0 +1,55 @@ +import {dialog, ipcMain, shell} from "electron"; +import fileIndex from "./index"; + +ipcMain.handle('file:openFile', async (_, options) => { + const res = await dialog + .showOpenDialog({ + properties: ['openFile'], + ...options + }) + .catch(e => { + }) + if (!res || res.canceled) { + return null + } + return res.filePaths?.[0] || null +}) + +ipcMain.handle('file:openDirectory', async (_, options) => { + const res = await dialog + .showOpenDialog({ + properties: ['openDirectory'], + ...options + }) + .catch(e => { + }) + if (!res || res.canceled) { + return null + } + return res.filePaths?.[0] || null +}) + +ipcMain.handle('file:openSave', async (_, options) => { + const res = await dialog + .showSaveDialog({ + ...options + }) + .catch(e => { + }) + if (!res || res.canceled) { + return null + } + return res.filePath || null +}) + +ipcMain.handle('file:openPath', async (_, path, options) => { + return shell.openPath(path) +}) + +export default { + ...fileIndex, +} + +export const Files = { + ...fileIndex +} diff --git a/electron/mapi/file/render.ts b/electron/mapi/file/render.ts new file mode 100644 index 0000000..bb58979 --- /dev/null +++ b/electron/mapi/file/render.ts @@ -0,0 +1,27 @@ +import fileIndex from './index' +import {ipcRenderer} from "electron"; + +const openFile = async (options: {} = {}) => { + return ipcRenderer.invoke('file:openFile', options) +} + +const openDirectory = async (options: {} = {}) => { + return ipcRenderer.invoke('file:openDirectory', options) +} + +const openSave = async (options: {} = {}) => { + return ipcRenderer.invoke('file:openSave', options) +} + +const openPath = async (path: string, options: {} = {}) => { + return ipcRenderer.invoke('file:openPath', path, options) +} + +export default { + ...fileIndex, + openFile, + openDirectory, + openSave, + openPath, +} + diff --git a/electron/mapi/keys/main.ts b/electron/mapi/keys/main.ts new file mode 100644 index 0000000..f97c735 --- /dev/null +++ b/electron/mapi/keys/main.ts @@ -0,0 +1,93 @@ +import {app, BrowserWindow, globalShortcut} from "electron"; +import {AppsMain} from "../app/main"; +import {ManagerHotkey} from "../manager/hotkey"; + +const eventListeners = {} + +// 连续点击的快捷键 +let continuousKeys = [] +const addKeyInput = (key: string, expire = 1000) => { + let now = Date.now() + continuousKeys.push({key, expire: now + expire}) + continuousKeys = continuousKeys.filter(item => item.expire > now) + for (let i = continuousKeys.length - 1; i >= 0; i--) { + const key = continuousKeys.filter((o, oIndex) => oIndex >= i).map(o => o.key).join('|') + if (eventListeners[key]) { + eventListeners[key]() + break + } + } +} + +const addMultiKeyListener = (keys: string[], callback: Function) => { + if (!Array.isArray(keys)) { + keys = [keys] + } + const key = keys.join('|') + eventListeners[key] = callback +} + +const createKeyInputListener = (key: string) => { + return () => { + addKeyInput(key) + } +} + +const keyMap = { + 'CommandOrControl+Shift+H': createKeyInputListener('CommandOrControl+Shift+H'), +} + +const ready = () => { + register() +} + +const destroy = () => { + globalShortcut.unregisterAll(); +} + +const register = () => { + + globalShortcut.unregisterAll(); + + app.on('browser-window-focus', () => { + for (let key in keyMap) { + globalShortcut.register(key, keyMap[key]) + } + }); + + app.on('browser-window-blur', () => { + for (let key in keyMap) { + globalShortcut.unregister(key) + } + }) + + addMultiKeyListener([ + 'CommandOrControl+Shift+H', 'CommandOrControl+Shift+H', 'CommandOrControl+Shift+H' + ], () => { + let focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) { + if (focusedWindow.webContents.isDevToolsOpened()) { + focusedWindow.webContents.closeDevTools(); + } else { + focusedWindow.webContents.openDevTools({ + mode: 'detach', + activate: false, + title: 'FocusedWindow', + }); + } + } + }); + + ManagerHotkey.register().then() + +} + +export const KeysMain = { + register +} + +export default { + ready, + destroy, +} + diff --git a/electron/mapi/keys/type.ts b/electron/mapi/keys/type.ts new file mode 100644 index 0000000..7404db7 --- /dev/null +++ b/electron/mapi/keys/type.ts @@ -0,0 +1,23 @@ +export enum HotkeyMouseButtonEnum { + LEFT = 1, + RIGHT = 2 +} + +export type HotkeyKeyItem = { + key: string + altKey: boolean + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + times: number +} + +export type HotkeyKeySimpleItem = { + type: 'Ctrl' | 'Alt' | 'Meta' +} + +export type HotkeyMouseItem = { + button: HotkeyMouseButtonEnum + type: 'click' | 'longPress' + clickTimes?: number +} diff --git a/electron/mapi/kvdb/kvdb.ts b/electron/mapi/kvdb/kvdb.ts new file mode 100644 index 0000000..fe8494e --- /dev/null +++ b/electron/mapi/kvdb/kvdb.ts @@ -0,0 +1,468 @@ +import path from 'path'; +import fs from 'fs'; +import PouchDB from 'pouchdb'; +import {DBError, Doc, DocRes} from "./types"; + +import replicationStream from 'pouchdb-replication-stream'; +import load from 'pouchdb-load'; +import {KVDBVersionManager} from "./version"; +import {Log} from "../log/main"; + +import ndj from 'ndjson'; +import through from 'through2'; +import {WebDav} from "./webdav"; + +PouchDB.plugin(replicationStream.plugin); +// @ts-ignore +PouchDB.adapter('writableStream', replicationStream.adapters.writableStream); +PouchDB.plugin({loadIt: load.load}); + +export default class KVDB { + readonly docMaxByteLength; + readonly docAttachmentMaxByteLength; + public dbpath; + public defaultDbName; + public pouchDB: any; + public versionControl: boolean; + + constructor(dbPath: string) { + // 2M + this.docMaxByteLength = 2 * 1024 * 1024; + // 20M + this.docAttachmentMaxByteLength = 20 * 1024 * 1024; + this.dbpath = dbPath; + this.defaultDbName = path.join(dbPath, 'kvdb'); + this.versionControl = true; + } + + init(): void { + fs.existsSync(this.dbpath) || fs.mkdirSync(this.dbpath, { + recursive: true + }); + this.pouchDB = new PouchDB(this.defaultDbName, {auto_compaction: true, adapter: 'leveldb'}); + } + + getDocId(name: string, id: string): string { + return name + '/' + id; + } + + replaceDocId(name: string, id: string): string { + return id.replace(name + '/', ''); + } + + errorInfo(name: string, message: string): DBError { + return {error: true, name, message}; + } + + private checkDocSize(doc: Doc) { + if (Buffer.byteLength(JSON.stringify(doc)) > this.docMaxByteLength) { + return this.errorInfo( + 'exception', + `doc max size ${this.docMaxByteLength / 1024 / 1024} M` + ); + } + return false; + } + + async put( + name: string, + doc: Doc, + strict = true + ): Promise { + if (strict) { + const err = this.checkDocSize(doc); + if (err) return err; + } + doc._id = this.getDocId(name, doc._id); + try { + const result: DocRes = await this.pouchDB.put(doc); + if (this.versionControl) { + if (doc._rev) { + KVDBVersionManager.update(doc._id).then() + } else { + KVDBVersionManager.insert(doc._id).then() + } + } + doc._id = result.id = this.replaceDocId(name, result.id); + return result; + } catch (e: any) { + doc._id = this.replaceDocId(name, doc._id); + return {id: doc._id, name: e.name, error: !0, message: e.message}; + } + } + + async putRaw(doc: Doc): Promise { + let result: Doc | null = null; + try { + result = await this.pouchDB.get(doc._id); + } catch (e) { + } + if (result) { + doc._rev = result._rev; + } + try { + return await this.pouchDB.put(doc); + } catch (e: any) { + return {id: doc._id, name: e.name, error: !0, message: e.message}; + } + } + + async get(name: string, id: string): Promise { + try { + const result: Doc = await this.pouchDB.get(this.getDocId(name, id)); + result._id = this.replaceDocId(name, result._id); + return result; + } catch (e) { + return null; + } + } + + async getRaw(id: string) { + try { + return await this.pouchDB.get(id); + } catch (e) { + return null; + } + } + + async remove(name: string, doc: Doc | string) { + try { + let target; + if ('object' == typeof doc) { + target = doc; + if (!target._id || 'string' !== typeof target._id) { + return this.errorInfo('exception', 'doc _id error'); + } + target._id = this.getDocId(name, target._id); + } else { + if ('string' !== typeof doc) { + return this.errorInfo('exception', 'param error'); + } + target = await this.pouchDB.get(this.getDocId(name, doc)); + } + const result: DocRes = await this.pouchDB.remove(target); + if (this.versionControl) { + KVDBVersionManager.remove(target._id).then() + } + target._id = result.id = this.replaceDocId(name, result.id); + return result; + } catch (e: any) { + if ('object' === typeof doc) { + doc._id = this.replaceDocId(name, doc._id); + } + return this.errorInfo(e.name, e.message); + } + } + + async removeRaw(doc: Doc) { + try { + return await this.pouchDB.remove(doc); + } catch (e) { + return null; + } + } + + async bulkPut( + name: string, + docs: Array> + ): Promise> { + let result; + try { + if (!Array.isArray(docs)) return this.errorInfo('exception', 'not array'); + if (docs.find((e) => !e._id)) + return this.errorInfo('exception', 'doc not _id field'); + if (new Set(docs.map((e) => e._id)).size !== docs.length) + return this.errorInfo('exception', '_id value exists as'); + for (const doc of docs) { + const err = this.checkDocSize(doc); + if (err) return err; + doc._id = this.getDocId(name, doc._id); + } + result = await this.pouchDB.bulkDocs(docs); + result = result.map((res: any) => { + res.id = this.replaceDocId(name, res.id); + return res.error + ? { + id: res.id, + name: res.name, + error: true, + message: res.message, + } + : res; + }); + docs.forEach((doc) => { + if (this.versionControl) { + if (doc._rev) { + KVDBVersionManager.update(doc._id).then() + } else { + KVDBVersionManager.insert(doc._id).then() + } + } + doc._id = this.replaceDocId(name, doc._id); + }); + } catch (e) { + // + } + return result; + } + + async all( + name: string, + key: string | Array + ): Promise>> { + const config: any = {include_docs: true}; + if (key) { + if ('string' == typeof key) { + config.startkey = this.getDocId(name, key); + config.endkey = config.startkey + '￰'; + } else { + if (!Array.isArray(key)) + return this.errorInfo( + 'exception', + 'param only key(string) or keys(Array[string])' + ); + config.keys = key.map((key) => this.getDocId(name, key)); + } + } else { + config.startkey = this.getDocId(name, ''); + config.endkey = config.startkey + '￰'; + } + const result: Array = []; + try { + (await this.pouchDB.allDocs(config)).rows.forEach((res: any) => { + if (!res.error && res.doc) { + res.doc._id = this.replaceDocId(name, res.doc._id); + result.push(res.doc); + } + }); + } catch (e) { + // + } + return result; + } + + async allKeys( + name: string, + key: string | Array + ): Promise> { + const config: any = {include_docs: false}; + if (key) { + if ('string' == typeof key) { + config.startkey = this.getDocId(name, key); + config.endkey = config.startkey + '￰'; + } else { + if (!Array.isArray(key)) + return this.errorInfo( + 'exception', + 'param only key(string) or keys(Array[string])' + ); + config.keys = key.map((key) => this.getDocId(name, key)); + } + } else { + config.startkey = this.getDocId(name, ''); + config.endkey = config.startkey + '￰'; + } + const result: Array = []; + try { + (await this.pouchDB.allDocs(config)).rows.forEach((res: any) => { + if (!res.error && res.id) { + const id = this.replaceDocId(name, res.id); + result.push(id); + } + }); + } catch (e) { + // + } + return result; + } + + async count( + name: string, + key: string | Array + ): Promise { + const config: any = {include_docs: false}; + if (key) { + if ('string' == typeof key) { + config.startkey = this.getDocId(name, key); + config.endkey = config.startkey + '￰'; + } else { + if (!Array.isArray(key)) + return this.errorInfo( + 'exception', + 'param only key(string) or keys(Array[string])' + ); + config.keys = key.map((key) => this.getDocId(name, key)); + } + } else { + config.startkey = this.getDocId(name, ''); + config.endkey = config.startkey + '￰'; + } + try { + return (await this.pouchDB.allDocs(config)).rows.length; + } catch (e) { + // + } + return 0 + } + + public async postAttachment( + name: string, + docId: string, + attachment: Buffer | Uint8Array, + type: string + ) { + const buffer = Buffer.from(attachment); + if (buffer.byteLength > this.docAttachmentMaxByteLength) + return this.errorInfo( + 'exception', + 'attachment data up to ' + + this.docAttachmentMaxByteLength / 1024 / 1024 + + 'M' + ); + try { + const result = await this.pouchDB.put({ + _id: this.getDocId(name, docId), + _attachments: {0: {data: buffer, content_type: type}}, + }); + if (this.versionControl) { + KVDBVersionManager.insert(result.id).then() + } + result.id = this.replaceDocId(name, result.id); + return result; + } catch (e) { + return this.errorInfo(e.name, e.message); + } + } + + async getAttachment(name: string, docId: string, len = '0') { + try { + return await this.pouchDB.getAttachment(this.getDocId(name, docId), len); + } catch (e) { + return null; + } + } + + async getAttachmentRaw(docId: string, len = '0') { + try { + return await this.pouchDB.getAttachment(docId, len); + } catch (e) { + return null; + } + } + + public async dumpToFile(file: string, option?: {}): Promise { + try { + const writeStream = fs.createWriteStream(file); + await this.pouchDB.dump(writeStream, { + batch_size: 10, + }); + } catch (e) { + Log.info('kvdb.dumpToFile.error', e); + throw e + } + } + + public async importFromFile(file: string, option?: {}): Promise { + await this.pouchDB.destroy(); + const syncDb = new KVDB(this.dbpath); + syncDb.init(); + this.pouchDB = syncDb.pouchDB; + const rs = fs.createReadStream(file); + try { + await this.load(rs) + } catch (e) { + Log.info('kvdb.importFromFile.error', e); + throw e + } + } + + public async dumpToWavDav(file: string, option: { + url: string, + username: string, + password: string + }): Promise { + try { + const webdav = new WebDav(option); + await webdav.dump(this, file); + } catch (e) { + Log.info('kvdb.dumpToWavDav.error', e); + throw e + } + } + + public async importFromWebDav(file: string, option: { + url: string, + username: string, + password: string + }): Promise { + await this.pouchDB.destroy(); + const syncDb = new KVDB(this.dbpath); + syncDb.init(); + this.pouchDB = syncDb.pouchDB; + try { + const webdav = new WebDav(option); + await webdav.import(this, file); + } catch (e) { + Log.info('kvdb.importFromWebDav.error', e); + throw e + } + } + + public async load(readableStream: any) { + return new Promise((resolve, reject) => { + let error = null; + let queue = []; + readableStream + .pipe(ndj.parse()) + .on('error', function (errorCatched) { + error = errorCatched; + }) + .pipe(through.obj(function (data, _, next) { + if (!data.docs) { + return next(); + } + // lets smooth it out + data.docs.forEach(function (doc) { + this.push(doc); + }, this); + next(); + })) + .pipe(through.obj(function (doc, _, next) { + // console.log('doc', doc) + if (doc._attachments) { + for (const k in doc._attachments) { + if (doc._attachments[k].data) { + // console.log('doc._attachments[k].data', k, doc._attachments[k].data) + const bytes = doc._attachments[k].data.data; + const base64 = new Buffer(bytes).toString("base64"); + doc._attachments[k].data = base64 + } + } + } + queue.push(doc); + if (queue.length >= 10) { + this.push(queue); + queue = []; + } + next(); + }, function (next) { + if (queue.length) { + this.push(queue); + } + next(); + })) + .pipe(this.pouchDB.createWriteStream({new_edits: false})) + .on('error', function (errorCatched) { + error = errorCatched; + }) + .on('finish', function () { + if (error) { + reject(error) + } else { + resolve(undefined) + } + }); + }) + } + +} diff --git a/electron/mapi/kvdb/main.ts b/electron/mapi/kvdb/main.ts new file mode 100644 index 0000000..301eb48 --- /dev/null +++ b/electron/mapi/kvdb/main.ts @@ -0,0 +1,266 @@ +import KVDB from "./kvdb"; +import {AppEnv} from "../env"; +import {DBError, Doc} from "./types"; +import {ipcMain} from "electron"; +import {WebDav} from "./webdav"; + + +let kvdb: KVDB = null + +const init = () => { + kvdb = new KVDB(AppEnv.userData); + kvdb.init(); + // for (let i = 0; i < 1000; i++) { + // kvdb.putRaw({ + // _id: `data${i}`, + // data: i + // }) + // } + // const sync = async () => { + // KVDBCloudManager.sync().then(() => { + // setTimeout(sync, 5000) + // }) + // } + // setTimeout(sync, 1000) +} + +const raw = () => { + return kvdb +} + +const put = async (name: string, data: Doc) => { + const result = await kvdb.put(name, data) + if (result && (result as DBError).error) { + throw (result as DBError).message + } + return result as Doc +} + +const putForce = async (name: string, data: Doc) => { + const res = await get(name, data._id) + if (res) { + data._rev = res._rev + } + const result = await put(name, data) + if (result && (result as DBError).error) { + throw (result as DBError).message + } + return result as Doc +} + +const get = async (name: string, id: string) => { + return await kvdb.get(name, id) +} + +const getData = async (name: string, id: string, defaultValue: any = null) => { + const res = await get(name, id) + if (res) { + delete res._id + delete res._rev + delete res._attachments + } + return res ? res : defaultValue +} + +const remove = async (name: string, doc: Doc | string) => { + return await kvdb.remove(name, doc) +} + +const bulkDocs = async (name: string, docs: any[]) => { + const result = await kvdb.bulkPut(name, docs) + if (result && (result as DBError).error) { + throw (result as DBError).message + } + return result as Doc[] +} + +const allDocs = async (name: string, key: string): Promise => { + const result = await kvdb.all(name, key) + if (result && (result as DBError).error) { + throw (result as DBError).message + } + return result as Doc[] +} + +const allKeys = async (name: string, key: string): Promise => { + const result = await kvdb.allKeys(name, key) + if (result && (result as DBError).error) { + throw (result as DBError).message + } + return result as string[] +} + +const count = async (name: string, key: string) => { + const result = await kvdb.count(name, key) + if (result && (result as DBError).error) { + throw (result as DBError).message + } + return result as number +} + +const postAttachment = async (name: string, docId: string, attachment: any, type: string) => { + return await kvdb.postAttachment(name, docId, attachment, type) +} + +const getAttachment = async (name: string, docId: string) => { + return await kvdb.getAttachment(name, docId) +} + +const getAttachmentType = async (name: string, docId: string) => { + const res = await get(name, docId) + if (!res || !res._attachments) return null + const result = res._attachments[0] + return result ? result.content_type : null +} + +const dumpToFile = async (file: string) => { + return await kvdb.dumpToFile(file) +} + +const importFromFile = async (file: string) => { + return await kvdb.importFromFile(file) +} + +const testWebdav = async (option: { + url: string, + username: string, + password: string +}) => { + const webdav = new WebDav(option) + await webdav.checkConnection() +} + +const dumpToWebDav = async (file: string, option: { + url: string, + username: string, + password: string +}) => { + return await kvdb.dumpToWavDav(file, option) +} + +const importFromWebDav = async (file: string, option: { + url: string, + username: string, + password: string +}) => { + return await kvdb.importFromWebDav(file, option) +} + +const listWebDav = async (dir: string, option: { + url: string, + username: string, + password: string +}) => { + const webdav = new WebDav(option) + await webdav.checkConnection() + return await webdav.listDir(dir) +} + +ipcMain.handle('kvdb:put', (event, name: string, data: Doc) => { + return put(name, data); +}) + +ipcMain.handle('kvdb:putForce', (event, name: string, data: Doc) => { + return putForce(name, data); +}) + +ipcMain.handle('kvdb:get', (event, name: string, id: string) => { + return get(name, id); +}) + +ipcMain.handle('kvdb:remove', (event, name: string, doc: Doc | string) => { + return remove(name, doc); +}) + +ipcMain.handle('kvdb:bulkDocs', (event, name: string, docs: any[]) => { + return bulkDocs(name, docs); +}) + +ipcMain.handle('kvdb:allDocs', (event, name: string, key: string) => { + return allDocs(name, key); +}) + +ipcMain.handle('kvdb:allKeys', (event, name: string, key: string) => { + return allKeys(name, key); +}) + +ipcMain.handle('kvdb:count', (event, name: string, key: string) => { + return count(name, key); +}) + +ipcMain.handle('kvdb:postAttachment', (event, name: string, docId: string, attachment: any, type: string) => { + return postAttachment(name, docId, attachment, type); +}) + +ipcMain.handle('kvdb:getAttachment', (event, name: string, docId: string) => { + return getAttachment(name, docId); +}) + +ipcMain.handle('kvdb:getAttachmentType', (event, name: string, docId: string) => { + return getAttachmentType(name, docId); +}) + +ipcMain.handle('kvdb:dumpToFile', (event, file: string) => { + return dumpToFile(file); +}) + +ipcMain.handle('kvdb:importFromFile', (event, file: string) => { + return importFromFile(file); +}) + +ipcMain.handle('kvdb:testWebdav', (event, option: { + url: string, + username: string, + password: string +}) => { + return testWebdav(option); +}) + +ipcMain.handle('kvdb:dumpToWebDav', (event, file: string, option: { + url: string, + username: string, + password: string +}) => { + return dumpToWebDav(file, option); +}) + +ipcMain.handle('kvdb:importFromWebDav', (event, file: string, option: { + url: string, + username: string, + password: string +}) => { + return importFromWebDav(file, option); +}) + +ipcMain.handle('kvdb:listWebDav', (event, dir: string, option: { + url: string, + username: string, + password: string +}) => { + return listWebDav(dir, option); +}) + +export const KVDBMain = { + raw, + put, + putForce, + get, + getData, + remove, + bulkDocs, + allDocs, + allKeys, + postAttachment, + getAttachment, + getAttachmentType, + dumpToFile, + importFromFile, + dumpToWebDav, + importFromWebDav, + listWebDav, +} + +export default { + init, + ...KVDBMain +} diff --git a/electron/mapi/kvdb/render.ts b/electron/mapi/kvdb/render.ts new file mode 100644 index 0000000..8b0529b --- /dev/null +++ b/electron/mapi/kvdb/render.ts @@ -0,0 +1,106 @@ +import {Doc} from "./types"; +import {ipcRenderer} from "electron"; + +const put = async (name: string, doc: Doc) => { + return ipcRenderer.invoke('kvdb:put', name, doc) +} + +const putForce = async (name: string, doc: Doc) => { + return ipcRenderer.invoke('kvdb:putForce', name, doc) +} + +const get = async (name: string, id: string) => { + return ipcRenderer.invoke('kvdb:get', name, id) +} + +const remove = async (name: string, doc: Doc | string) => { + return ipcRenderer.invoke('kvdb:remove', name, doc) +} + +const bulkDocs = async (name: string, docs: any[]) => { + return ipcRenderer.invoke('kvdb:bulkDocs', name, docs) +} + +const allDocs = async (name: string, key: string) => { + return ipcRenderer.invoke('kvdb:allDocs', name, key) +} + +const allKeys = async (name: string, key: string) => { + return ipcRenderer.invoke('kvdb:allKeys', name, key) +} + +const count = async (name: string, key: string) => { + return ipcRenderer.invoke('kvdb:count', name, key) +} + +const postAttachment = async (name: string, docId: string, attachment: any, type: string) => { + return ipcRenderer.invoke('kvdb:postAttachment', name, docId, attachment, type) +} + +const getAttachment = async (name: string, docId: string) => { + return ipcRenderer.invoke('kvdb:getAttachment', name, docId) +} + +const getAttachmentType = async (name: string, docId: string) => { + return ipcRenderer.invoke('kvdb:getAttachmentType', name, docId) +} + +const dumpToFile = async (file: string) => { + return ipcRenderer.invoke('kvdb:dumpToFile', file) +} + +const importFromFile = async (file: string) => { + return ipcRenderer.invoke('kvdb:importFromFile', file) +} + +const testWebdav = async (option: { + url: string, + username: string, + password: string +}) => { + return ipcRenderer.invoke('kvdb:testWebdav', option) +} + +const dumpToWebDav = async (file: string, option: { + url: string, + username: string, + password: string +}) => { + return ipcRenderer.invoke('kvdb:dumpToWebDav', file, option) +} + +const importFromWebDav = async (file: string, option: { + url: string, + username: string, + password: string +}) => { + return ipcRenderer.invoke('kvdb:importFromWebDav', file, option) +} + +const listWebDav = async (dir: string, option: { + url: string, + username: string, + password: string +}) => { + return ipcRenderer.invoke('kvdb:listWebDav', dir, option) +} + +export default { + put, + putForce, + get, + remove, + bulkDocs, + allDocs, + allKeys, + count, + postAttachment, + getAttachment, + getAttachmentType, + dumpToFile, + importFromFile, + testWebdav, + dumpToWebDav, + importFromWebDav, + listWebDav, +} diff --git a/electron/mapi/kvdb/types.ts b/electron/mapi/kvdb/types.ts new file mode 100644 index 0000000..6877573 --- /dev/null +++ b/electron/mapi/kvdb/types.ts @@ -0,0 +1,33 @@ +type RevisionId = string; + + +export type Doc> = { + _id: string, + _rev?: string, + _attachments?: any; +} & T + +export interface DocRes { + id: string; + ok: boolean; + rev: RevisionId; + _id: string; + data?: any; +} + +export interface DBError { + status?: number | undefined; + name?: string | undefined; + message?: string | undefined; + reason?: string | undefined; + error?: string | boolean | undefined; + id?: string | undefined; + rev?: RevisionId | undefined; +} + +export interface AllDocsOptions { + include_docs?: boolean; + startkey?: string; + endkey?: string; + keys?: string[]; +} diff --git a/electron/mapi/kvdb/version.ts b/electron/mapi/kvdb/version.ts new file mode 100644 index 0000000..455d2ea --- /dev/null +++ b/electron/mapi/kvdb/version.ts @@ -0,0 +1,45 @@ +import DBMain from "../db/main"; +import {StrUtil} from "../../lib/util"; + +export const KVDBVersionManager = { + async _getExist(name: string) { + const records = await DBMain.select("select * from kvdb_data where name = ? and isDeleted = 0", [name]) + for (let i = 1; i < records.length; i++) { + await DBMain.delete("delete from kvdb_data where id = ?", [records[i].id]) + } + return records.length > 0 ? records[0] : null + }, + async update(name: string) { + if (this.shouldIgnore(name)) { + return + } + // console.log('update', {name}) + const exist = await this._getExist(name) + if (exist) { + await DBMain.update("update kvdb_data set version = -1 where id = ?", [exist.id]) + } else { + await DBMain.insert("insert into kvdb_data (id, name, version, cloudVersion, isDeleted) values (?,?,-1,0,0)", [StrUtil.bigIntegerId(), name]) + } + }, + async insert(name: string) { + await this.update(name) + }, + async remove(name: string) { + if (this.shouldIgnore(name)) { + return + } + const exist = await this._getExist(name) + // console.log('remove', {name, exist}) + if (exist) { + await DBMain.update("update kvdb_data set isDeleted = 1, version = -1 where id = ?", [exist.id]) + } else { + await DBMain.insert("insert into kvdb_data (id, name, version, cloudVersion, isDeleted ) values (?, ?, -1, 0, 1)", [StrUtil.bigIntegerId(), name]) + } + }, + shouldIgnore(name: string) { + return [ + // 系统存储版本号的kvdb + 'SYS/syncCloudVersion', + ].includes(name) + }, +} diff --git a/electron/mapi/kvdb/webdav.ts b/electron/mapi/kvdb/webdav.ts new file mode 100644 index 0000000..dc624d6 --- /dev/null +++ b/electron/mapi/kvdb/webdav.ts @@ -0,0 +1,64 @@ +import MemoryStream from 'memorystream'; + +import {AuthType, createClient} from 'webdav'; +import {WebDAVClient} from 'webdav/dist/node/types'; +import KVDB from "./kvdb"; + +type WebDavOptions = { + username: string; + password: string; + url: string; +}; + +export class WebDav { + + public client: WebDAVClient; + + constructor({username, password, url}: WebDavOptions) { + // console.log('WebDavOptions', {username, password, url}) + this.client = createClient(url, { + authType: AuthType.Auto, + username, + password, + }); + } + + async checkConnection(): Promise { + await this.client.exists('/'); + } + + async listDir(dir: string): Promise { + dir = dir.endsWith('/') ? dir : dir + '/'; + const result = await this.client.getDirectoryContents(dir); + return (result as any[]).map(item => item.basename); + } + + async dump(kvdb: KVDB, file: string): Promise { + await this.checkConnection() + const fileDir = file.substring(0, file.lastIndexOf('/')); + if (!(await this.client.exists(fileDir + '/'))) { + await this.client.createDirectory(fileDir, { + recursive: true, + }); + } + const ws = new MemoryStream() + kvdb.pouchDB.dump(ws, { + batch_size: 10, + }); + return new Promise((resolve, reject) => { + ws.pipe(this.client.createWriteStream(file, {}, () => { + resolve() + })); + }) + } + + async import(kvdb: KVDB, file: string): Promise { + // console.log('import', file) + await this.checkConnection() + if (!(await this.client.exists(file))) { + throw 'FileNotFound' + } + const rs = this.client.createReadStream(file); + await kvdb.load(rs); + } +} diff --git a/electron/mapi/lang/main.ts b/electron/mapi/lang/main.ts new file mode 100644 index 0000000..aed82d3 --- /dev/null +++ b/electron/mapi/lang/main.ts @@ -0,0 +1,86 @@ +import {Files} from "../file/main"; +import {AppEnv} from "../env"; +import {JsonUtil, StrUtil} from "../../lib/util"; +import {langMessageList} from "../../config/lang"; +import {ipcMain} from "electron"; + +const fileSyncer = { + lock: {}, + readJson: async function (file: string) { + if (this.lock[file]) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(this.readJson(file)) + }, 100) + }) + } + this.lock[file] = true + let filePath = Files.absolutePath([ + AppEnv.appRoot, + file, + ].join('/')) + const sourceContent = (await Files.read(filePath)) || '{}' + this.lock[file] = sourceContent + return JSON.parse(sourceContent) + }, + writeJson: async function (file: string, data: any, order: 'key' | 'value' = 'key') { + if (!this.lock[file]) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(this.writeJson(file, data)) + }, 100) + }) + } + let filePath = Files.absolutePath([ + AppEnv.appRoot, + file, + ].join('/')) + let jsonString + if (order === 'key') { + jsonString = JsonUtil.stringifyOrdered(data) + } else { + jsonString = JsonUtil.stringifyValueOrdered(data) + } + if (jsonString !== this.lock[file]) { + await Files.write(filePath, jsonString) + } + this.lock[file] = false + } +} + +const writeSourceKey = async (key: string) => { + const json = await fileSyncer.readJson('src/lang/source.json') + const sourceIds: string[] = Object.values(json) + if (!json[key]) { + json[key] = StrUtil.hashCodeWithDuplicateCheck(key, sourceIds) + console.log('Lang.autoWriteSourceKey', key, json[key]) + } + await fileSyncer.writeJson('src/lang/source.json', json) + for (let l of langMessageList) { + const jsonLang = await fileSyncer.readJson(`src/lang/${l.name}.json`) + jsonLang[json[key]] = key + await fileSyncer.writeJson(`src/lang/${l.name}.json`, jsonLang) + } +} + +const writeSourceKeyUse = async (key: string) => { + const json = await fileSyncer.readJson('src/lang/source-use.json') + if (!json[key]) { + json[key] = 1 + } else { + json[key]++ + } + await fileSyncer.writeJson('src/lang/source-use.json', json, 'value') +} + +ipcMain.handle('lang:writeSourceKey', async (_, key) => { + await writeSourceKey(key) +}) +ipcMain.handle('lang:writeSourceKeyUse', async (_, key) => { + await writeSourceKeyUse(key) +}) + +export default { + writeSourceKey, + writeSourceKeyUse +} diff --git a/electron/mapi/lang/render.ts b/electron/mapi/lang/render.ts new file mode 100644 index 0000000..e2836fe --- /dev/null +++ b/electron/mapi/lang/render.ts @@ -0,0 +1,16 @@ +import {ipcRenderer} from "electron"; + + +const writeSourceKey = async (key: string) => { + return ipcRenderer.invoke('lang:writeSourceKey', key) +} + +const writeSourceKeyUse = async (key: string) => { + return ipcRenderer.invoke('lang:writeSourceKeyUse', key) +} + + +export default { + writeSourceKey, + writeSourceKeyUse +} diff --git a/electron/mapi/log/index.ts b/electron/mapi/log/index.ts new file mode 100644 index 0000000..4e9157e --- /dev/null +++ b/electron/mapi/log/index.ts @@ -0,0 +1,132 @@ +import electron from "electron"; +import date from "date-and-time"; +import path from "node:path"; +import {AppEnv} from "../env"; +import fs from "node:fs"; + +let fileName = null +let fileStream = null + +const stringDatetime = () => { + return date.format(new Date(), 'YYYYMMDD') +} +const logsDir = () => { + return path.join(AppEnv.userData, 'logs') +} + +const appLogsDir = () => { + return path.join(AppEnv.userData, 'data/logs') +} + +const root = () => { + return logsDir() +} + +const file = () => { + return path.join(logsDir(), 'log_' + stringDatetime() + '.log') +} + +const cleanOldLogs = (keepDays: number) => { + const logDirs = [ + // 系统日志 + logsDir(), + // 应用日志 + appLogsDir() + ] + for (const logDir of logDirs) { + if (!fs.existsSync(logDir)) { + return + } + const files = fs.readdirSync(logDir) + const now = new Date() + // console.log('cleanOldLogs', logDir, files) + for (let file of files) { + const filePath = path.join(logDir, file) + let date = null + for (let s of file.split('_')) { + // 匹配 YYYYMMDD + if (s.match(/^\d{8}$/)) { + date = s + break + } + } + if (!date) { + continue + } + const fileDate = new Date( + parseInt(date.substring(0, 4)), + parseInt(date.substring(4, 6)) - 1, + parseInt(date.substring(6, 8)) + ) + const diff = Math.abs(now.getTime() - fileDate.getTime()) + const diffDays = Math.ceil(diff / (1000 * 3600 * 24)) + // console.log('fileDate', file, fileDate, diffDays) + if (diffDays > keepDays) { + fs.unlinkSync(filePath) + } + } + } +} + +const log = (level: 'INFO' | 'ERROR', label: string, data: any = null) => { + if (fileName !== file()) { + fileName = file() + const logDir = logsDir() + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir) + } + if (fileStream) { + fileStream.end() + } + fileStream = fs.createWriteStream(fileName, {flags: 'a'}) + cleanOldLogs(14) + } + let line = [] + line.push(date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')) + line.push(level) + line.push(label) + if (data) { + if (!['number', 'string'].includes(typeof data)) { + data = JSON.stringify(data) + } + line.push(data) + } + console.log(line.join(' - ')) + fileStream.write(line.join(' - ') + "\n") +} + +const info = (label: string, data: any = null) => { + return log('INFO', label, data) +} +const error = (label: string, data: any = null) => { + return log('ERROR', label, data) +} + +const infoRenderOrMain = (label: string, data: any = null) => { + if (electron.ipcRenderer) { + return electron.ipcRenderer.invoke('log:info', label, data) + } else { + return info(label, data) + } +} +const errorRenderOrMain = (label: string, data: any = null) => { + if (electron.ipcRenderer) { + return electron.ipcRenderer.invoke('log:error', label, data) + } else { + return error(label, data) + } +} + + +export default { + root, + info, + error, + infoRenderOrMain, + errorRenderOrMain, +} + +export const Log = { + info: infoRenderOrMain, + error: errorRenderOrMain, +} diff --git a/electron/mapi/log/main.ts b/electron/mapi/log/main.ts new file mode 100644 index 0000000..825526b --- /dev/null +++ b/electron/mapi/log/main.ts @@ -0,0 +1,19 @@ +import {ipcMain} from "electron"; +import logIndex from './index' + +ipcMain.handle('log:info', (event, label: string, data: any) => { + logIndex.info(label, data) +}) +ipcMain.handle('log:error', (event, label: string, data: any) => { + logIndex.error(label, data) +}) + +export default { + info: logIndex.info, + error: logIndex.error, +} + +export const Log = { + info: logIndex.info, + error: logIndex.error, +} diff --git a/electron/mapi/log/render.ts b/electron/mapi/log/render.ts new file mode 100644 index 0000000..c2d84ad --- /dev/null +++ b/electron/mapi/log/render.ts @@ -0,0 +1,7 @@ +import logIndex from './index' + +export default { + root: logIndex.root, + info: logIndex.infoRenderOrMain, + error: logIndex.errorRenderOrMain, +} diff --git a/electron/mapi/main.ts b/electron/mapi/main.ts new file mode 100644 index 0000000..05d3bb8 --- /dev/null +++ b/electron/mapi/main.ts @@ -0,0 +1,50 @@ +import config from "./config/main"; +import log from "./log/main"; +import app from "./app/main"; +import storage from "./storage/main"; +import db from "./db/main"; +import file from "./file/main"; +import event from "./event/main"; +import ui from "./ui"; +import keys from "./keys/main"; +import user from "./user/main"; +import misc from "./misc/main"; + +import kvdb from "./kvdb/main"; +import server from "./server/main"; +import manager from "./manager/main"; + +const $mapi = { + app, + log, + config, + storage, + db, + file, + event, + ui, + keys, + user, + misc, + server, + manager, + kvdb, +} + +export const MAPI = { + init() { + $mapi.user.init() + $mapi.db.init() + $mapi.event.init() + $mapi.kvdb.init() + $mapi.manager.init() + }, + ready() { + $mapi.keys.ready() + $mapi.manager.ready() + }, + destroy() { + $mapi.keys.destroy() + $mapi.manager.destroy() + } +} diff --git a/electron/mapi/manager/automation/index.ts b/electron/mapi/manager/automation/index.ts new file mode 100644 index 0000000..efd5fa1 --- /dev/null +++ b/electron/mapi/manager/automation/index.ts @@ -0,0 +1,25 @@ +import {activeWindow} from 'get-windows'; +import {ActiveWindow} from "../../../../src/types/Manager"; +import {isWin} from "../../../lib/env"; + +export const ManagerAutomation = { + init() { + }, + async getActiveWindow(): Promise { + const win = { + name: '', + title: '', + attr: {} + } as ActiveWindow + const active = await activeWindow() + if (active) { + win.name = active.owner?.name || '' + win.title = active.title + if ('url' in active) { + win.attr['url'] = active.url + '' + } + } + + return win + } +} diff --git a/electron/mapi/manager/backend/index.ts b/electron/mapi/manager/backend/index.ts new file mode 100644 index 0000000..80d87db --- /dev/null +++ b/electron/mapi/manager/backend/index.ts @@ -0,0 +1,53 @@ +import {ActionRecord, PluginRecord} from "../../../../src/types/Manager"; +import {ManagerSystem} from "../system"; +import fs from "node:fs"; +import {ImportUtil} from "../../../lib/util"; +import {PluginSdkCreate} from "../plugin/sdk"; + +export const ManagerBackend = { + async run(plugin: PluginRecord, type: 'hook' | 'event' | 'action', key: string, data: any, option?: { + rejectIfError: boolean + }) { + option = Object.assign({ + rejectIfError: false + }, option) + if (!plugin.runtime?.root) { + throw `PluginRootNotFound:${plugin.name}` + } + const backendPath = `${plugin.runtime?.root}/backend.cjs` + if (!fs.existsSync(backendPath)) { + if (option.rejectIfError) { + throw `BackendFileNotFound:${backendPath}` + } + return + } + const backend = await ImportUtil.loadCommonJs(backendPath) + if (!(type in backend)) { + if (option.rejectIfError) { + throw `BackendTypeNotFound:${type}` + } + return + } + if (!(key in backend[type])) { + if (option.rejectIfError) { + throw `BackendKeyNotFound:${type}.${key}` + } + return + } + const func = backend[type][key] + const sdk = PluginSdkCreate(plugin) + return await func(sdk, data) + }, + async runAction(plugin: PluginRecord, action: ActionRecord, option?: {}) { + const codeData = {} + codeData['actionName'] = action.name + codeData['actionMatch'] = action.runtime?.match + const callback = ManagerSystem.getActionBackendFunc(plugin.name, action.name) + if (callback) { + return await callback(codeData) + } + return await this.run(plugin, 'action', action.name, codeData, { + rejectIfError: true + }) + } +} diff --git a/electron/mapi/manager/clipboard/clipboardFiles.ts b/electron/mapi/manager/clipboard/clipboardFiles.ts new file mode 100644 index 0000000..c5e2feb --- /dev/null +++ b/electron/mapi/manager/clipboard/clipboardFiles.ts @@ -0,0 +1,86 @@ +import {clipboard} from 'electron'; +import plist from 'plist'; +import fs from 'fs'; +import path from 'path'; +import ofs from 'original-fs'; +import {isLinux, isMac, isWin} from "../../../lib/env"; +import electronClipboardEx from 'electron-clipboard-ex'; + +export const getClipboardFiles = (): ClipboardFileItem[] => { + let fileInfo: any; + if (isMac) { + if (!clipboard.has('NSFilenamesPboardType')) { + return []; + } + const result = clipboard.read('NSFilenamesPboardType'); + if (!result) { + return []; + } + try { + fileInfo = plist.parse(result); + } catch (e) { + return []; + } + } else if (isWin) { + try { + /* eslint-disable */ + fileInfo = electronClipboardEx.readFilePaths(); + } catch (e) { + // todo + } + } else if (isLinux) { + if (!clipboard.has('text/uri-list')) { + return []; + } + const result = clipboard.read('text/uri-list').match(/^file:\/\/\/.*/gm); + if (!result || !result.length) { + return []; + } + fileInfo = result.map((e) => + decodeURIComponent(e).replace(/^file:\/\//, '') + ); + } + if (!Array.isArray(fileInfo)) { + return []; + } + const target: any = fileInfo + .map((p) => { + if (!fs.existsSync(p)) return false; + let info; + try { + info = ofs.lstatSync(p); + } catch (e) { + return false; + } + let fileExt = null + if (info.isFile()) { + fileExt = path.extname(p).toLowerCase().replace(/^./, ''); + } + return { + isFile: info.isFile(), + isDirectory: info.isDirectory(), + name: path.basename(p) || p, + path: p, + fileExt: fileExt, + }; + }) + .filter(Boolean); + return target.length ? target : []; +} + +export const setClipboardFiles = (files: string[]) => { + if (!files || !files.length) { + return; + } + if (isMac) { + clipboard.writeBuffer( + 'NSFilenamesPboardType', + Buffer.from(plist.build(files)) + ); + } else if (isWin) { + electronClipboardEx.writeFilePaths(files); + } else if (isLinux) { + // @ts-ignore + clipboard.write('text/uri-list', files.map((e) => `file://${e}`).join('\n')); + } +} diff --git a/electron/mapi/manager/clipboard/index.ts b/electron/mapi/manager/clipboard/index.ts new file mode 100644 index 0000000..c6ab511 --- /dev/null +++ b/electron/mapi/manager/clipboard/index.ts @@ -0,0 +1,172 @@ +import {AppsMain} from "../../app/main"; +import {ClipboardDataType, ClipboardHistoryRecord} from "../../../../src/types/Manager"; +import {Files} from "../../file/main"; +import {EncodeUtil, FileUtil, StrUtil, TimeUtil} from "../../../lib/util"; +import StorageMain from "../../storage/main"; +import {getClipboardFiles, setClipboardFiles} from "./clipboardFiles"; +import {clipboard} from "electron"; +import {isMac} from "../../../lib/env"; +import {KeyboardKey, ManagerHotkeySimulate} from "../hotkey/simulate"; + +export const ManagerClipboard = { + running: true, + interval: 1000, + timer: null, + lastContent: null, + encryptKey: null, + gettingSelectedContent: false, + async getSelectedContent(): Promise { + this.gettingSelectedContent = true + const old = await this._getClipboardContent() + clipboard.clear(); + ManagerHotkeySimulate.keyTap(KeyboardKey.C, [isMac ? KeyboardKey.Meta : KeyboardKey.Ctrl]) + // await new Promise((resolve) => setTimeout(resolve, 200)); + const select = await this._getClipboardContent() + clipboard.clear(); + if (old) { + switch (old.type) { + case 'file': + setClipboardFiles(old.files.map(file => file.path)) + break + case 'image': + AppsMain.setClipboardImage(old.image) + break + case 'text': + AppsMain.setClipboardText(old.text) + break + } + } + this.gettingSelectedContent = false + return select + }, + async init() { + this.encryptKey = await StorageMain.get('clipboard', 'encryptKey', null) + if (!this.encryptKey) { + this.encryptKey = StrUtil.randomString(16) + await StorageMain.set('clipboard', 'encryptKey', this.encryptKey) + } + this.monitorStart() + // console.log('all', await this.list()) + }, + async _getClipboardContent(): Promise { + const files = getClipboardFiles() + if (files.length) { + return { + type: 'file', + files: files + } as ClipboardDataType + } + const image = AppsMain.getClipboardImage() + if (image) { + return { + type: 'image', + image: image + } as ClipboardDataType + } + const text = AppsMain.getClipboardText() + if (text) { + return { + type: 'text', + text: text + } as ClipboardDataType + } + return null + }, + async _watchChange() { + if (this.gettingSelectedContent) { + return + } + const content = await this._getClipboardContent() + // console.log('content', content) + if (content === null) { + return + } + if (null == this.lastContent || JSON.stringify(content) !== JSON.stringify(this.lastContent)) { + this.lastContent = content + this.onChange(content).then() + return + } + }, + _watch() { + this._watchChange() + .finally(() => { + if (this.running) { + setTimeout(() => { + this._watch() + }, this.interval); + } + }) + }, + monitorStart() { + this.running = true; + this._watch(); + }, + monitorStop() { + this.running = false; + }, + encrypt(data: ClipboardHistoryRecord) { + const dataJson = JSON.stringify(data) + return EncodeUtil.aesEncode(dataJson, this.encryptKey) + }, + decrypt(data: string): ClipboardHistoryRecord { + data = EncodeUtil.aesDecode(data, this.encryptKey) + return JSON.parse(data) as ClipboardHistoryRecord + }, + async onChange(data: ClipboardDataType) { + // console.log('clipboard.onChange', data) + const filename = TimeUtil.timestampDayStart() + const saveData = { + type: data.type, + timestamp: TimeUtil.timestamp(), + files: data.files, + image: data.image, + text: data.text + } as ClipboardHistoryRecord + if (saveData.image) { + const imageMd5 = EncodeUtil.md5(saveData.image) + let imageFile = `clipboard/${filename}/${imageMd5}` + Files.writeBuffer(imageFile, FileUtil.base64ToBuffer(saveData.image)).then() + saveData.image = imageMd5 + } + const dataString = this.encrypt(saveData) + Files.appendText(`clipboard/${filename}/data`, `${dataString}\n`).then() + }, + async list(): Promise { + const fullPath = await Files.fullPath('clipboard') + const dateDir = await Files.list('clipboard') + const result = [] + for (const dir of dateDir) { + const data = await Files.read(`clipboard/${dir.name}/data`) + for (const line of data.split('\n')) { + if (!line) { + continue + } + const record = this.decrypt(line) + if (record.image) { + record.image = `file://${fullPath}/${dir.name}/${record.image}` + } + result.push(record) + } + } + return result.reverse() + }, + async clear() { + await Files.deletes('clipboard') + }, + async delete(timestamp: number) { + const date = TimeUtil.timestampDayStart(timestamp) + const data = await Files.read(`clipboard/${date}/data`) + const lines = data.split('\n') + const result = [] + for (const line of lines) { + if (!line) { + continue + } + const record = this.decrypt(line) + if (record.timestamp !== timestamp) { + result.push(line) + } + } + await Files.write(`clipboard/${date}/data`, result.join('\n')) + } +} diff --git a/electron/mapi/manager/code/index.ts b/electron/mapi/manager/code/index.ts new file mode 100644 index 0000000..697b08e --- /dev/null +++ b/electron/mapi/manager/code/index.ts @@ -0,0 +1,20 @@ +import {ActionRecord, PluginRecord} from "../../../../src/types/Manager"; +import {ManagerSystem} from "../system"; +import {ManagerWindow} from "../window"; + + +export const ManagerCode = { + async execute(plugin: PluginRecord, action: ActionRecord, option?: {}) { + const codeData = {} + codeData['actionName'] = action.name + codeData['actionMatch'] = action.runtime?.match + codeData['requestId'] = action.runtime?.requestId + const callback = ManagerSystem.getActionCodeFunc(plugin.name, action.name) + if (callback) { + return await callback(codeData) + } + return await ManagerWindow.openForCode(plugin, action, { + codeData + }) + } +} diff --git a/electron/mapi/manager/config/config.ts b/electron/mapi/manager/config/config.ts new file mode 100644 index 0000000..0ef948e --- /dev/null +++ b/electron/mapi/manager/config/config.ts @@ -0,0 +1,287 @@ +import { + ActionRecord, + ConfigRecord, + LaunchRecord, + PluginActionRecord, + PluginConfig, + PluginRecord +} from "../../../../src/types/Manager"; + +import {KVDBMain} from "../../kvdb/main" +import {CommonConfig} from "../../../config/common"; +import {ManagerHotkey} from "../hotkey"; +import {MemoryCacheUtil} from "../../../lib/util"; +import {ManagerPlugin} from "../plugin"; +import {ManagerSystem} from "../system"; +import {isWin} from "../../../lib/env"; + +const defaultConfig: ConfigRecord = { + mainTrigger: { + key: 'Space', + altKey: true, + ctrlKey: false, + metaKey: false, + shiftKey: false, + times: 1, + }, + fastPanelTrigger: { + type: 'Ctrl', + } + // fastPanelTriggerButton: { + // button: HotkeyMouseButtonEnum.RIGHT, + // type: 'longPress', + // }, +} + +export const ManagerConfig = { + configOld: null as ConfigRecord | null, + async clearCache() { + MemoryCacheUtil.forget('Config') + MemoryCacheUtil.forget('DisabledActionMatches') + MemoryCacheUtil.forget('PinActions') + MemoryCacheUtil.forget('Launches') + MemoryCacheUtil.forget('CustomActions') + MemoryCacheUtil.forget('HistoryActions') + MemoryCacheUtil.forget('PluginConfig') + }, + async get(): Promise { + return MemoryCacheUtil.remember('Config', async () => { + // reset config + // await this.save(defaultConfig) + const config = await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbConfigId) + if (!config) { + await this.save(defaultConfig) + this.configOld = defaultConfig + return defaultConfig + } + let changed = false + for (const key in defaultConfig) { + if (key in config) { + if (typeof config[key] === 'object') { + for (const subKey in defaultConfig[key]) { + if (subKey in config[key]) { + } else { + config[key][subKey] = defaultConfig[key][subKey] + changed = true + } + } + } + } else { + config[key] = defaultConfig[key] + changed = true + } + } + if (changed) { + await this.save(config) + } + this.configOld = config + return config + }) + }, + async save(config: ConfigRecord): Promise { + delete config['data'] + const doc = { + _id: CommonConfig.dbConfigId, + ...config + } + await KVDBMain.putForce(CommonConfig.dbSystem, doc) + let changed = false + if (this.configOld) { + if (!changed && JSON.stringify(this.configOld.mainTrigger) !== JSON.stringify(config.mainTrigger)) { + changed = true + } + if (!changed && JSON.stringify(this.configOld.fastPanelTrigger) !== JSON.stringify(config.fastPanelTrigger)) { + changed = true + } + } + MemoryCacheUtil.forget('Config') + if (changed) { + ManagerHotkey.configInit().then() + } + }, + async listDisabledActionMatch() { + return MemoryCacheUtil.remember('DisabledActionMatches', async () => { + return await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbDisabledActionMatchId) || {} + }) + }, + async toggleDisabledActionMatch(pluginName: string, actionName: string, matchName: string) { + let matches = await this.listDisabledActionMatch() + if (!matches) { + matches = {} + } + if (!matches[pluginName]) { + matches[pluginName] = {} + } + if (!matches[pluginName][actionName]) { + matches[pluginName][actionName] = [] + } + let disabled = false + if (matches[pluginName][actionName].includes(matchName)) { + matches[pluginName][actionName] = matches[pluginName][actionName].filter(v => v !== matchName) + if (!matches[pluginName][actionName].length) { + delete matches[pluginName][actionName] + } + if (!Object.keys(matches[pluginName]).length) { + delete matches[pluginName] + } + } else { + matches[pluginName][actionName].push(matchName) + disabled = true + } + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbDisabledActionMatchId, + ...matches + }) + MemoryCacheUtil.forget('DisabledActionMatches') + return disabled + }, + async listPinAction(): Promise { + return MemoryCacheUtil.remember('PinActions', async () => { + const res = await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbPinActionId) + if (!res) { + return [] + } + return res['records'] || [] + }) + }, + async togglePinAction(pluginName: string, actionName: string) { + let pinActions = await this.listPinAction() + const saveAction = { + pluginName: pluginName, + actionName: actionName, + } as PluginActionRecord + const exists = pinActions.find(v => v.pluginName === saveAction.pluginName && v.actionName === saveAction.actionName) + if (exists) { + pinActions = pinActions.filter(v => v.pluginName !== saveAction.pluginName || v.actionName !== saveAction.actionName) + } else { + pinActions.unshift(saveAction) + } + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbPinActionId, + records: pinActions + }) + MemoryCacheUtil.forget('PinActions') + }, + async listLaunch(): Promise { + return MemoryCacheUtil.remember('Launches', async () => { + const res = await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbLaunchId) + if (!res) { + return [] + } + return res['records'] || [] + }) + }, + async updateLaunch(records: LaunchRecord[]) { + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbLaunchId, + records: records + }) + MemoryCacheUtil.forget('Launches') + ManagerHotkey.configInit() + }, + async getCustomAction(): Promise> { + return MemoryCacheUtil.remember('CustomActions', async () => { + return await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbCustomActionId) || {} + }) + }, + async addCustomAction(plugin: PluginRecord, action: ActionRecord | ActionRecord[]) { + const customAction = await this.getCustomAction() + if (!(plugin.name in customAction)) { + customAction[plugin.name] = [] + } + if (!Array.isArray(action)) { + action = [action] + } + for (let a of action) { + a = ManagerPlugin.normalAction(a, plugin) + let replace = false + for (let i = 0; i < customAction[plugin.name].length; i++) { + if (customAction[plugin.name][i].name === a.name) { + customAction[plugin.name][i] = a + replace = true + break + } + } + if (!replace) { + customAction[plugin.name].push(a) + } + } + delete customAction['data'] + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbCustomActionId, + ...customAction + }) + MemoryCacheUtil.forget('CustomActions') + }, + async removeCustomAction(plugin: PluginRecord, name: string) { + const customAction = await this.getCustomAction() + if (!(plugin.name in customAction)) { + return + } + customAction[plugin.name] = customAction[plugin.name].filter(v => v.name !== name) + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbCustomActionId, + ...customAction + }) + MemoryCacheUtil.forget('CustomActions') + }, + async getHistoryAction(): Promise { + return MemoryCacheUtil.remember('HistoryActions', async () => { + const res = await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbHistoryActionId) + if (!res) { + return [] + } + return res['records'] || [] + }) + }, + async addHistoryAction(plugin: PluginRecord, action: ActionRecord) { + let historyActions = await this.getHistoryAction() + const saveAction = { + pluginName: plugin.name, + actionName: action.name, + } as PluginActionRecord + // remove duplicate + historyActions = historyActions.filter(v => v.pluginName !== saveAction.pluginName || v.actionName !== saveAction.actionName) + historyActions.unshift(saveAction) + if (historyActions.length > 100) { + historyActions.pop() + } + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbHistoryActionId, + records: historyActions + }) + MemoryCacheUtil.forget('HistoryActions') + }, + async getPluginConfigAll(): Promise> { + return MemoryCacheUtil.remember('PluginConfig', async () => { + const res = await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbPluginConfigId) + if (!res) { + return {} + } + return res['records'] || {} + }) + }, + async getPluginConfig(pluginName: string): Promise { + const res = await this.getPluginConfigAll() + return res[pluginName] || {} + }, + async setPluginConfigItem(pluginName: string, key: string, value: any) { + const config = await this.getPluginConfig(pluginName) + config[key] = value + await ManagerConfig.setPluginConfig(pluginName, config) + if (ManagerSystem.match(pluginName)) { + await ManagerSystem.clearCache() + } else { + await ManagerPlugin.clearCache() + } + }, + async setPluginConfig(pluginName: string, config: PluginConfig) { + const pluginConfig = await this.getPluginConfigAll() + pluginConfig[pluginName] = config + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbPluginConfigId, + records: pluginConfig + }) + MemoryCacheUtil.forget('PluginConfig') + } +} diff --git a/electron/mapi/manager/hotkey/handle.ts b/electron/mapi/manager/hotkey/handle.ts new file mode 100644 index 0000000..1e054b1 --- /dev/null +++ b/electron/mapi/manager/hotkey/handle.ts @@ -0,0 +1,30 @@ +import {ManagerPluginEvent} from "../plugin/event"; +import {ManagerConfig} from "../config/config"; + + +export const ManagerHotkeyHandle = { + async mainTrigger() { + if (await ManagerPluginEvent.isMainWindowShown(null, null)) { + await ManagerPluginEvent.hideMainWindow(null, null) + } else { + await ManagerPluginEvent.showMainWindow(null, null) + } + }, + async fastPanelTrigger() { + if (await ManagerPluginEvent.isFastPanelWindowShown(null, null)) { + await ManagerPluginEvent.hideFastPanelWindow(null, null) + } else { + await ManagerPluginEvent.showFastPanelWindow(null, null) + } + }, + async launch(index: string) { + const i = parseInt(index) + const launches = await ManagerConfig.listLaunch() + if (i < launches.length) { + await ManagerPluginEvent.redirect(null, { + keywordsOrAction: launches[i].keyword + }) + } + } +} + diff --git a/electron/mapi/manager/hotkey/index.ts b/electron/mapi/manager/hotkey/index.ts new file mode 100644 index 0000000..52221be --- /dev/null +++ b/electron/mapi/manager/hotkey/index.ts @@ -0,0 +1,328 @@ +import {uIOhook, UiohookKey} from "uiohook-napi"; +import {ManagerConfig} from "../config/config"; +import {ManagerHotkeyHandle} from "./handle"; +import {HotkeyMouseButtonEnum} from "../../keys/type"; +import {Events} from "../../event/main"; +import {KeysMain} from "../../keys/main"; +import {globalShortcut} from "electron"; +import {isMac} from "../../../lib/env"; + +type HotkeyKeyItem = { + name: string + + keycode: any + altKey: boolean + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + times: number + + expireTimer?: number +} + +type HotkeyKeySimpleItem = { + name: string + + type: 'Ctrl' | 'Alt' | 'Meta' +} + +type HotkeyMouseItem = { + name: string + + button: HotkeyMouseButtonEnum + type: 'click' | 'longPress' + clickTimes?: number + + expireTime?: number + expireCount?: number +} + +const keyToKeyCode = (key: string) => { + if (key in UiohookKey) { + return UiohookKey[key] + } + return 0 +} + +const keyCodeToKey = (keyCode: number) => { + for (const key in UiohookKey) { + if (UiohookKey[key] === keyCode) { + return key + } + } + return '' +} + +export const ManagerHotkey = { + isGrab: false, + keyMultiDelayTime: 500, + keyConfigs: [ + // { + // name: 'mainTrigger', + // keycode: UiohookKey.Space, + // altKey: false, + // ctrlKey: false, + // metaKey: true, + // shiftKey: false, + // times: 1, + // }, + ] as HotkeyKeyItem[], + keySimpleConfigs: [ + // { + // name: 'fastPanelTrigger', + // type: 'Ctrl' + // } + ] as HotkeyKeySimpleItem[], + mouseLongPressTime: 500, + mouseConfigs: [ + // { + // name: 'fastPanelTrigger', + // type: 'click', + // button: HotkeyButtonEnum.RIGHT, + // clickTimes: 1, + // }, + // { + // name: 'fastPanelTrigger2', + // type: 'longPress', + // button: HotkeyButtonEnum.RIGHT, + // } + ] as HotkeyMouseItem[], + + _keySimple: { + Ctrl: null as null | 'down' | 'up', + Alt: null as null | 'down' | 'up', + Meta: null as null | 'down' | 'up', + }, + + init() { + uIOhook.on('keydown', (e) => { + if (this.isGrab) { + const data = { + type: 'keydown', + key: keyCodeToKey(e.keycode), + altKey: e.altKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + shiftKey: e.shiftKey, + } + Events.broadcast('HotkeyWatch', data) + return + } + // console.log('ManagerHotkey.keydown', e, this.keyConfigs) + for (const item of this.keyConfigs) { + if (item.keycode !== e.keycode || + item.altKey !== e.altKey || + item.ctrlKey !== e.ctrlKey || + item.metaKey !== e.metaKey || + item.shiftKey !== e.shiftKey) { + continue + } + if (!item.times || item.times <= 1) { + this.fire(item.name) + continue + } + const now = Date.now() + if (!item.expireTime) { + item.expireTime = now + this.keyMultiDelayTime + item.expireCount = 1 + } else { + if (now > item.expireTime) { + item.expireTime = now + this.keyMultiDelayTime + item.expireCount = 1 + } else { + item.expireCount++ + if (item.expireCount >= item.times) { + this.fire(item.name) + item.expireTime = 0 + item.expireCount = 0 + } + } + } + } + for (const k in this._keySimple) { + if (this._keySimple[k] === 'down') { + this._keySimple[k] = null + } + } + if (e.keycode === UiohookKey.Ctrl && !e.altKey && e.ctrlKey && !e.metaKey && !e.shiftKey) { + this._keySimple.Ctrl = 'down' + } else if (e.keycode === UiohookKey.Alt && e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + this._keySimple.Alt = 'down' + } else if (e.keycode === UiohookKey.Meta && !e.altKey && !e.ctrlKey && e.metaKey && !e.shiftKey) { + this._keySimple.Meta = 'down' + } + }) + uIOhook.on('keyup', (e) => { + if (e.keycode === UiohookKey.Ctrl && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && this._keySimple.Ctrl === 'down') { + this._keySimple.Ctrl = 'up' + this.keySimpleConfigs.filter(item => item.type === 'Ctrl').forEach(item => this.fire(item.name)) + } else if (e.keycode === UiohookKey.Alt && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && this._keySimple.Alt === 'down') { + this._keySimple.Alt = 'up' + this.keySimpleConfigs.filter(item => item.type === 'Alt').forEach(item => this.fire(item.name)) + } else if (e.keycode === UiohookKey.Meta && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && this._keySimple.Meta === 'down') { + this._keySimple.Meta = 'up' + this.keySimpleConfigs.filter(item => item.type === 'Meta').forEach(item => this.fire(item.name)) + } + }) + // uIOhook.on('mousedown', (e) => { + // // console.log('ManagerHotkey.mousedown', e) + // for (const item of this.mouseConfigs) { + // if (item.button !== e.button) { + // continue + // } + // if (item.type === 'click') { + // if (!item.clickTimes || item.clickTimes <= 1) { + // this.fire(item.name) + // } else if (item.clickTimes === e.clicks) { + // this.fire(item.name) + // } + // } else if (item.type === 'longPress') { + // item.expireTimer = setTimeout(() => { + // this.fire(item.name) + // item.expireTimer = 0 + // }, this.mouseLongPressTime) + // } + // } + // }) + // uIOhook.on('mouseup', (e) => { + // // console.log('ManagerHotkey.mouseup', e) + // for (const item of this.mouseConfigs) { + // if (item.button === HotkeyMouseButtonEnum.LEFT && e.button !== 1) { + // continue + // } + // if (item.button === HotkeyMouseButtonEnum.RIGHT && e.button !== 2) { + // continue + // } + // if (item.type === 'longPress') { + // if (item.expireTimer) { + // clearTimeout(item.expireTimer) + // item.expireTimer = 0 + // } + // } + // } + // }) + uIOhook.start() + this.configInit().then() + }, + + destroy() { + uIOhook.stop() + }, + + async register() { + + // console.log('ManagerHotkey.register', this.keyConfigs) + for (const keyConfig of this.keyConfigs) { + const accelerator = [] + if (keyConfig.ctrlKey) { + accelerator.push('Control') + } + if (keyConfig.metaKey) { + accelerator.push('Meta') + } + if (keyConfig.altKey) { + accelerator.push('Alt') + } + if (keyConfig.shiftKey) { + accelerator.push('Shift') + } + accelerator.push(keyCodeToKey(keyConfig.keycode)) + globalShortcut.register(accelerator.join('+'), () => { + this.fire(keyConfig.name) + }) + } + this.keyConfigs = this.keyConfigs.filter(item => item.times && item.times > 1) + }, + + async configInit() { + + this.keyConfigs = [] + + const config = await ManagerConfig.get() + if (config.mainTrigger) { + this.keyConfigs.push({ + name: 'mainTrigger', + keycode: keyToKeyCode(config.mainTrigger.key), + altKey: config.mainTrigger.altKey, + ctrlKey: config.mainTrigger.ctrlKey, + metaKey: config.mainTrigger.metaKey, + shiftKey: config.mainTrigger.shiftKey, + times: config.mainTrigger.times, + }) + } + this.keySimpleConfigs = [] + if (config.fastPanelTrigger) { + this.keySimpleConfigs.push({ + name: 'fastPanelTrigger', + type: config.fastPanelTrigger.type, + }) + } + + const launches = await ManagerConfig.listLaunch() + launches.forEach((launch, launchIndex) => { + if (launch.hotkey && launch.keyword) { + this.keyConfigs.push({ + name: `launch:${launchIndex}`, + keycode: keyToKeyCode(launch.hotkey.key), + altKey: launch.hotkey.altKey, + ctrlKey: launch.hotkey.ctrlKey, + metaKey: launch.hotkey.metaKey, + shiftKey: launch.hotkey.shiftKey, + times: launch.hotkey.times, + }) + } + }) + + // this.mouseConfigs = [] + // if (config.fastPanelTriggerButton) { + // this.mouseConfigs.push({ + // name: 'fastPanelTrigger', + // type: config.fastPanelTriggerButton.type, + // button: config.fastPanelTriggerButton.button, + // clickTimes: config.fastPanelTriggerButton.clickTimes, + // }) + // } + + KeysMain.register() + }, + + async watch() { + this.isGrab = true + }, + async unwatch() { + this.isGrab = false + }, + + eventListeners: {}, + fire(eventName: string, ...args: any[]) { + // console.log('ManagerHotkey.fire', eventName, args) + let eventParam = '' + if (eventName.includes(':')) { + const pcs = eventName.split(':') + if (pcs.length > 1) { + eventName = pcs[0] + eventParam = pcs[1] + } + } + if (eventName in ManagerHotkeyHandle) { + ManagerHotkeyHandle[eventName](eventParam) + } + if (!this.eventListeners[eventName]) { + return + } + this.eventListeners[eventName].forEach(cb => cb(...args)) + }, + on(eventName: string, callback: Function) { + if (!this.eventListeners[eventName]) { + this.eventListeners[eventName] = [] + } + this.eventListeners[eventName].push(callback) + }, + off(eventName: string, callback: Function) { + if (!this.eventListeners[eventName]) { + return + } + this.eventListeners[eventName] = this.eventListeners[eventName].filter(cb => cb !== callback) + }, + +} diff --git a/electron/mapi/manager/hotkey/simulate.ts b/electron/mapi/manager/hotkey/simulate.ts new file mode 100644 index 0000000..3f04c7b --- /dev/null +++ b/electron/mapi/manager/hotkey/simulate.ts @@ -0,0 +1,14 @@ +import {uIOhook, UiohookKey} from "uiohook-napi"; + +export const KeyboardKey = { + ...UiohookKey +} + +export const ManagerHotkeySimulate = { + toCode(key: string) { + return key in KeyboardKey ? KeyboardKey[key] : key + }, + keyTap(key: number, modifiers?: number[]) { + uIOhook.keyTap(key, modifiers) + } +} diff --git a/electron/mapi/manager/lib/cache.ts b/electron/mapi/manager/lib/cache.ts new file mode 100644 index 0000000..4ab96e0 --- /dev/null +++ b/electron/mapi/manager/lib/cache.ts @@ -0,0 +1,69 @@ +import {Files} from "../../file/main"; +import {Log} from "../../log/main"; + +export const ManagerFileCacheUtil = { + async get(name: string, defaultValue: any = null) { + const content = await Files.read(`cache/${name}.json`) + if (content) { + let json = null + try { + json = JSON.parse(content) + } catch (e) { + Log.error('Plugin.App.Error', e) + } + if (!json || !('expire' in json) || !('value' in json)) { + await Files.deletes(`cache/${name}.json`) + return defaultValue + } + if (json.expire > 0 && json.expire < Date.now()) { + await Files.deletes(`cache/${name}.json`) + return defaultValue + } + return json.value + } + return defaultValue + }, + async getIgnoreExpire(name: string, defaultValue: any = null): Promise<{ + isCache: boolean, + value: any, + expire: number + }> { + const content = await Files.read(`cache/${name}.json`) + if (content) { + let json = null + try { + json = JSON.parse(content) + } catch (e) { + Log.error('Plugin.App.Error', e) + } + if (!json || !('value' in json)) { + await Files.deletes(`cache/${name}.json`) + return { + isCache: false, + value: defaultValue, + expire: 0 + } + } + return { + isCache: true, + value: json.value, + expire: json.expire + } + } + return { + isCache: false, + value: defaultValue, + expire: 0 + } + }, + async set(name: string, value: any, expire: number = 0) { + const json = { + expire: expire > 0 ? Date.now() + expire : 0, + value: value + } + await Files.write(`cache/${name}.json`, JSON.stringify(json)) + }, + async forget(name: string) { + await Files.deletes(`cache/${name}.json`) + } +} diff --git a/electron/mapi/manager/lib/hooks.ts b/electron/mapi/manager/lib/hooks.ts new file mode 100644 index 0000000..db4d0c0 --- /dev/null +++ b/electron/mapi/manager/lib/hooks.ts @@ -0,0 +1,69 @@ +import {BrowserView, BrowserWindow} from "electron"; +import {AppsMain} from "../../app/main"; + +type PluginHookType = never + | 'PluginReady' + | 'PluginExit' + | 'SubInputChange' + | 'ScreenCapture' + +type HookType = never + | 'Show' + | 'Hide' + | 'SetSubInput' + | 'RemoveSubInput' + | 'SetSubInputValue' + | 'PluginInit' + | 'PluginExit' + | 'PluginAlreadyOpened' + | 'PluginDetached' + | 'PluginState' + | 'PluginCodeRunning' + | 'PluginCodeSuccess' + | 'PluginCodeError' + | 'Maximize' + | 'Unmaximize' + | 'EnterFullScreen' + | 'LeaveFullScreen' + +export const executePluginHooks = async (view: BrowserView, hook: PluginHookType, data?: any) => { + const evalJs = ` + if(window.focusany && window.focusany.hooks && typeof window.focusany.hooks.on${hook} === 'function' ) { + try { + window.focusany.hooks.on${hook}(${JSON.stringify(data)}); + } catch(e) { + console.log('executePluginHooks.on${hook}.error', e); + } + }`; + return view.webContents?.executeJavaScript(evalJs); +}; + +export const executeHooks = async (win: BrowserWindow, hook: HookType, data?: any) => { + const evalJs = ` + if(window.__page && window.__page.hooks && typeof window.__page.hooks.on${hook} === 'function' ) { + try { + window.__page.hooks.on${hook}(${JSON.stringify(data)}); + } catch(e) { + console.log('executeHooks.on${hook}.error', e); + } + }`; + return win.webContents?.executeJavaScript(evalJs); +} + +export const executeDarkMode = async (view: BrowserWindow | BrowserView, data: { + isSystem: boolean, +}) => { + data = Object.assign({ + isSystem: false + }, data) + if (await AppsMain.shouldDarkMode()) { + // body and html + view.webContents.executeJavaScript(` + document.body.setAttribute('data-theme', 'dark'); + document.documentElement.setAttribute('data-theme', 'dark'); + `); + if (data.isSystem) { + view.webContents.executeJavaScript(`document.body.setAttribute('arco-theme', 'dark');`); + } + } +} diff --git a/electron/mapi/manager/main.ts b/electron/mapi/manager/main.ts new file mode 100644 index 0000000..96ad193 --- /dev/null +++ b/electron/mapi/manager/main.ts @@ -0,0 +1,373 @@ +import {BrowserWindow, ipcMain} from "electron"; +import {ActionRecord, ActionTypeEnum, FilePluginRecord, LaunchRecord, PluginRecord} from "../../../src/types/Manager"; +import {ManagerPlugin} from "./plugin"; +import {ManagerWindow} from "./window"; +import {ManagerPluginEvent} from "./plugin/event"; +import {ManagerClipboard} from "./clipboard"; +import {ManagerConfig} from "./config/config"; +import {AppRuntime} from "../env"; +import {ManagerSystem} from "./system"; +import {ManagerHotkey} from "./hotkey"; +import {ManagerSystemPluginFile} from "./system/plugin/file"; +import {Manager} from "./manager"; +import {PluginContext, SearchQuery} from "./type"; +import {ManagerPluginStore} from "./system/plugin/store/index"; +import {getClipboardFiles} from "./clipboard/clipboardFiles"; +import {Permissions} from "../../lib/permission"; +import {Page} from "../../page"; +import {ManagerAutomation} from "./automation"; +import {ManagerBackend} from "./backend"; + +const init = () => { + ManagerClipboard.init().then() +} + +const ready = () => { + Permissions.checkAccessibilityAccess().then(enable => { + if (enable) { + ManagerHotkey.init() + } else { + Page.open('setup').then() + } + }) + Permissions.checkScreenCaptureAccess().then(enable => { + if (enable) { + ManagerAutomation.init() + } else { + Page.open('setup').then() + } + }) +} + +const destroy = () => { + ManagerClipboard.monitorStop() + ManagerHotkey.destroy() +} + +ipcMain.handle('manager:getConfig', async (event) => { + return await ManagerConfig.get() +}) + +ipcMain.handle('manager:setConfig', async (event, config) => { + return await ManagerConfig.save(config) +}) + +ipcMain.handle('manager:show', async (event) => { + return await ManagerPluginEvent.showMainWindow(null, null) +}) + +ipcMain.handle('manager:hide', async (event) => { + return await ManagerPluginEvent.hideMainWindow(null, null) +}) + +ipcMain.handle('manager:getClipboardFiles', async (event) => { + return getClipboardFiles() +}) + +ipcMain.handle('manager:getSelectedContent', async (event) => { + return Manager.selectedContent +}) + +ipcMain.handle('manager:listPlugin', async (event, option?: {}) => { + return await Manager.listPlugin() +}) + +ipcMain.handle('manager:installPlugin', async (event, fileOrPath: string, option?: {}) => { + return await ManagerPlugin.installFromFileOrDir(fileOrPath) +}) + +ipcMain.handle('manager:refreshInstallPlugin', async (event, pluginName: string, option?: {}) => { + return await ManagerPlugin.refreshInstall(pluginName) +}) + +ipcMain.handle('manager:uninstallPlugin', async (event, pluginName: string, option?: {}) => { + // console.log('manager:uninstallPlugin', pluginName) + return await ManagerPlugin.uninstall(pluginName) +}) + +ipcMain.handle('manager:getPluginInstalledVersion', async (event, pluginName: string, option?: {}) => { + return await ManagerPlugin.getPluginInstalledVersion(pluginName) +}) + +ipcMain.handle('manager:listDisabledActionMatch', async (event, option?: {}) => { + return await ManagerConfig.listDisabledActionMatch() +}) + +ipcMain.handle('manager:toggleDisabledActionMatch', async (event, pluginName: string, actionName: string, matchName: string, option?: {}) => { + return await ManagerConfig.toggleDisabledActionMatch(pluginName, actionName, matchName) +}) + +ipcMain.handle('manager:listPinAction', async (event, option?: {}) => { + return await ManagerConfig.listPinAction() +}) + +ipcMain.handle('manager:togglePinAction', async (event, pluginName: string, actionName: string, option?: {}) => { + return await ManagerConfig.togglePinAction(pluginName, actionName) +}) + +ipcMain.handle('manager:clearCache', async (event, option?: {}) => { + await ManagerConfig.clearCache() + await ManagerSystem.clearCache() + await ManagerPlugin.clearCache() +}) + +ipcMain.handle('manager:hotKeyWatch', async (event, option?: {}) => { + await ManagerHotkey.watch() +}) + +ipcMain.handle('manager:hotKeyUnwatch', async (event, option?: {}) => { + await ManagerHotkey.unwatch() +}) + +ipcMain.handle('manager::listAction', async (event) => { + return Manager.listAction() +}) + +ipcMain.handle('manager:searchFastPanelAction', async (event, query: SearchQuery, option?: {}) => { + query = Object.assign({ + keywords: '', + currentFiles: [], + currentImage: '', + currentText: '', + }, query) + + // console.log('manager:searchFastPanelAction', query) + + const request = Manager.createSearchRequest(query) + const result = { + id: request.id, + fastPanelActions: [] as ActionRecord[], + } + + let actions: ActionRecord[] = await Manager.listAction(request) + // Files.write('actions.json', JSON.stringify(actions)) + + const uniqueRemover = new Set() + result.fastPanelActions = [ + ...await Manager.matchActions(uniqueRemover, actions, query), + ...await Manager.searchActions(uniqueRemover, actions, query), + ...await Manager.historyActions(uniqueRemover, actions, query), + ] + + for (const a of result.fastPanelActions) { + if (a.type === ActionTypeEnum.VIEW) { + const plugin = await Manager.getPlugin(a.pluginName) + const { + nodeIntegration, + preloadBase, + mainFastPanel, + } = ManagerPlugin.getInfo(plugin) + a.runtime.view = { + nodeIntegration, + preloadBase, + mainFastPanel, + } + for (const k of ['preloadBase', 'mainFastPanel']) { + if (a.runtime.view[k] && !a.runtime.view[k].startsWith('file://')) { + a.runtime.view[k] = 'file://' + a.runtime.view[k] + } + } + } + } + + return result +}) + +ipcMain.handle('manager:searchAction', async (event, query: SearchQuery, option?: {}) => { + query = Object.assign({ + keywords: '', + currentFiles: [], + currentImage: '', + currentText: '', + }, query) + // console.log('manager:searchAction', query) + + const request = Manager.createSearchRequest(query) + const result = { + id: request.id, + searchActions: [], + matchActions: [], + historyActions: [], + pinActions: [], + } + + let actions: ActionRecord[] = await Manager.listAction(request) + + // Files.write('actions.json', JSON.stringify(actions)) + + const uniqueRemover = new Set() + result.searchActions = await Manager.searchActions(uniqueRemover, actions, query) + result.matchActions = await Manager.matchActions(uniqueRemover, actions, query) + if (!query.keywords) { + result.historyActions = await Manager.historyActions(uniqueRemover, actions, query) + result.pinActions = await Manager.pinActions(uniqueRemover, actions, query) + } + + return result +}) + +ipcMain.handle('manager:subInputChange', async (event, keywords: string, option?: {}) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + await ManagerWindow.subInputChange(senderWindow, keywords) +}) + + +ipcMain.handle('manager:openPlugin', async (event, pluginName: string, option?: {}) => { + await Manager.openPlugin(pluginName) +}) + +ipcMain.handle('manager:openAction', async (event, action: ActionRecord, option?: {}) => { + await Manager.openAction(action) +}) + +ipcMain.handle('manager:closeMainPlugin', async (event, plugin?: PluginRecord, option?: {}) => { + await ManagerWindow.close(plugin) +}) + +ipcMain.handle('manager:openMainPluginDevTools', async (event, plugin?: PluginRecord, option?: {}) => { + await ManagerWindow.openMainPluginDevTools(plugin) +}) + +ipcMain.handle('manager:detachPlugin', async (event, option) => { + await ManagerWindow.detach() +}) + +ipcMain.handle('manager:toggleDetachPluginAlwaysOnTop', async (event, alwaysOnTop: boolean, option?: {}) => { + const view = ManagerWindow.getViewByWebContents(event.sender) + return ManagerWindow.toggleDetachPluginAlwaysOnTop(view, alwaysOnTop, option) +}) + +ipcMain.handle('manager:setDetachPluginZoom', async (event, zoom: number, option?: {}) => { + const view = ManagerWindow.getViewByWebContents(event.sender) + await ManagerWindow.setDetachPluginZoom(view, zoom, option) + await ManagerConfig.setPluginConfigItem(view._plugin.name, 'zoom', zoom) +}) + +ipcMain.handle('manager:closeDetachPlugin', async (event) => { + const view = ManagerWindow.getViewByWebContents(event.sender) + await ManagerWindow.closeDetachPlugin(view) +}) + +ipcMain.handle('manager:openDetachPluginDevTools', async (event, option?: {}) => { + const view = ManagerWindow.getViewByWebContents(event.sender) + await ManagerWindow.openDetachPluginDevTools(view) +}) + +ipcMain.handle('manager:setPluginAutoDetach', async (event, autoDetach: boolean, option?: {}) => { + const view = ManagerWindow.getViewByWebContents(event.sender) + await ManagerConfig.setPluginConfigItem(view._plugin.name, 'autoDetach', autoDetach) +}) + +ipcMain.handle('manager:getPluginConfig', async (event, pluginName: string, option?: {}) => { + return await ManagerConfig.getPluginConfig(pluginName) +}) + +ipcMain.handle('manager:listFilePluginRecords', async (event, option?: {}) => { + return await ManagerSystemPluginFile.list() +}) + +ipcMain.handle('manager:updateFilePluginRecords', async (event, records: FilePluginRecord[], option?: {}) => { + return await ManagerSystemPluginFile.update(records) +}) + +ipcMain.handle('manager:listLaunchRecords', async (event, option?: {}) => { + return await ManagerConfig.listLaunch() +}) + +ipcMain.handle('manager:updateLaunchRecords', async (event, records: LaunchRecord[], option?: {}) => { + return await ManagerConfig.updateLaunch(records) +}) + +ipcMain.handle('manager:storeInstall', async (event, pluginName: string, option?: {}) => { + return await ManagerPluginStore.install(pluginName, option) +}) + +ipcMain.handle('manager:storePublish', async (event, pluginName: string, option?: {}) => { + return await ManagerPluginStore.publish(pluginName, option) +}) + +ipcMain.handle('manager:storePublishInfo', async (event, pluginName: string, option?: {}) => { + return await ManagerPluginStore.publishInfo(pluginName, option) +}) + +ipcMain.handle('manager:clipboardList', async (event, option?: {}) => { + return await ManagerClipboard.list() +}) + +ipcMain.handle('manager:clipboardClear', async (event, option?: {}) => { + return await ManagerClipboard.clear() +}) + +ipcMain.handle('manager:clipboardDelete', async (event, timestamp: number, option?: {}) => { + return await ManagerClipboard.delete(timestamp) +}) + +const getViewByEvent = (event) => { + let view = ManagerWindow.getViewByWebContents(event.sender) + if (!view) { + try { + const userAgent = event.sender.getUserAgent() + const match = userAgent.match(/PluginAction\/([^/]+)\/([^/]+)$/) + if (match) { + const pluginName = match[1] + const actionName = match[2] + view = { + _plugin: Manager.getPluginSync(pluginName), + } as PluginContext + } + } catch (e) { + } + } + return view +} + +ipcMain.on('FocusAny.Event', async (_event, payload: any) => { + const view = getViewByEvent(_event) + const {id, event, data} = payload + // console.log('FocusAny.Event', {id, event, data, view}) + const plugin = view._plugin + const result = await ManagerBackend.run(plugin, 'event', event, data, { + rejectIfError: true + }) + const resultEvent = `FocusAny.Event.${id}` + view.webContents.send(resultEvent, result) +}) + +ipcMain.on('FocusAny.Plugin', (event, payload: { + type: string, + data: any +}) => { + const view = getViewByEvent(event) + const {type, data} = payload + ManagerPluginEvent[type](view, data).then(result => { + event.returnValue = result + }).catch(e => { + event.returnValue = e + }) +}) + +ipcMain.on('SendTo', (event, winId: number, type: string, ...args: any) => { + // console.log('SendTo', event.sender.getType(), event.sender.id, {winId, type, payload}) + BrowserWindow.getAllWindows().forEach(w => { + if (w === AppRuntime.fastPanelWindow) { + return + } + if (w === AppRuntime.mainWindow) { + for (let v of w.getBrowserViews()) { + if (v.webContents.id === winId) { + v.webContents.send(type, event.sender.id, ...args) + } + } + } else { + if (w.webContents.id === winId) { + w.webContents.send(type, event.sender.id, ...args) + } + } + }) +}) + +export default { + init, + ready, + destroy +} diff --git a/electron/mapi/manager/manager.ts b/electron/mapi/manager/manager.ts new file mode 100644 index 0000000..fafb297 --- /dev/null +++ b/electron/mapi/manager/manager.ts @@ -0,0 +1,408 @@ +import { + ActionMatchFile, + ActionMatchKey, + ActionMatchRegex, + ActionMatchText, + ActionMatchTypeEnum, ActionMatchWindow, + ActionRecord, + ActionTypeEnum, ActiveWindow, + PluginRecord, + SelectedContent +} from "../../../src/types/Manager"; +import {ManagerSystem} from "./system"; +import {ManagerPlugin} from "./plugin"; +import {ManagerConfig} from "./config/config"; +import {SearchQuery} from "./type"; +import {PinyinUtil} from "../../lib/pinyin-util"; +import {exec} from "child_process"; +import {ManagerPluginEvent} from "./plugin/event"; +import {ManagerWindow} from "./window"; +import {ManagerCode} from "./code"; +import {ManagerBackend} from "./backend"; +import {ReUtil, StrUtil} from "../../lib/util"; +import {Events} from "../event/main"; +import {AppRuntime} from "../env"; + +type SearchRequest = { + id: string, + query: SearchQuery, +} + +let plugins: PluginRecord[] = [] + +export const Manager = { + selectedContent: null as SelectedContent | null, + activeWindow: null as ActiveWindow | null, + searchRequests: [] as SearchRequest[], + createSearchRequest(query: SearchQuery) { + const id = StrUtil.randomString(8) + if (this.searchRequests.length > 3) { + this.searchRequests.shift() + } + const request = { + id, + query, + } + this.searchRequests.push(request) + return request + }, + getSearchRequestQuery(id: string) { + for (const s of this.searchRequests) { + if (s.id === id) { + return s.query + } + } + return null + }, + async openPlugin(pluginName: string) { + const plugin = await this.getPlugin(pluginName) + if (!plugin) { + throw 'PluginNotExists' + } + if (!plugin.actions || !plugin.actions.length) { + throw 'PluginNoActions' + } + for (const a of plugin.actions) { + if (a.type === ActionTypeEnum.WEB) { + await this.openAction(a) + return + } + } + }, + async openAction(action: ActionRecord) { + const plugin = await Manager.getPlugin(action.fullName.split('/')[0]) + // console.log('manager:openAction', plugin, action, action.fullName.split('/')[0]) + if (!plugin) { + return + } + if (!action.runtime) { + action.runtime = { + searchScore: 0, + searchTitleMatched: '', + match: null, + } + } + switch (action.type) { + case ActionTypeEnum.COMMAND: + exec(action.data.command) + break + case ActionTypeEnum.WEB: + if (!await ManagerPluginEvent.isMainWindowShown(null, null)) { + await ManagerPluginEvent.showMainWindow(null, null) + } + await ManagerWindow.open(plugin, action) + break + case ActionTypeEnum.CODE: + ManagerCode.execute(plugin, action).then() + break + case ActionTypeEnum.BACKEND: + ManagerBackend.runAction(plugin, action).then() + break + } + await ManagerConfig.addHistoryAction(plugin, action) + }, + async getPlugin(name: string) { + for (let p of await this.listPlugin()) { + if (p.name === name) { + return p + } + } + return null + }, + getPluginSync(name: string) { + for (let p of plugins) { + if (p.name === name) { + return p + } + } + return null + }, + async listPlugin() { + plugins = [ + ...await ManagerSystem.list(), + ...await ManagerPlugin.list(), + ] + const customActions = await ManagerConfig.getCustomAction() + for (const p of plugins) { + if (!(p.name in customActions)) { + continue + } + p.actions = p.actions.concat(customActions[p.name]) + } + return plugins + }, + async listAction(request?: SearchRequest) { + let actions: ActionRecord[] = [ + ...await ManagerSystem.listAction(), + ...await ManagerPlugin.listAction(), + ] + const customActions = await ManagerConfig.getCustomAction() + for (const customAction of Object.values(customActions)) { + actions = actions.concat(customAction) + } + for (let a of actions) { + a.runtime = { + searchScore: 0, + searchTitleMatched: '', + match: null, + requestId: request ? request.id : null, + } + } + return actions + }, + async searchOneAction(keywordsOrAction: string | string[], query: SearchQuery) { + const request = this.createSearchRequest(query) + query = Object.assign({ + keywords: '', + currentFiles: [], + currentImage: '', + currentText: '', + }, query) + const actions = await this.listAction(request) + let action: ActionRecord = null + if (typeof keywordsOrAction === 'string') { + const uniqueRemover = new Set() + const results = await this.searchActions(uniqueRemover, actions, { + ...query, + keywords: keywordsOrAction, + }) + if (results.length > 0) { + action = results[0] + } + } else { + const fullName = keywordsOrAction.join('/') + for (let a of actions) { + if (a.fullName === fullName) { + action = a + break + } + } + } + return action + }, + async searchActions(uniqueRemover: Set, actions: ActionRecord[], query: SearchQuery): Promise { + let results = [] + if (!query.keywords) { + return results + } + for (const a of actions) { + if (!a.matches || uniqueRemover.has(a.fullName)) { + continue + } + let searchScoreMax = 0 + let runtimeSearchTitleMatched = '' + let runtimeMatch = null + for (const m of a.matches) { + if (m.type === ActionMatchTypeEnum.TEXT) { + if (('minLength' in m) && query.keywords.length < m.minLength) { + continue + } + if (('maxLength' in m) && query.keywords.length > m.maxLength) { + continue + } + const textMatch = PinyinUtil.match((m as ActionMatchText).text, query.keywords) + if (textMatch.matched && textMatch.similarity > searchScoreMax) { + searchScoreMax = textMatch.similarity + runtimeSearchTitleMatched = textMatch.inputMark + runtimeMatch = m + } + } else if (m.type === ActionMatchTypeEnum.KEY) { + if ((m as ActionMatchKey).key === query.keywords) { + searchScoreMax = 1 + runtimeSearchTitleMatched = PinyinUtil.mark(query.keywords) + runtimeMatch = m + } + } + } + // console.log('searchScoreMax', a.name, searchScoreMax, a.runtime.searchScore > 0) + if (searchScoreMax > 0) { + a.runtime.searchScore = searchScoreMax + a.runtime.searchTitleMatched = runtimeSearchTitleMatched + a.runtime.match = runtimeMatch + results.push(a) + uniqueRemover.add(a.fullName) + } + } + // sort by similarity + results = results.sort((a, b) => { + return b.runtime.searchScore - a.runtime.searchScore + }) + return results + }, + async matchActions(uniqueRemover: Set, actions: ActionRecord[], query: SearchQuery): Promise { + let results = [] + if (!query.keywords && !query.currentImage && !query.currentFiles.length && !query.currentText && !this.activeWindow) { + return results + } + const keywords = query.currentText || query.keywords + for (const a of actions) { + if (!a.matches || uniqueRemover.has(a.fullName)) { + continue + } + let searchScoreMax = 0 + let runtimeSearchTitleMatched = '' + let runtimeMatch = null + for (const m of a.matches) { + if (m.type === ActionMatchTypeEnum.REGEX) { + if (!keywords) { + continue + } + if ('minLength' in m && keywords.length < m.minLength) { + continue + } + if ('maxLength' in m && keywords.length > m.maxLength) { + continue + } + if (ReUtil.match((m as ActionMatchRegex).regex, keywords)) { + searchScoreMax = 1 + runtimeSearchTitleMatched = (m as ActionMatchRegex).title || a.title + runtimeMatch = m + break + } + } else if (m.type === ActionMatchTypeEnum.FILE) { + let files = query.currentFiles + if (files.length <= 0) { + continue + } + // console.log('file', JSON.stringify({m, files}, null, 2)) + if ('filterFileType' in m) { + if (m.filterFileType === 'file') { + files = files.filter(f => f.isFile) + } else if (m.filterFileType === 'directory') { + files = files.filter(f => f.isDirectory) + } + } + if ('filterExtensions' in m) { + files = files.filter(f => f.isFile && (m as ActionMatchFile).filterExtensions.includes(f.fileExt)) + } + if (('minCount' in m) && files.length < m.minCount) { + continue + } + if (('maxCount' in m) && files.length > m.maxCount) { + continue + } + if (files.length <= 0) { + continue + } + searchScoreMax = 1 + runtimeSearchTitleMatched = (m as ActionMatchFile).title || a.title + runtimeMatch = m + break + } else if (m.type === ActionMatchTypeEnum.IMAGE) { + const image = query.currentImage + if (!image) { + continue + } + searchScoreMax = 1 + runtimeSearchTitleMatched = (m as ActionMatchFile).title || a.title + runtimeMatch = m + } else if (m.type === ActionMatchTypeEnum.WINDOW) { + const activeWindow = this.activeWindow + if (!activeWindow) { + continue + } + if ((m as ActionMatchWindow).nameRegex && !ReUtil.match((m as ActionMatchWindow).nameRegex, activeWindow.name)) { + continue + } + if ((m as ActionMatchWindow).titleRegex && !ReUtil.match((m as ActionMatchWindow).titleRegex, activeWindow.title)) { + continue + } + if ((m as ActionMatchWindow).attrRegex) { + let pass = true + for (const key in (m as ActionMatchWindow).attrRegex) { + if (!ReUtil.match((m as ActionMatchWindow).attrRegex[key], activeWindow.attr[key])) { + pass = false + break + } + } + if (!pass) { + continue + } + } + searchScoreMax = 1 + runtimeSearchTitleMatched = a.title + runtimeMatch = m + break + } + } + // console.log('searchScoreMax', a.name, searchScoreMax, a.runtime.searchScore > 0) + if (searchScoreMax > 0) { + a.runtime.searchScore = searchScoreMax + a.runtime.searchTitleMatched = runtimeSearchTitleMatched + a.runtime.match = runtimeMatch + results.push(a) + uniqueRemover.add(a.fullName) + } + } + // sort by similarity + results = results.sort((a, b) => { + return b.runtime.searchScore - a.runtime.searchScore + }) + return results + }, + async historyActions(uniqueRemover: Set, actions: ActionRecord[], query: SearchQuery) { + const historyActions = await ManagerConfig.getHistoryAction() + const actionMap = new Map() + for (const a of actions) { + actionMap.set(a.fullName, a) + } + const results = [] + for (const h of historyActions) { + const fullName = h.pluginName + '/' + h.actionName + if (uniqueRemover.has(fullName)) { + continue + } + if (actionMap.has(fullName)) { + results.push(actionMap.get(fullName)) + uniqueRemover.add(fullName) + } + } + return results + }, + async pinActions(uniqueRemover: Set, actions: ActionRecord[], query: SearchQuery) { + const pinActions = await ManagerConfig.listPinAction() + const actionMap = new Map() + for (const a of actions) { + actionMap.set(a.fullName, a) + } + const results: ActionRecord[] = [] + for (const p of pinActions) { + const fullName = p.pluginName + '/' + p.actionName + if (uniqueRemover.has(fullName)) { + continue + } + if (actionMap.has(fullName)) { + results.push(actionMap.get(fullName)) + uniqueRemover.add(fullName) + } + } + return results + }, + async sendBroadcast(pluginName: string, type: string, data: any) { + for (const view of ManagerWindow.listBrowserViews()) { + if (view._plugin && view._plugin.name === pluginName) { + Events.sendRaw(view.webContents, 'BROADCAST', { + type, data + }) + } + } + }, + async setNotice(notice: { + text: string, + type?: 'info' | 'error' | 'success', + duration?: number, + } | string) { + if (typeof notice === 'string') { + notice = {text: notice} + } + notice = Object.assign({ + text: '', + type: 'info', + duration: 0, + }, notice) + Events.broadcast('Notice', notice, { + limit: true, + scopes: ['main'] + }) + } +} diff --git a/electron/mapi/manager/plugin/event.ts b/electron/mapi/manager/plugin/event.ts new file mode 100644 index 0000000..20259fe --- /dev/null +++ b/electron/mapi/manager/plugin/event.ts @@ -0,0 +1,409 @@ +import {AppRuntime} from "../../env"; +import {app, BrowserView, BrowserWindow, clipboard, dialog, nativeImage, Notification, screen, shell} from "electron"; +import {WindowConfig} from "../../../config/window"; +import {ManagerWindow} from "../window"; +import {DBError} from "../../kvdb/types"; +import {screenCapture} from "./screenCapture"; +import {executeHooks, executePluginHooks} from "../lib/hooks"; +import {AppPosition} from "../../app/lib/position"; +import {Log} from "../../log/main"; +import {AppsMain} from "../../app/main"; +import {CommonConfig} from "../../../config/common"; +import {KVDBMain} from "../../kvdb/main"; +import {ManagerConfig} from "../config/config"; +import {PluginContext} from "../type"; +import {Manager} from "../manager"; +import {ManagerPlugin} from "./index"; +import {isLinux, isMac, isWin, platformArch, platformName, platformUUID} from "../../../lib/env"; +import {EncodeUtil} from "../../../lib/util"; +import {getClipboardFiles, setClipboardFiles} from "../clipboard/clipboardFiles"; +import {ManagerHotkeySimulate} from "../hotkey/simulate"; +import {ManagerClipboard} from "../clipboard"; +import {ManagerAutomation} from "../automation"; +import {AppConfig} from "../../../../src/config"; + +const getHeadHeight = (win: BrowserWindow) => { + if (win === AppRuntime.mainWindow) { + return WindowConfig.minHeight + } else { + return WindowConfig.detachWindowTitleHeight + } +} + +export const ManagerPluginEvent = { + isMacOs: async (context: PluginContext, data: any) => { + return isMac + }, + isWindows: async (context: PluginContext, data: any) => { + return isWin + }, + isLinux: async (context: PluginContext, data: any) => { + return isLinux + }, + getPlatformArch: async (context: PluginContext, data: any) => { + return platformArch() + }, + isMainWindowShown: async (context: PluginContext, data: any) => { + const win = AppRuntime.mainWindow + return win.isVisible() && win.isFocused(); + }, + hideMainWindow: async (context: PluginContext, data: any) => { + AppRuntime.mainWindow.hide(); + }, + showMainWindow: async (context: PluginContext, data: any) => { + Manager.selectedContent = await ManagerClipboard.getSelectedContent() + Manager.activeWindow = await ManagerAutomation.getActiveWindow() + const {x: wx, y: wy} = AppPosition.get('main', (screenX, screenY, screenWidth, screenHeight) => { + // console.log('calculator', {screenX, screenY, screenWidth, screenHeight}); + return { + x: screenX + screenWidth / 2 - WindowConfig.mainWidth / 2, + y: screenY + screenHeight / 8, + } + }) + const win = AppRuntime.mainWindow + win.setAlwaysOnTop(false); + win.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true}); + win.focus(); + win.setVisibleOnAllWorkspaces(false, { + visibleOnFullScreen: true, + }); + win.setPosition(wx, wy); + win.show(); + }, + isFastPanelWindowShown: async (context: PluginContext, data: any) => { + const win = AppRuntime.fastPanelWindow + return win.isVisible() && win.isFocused(); + }, + showFastPanelWindow: async (context: PluginContext, data: any) => { + Manager.selectedContent = await ManagerClipboard.getSelectedContent() + Manager.activeWindow = await ManagerAutomation.getActiveWindow() + const win = AppRuntime.fastPanelWindow + const {x, y} = AppPosition.getContextMenuPosition(WindowConfig.fastPanelWidth, WindowConfig.fastPanelHeight); + win.setAlwaysOnTop(false); + win.setVisibleOnAllWorkspaces(true, {visibleOnFullScreen: true}); + win.focus(); + win.setVisibleOnAllWorkspaces(false, { + visibleOnFullScreen: true, + }); + win.setPosition(x, y); + win.show(); + }, + hideFastPanelWindow: async (context: PluginContext, data: any) => { + const win = AppRuntime.fastPanelWindow + win.hide(); + }, + showOpenDialog: async (context: PluginContext, data: any) => { + return dialog.showOpenDialogSync(context._window, data); + }, + showSaveDialog: async (context: PluginContext, data: any) => { + return dialog.showSaveDialogSync(context._window, data); + }, + setExpendHeight: async (context: PluginContext, data: any) => { + const targetHeight = data as number; + const win = context._window + win.setSize(win.getSize()[0], targetHeight); + const screenPoint = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(screenPoint); + const position = win.getPosition()[1] + targetHeight > display.bounds.height + ? targetHeight - getHeadHeight(win) : 0; + // originWindow.webContents.executeJavaScript( + // `window.setPosition && typeof window.setPosition === "function" && window.setPosition(${position})` + // ); + }, + setSubInput: async (context: PluginContext, data: any) => { + const win = context._window; + const payload = { + placeholder: data.placeholder, + isFocus: data.isFocus, + }; + if (data.isFocus) { + win.webContents.focus(); + } + await executeHooks(win, 'SetSubInput', payload); + }, + removeSubInput: async (context: PluginContext, data: any) => { + await executeHooks(context._window, 'RemoveSubInput'); + }, + setSubInputValue: async (context: PluginContext, data: any) => { + const {text} = data; + await executeHooks(context._window, 'SetSubInputValue', text); + // this.sendSubInputChangeEvent({ data }); + }, + subInputBlur: async (context: PluginContext, data: any) => { + (context as BrowserView).webContents.focus() + }, + getPluginRoot: async (context: PluginContext, data: any) => { + if (context._plugin.runtime && context._plugin.runtime.root) { + return context._plugin.runtime.root; + } + return null + }, + getPluginConfig: async (context: PluginContext, data: any) => { + if (context._plugin) { + return context._plugin; + } + return null; + }, + getPluginInfo: async (context: PluginContext, data: any) => { + if (context._plugin) { + return ManagerPlugin.getInfo(context._plugin) + } + return null; + }, + getPluginEnv: async (context: PluginContext, data: any) => { + if (context._plugin) { + if (context._plugin.env && context._plugin.env.env) { + return context._plugin.env.env + } + } + return 'prod' + }, + getQuery: async (context: PluginContext, data: any): Promise => { + const {requestId} = data; + return Manager.getSearchRequestQuery(requestId) || { + keywords: '', + currentFiles: [], + currentImage: '', + currentText: '', + selectedContent: null + } + }, + getPath: async (context: PluginContext, data: any) => { + return app.getPath(data.name); + }, + showToast: async (context: PluginContext, data: any) => { + let {body, options} = data; + options = Object.assign({ + duration: 0, + status: 'success', + }, options) + AppsMain.toast(body, options) + }, + showNotification: async (context: PluginContext, data: any) => { + let {body, clickActionName} = data; + if (!Notification.isSupported()) { + Log.error('ManagerEvent.showNotification.Notification is not supported'); + return; + } + if ('string' != typeof body) { + body = String(body) + } + const plugin = context._plugin + let icon = plugin.logo; + if (icon && icon.startsWith('file://')) { + icon = icon.substring(7); + } + const notify = new Notification({ + title: plugin ? plugin.title : null, + body, + icon, + }); + notify.show(); + }, + showMessageBox: async (context: PluginContext, data: any) => { + const {title, message, yes, no} = data; + const buttons = [] + if (yes) { + buttons.push(yes) + } + if (no) { + buttons.push(no) + } + const result = await dialog.showMessageBox({ + type: 'info', + title: title || '提示', + message: message, + buttons: buttons, + defaultId: 0, + cancelId: 1, + }); + if (result.response === 0) { + return true; + } + return false; + }, + copyImage: async (context: PluginContext, data: any) => { + let image; + if (data.img.startsWith('data:image/')) { + image = nativeImage.createFromDataURL(data.img); + } else { + image = nativeImage.createFromPath(data.img); + } + clipboard.writeImage(image); + }, + copyText: async (context: PluginContext, data: any) => { + clipboard.writeText(String(data.text)); + }, + copyFile: async (context: PluginContext, data: any) => { + let {file} = data; + if (file) { + if (!Array.isArray(file)) { + file = [file] + } + setClipboardFiles(file) + return true; + } + return false; + }, + getClipboardText: async (context: PluginContext, data: any) => { + return AppsMain.getClipboardText() + }, + getClipboardImage: async (context: PluginContext, data: any) => { + return AppsMain.getClipboardImage() + }, + getClipboardFiles: async (context: PluginContext, data: any) => { + return getClipboardFiles() + }, + shellBeep: async (context: PluginContext, data: any) => { + shell.beep() + }, + getFileIcon: async (context: PluginContext, data: any) => { + const nativeImage = await app.getFileIcon(data.path, { + size: 'normal', + }); + return nativeImage.toDataURL(); + }, + shellShowItemInFolder: async (context: PluginContext, data: any) => { + shell.showItemInFolder(data.path); + }, + simulateKeyboardTap: async (context: PluginContext, data: any) => { + const {key, modifier} = data; + ManagerHotkeySimulate.keyTap(ManagerHotkeySimulate.toCode(key), modifier || []) + }, + screenCapture: async (context: PluginContext, data: any) => { + screenCapture((image: string) => { + if (context['_screenCaptureCallback']) { + context['_screenCaptureCallback']({image}) + } else { + executePluginHooks(context as BrowserView, 'ScreenCapture', { + image: image + }); + } + }) + }, + getNativeId: async (context: PluginContext, data: any) => { + return [ + platformName(), + EncodeUtil.md5(platformUUID()) + ].join('_') + }, + getAppVersion: async (context: PluginContext, data: any) => { + return AppConfig.version + }, + outPlugin: async (context: PluginContext, data: any) => { + await ManagerWindow.close(context._plugin); + }, + isDarkColors: async (context: PluginContext, data: any) => { + return await AppsMain.shouldDarkMode() + }, + redirect: async (context: PluginContext, data: any) => { + let {keywordsOrAction, query} = data; + query = Object.assign({ + keywords: '', + currentFiles: [], + currentImage: '', + currentText: '', + }, query) + // console.log('redirect', {keywordsOrAction, query}) + const action = await Manager.searchOneAction(keywordsOrAction, query) + if (!action) { + return false + } + await Manager.openAction(action) + }, + getActions: async (context: PluginContext, data: any) => { + let {names} = data; + names = names || [] + const customActions = await ManagerConfig.getCustomAction() + const plugin = context._plugin + if (!(plugin.name in customActions)) { + return [] + } + return customActions[plugin.name] + .filter(m => { + if (names.length > 0) { + return names.includes(m.name) + } + return true + }) + .map(m => { + return m + }) + }, + setAction: async (context: PluginContext, data: any) => { + const {action} = data; + const plugin = context._plugin + await ManagerConfig.addCustomAction(plugin, action) + return true + }, + removeAction: async (context: PluginContext, data: any) => { + const {name} = data; + const plugin = context._plugin + await ManagerConfig.removeCustomAction(plugin, name) + return true + }, + + + // db + dbPut: async (context: PluginContext, data: any) => { + return await KVDBMain.put(context._plugin.name, data.doc); + }, + dbGet: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + return await KVDBMain.get(context._plugin.name, data.id); + }, + dbRemove: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + return await KVDBMain.remove(context._plugin.name, data.doc); + }, + dbBulkDocs: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + return await KVDBMain.bulkDocs(context._plugin.name, data.docs); + }, + dbAllDocs: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + return await KVDBMain.allDocs(context._plugin.name, data.key); + }, + dbPostAttachment: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + return await KVDBMain.postAttachment(context._plugin.name, data.docId, data.attachment, data.type); + }, + dbGetAttachment: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + return await KVDBMain.getAttachment(context._plugin.name, data.docId); + }, + dbGetAttachmentType: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + return await KVDBMain.getAttachmentType(context._plugin.name, data.docId); + }, + + // dbStorage + dbStorageSetItem: async (context: PluginContext, data: any) => { + // const plugin = ManagerWindow.getPluginByWindow(win); + const plugin = context._plugin + const {key, value} = data; + const id = `${CommonConfig.dbPluginStorageIdPrefix}/${key}`; + const doc = {_id: id, data: value, _rev: undefined}; + const result = await KVDBMain.get(plugin.name, id); + if (result) { + doc._rev = result._rev; + } + const res = await KVDBMain.put(plugin.name, doc); + if ((res as DBError).error) throw new Error((res as DBError).message); + }, + dbStorageGetItem: async (context: PluginContext, data: any) => { + const plugin = context._plugin + const {key} = data; + const id = `${CommonConfig.dbPluginStorageIdPrefix}/${key}`; + const result = await KVDBMain.get(plugin.name, id); + return result ? result.data : null; + }, + dbStorageRemoveItem: async (context: PluginContext, data: any) => { + const plugin = context._plugin + const {key} = data; + const id = `${CommonConfig.dbPluginStorageIdPrefix}/${key}`; + const result = await KVDBMain.get(plugin.name, id); + if (!result) return; + await KVDBMain.remove(plugin.name, result); + }, +} diff --git a/electron/mapi/manager/plugin/index.ts b/electron/mapi/manager/plugin/index.ts new file mode 100644 index 0000000..973abc2 --- /dev/null +++ b/electron/mapi/manager/plugin/index.ts @@ -0,0 +1,528 @@ +import { + ActionMatch, + ActionMatchKey, + ActionMatchRegex, + ActionMatchText, + ActionMatchTypeEnum, + ActionRecord, + ActionTypeEnum, + PluginEnv, + PluginRecord, + PluginType +} from "../../../../src/types/Manager"; +import {Files} from "../../file/main"; +import {preloadDefault, preloadPluginDefault, rendererDistPath, rendererIsUrl} from "../../../lib/env-main"; +import {join} from "node:path"; +import {KVDBMain} from "../../kvdb/main"; +import {CommonConfig} from "../../../config/common"; +import {MemoryCacheUtil, StrUtil, UIUtil, VersionUtil} from "../../../lib/util"; +import {MiscMain} from "../../misc/main"; +import {platformName} from "../../../lib/env"; +import {AppConfig} from "../../../../src/config"; +import {WindowConfig} from "../../../config/window"; +import {AppsMain} from "../../app/main"; +import {ManagerConfig} from "../config/config"; +import {ManagerBackend} from "../backend"; + +type PluginInfo = { + type: PluginType, + name: string, + version: string, + root: string, + config: PluginRecord, +} + +export const ManagerPlugin = { + async clearCache() { + MemoryCacheUtil.forget('Plugins') + MemoryCacheUtil.forget('PluginActions') + }, + getInfo(plugin: PluginRecord) { + // nodeIntegration + let nodeIntegration = false + if (plugin.type === PluginType.SYSTEM) { + nodeIntegration = true + } else if (plugin.setting && plugin.setting.nodeIntegration) { + nodeIntegration = true + } + // preloadBase + let preloadBase = preloadPluginDefault + if (plugin.setting && plugin.setting.preloadBase) { + preloadBase = plugin.setting.preloadBase + if (preloadBase === '') { + preloadBase = preloadDefault + } + } + // preload + let preload = plugin.preload || null; + if (preload) { + if (preload === '') { + preload = preloadDefault + } else { + preload = join(plugin.runtime?.root, preload) + } + } + if (preload && preloadBase === preload) { + preload = null + } + // main && mainFastPanel + let main = plugin.main; + let mainFastPanel = plugin.mainFastPanel; + if (!mainFastPanel) { + mainFastPanel = main + } + if (plugin.runtime?.root) { + if (!rendererIsUrl(main)) { + main = join(plugin.runtime?.root, main) + } + } else if (main.includes('')) { + main = main.replace('/', '') + main = rendererDistPath(main) + } + if (plugin.runtime?.root) { + if (!rendererIsUrl(mainFastPanel)) { + mainFastPanel = join(plugin.runtime?.root, mainFastPanel) + } + } else if (mainFastPanel.includes('')) { + mainFastPanel = mainFastPanel.replace('/', '') + mainFastPanel = rendererDistPath(mainFastPanel) + } + + // auto detach + let autoDetach = false + if (plugin.setting && plugin.setting.autoDetach) { + autoDetach = true + } + if (!autoDetach && plugin.runtime.config && plugin.runtime.config.autoDetach) { + autoDetach = true + } + // width & height + let width = WindowConfig.pluginWidth + let height = WindowConfig.pluginHeight + if (plugin.setting) { + const display = AppsMain.getCurrentScreenDisplay() + if (plugin.setting.width) { + width = UIUtil.sizeToPx(plugin.setting.width + '', display.workArea.width) + autoDetach = true + } + if (plugin.setting.height) { + height = UIUtil.sizeToPx(plugin.setting.height + '', display.workArea.height) + autoDetach = true + } + } + // singleton + let singleton = false + if (plugin.setting && plugin.setting.singleton) { + singleton = true + } + // zoom + let zoom = 100 + if (plugin.setting && plugin.setting.zoom) { + zoom = plugin.setting.zoom + } + if (plugin.runtime.config && plugin.runtime.config.zoom) { + zoom = plugin.runtime.config.zoom + } + return { + nodeIntegration, + preloadBase, + preload, + main, + mainFastPanel, + width, + height, + autoDetach, + singleton, + zoom, + } + }, + normalAction(action: ActionRecord, plugin: PluginRecord) { + const matches: ActionMatch[] = [] + for (let m of action.matches) { + if (typeof m === 'string') { + m = { + type: ActionMatchTypeEnum.TEXT, + text: m, + } as any + } + if (!m.name) { + switch (m.type) { + case ActionMatchTypeEnum.TEXT: + m.name = (m as ActionMatchText).text + break + case ActionMatchTypeEnum.KEY: + m.name = (m as ActionMatchKey).key + break + case ActionMatchTypeEnum.REGEX: + m.name = (m as ActionMatchRegex).regex + break + case ActionMatchTypeEnum.FILE: + case ActionMatchTypeEnum.IMAGE: + case ActionMatchTypeEnum.WINDOW: + m.name = StrUtil.hashCode(JSON.stringify(m)) + break + } + } + matches.push(m) + } + const normalAction = { + fullName: `${plugin.name}/${action.name}`, + pluginName: plugin.name, + name: action.name, + title: action.title, + icon: action.icon || plugin.logo, + type: action.type || ActionTypeEnum.WEB, + pluginType: plugin.type, + matches: matches, + data: action.data || {}, + platform: action.platform || ['win', 'osx', 'linux'], + } as ActionRecord + if (plugin.runtime.root) { + if (normalAction.icon && !normalAction.icon.startsWith('file://')) { + normalAction.icon = `file://${plugin.runtime.root}/${normalAction.icon}` + } + } + return normalAction + }, + async initIfNeed(plugin: PluginRecord, option: { + type: PluginType, + root?: string, + configJson?: any + }) { + option = Object.assign({ + type: null + }, option) + + if (!option.type) { + throw 'PluginTypeError' + } + + // console.log('ManagerPlugin.init', plugin.name, !plugin.runtime) + + if (plugin.runtime) { + return plugin + } + + plugin.platform = plugin.platform || ['win', 'osx', 'linux'] + plugin.versionRequire = plugin.versionRequire || '*' + + plugin.logo = plugin.logo || null + plugin.main = plugin.main || null + plugin.mainFastPanel = plugin.mainFastPanel || plugin.main + plugin.preload = plugin.preload || null + plugin.author = plugin.author || null + plugin.homepage = plugin.homepage || null + + plugin.setting = Object.assign({ + keepCodeDevTools: false, + }, plugin.setting) + + plugin.type = option.type + plugin.env = PluginEnv.PROD + + plugin.runtime = { + root: option.root, + config: await ManagerConfig.getPluginConfig(plugin.name), + } + + if (plugin.runtime.root) { + if (plugin.logo && !plugin.logo.startsWith('file://')) { + plugin.logo = `file://${plugin.runtime.root}/${plugin.logo}` + } + } + + for (let aIndex = 0; aIndex < plugin.actions.length; aIndex++) { + const a = this.normalAction(plugin.actions[aIndex], plugin) + if (!a.platform.includes(platformName())) { + continue + } + plugin.actions[aIndex] = a + } + + const configJson = option.configJson || null + if (configJson) { + if (configJson['development']) { + plugin.env = PluginEnv.DEV + if (configJson['development'].env) { + plugin.env = configJson['development'].env as any + } + if (PluginEnv.DEV === plugin.env) { + if (configJson['development'].main) { + plugin.main = configJson['development'].main + } + if (configJson['development'].mainFastPanel) { + plugin.mainFastPanel = configJson['development'].mainFastPanel + } + } + } + } + + return plugin + }, + async configCheck(config: any) { + if (!config) { + throw `PluginFormatError` + } + if (!config.name || !config.version) { + throw `PluginFormatError` + } + const existsP = await this.get(config.name) + if (existsP) { + throw `PluginAlreadyExists : ${config.name}` + } + if (!config.platform) { + config.platform = ['win', 'osx', 'linux'] + } + if (!config.platform.includes(platformName())) { + throw `PluginNotSupportPlatform : ${config.name}` + } + if (!config.versionRequire) { + config.versionRequire = '*' + } + if (!VersionUtil.match(config.versionRequire, AppConfig.version)) { + throw `PluginVersionNotMatch : ${config.name}` + } + }, + async parsePackage(file: string, option?: {}) { + option = Object.assign({}, option) + if (!file.endsWith('.zip')) { + throw `PluginFormatError` + } + let config = null + try { + config = await MiscMain.getZipFileContent(file, 'config.json') + } catch (e) { + throw `PluginFormatError` + } + if (!config) { + throw `PluginFormatError` + } + try { + config = JSON.parse(config as string) + } catch (e) { + throw `PluginFormatError` + } + if (!config) { + throw `PluginFormatError` + } + if (!config.name || !config.version) { + throw `PluginFormatError` + } + const target = await Files.fullPath(`plugin/${config.name}`) + return { + name: config.name, + version: config.version, + target, + } + }, + async installFromFileOrDir(fileOrPath: string, type?: PluginType) { + let guessType = type || PluginType.DIR + if (!await Files.isDirectory(fileOrPath, { + isFullPath: true + })) { + guessType = PluginType.ZIP + const {name, version, target} = await this.parsePackage(fileOrPath) + const plugin = await ManagerPlugin.get(name) + if (await Files.exists(target, { + isFullPath: true + })) { + if (!plugin) { + await Files.deletes(target, { + isFullPath: true + }) + } + } + try { + await MiscMain.unzip(fileOrPath, target) + fileOrPath = target + } catch (e) { + throw 'PluginInstallError' + } + } + return await this.install(fileOrPath, type || guessType) + }, + async install(root: string, type: PluginType) { + const p = await this._readPluginInfo(root) + if (!p) { + throw `PluginNotValid : ${root}` + } + const existsP = await this.get(p.name) + if (existsP) { + throw `PluginAlreadyExists : ${p.name}` + } + const plugin = await this.initIfNeed(p, { + type, + root, + configJson: p + }) + if (!plugin.platform.includes(platformName())) { + throw `PluginNotSupportPlatform : ${plugin.name}` + } + if (!VersionUtil.match(plugin.versionRequire, AppConfig.version)) { + throw `PluginVersionNotMatch : ${plugin.name}` + } + const runtime = plugin.runtime + delete plugin.runtime + const info: PluginInfo = { + type, + version: plugin.version, + name: plugin.name, + root, + config: plugin + } + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: `${CommonConfig.dbPluginIdPrefix}/${info.name}`, + ...info + }) + await this.clearCache() + setTimeout(async () => { + plugin.runtime = runtime + await ManagerBackend.run(plugin, 'hook', 'installed', {}) + }, 1000) + }, + async refreshInstall(name: string) { + const doc = await KVDBMain.get(CommonConfig.dbSystem, `${CommonConfig.dbPluginIdPrefix}/${name}`) + if (!doc) { + throw `PluginNotFound : ${name}` + } + const pluginInfo: PluginInfo = doc as any + const root = pluginInfo.root + const p = await this._readPluginInfo(root) + if (!p) { + throw `PluginNotValid : ${root}` + } + const plugin = await this.initIfNeed(p, { + type: pluginInfo.type, + root, + configJson: p + }) + delete plugin.runtime + const info: PluginInfo = { + type: pluginInfo.type, + version: plugin.version, + name: plugin.name, + root, + config: plugin + } + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: `${CommonConfig.dbPluginIdPrefix}/${info.name}`, + ...info + }) + await this.clearCache() + setTimeout(async () => { + await ManagerBackend.run(plugin, 'hook', 'installed', {}) + }, 1000) + }, + async uninstall(name: string) { + const plugin = await this.get(name) + if (!plugin) { + throw `PluginNotFound : ${name}` + } + const pi = await KVDBMain.get(CommonConfig.dbSystem, `${CommonConfig.dbPluginIdPrefix}/${name}`) + if (!pi) { + throw `PluginNotFound : ${name}` + } + const info: PluginInfo = pi as any + if (!info.name || !info.version || !info.type || !info.config) { + throw `PluginNotFound : ${name}` + } + await ManagerBackend.run(plugin, 'hook', 'beforeUninstall', {}) + if (info.type === PluginType.STORE || info.type === PluginType.ZIP) { + if (info.root) { + await Files.deletes(info.root, { + isFullPath: true + }) + } + } + await KVDBMain.remove(CommonConfig.dbSystem, pi) + await this.clearCache() + }, + async getPluginInstalledVersion(name: string) { + const plugin = await this.get(name) + if (!plugin) { + return null + } + return plugin.version + }, + async list() { + const plugins = await MemoryCacheUtil.remember('Plugins', async () => { + // await this.install(`${process.cwd()}/plugin-examples/plugin-example`, 'system') + let plugins: PluginRecord[] = [] + const pluginInfos = await KVDBMain.allDocs(CommonConfig.dbSystem, `${CommonConfig.dbPluginIdPrefix}/`) + for (const pi of pluginInfos) { + const info: PluginInfo = pi as any + if (!info.name || !info.version || !info.type || !info.config) { + await KVDBMain.remove(CommonConfig.dbSystem, pi) + continue + } + let configJson = null + if (info.type === PluginType.DIR) { + configJson = await this._readPluginInfo(info.root) + info.config = configJson + if (!info.config) { + // 本地插件可能已经被删除 + await KVDBMain.remove(CommonConfig.dbSystem, pi) + continue + } + } + plugins.push(await this.initIfNeed(info.config, { + type: info.type, + root: info.root, + configJson + })) + } + // console.log('plugins', JSON.stringify(plugins)) + return plugins + }) + // 有开发选项并且是开发环境的插件,每次都重新读取 config + for (let pIndex = 0; pIndex < plugins.length; pIndex++) { + const p = plugins[pIndex] + if (p.type === PluginType.DIR && p.env === 'dev' && p.runtime.root) { + const configJson = await this._readPluginInfo(p.runtime.root) + plugins[pIndex] = await this.initIfNeed(p, { + type: p.type, + root: p.runtime.root, + configJson + }) + } + } + return plugins + }, + async get(name: string) { + for (const p of await this.list()) { + if (p.name === name) { + return p + } + } + return null + }, + async _readPluginInfo(root: string) { + root = root.replace(/[\\/]+$/, '') + const configPath = root + '/config.json' + const config = await Files.read(configPath, { + isFullPath: true + }) + if (!config) { + return null + } + try { + let configJson = JSON.parse(config) + if (!configJson) { + return null + } + return configJson + } catch (e) { + } + return null + }, + async listAction() { + return await MemoryCacheUtil.remember('PluginActions', async () => { + let actions: ActionRecord[] = [] + const plugins = await this.list() + for (const p of plugins) { + actions = actions.concat(p.actions) + } + return actions + }) + } +} diff --git a/electron/mapi/manager/plugin/screenCapture.ts b/electron/mapi/manager/plugin/screenCapture.ts new file mode 100644 index 0000000..0fa5010 --- /dev/null +++ b/electron/mapi/manager/plugin/screenCapture.ts @@ -0,0 +1,40 @@ +import {clipboard, Notification} from 'electron'; +import {exec, execFile} from 'child_process'; +import {extraResolve, isMac, isWin} from "../../../lib/env"; + +const forWindows = (cb: (image: string) => void) => { + const screenCaptureUrl = extraResolve('win/ScreenCapture.exe'); + const screen_window = execFile(screenCaptureUrl); + screen_window.on('exit', (code) => { + if (code) { + const image = clipboard.readImage(); + cb && cb(image.isEmpty() ? '' : image.toDataURL()); + } + }); +}; + +const forMac = (cb: (image: string) => void) => { + exec('screencapture -i -r -c', () => { + const image = clipboard.readImage(); + cb && cb(image.isEmpty() ? '' : image.toDataURL()); + }); +}; + +const forLinux = (cb: (image: string) => void) => { + const notify = new Notification({ + title: '截图', + body: '请使用截图工具截图', + }); + notify.show(); +} + +export const screenCapture = (cb: (image: string) => void) => { + clipboard.writeText(''); + if (isMac) { + forMac(cb); + } else if (isWin) { + forWindows(cb); + } else { + forLinux(cb); + } +}; diff --git a/electron/mapi/manager/plugin/sdk.ts b/electron/mapi/manager/plugin/sdk.ts new file mode 100644 index 0000000..5d10566 --- /dev/null +++ b/electron/mapi/manager/plugin/sdk.ts @@ -0,0 +1,276 @@ +import {PluginRecord} from "../../../../src/types/Manager"; +import {ManagerPluginEvent} from "./event"; +import {BrowserWindow, screen, shell} from "electron"; +import os from "os"; +import path from "path"; +import {EncodeUtil, FileUtil, StrUtil, TimeUtil} from "../../../lib/util"; +import {PluginContext} from "../type"; + +export const PluginSdkCreate = (plugin: PluginRecord) => { + const context = { + _window: null, + _plugin: plugin, + } as PluginContext + const sdk = { + async isMacOs() { + return os.type() === 'Darwin'; + }, + async isWindows() { + return os.type() === 'Windows_NT'; + }, + async isLinux() { + return os.type() === 'Linux'; + }, + async getPlatformArch() { + return ManagerPluginEvent.getPlatformArch(context, {}) + }, + async isMainWindowShown() { + return ManagerPluginEvent.isMainWindowShown(context, {}) + }, + async hideMainWindow() { + return ManagerPluginEvent.hideMainWindow(context, {}) + }, + async showMainWindow() { + return ManagerPluginEvent.showMainWindow(context, {}) + }, + async isFastPanelWindowShown() { + return ManagerPluginEvent.isFastPanelWindowShown(context, {}) + }, + async showFastPanelWindow() { + return ManagerPluginEvent.showFastPanelWindow(context, {}) + }, + async hideFastPanelWindow() { + return ManagerPluginEvent.hideFastPanelWindow(context, {}) + }, + async showOpenDialog() { + return ManagerPluginEvent.showOpenDialog(context, {}) + }, + async showSaveDialog() { + return ManagerPluginEvent.showSaveDialog(context, {}) + }, + async getPluginRoot() { + return plugin.runtime?.root + }, + async getPluginConfig() { + return ManagerPluginEvent.getPluginConfig(context, {}) + }, + async getPluginInfo() { + return ManagerPluginEvent.getPluginInfo(context, {}) + }, + async getPluginEnv() { + return ManagerPluginEvent.getPluginEnv(context, {}) + }, + async getPath(name: 'home' | 'appData' | 'userData' | 'temp' | 'exe' | 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos' | 'logs') { + return ManagerPluginEvent.getPath(context, {name}) + }, + async showToast(body: string, options?: { + duration?: number, + status?: 'info' | 'success' | 'error' + }) { + ManagerPluginEvent.showToast(context, {body, options}).then() + }, + async showNotification(body: string, clickActionName: string) { + return ManagerPluginEvent.showNotification(context, {body, clickActionName}) + }, + async showMessageBox(message: string, options: { + title?: string, + yes?: string, + no?: string, + }) { + return ManagerPluginEvent.showMessageBox(context, {message, ...options}) + }, + async copyImage(img: string) { + return ManagerPluginEvent.copyImage(context, {img}) + }, + async copyText(text: string) { + return ManagerPluginEvent.copyText(context, {text}) + }, + async copyFile(file: string) { + return ManagerPluginEvent.copyFile(context, {file}) + }, + async getClipboardText() { + return ManagerPluginEvent.getClipboardText(context, {}) + }, + async getClipboardImage() { + return ManagerPluginEvent.getClipboardImage(context, {}) + }, + async getClipboardFiles(): Promise<{ + name: string, + pathname: string, + isDirectory: boolean, + size: number, + lastModified: number, + }[]> { + return (await ManagerPluginEvent.getClipboardFiles(context, {})) as any + }, + async shellOpenExternal(url: string) { + await shell.openExternal(url) + }, + async shellOpenPath(path: string) { + await shell.openPath(path).then() + }, + async shellShowItemInFolder(path: string) { + await ManagerPluginEvent.shellShowItemInFolder(context, {path}) + }, + async shellBeep() { + return ManagerPluginEvent.shellBeep(context, {}) + }, + async getFileIcon(path: string) { + return ManagerPluginEvent.getFileIcon(context, {path}) + }, + async simulateKeyboardTap(key: string, ...modifier: any[]) { + return ManagerPluginEvent.simulateKeyboardTap(context, {key, modifier}) + }, + async getCursorScreenPoint() { + return screen.getCursorScreenPoint(); + }, + async getDisplayNearestPoint(point: { + x: number, + y: number + }) { + return screen.getDisplayNearestPoint(point); + }, + // sendTo + async createBrowserWindow(url: string, options: any, callback: any) { + const pluginRoot = await this.getPluginRoot(); + url = path.join(pluginRoot, url); + let preloadPath = null + if (options.webPreferences && options.webPreferences.preload) { + preloadPath = path.join(pluginRoot, options.webPreferences.preload) + } + if (url.startsWith('http://') || url.startsWith('https://')) { + // do nothing + } else { + url = `file://${url}` + } + options = options || {}; + let win = new BrowserWindow({ + useContentSize: true, + resizable: true, + title: options.title || '新窗口', + show: true, + backgroundColor: '#fff', + ...options, + webPreferences: { + webSecurity: false, + backgroundThrottling: false, + contextIsolation: false, + webviewTag: true, + nodeIntegration: true, + spellcheck: false, + partition: null, + ...(options.webPreferences || {}), + preload: preloadPath, + }, + }); + win.loadURL(url); + win.on('closed', () => { + win = undefined; + }); + win.once('ready-to-show', () => { + win.show(); + }); + win.webContents.on('dom-ready', () => { + callback && callback(); + }); + return win; + }, + async screenCapture(cb: Function) { + context['_screenCaptureCallback'] = (data: { + image: string + }) => { + cb && cb(data.image); + }; + return ManagerPluginEvent.screenCapture(context, {cb}) + }, + getNativeId() { + return ManagerPluginEvent.getNativeId(context, {}) + }, + getAppVersion() { + return ManagerPluginEvent.getAppVersion(context, {}) + }, + async isDarkColors() { + return ManagerPluginEvent.isDarkColors(context, {}) + }, + async redirect(keywordsOrAction: string | string[], payload: any) { + return ManagerPluginEvent.redirect(context, {keywordsOrAction, payload}) + }, + async getActions(names?: string[]) { + return ManagerPluginEvent.getActions(context, {names}) + }, + async setAction(action: string) { + return ManagerPluginEvent.setAction(context, {action}) + }, + async removeAction(name: string) { + return ManagerPluginEvent.removeAction(context, {name}) + }, + db: { + async put(doc: { + _id: string, + data: any, + _rev?: string + }) { + return ManagerPluginEvent.dbPut(context, {doc}) + }, + async get(id: string) { + return ManagerPluginEvent.dbGet(context, {id}) + }, + async remove(doc: { + _id: string, + } | string) { + return ManagerPluginEvent.dbRemove(context, {doc}) + }, + async bulkDocs(docs: { + _id: string, + data: any, + _rev?: string + }[]) { + return ManagerPluginEvent.dbBulkDocs(context, {docs}) + }, + async allDocs(key: string | string[]) { + return ManagerPluginEvent.dbAllDocs(context, {key}) + }, + async postAttachment(docId: string, attachment: Buffer | Uint8Array, type: string) { + return ManagerPluginEvent.dbPostAttachment(context, {docId, attachment, type}) + }, + async getAttachment(docId: string) { + return ManagerPluginEvent.dbGetAttachment(context, {docId}) + }, + async getAttachmentType(docId: string) { + return ManagerPluginEvent.dbGetAttachmentType(context, {docId}) + }, + }, + dbStorage: { + async setItem(key: string, value: any) { + return ManagerPluginEvent.dbStorageSetItem(context, {key, value}) + }, + async getItem(key: string) { + return ManagerPluginEvent.dbStorageGetItem(context, {key}) + }, + async removeItem(key: string) { + return ManagerPluginEvent.dbStorageRemoveItem(context, {key}) + }, + }, + util: { + randomString(length: number) { + return StrUtil.randomString(length) + }, + bufferToBase64(buffer: Buffer) { + return FileUtil.bufferToBase64(buffer) + }, + datetimeString() { + return TimeUtil.datetimeString() + }, + base64Encode(data: any) { + return EncodeUtil.base64Encode(data) + }, + base64Decode(data: string) { + return EncodeUtil.base64Decode(data) + }, + md5(data: string) { + return EncodeUtil.md5(data) + } + } + } + return sdk +} diff --git a/electron/mapi/manager/render.ts b/electron/mapi/manager/render.ts new file mode 100644 index 0000000..4d088d3 --- /dev/null +++ b/electron/mapi/manager/render.ts @@ -0,0 +1,230 @@ +import {ipcRenderer} from "electron"; +import {ActionRecord, ConfigRecord, PluginRecord} from "../../../src/types/Manager"; + +const getConfig = async () => { + return ipcRenderer.invoke('manager:getConfig') +} + +const setConfig = async (config: ConfigRecord) => { + return ipcRenderer.invoke('manager:setConfig', config) +} + +const show = async () => { + return ipcRenderer.invoke('manager:show') +} + +const hide = async () => { + return ipcRenderer.invoke('manager:hide') +} + +const getClipboardFiles = async () => { + return ipcRenderer.invoke('manager:getClipboardFiles') +} + +const getSelectedContent = async () => { + return ipcRenderer.invoke('manager:getSelectedContent') +} + +const listPlugin = async (option?: {}) => { + return ipcRenderer.invoke('manager:listPlugin', option) +} + +const installPlugin = async (fileOrPath: string, option?: {}) => { + return ipcRenderer.invoke('manager:installPlugin', fileOrPath, option) +} + +const refreshInstallPlugin = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:refreshInstallPlugin', pluginName, option) +} + +const uninstallPlugin = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:uninstallPlugin', pluginName, option) +} + +const getPluginInstalledVersion = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:getPluginInstalledVersion', pluginName, option) +} + +const listDisabledActionMatch = async (option?: {}) => { + return ipcRenderer.invoke('manager:listDisabledActionMatch', option) +} + +const toggleDisabledActionMatch = async (pluginName: string, actionName: string, matchName: string, option?: {}) => { + return ipcRenderer.invoke('manager:toggleDisabledActionMatch', pluginName, actionName, matchName, option) +} + +const listPinAction = async (option?: {}) => { + return ipcRenderer.invoke('manager:listPinAction', option) +} + +const togglePinAction = async (pluginName: string, actionName: string, option?: {}) => { + return ipcRenderer.invoke('manager:togglePinAction', pluginName, actionName, option) +} + +const clearCache = async (option?: {}) => { + return ipcRenderer.invoke('manager:clearCache', option) +} + +const hotKeyWatch = async (option?: {}) => { + return ipcRenderer.invoke('manager:hotKeyWatch', option) +} + +const hotKeyUnwatch = async (option?: {}) => { + return ipcRenderer.invoke('manager:hotKeyUnwatch', option) +} + +const searchFastPanelAction = async (query: { + currentFiles: any[], + currentImage: string, +}, option?: {}) => { + return ipcRenderer.invoke('manager:searchFastPanelAction', query, option) +} + +const searchAction = async (query: { + keywords: string, + currentFiles: any[], + currentImage: string, +}, option?: {}) => { + return ipcRenderer.invoke('manager:searchAction', query, option) +} + +const subInputChange = (keywords: string, option?: {}) => { + return ipcRenderer.invoke('manager:subInputChange', keywords, option) +} + +const openPlugin = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:openPlugin', pluginName, option) +} + +const openAction = async (action: ActionRecord, option?: {}) => { + return ipcRenderer.invoke('manager:openAction', action, option) +} + +const closeMainPlugin = async (plugin?: PluginRecord, option?: {}) => { + return ipcRenderer.invoke('manager:closeMainPlugin', plugin, option) +} + +const openMainPluginDevTools = async (plugin?: PluginRecord, option?: {}) => { + return ipcRenderer.invoke('manager:openMainPluginDevTools', plugin, option) +} +const detachPlugin = async (option?: {}) => { + return ipcRenderer.invoke('manager:detachPlugin', option) +} + +const toggleDetachPluginAlwaysOnTop = async (alwaysOnTop: boolean, option?: {}) => { + return ipcRenderer.invoke('manager:toggleDetachPluginAlwaysOnTop', alwaysOnTop, option) +} + +const setDetachPluginZoom = async (zoom: number, option?: {}) => { + return ipcRenderer.invoke('manager:setDetachPluginZoom', zoom, option) +} + +const closeDetachPlugin = async (option?: {}) => { + return ipcRenderer.invoke('manager:closeDetachPlugin') +} + +const openDetachPluginDevTools = async (option?: {}) => { + return ipcRenderer.invoke('manager:openDetachPluginDevTools', option) +} + +const setPluginAutoDetach = async (autoDetach: boolean, option?: {}) => { + return ipcRenderer.invoke('manager:setPluginAutoDetach', autoDetach, option) +} + +const getPluginConfig = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:getPluginConfig', pluginName, option) +} + +const listFilePluginRecords = async (option?: {}) => { + return ipcRenderer.invoke('manager:listFilePluginRecords', option) +} + +const updateFilePluginRecords = async (records: PluginRecord[], option?: {}) => { + return ipcRenderer.invoke('manager:updateFilePluginRecords', records, option) +} + +const listLaunchRecords = async (option?: {}) => { + return ipcRenderer.invoke('manager:listLaunchRecords', option) +} + +const updateLaunchRecords = async (records: PluginRecord[], option?: {}) => { + return ipcRenderer.invoke('manager:updateLaunchRecords', records, option) +} + +const storeInstall = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:storeInstall', pluginName, option) +} + +const storePublish = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:storePublish', pluginName, option) +} + +const storePublishInfo = async (pluginName: string, option?: {}) => { + return ipcRenderer.invoke('manager:storePublishInfo', pluginName, option) +} + +const clipboardList = async (option?: {}) => { + return ipcRenderer.invoke('manager:clipboardList', option) +} + +const clipboardClear = async (option?: {}) => { + return ipcRenderer.invoke('manager:clipboardClear', option) +} + +const clipboardDelete = async (timestamp: number, option?: {}) => { + return ipcRenderer.invoke('manager:clipboardDelete', timestamp, option) +} + +export default { + + getConfig, + setConfig, + + show, + hide, + + getClipboardFiles, + getSelectedContent, + listPlugin, + installPlugin, + refreshInstallPlugin, + uninstallPlugin, + getPluginInstalledVersion, + listDisabledActionMatch, + toggleDisabledActionMatch, + listPinAction, + togglePinAction, + clearCache, + hotKeyWatch, + hotKeyUnwatch, + + searchFastPanelAction, + searchAction, + subInputChange, + openPlugin, + openAction, + closeMainPlugin, + openMainPluginDevTools, + detachPlugin, + + toggleDetachPluginAlwaysOnTop, + setDetachPluginZoom, + closeDetachPlugin, + openDetachPluginDevTools, + setPluginAutoDetach, + getPluginConfig, + + listFilePluginRecords, + updateFilePluginRecords, + listLaunchRecords, + updateLaunchRecords, + + storeInstall, + storePublish, + storePublishInfo, + + clipboardList, + clipboardClear, + clipboardDelete, + +} diff --git a/electron/mapi/manager/storage/index.ts b/electron/mapi/manager/storage/index.ts new file mode 100644 index 0000000..0959eb2 --- /dev/null +++ b/electron/mapi/manager/storage/index.ts @@ -0,0 +1,5 @@ +export const ManagerStorage = { + listPlugins() { + + } +} diff --git a/electron/mapi/manager/system/asset/about.svg b/electron/mapi/manager/system/asset/about.svg new file mode 100644 index 0000000..dbd8ad1 --- /dev/null +++ b/electron/mapi/manager/system/asset/about.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/apple.svg b/electron/mapi/manager/system/asset/apple.svg new file mode 100644 index 0000000..5df2fd3 --- /dev/null +++ b/electron/mapi/manager/system/asset/apple.svg @@ -0,0 +1 @@ + diff --git a/electron/mapi/manager/system/asset/command.svg b/electron/mapi/manager/system/asset/command.svg new file mode 100644 index 0000000..3cfe782 --- /dev/null +++ b/electron/mapi/manager/system/asset/command.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/database.svg b/electron/mapi/manager/system/asset/database.svg new file mode 100644 index 0000000..b448c73 --- /dev/null +++ b/electron/mapi/manager/system/asset/database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/folder.svg b/electron/mapi/manager/system/asset/folder.svg new file mode 100644 index 0000000..143d37b --- /dev/null +++ b/electron/mapi/manager/system/asset/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/guide.svg b/electron/mapi/manager/system/asset/guide.svg new file mode 100644 index 0000000..275572b --- /dev/null +++ b/electron/mapi/manager/system/asset/guide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/icon.ts b/electron/mapi/manager/system/asset/icon.ts new file mode 100644 index 0000000..b9ac776 --- /dev/null +++ b/electron/mapi/manager/system/asset/icon.ts @@ -0,0 +1,44 @@ +import pluginSystem from './plugin-system.svg' +import pluginStore from './plugin-store.svg' +import pluginWorkflow from './plugin-workflow.svg' +import pluginApp from './plugin-app.svg' + +import searchKeyword from './search-keyword.svg' +import searchMatch from './search-match.svg' + +import command from './command.svg' +import database from './database.svg' +import folder from './folder.svg' +import screenshot from './screenshot.svg' +import plugin from './plugin.svg' +import thunder from './thunder.svg' +import guide from './guide.svg' +import user from './user.svg' +import about from './about.svg' +import apple from './apple.svg' +import windows from './windows.svg' +import linux from './linux.svg' + +export const SystemIcons = { + + pluginSystem, + pluginStore, + pluginWorkflow, + pluginApp, + + searchKeyword, + searchMatch, + + command, + database, + folder, + screenshot, + plugin, + thunder, + guide, + user, + about, + apple, + windows, + linux, +} diff --git a/electron/mapi/manager/system/asset/linux.svg b/electron/mapi/manager/system/asset/linux.svg new file mode 100644 index 0000000..105d804 --- /dev/null +++ b/electron/mapi/manager/system/asset/linux.svg @@ -0,0 +1 @@ + diff --git a/electron/mapi/manager/system/asset/plugin-app.svg b/electron/mapi/manager/system/asset/plugin-app.svg new file mode 100644 index 0000000..1c82f0a --- /dev/null +++ b/electron/mapi/manager/system/asset/plugin-app.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/plugin-store.svg b/electron/mapi/manager/system/asset/plugin-store.svg new file mode 100644 index 0000000..fdb78ef --- /dev/null +++ b/electron/mapi/manager/system/asset/plugin-store.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/plugin-system.svg b/electron/mapi/manager/system/asset/plugin-system.svg new file mode 100644 index 0000000..1fa82ef --- /dev/null +++ b/electron/mapi/manager/system/asset/plugin-system.svg @@ -0,0 +1 @@ + diff --git a/electron/mapi/manager/system/asset/plugin-workflow.svg b/electron/mapi/manager/system/asset/plugin-workflow.svg new file mode 100644 index 0000000..04cc819 --- /dev/null +++ b/electron/mapi/manager/system/asset/plugin-workflow.svg @@ -0,0 +1 @@ + diff --git a/electron/mapi/manager/system/asset/plugin.svg b/electron/mapi/manager/system/asset/plugin.svg new file mode 100644 index 0000000..c63da30 --- /dev/null +++ b/electron/mapi/manager/system/asset/plugin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/screenshot.svg b/electron/mapi/manager/system/asset/screenshot.svg new file mode 100644 index 0000000..923699c --- /dev/null +++ b/electron/mapi/manager/system/asset/screenshot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/search-keyword.svg b/electron/mapi/manager/system/asset/search-keyword.svg new file mode 100644 index 0000000..47ca31a --- /dev/null +++ b/electron/mapi/manager/system/asset/search-keyword.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/search-match.svg b/electron/mapi/manager/system/asset/search-match.svg new file mode 100644 index 0000000..8d392cd --- /dev/null +++ b/electron/mapi/manager/system/asset/search-match.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/thunder.svg b/electron/mapi/manager/system/asset/thunder.svg new file mode 100644 index 0000000..0b56652 --- /dev/null +++ b/electron/mapi/manager/system/asset/thunder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/user.svg b/electron/mapi/manager/system/asset/user.svg new file mode 100644 index 0000000..9d5a25e --- /dev/null +++ b/electron/mapi/manager/system/asset/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/electron/mapi/manager/system/asset/windows.svg b/electron/mapi/manager/system/asset/windows.svg new file mode 100644 index 0000000..0473750 --- /dev/null +++ b/electron/mapi/manager/system/asset/windows.svg @@ -0,0 +1 @@ + diff --git a/electron/mapi/manager/system/index.ts b/electron/mapi/manager/system/index.ts new file mode 100644 index 0000000..6c246bb --- /dev/null +++ b/electron/mapi/manager/system/index.ts @@ -0,0 +1,76 @@ +import {ActionRecord, PluginRecord, PluginType} from "../../../../src/types/Manager"; +import {SystemPlugin} from "./plugin/system"; +import {SystemActionCode} from "./plugin/system/action"; +import {StorePlugin} from "./plugin/store"; +import {StoreActionCode} from "./plugin/store/action"; +import {MemoryCacheUtil} from "../../../lib/util"; +import {ManagerPlugin} from "../plugin"; +import {getAppPlugin} from "./plugin/app"; +import {getFilePlugin} from "./plugin/file"; + +const pluginActionCode = { + system: SystemActionCode, + store: StoreActionCode, +} + +const systemPlugin = new Set([ + 'system', + 'store', + 'workflow', + 'app', + 'file', +]) + +const pluginActionBackend = {} + +export const ManagerSystem = { + async clearCache() { + for (const p of await this.list()) { + delete p.runtime + } + MemoryCacheUtil.forget('SystemActions') + }, + match(name: string) { + return systemPlugin.has(name) + }, + async list() { + const plugins: (PluginRecord | any)[] = [ + SystemPlugin, + StorePlugin, + getAppPlugin, + getFilePlugin, + ] + for (let i = 0; i < plugins.length; i++) { + if (typeof plugins[i] === 'function') { + plugins[i] = await plugins[i]() + } + plugins[i] = await ManagerPlugin.initIfNeed(plugins[i], { + type: PluginType.SYSTEM, + root: null + }) + } + return (plugins as PluginRecord[]) + }, + getActionCodeFunc(pluginName: string, name: string) { + if (!pluginActionCode[pluginName]) { + return null + } + return pluginActionCode[pluginName][name] || null + }, + getActionBackendFunc(pluginName: string, name: string) { + if (!pluginActionBackend[pluginName]) { + return null + } + return pluginActionBackend[pluginName][name] || null + }, + async listAction() { + return await MemoryCacheUtil.remember('SystemActions', async () => { + let actions: ActionRecord[] = [] + const plugins = await this.list() + for (const p of plugins) { + actions = actions.concat(p.actions) + } + return actions + }) + } +} diff --git a/electron/mapi/manager/system/plugin/app.ts b/electron/mapi/manager/system/plugin/app.ts new file mode 100644 index 0000000..efc7928 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app.ts @@ -0,0 +1,125 @@ +import { + ActionMatch, + ActionMatchTypeEnum, + ActionRecord, + ActionTypeEnum, + PluginRecord +} from "../../../../../src/types/Manager"; +import {isLinux, isMac, isWin} from "../../../../lib/env"; +import {ManagerAppMac} from "./app/mac"; +import {MemoryCacheUtil} from "../../../../lib/util"; +import {AppRecord} from "./app/type"; +import {SystemIcons} from "../asset/icon"; +import {ManagerAppWin} from "./app/win"; +import {ManagerAppLinux} from "./app/linux"; +import {ManagerSystem} from "../index"; +import {ManagerFileCacheUtil} from "../../lib/cache"; +import {Manager} from "../../manager"; + +let logo = SystemIcons.windows +if (isMac) { + logo = SystemIcons.apple +} else if (isLinux) { + logo = SystemIcons.linux +} + +export const AppPlugin: PluginRecord = { + name: 'app', + title: '应用软件', + version: '1.0.0', + logo: logo, + description: '提供系统应用软件的搜索和打开', + main: null, + preload: null, + actions: [] +} + +const list = async () => { + let apps: AppRecord[] = [] + if (isMac) { + apps = await ManagerAppMac.list() + } else if (isWin) { + apps = await ManagerAppWin.list() + } else if (isLinux) { + apps = await ManagerAppLinux.list() + } + return apps +} + +const listActions = async () => { + // await sleep(3500) + return await MemoryCacheUtil.remember('AppActions', async () => { + const actions: ActionRecord[] = [] + const apps = await list() + apps.forEach(app => { + const matches: ActionMatch[] = [] + matches.push({ + type: ActionMatchTypeEnum.TEXT, + text: app.name + } as ActionMatch) + if (app.title !== app.name) { + matches.push({ + type: ActionMatchTypeEnum.TEXT, + text: app.title + } as ActionMatch) + } + actions.push({ + fullName: `${AppPlugin.name}/${app.name}`, + pluginName: AppPlugin.name, + name: app.name, + title: app.title, + icon: app.icon, + type: ActionTypeEnum.COMMAND, + matches: matches, + data: { + command: app.command + } + }) + }) + // console.log('actions', actions) + return actions + }) +} + +type ActionInfo = { + time: number + actions: ActionRecord[] +} + +let listActionRunning = false +export const getAppPlugin = async () => { + AppPlugin.actions = [] + let toastTimer = null + const cacheInfo = await ManagerFileCacheUtil.getIgnoreExpire('AppActions', []) + AppPlugin.actions = cacheInfo.value + let shouldNotice = false + if (!cacheInfo.isCache || cacheInfo.expire < Date.now()) { + if (!listActionRunning) { + shouldNotice = true + listActionRunning = true + listActions().then(actions => { + // console.log('find.actions', actions) + AppPlugin.actions = actions + ManagerFileCacheUtil.set('AppActions', actions, 1000 * 3600) + if (toastTimer) { + clearTimeout(toastTimer) + } else if (shouldNotice) { + Manager.setNotice({ + text: '应用软件索引完成', + type: 'success', + duration: 5000, + }).then() + } + listActionRunning = false + ManagerSystem.clearCache() + }) + } + } + if (!AppPlugin.actions.length && shouldNotice) { + toastTimer = setTimeout(() => { + Manager.setNotice('正在分析应用软件,稍后才可以搜索到应用软件哦~').then() + toastTimer = null + }, 3000) + } + return AppPlugin +} diff --git a/electron/mapi/manager/system/plugin/app/linux/index.ts b/electron/mapi/manager/system/plugin/app/linux/index.ts new file mode 100644 index 0000000..766c836 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/linux/index.ts @@ -0,0 +1,23 @@ +import {listFiles} from "../util"; +import path from "path"; + +export const ManagerAppLinux = { + list: async () => { + return lists() + } +} + +const lists = async () => { + const files = await listFiles([ + "/usr/share/applications", + "/var/lib/snapd/desktop/applications", + `${process.env.HOME}/.local/share/applications`, + ]) + for (const file of files) { + if (path.extname(file.pathname) !== ".desktop") { + continue + } + //TODO + } + return [] +} diff --git a/electron/mapi/manager/system/plugin/app/mac/icon.ts b/electron/mapi/manager/system/plugin/app/mac/icon.ts new file mode 100644 index 0000000..03f0201 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/mac/icon.ts @@ -0,0 +1,88 @@ +import path from 'node:path'; +import fs from 'fs'; +import {exec} from 'child_process'; +import {Files} from "../../../../../file/main"; +import os from "os"; + +const iconTempDir = path.join(os.tmpdir(), 'focusany-app-icon'); +// console.log('iconTempDir', iconTempDir) +const defaultIcon = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns'; + +const getIconFile = (appFileInput) => { + return new Promise((resolve, reject) => { + const plistPath = path.join(appFileInput, 'Contents', 'Info.plist'); + Files.read(plistPath, { + isFullPath: true + }).then((plistContent) => { + if (plistContent) { + // parse CFBundleIconFile + const mat = plistContent.match(/CFBundleIconFile<\/key>\s*(.*?)<\/string>/) + if (mat) { + const CFBundleIconFile = mat[1] + const iconFile = path.join( + appFileInput, + 'Contents', + 'Resources', + CFBundleIconFile + ); + const iconFiles = [iconFile, iconFile + '.icns', iconFile + '.tiff'] + const existedIcon = iconFiles.find((iconFile) => { + return fs.existsSync(iconFile); + }); + // console.log('manager.app.mac.app2png.getIconFile', existedIcon) + resolve(existedIcon || defaultIcon); + return + } + } + resolve(defaultIcon); + }).catch((e) => { + console.log('manager.app.mac.app2png.getIconFile.error', e) + resolve(defaultIcon); + }) + }); +}; + +const tiffToPng = (iconFile, pngFileOutput) => { + return new Promise((resolve, reject) => { + exec( + `sips -s format png '${iconFile}' --out '${pngFileOutput}' --resampleHeightWidth 64 64`, + (error) => { + error ? reject(error) : resolve(null); + } + ); + }); +}; + +const app2png = (appFileInput, pngFileOutput) => { + return getIconFile(appFileInput).then((iconFile) => { + // console.log('manager.app.mac.app2png.app2png', iconFile, pngFileOutput) + return tiffToPng(iconFile, pngFileOutput); + }); +}; + + +export const getIcon = async (appPath: string, appName: string) => { + try { + const iconPathUrl = 'file://' + path.join(iconTempDir, `${encodeURIComponent(appName)}.png`) + const iconPath = path.join(iconTempDir, `${appName}.png`); + if (await Files.exists(iconPath, {isFullPath: true})) { + return iconPathUrl + } + const iconNone = path.join(iconTempDir, `${appName}.none`); + const iconNoneUrl = path.join(iconTempDir, `${appName}.none`); + if (await Files.exists(iconNone, {isFullPath: true})) { + return iconNoneUrl; + } + if (!await Files.exists(iconTempDir, {isFullPath: true})) { + fs.mkdirSync(iconTempDir, {recursive: true}); + } + await app2png(appPath, iconPath); + if (!await Files.exists(iconPath, {isFullPath: true})) { + fs.writeFileSync(iconNone, ''); + throw 'IconGetError'; + } + return iconPathUrl; + } catch (e) { + } + return `file://${defaultIcon}`; +} diff --git a/electron/mapi/manager/system/plugin/app/mac/index.ts b/electron/mapi/manager/system/plugin/app/mac/index.ts new file mode 100644 index 0000000..fa19a96 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/mac/index.ts @@ -0,0 +1,53 @@ +import {listFiles} from "../util"; +import path from 'node:path'; +import {AppRecord} from "../type"; +import {getIcon} from "./icon"; +import {ConfigLang} from "../../../../../../config/lang"; +import {getAppTitle} from "./title"; + +const appSet = new Set(); + +export const ManagerAppMac = { + list: async () => { + return lists() + }, +} + +const lists = async (): Promise => { + appSet.clear() + let files = await listFiles([ + '/Applications', + '~/Applications', + '/System/Applications', + '/System/Library/PreferencePanes', + ]) + const apps = [] + const locale = ConfigLang.getLocale() + for (const f of files) { + if (appSet.has(f.pathname)) { + // console.log('appSet.has', f.pathname) + continue + } + const extname = path.extname(f.pathname); + if (extname !== '.app' && extname !== '.prefPane') { + continue + } + const app = { + name: f.name.replace(/\.(app|prefPane)$/, ''), + title: f.name, + pathname: f.pathname, + icon: null, + command: null, + } + app.icon = await getIcon(app.pathname, app.name) + app.title = await getAppTitle(locale, app.pathname, app.name); + if (!app.icon) { + continue + } + app.command = `open ${app.pathname.replace(/ /g, '\\ ') as string}` + appSet.add(app.pathname) + apps.push(app) + } + return apps +} + diff --git a/electron/mapi/manager/system/plugin/app/mac/title.ts b/electron/mapi/manager/system/plugin/app/mac/title.ts new file mode 100644 index 0000000..efe44b2 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/mac/title.ts @@ -0,0 +1,42 @@ +import {Files} from "../../../../../file/main"; +import fs from "node:fs"; +import {IconvUtil} from "../../../../../../lib/util"; + +const langDirMap = { + 'en-US': ['en.lproj'], + 'zh-CN': ['zh-Hans.lproj', 'zh_CN.lproj'], +} + +export const getAppTitle = async (locale: string, pathname: string, name: string) => { + if (!(locale in langDirMap)) { + return name + } + const langDirs = langDirMap[locale] + // console.log('langDirs', langDirs) + for (const langDir of langDirs) { + const infoPlistPath = pathname + '/Contents/Resources/' + langDir + '/InfoPlist.strings' + // console.log('infoPlistPath', infoPlistPath) + if (!await Files.exists(infoPlistPath, {isFullPath: true})) { + continue + } + const buffer = await Files.readBuffer(infoPlistPath, { + isFullPath: true, + }) + const content = IconvUtil.bufferToUtf8(buffer) as string + // console.log('content', infoPlistPath, content.toString('utf8')) + // CFBundleName = "网易邮箱大师"; + if (content) { + // console.log('content', JSON.stringify(content)) + // CFBundleDisplayName = "网易邮箱大师"; + const reg = new RegExp('"?CFBundleDisplayName"?.*?=.*?"(.*)".*?;') + const match = content.match(reg) + if (match) { + // console.log('content.result', match[1]) + return match[1] + } + } + } + // console.log('===============') + // console.log('getAppTitle', locale, pathname, name) + return name +} diff --git a/electron/mapi/manager/system/plugin/app/type.ts b/electron/mapi/manager/system/plugin/app/type.ts new file mode 100644 index 0000000..10b97a6 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/type.ts @@ -0,0 +1,7 @@ +export type AppRecord = { + name: string, + title: string, + pathname:string, + icon: string, + command: string , +} diff --git a/electron/mapi/manager/system/plugin/app/util/index.ts b/electron/mapi/manager/system/plugin/app/util/index.ts new file mode 100644 index 0000000..96fcde2 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/util/index.ts @@ -0,0 +1,19 @@ +import {Files} from "../../../../../file/main"; + +export const listFiles = async (paths: string[]): Promise<{ + name: string, + pathname: string, + isDirectory: boolean, + size: number, + lastModified: number +}[]> => { + let results: any[] = [] + for (const path of paths) { + for (let p of await Files.list(path, { + isFullPath: true + })) { + results.push(p) + } + } + return results +} diff --git a/electron/mapi/manager/system/plugin/app/win/icon.ts b/electron/mapi/manager/system/plugin/app/win/icon.ts new file mode 100644 index 0000000..f784a69 --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/win/icon.ts @@ -0,0 +1,36 @@ +import path from "path"; +import fs from "fs"; + +import extractFileIcon from 'extract-file-icon' +import os from "os"; + +const iconTempDir = path.join(os.tmpdir(), 'focusany-app-icon'); + +export const getIcon = async (appPath: string, appName: string) => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const iconPath = path.join(iconTempDir, `${appName}.png`); + const iconPathUrl = `file://${iconPath}`; + // console.log('iconPath', iconPath, appName, appPath); + if (fs.existsSync(iconPath)) { + return iconPathUrl + } + const iconNone = path.join(iconTempDir, `${appName}.none`); + const iconNoneUrl = `file://${iconNone}`; + if (fs.existsSync(iconNone)) { + return iconNoneUrl; + } + if (!fs.existsSync(iconTempDir)) { + fs.mkdirSync(iconTempDir, {recursive: true}); + } + const buffer = extractFileIcon(appPath, 32); + fs.writeFileSync(iconPath, buffer, 'base64'); + if (fs.existsSync(iconPath)) { + return iconPathUrl + } else { + fs.writeFileSync(iconNone, ''); + } + } catch (e) { + } + return null +}; diff --git a/electron/mapi/manager/system/plugin/app/win/index.ts b/electron/mapi/manager/system/plugin/app/win/index.ts new file mode 100644 index 0000000..aae821f --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/win/index.ts @@ -0,0 +1,71 @@ +import {listFiles} from "../util"; +import path from "path"; +import os from "os"; +import {shell} from "electron"; +import {AppRecord} from "../type"; +import {ShellUtil} from "../../../../../../lib/util"; +import {getIcon} from "./icon"; +import {getAppTitle} from "./title"; + +export const ManagerAppWin = { + list: async () => { + + return lists() + } +} + +const apps: AppRecord[] = []; +const appSet = new Set(); + +const blackList = [ + 'msiexec.exe' +] + +const readDir = async (dir: string) => { + let files = await listFiles([dir]) + for (const f of files) { + if (f.isDirectory) { + await readDir(f.pathname) + } else { + let name = f.name.split('.')[0] + let appDetail: any = {}; + try { + appDetail = shell.readShortcutLink(f.pathname); + } catch (e) { + // + } + const pathname = appDetail.target + if (!pathname + || appSet.has(pathname) + || !pathname.endsWith('.exe') + || pathname.endsWith('uninst.exe') + || pathname.endsWith('uninstall.exe') + ) { + continue + } + appSet.add(pathname) + name = path.basename(appDetail.target, '.exe') + if (blackList.includes(name)) { + continue + } + const title = await getAppTitle('zh-CN', pathname, name) + const app = { + name, + title, + pathname, + icon: await getIcon(appDetail.target, name), + command: `start "dummyclient" ${ShellUtil.quotaPath(appDetail.target)}` + } + // console.log('app', app) + apps.push(app) + } + } +} + +const lists = async (): Promise => { + appSet.clear() + await readDir('C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs') + await readDir(path.join(os.homedir(), './AppData/Roaming', 'Microsoft\\Windows\\Start Menu\\Programs')) + // console.log('apps', apps) + return apps +} diff --git a/electron/mapi/manager/system/plugin/app/win/title.ts b/electron/mapi/manager/system/plugin/app/win/title.ts new file mode 100644 index 0000000..d46f6cd --- /dev/null +++ b/electron/mapi/manager/system/plugin/app/win/title.ts @@ -0,0 +1,27 @@ +import {exec} from "child_process"; +import {IconvUtil} from "../../../../../../lib/util"; + +export const getAppTitle = async (locale: string, pathname: string, name: string) => { + // (Get-ItemProperty -Path 'C:\\Program Files (x86)\\360\\360zip\\360zip.exe').VersionInfo.FileDescription + // (Get-ItemProperty -Path 'C:\\Program Files (x86)\\360\\360Safe\\360Safe.exe').VersionInfo.FileDescription + // (Get-ItemProperty -Path 'C:\\Windows\\SysWOW64\\msiexec.exe').VersionInfo.FileDescription + const command = `powershell -Command "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; (Get-ItemProperty -Path '${pathname}').VersionInfo.FileDescription"`; + return new Promise((resolve, reject) => { + exec(command, { + encoding: 'utf-8' + }, (error, stdout, stderr) => { + if (error) { + resolve(name) + } else { + // console.log('win.getAppTitle', { + // locale, + // pathname, + // name, + // stdout: stdout, + // title: stdout.toString()?.trim(), + // }) + resolve(stdout.toString()?.trim()); + } + }); + }); +} diff --git a/electron/mapi/manager/system/plugin/file.ts b/electron/mapi/manager/system/plugin/file.ts new file mode 100644 index 0000000..5d55556 --- /dev/null +++ b/electron/mapi/manager/system/plugin/file.ts @@ -0,0 +1,75 @@ +import { + ActionMatch, + ActionMatchTypeEnum, + ActionRecord, + ActionTypeEnum, + FilePluginRecord, + PluginRecord +} from "../../../../../src/types/Manager"; +import {MemoryCacheUtil, ShellUtil} from "../../../../lib/util"; +import {SystemIcons} from "../asset/icon"; +import {KVDBMain} from "../../../kvdb/main"; +import {CommonConfig} from "../../../../config/common"; + +export const FilePlugin: PluginRecord = { + name: 'file', + title: '文件启动', + version: '1.0.0', + logo: SystemIcons.folder, + description: '提供文件一键启动功能', + main: null, + preload: null, + actions: [] +} + +const listActions = async () => { + return await MemoryCacheUtil.remember('FileActions', async () => { + const actions: ActionRecord[] = [] + const records = await ManagerSystemPluginFile.list() + records.forEach((record, recordIndex) => { + actions.push({ + fullName: `${FilePlugin.name}/${record.title}`, + pluginName: FilePlugin.name, + name: record.title, + title: record.title, + icon: record.icon, + type: ActionTypeEnum.COMMAND, + matches: [ + { + type: ActionMatchTypeEnum.TEXT, + text: record.title + } as ActionMatch + ], + data: { + command: "open " + ShellUtil.quotaPath(record.path) + } + }) + }) + return actions + }) +} + +export const getFilePlugin = async () => { + FilePlugin.actions = await listActions() + return FilePlugin +} + +export const ManagerSystemPluginFile = { + async list(): Promise { + return MemoryCacheUtil.remember('Files', async () => { + const res = await KVDBMain.getData(CommonConfig.dbSystem, CommonConfig.dbFileId) + if (res) { + return res['records'] || [] + } + return [] + }) + }, + async update(records: FilePluginRecord[]) { + await KVDBMain.putForce(CommonConfig.dbSystem, { + _id: CommonConfig.dbFileId, + records: records + }) + MemoryCacheUtil.forget('Files') + MemoryCacheUtil.forget('FileActions') + } +} diff --git a/electron/mapi/manager/system/plugin/store.ts b/electron/mapi/manager/system/plugin/store.ts new file mode 100644 index 0000000..88ab93b --- /dev/null +++ b/electron/mapi/manager/system/plugin/store.ts @@ -0,0 +1,23 @@ +import {ActionTypeEnum, PluginRecord} from "../../../../../src/types/Manager"; +import {SystemIcons} from "../asset/icon"; + +export const StorePlugin: PluginRecord = { + name: 'store', + title: '插件市场', + version: '1.0.0', + logo: SystemIcons.pluginStore, + description: '提供插件应用市场管理功能', + main: '/page/store.html', + preload: '', + actions: [ + { + name: "default", + title: "插件市场", + type: ActionTypeEnum.WEB, + icon: SystemIcons.pluginStore, + matches: [ + '插件市场', 'store', + ] as any + }, + ] +} diff --git a/electron/mapi/manager/system/plugin/store/action.ts b/electron/mapi/manager/system/plugin/store/action.ts new file mode 100644 index 0000000..da1eb34 --- /dev/null +++ b/electron/mapi/manager/system/plugin/store/action.ts @@ -0,0 +1,7 @@ +import {ActionTypeCodeData} from "../../../../../../src/types/Manager"; +import {screenCapture} from "../../../plugin/screenCapture"; +import {AppsMain} from "../../../../app/main"; + +export const StoreActionCode = { + +} diff --git a/electron/mapi/manager/system/plugin/store/index.ts b/electron/mapi/manager/system/plugin/store/index.ts new file mode 100644 index 0000000..d644ee4 --- /dev/null +++ b/electron/mapi/manager/system/plugin/store/index.ts @@ -0,0 +1,245 @@ +import {UserApi} from "../../../../user/main"; +import {Files} from "../../../../file/main"; +import {Manager} from "../../../manager"; +import {PluginType} from "../../../../../../src/types/Manager"; +import {ManagerPlugin} from "../../../plugin"; +// @ts-ignore +import {mapError} from "../../../../../../src/lib/error"; +import {Misc} from "../../../../misc"; +import fs from "node:fs"; +import {resolve} from "node:path"; +import {MarkdownUtil} from "../../../../../lib/util"; + +export const ManagerPluginStore = { + async install(pluginName: string, option?: { + version?: string, + }) { + option = Object.assign({ + version: null, + }, option) + const payload = { + plugin: pluginName, + version: option['version'], + } + const existPlugin = await ManagerPlugin.get(pluginName) + let isUpgrade = false + if (existPlugin && existPlugin.version !== option['version']) { + isUpgrade = true + } + try { + if (isUpgrade) { + await ManagerPlugin.uninstall(pluginName) + } + const infoRes = await UserApi.post('store/plugin_info_guest', payload) + await ManagerPlugin.configCheck(infoRes.data['config']) + // console.log('ManagerPluginStore.install', JSON.stringify({pluginName, option, data: infoRes.data}, null, 2)); + const packageRes = await UserApi.post('store/plugin_package_guest', payload) + const packageUrl = packageRes.data['package'] + const packageMd5 = packageRes.data['packageMd5'] + // console.log('ManagerPluginStore.install', JSON.stringify({pluginName, option, packageRes}, null, 2)); + const tempFile = await Files.temp('zip') + // console.log('ManagerPluginStore.install', JSON.stringify({pluginName, option, tempFile}, null, 2)); + // console.log('ManagerPluginStore.install.downloadStart'); + let lastPercent = 0 + await Files.download(packageUrl, tempFile, { + isFullPath: true, + progress(percent, total) { + const p = Math.floor(percent * 100 * 0.99) + if (lastPercent != p) { + lastPercent = p + // console.log('ManagerPluginStore.install.downloadProgress', {p, total}); + Manager.sendBroadcast('store', 'PluginInstallProgress', { + pluginName: pluginName, + percent: p, + }) + } + }, + }) + // console.log('ManagerPluginStore.install.downloadEnd'); + // console.log('ManagerPluginStore.install.start'); + await ManagerPlugin.installFromFileOrDir(tempFile, PluginType.STORE) + // console.log('ManagerPluginStore.install.end'); + } catch (e) { + throw mapError(e) + } + }, + async publish(pluginName: string, option?: { + version?: string, + }) { + option = Object.assign({ + version: null, + }, option) + const plugin = await Manager.getPlugin(pluginName) + if (!plugin) { + throw 'PluginNotExists' + } + if (plugin.type !== PluginType.DIR) { + throw 'PluginNotPublishAble' + } + if (plugin.version !== option['version']) { + throw 'PublishVersionNotMatch' + } + if (!plugin.runtime.root) { + throw 'PluginNotPublishAble' + } + const root = plugin.runtime.root + const config = await Files.read(resolve(root, 'config.json'), { + isFullPath: true, + }) + if (!config) { + throw 'PluginFormatError' + } + let configJson = null + try { + configJson = JSON.parse(config) + } catch (e) { + } + if (!configJson) { + throw 'PluginFormatError' + } + const payload = { + plugin: pluginName, + version: option['version'], + feature: null, + content: null, + package: null, + } + configJson["development"] = configJson["development"] || {} + if (configJson["development"]["env"] === 'dev') { + throw 'PluginEnvError' + } + configJson["development"]["releaseDoc"] = configJson["development"]["releaseDoc"] || 'release.md' + const releaseDocPath = resolve(root, configJson["development"]["releaseDoc"]); + const releaseDoc = await Files.read(releaseDocPath, { + isFullPath: true, + }) + if (releaseDoc) { + const parts = releaseDoc.split('---') + for (const part of parts) { + let lines = part.split('\n') + while (!payload.feature && lines.length) { + const line = lines.shift() + // ## x.x.x 功能特性 + if (line.startsWith('##')) { + const parts = line.split(' ') + if (parts.length === 3) { + if (parts[1] === payload.version) { + payload.feature = parts[2] + payload.content = MarkdownUtil.toHtml(lines.join('\n').trim()) + break + } + } + } + } + if (payload.feature) { + break + } + } + } + if (!payload.feature || !payload.content) { + throw 'PluginReleaseDocNotFound' + } + const pluginInfo = await this._getPluginInfo(root, configJson) + const tempFile = await Files.temp('zip') + await Misc.zip(tempFile, plugin.runtime.root) + if (!fs.existsSync(tempFile)) { + throw 'PluginZipError' + } + const buffer = await Files.readBuffer(tempFile, { + isFullPath: true, + }) + payload.package = buffer.toString('base64') + await Files.deletes(tempFile, { + isFullPath: true, + }) + return await UserApi.post('store/plugin_publish', { + ...payload, + ...pluginInfo, + }) + }, + async publishInfo(pluginName: string, option?: { + version?: string, + }) { + option = Object.assign({ + version: null, + }, option) + const plugin = await Manager.getPlugin(pluginName) + if (!plugin) { + throw 'PluginNotExists' + } + if (plugin.type !== PluginType.DIR) { + throw 'PluginNotPublishAble' + } + if (plugin.version !== option['version']) { + throw 'PublishVersionNotMatch' + } + if (!plugin.runtime.root) { + throw 'PluginNotPublishAble' + } + const root = plugin.runtime.root + const config = await Files.read(resolve(root, 'config.json'), { + isFullPath: true, + }) + if (!config) { + throw 'PluginFormatError' + } + let configJson = null + try { + configJson = JSON.parse(config) + } catch (e) { + } + if (!configJson) { + throw 'PluginFormatError' + } + const payload = { + plugin: pluginName, + version: option['version'], + } + const pluginInfo = await this._getPluginInfo(root, configJson) + return await UserApi.post('store/plugin_publish_info', { + ...payload, + ...pluginInfo, + }) + }, + async _getPluginInfo(root: string, configJson: any) { + const result = { + pluginContent: null, + pluginPreview: null, + } + configJson["development"] = configJson["development"] || {} + configJson["development"]["contentDoc"] = configJson["development"]["contentDoc"] || 'content.md' + const contentDocPath = resolve(root, configJson["development"]["contentDoc"]); + const contentDoc = await Files.read(contentDocPath, { + isFullPath: true, + }) + if (contentDoc) { + result.pluginContent = MarkdownUtil.toHtml(contentDoc) + } + configJson["development"]["previewDoc"] = configJson["development"]["previewDoc"] || 'preview.md' + const previewDocPath = resolve(root, configJson["development"]["previewDoc"]); + const previewDoc = await Files.read(previewDocPath, { + isFullPath: true, + }) + if (previewDoc) { + const images = [] + previewDoc.split("\n").forEach((line: string) => { + // https://example.com/path/to/image.png + // ![image](https://example.com/path/to/image.png) + const match = line.match(/!\[.*?\]\((.*?)\)/) + if (match) { + images.push(match[1].trim()) + } else { + images.push(line.trim()) + } + }) + result.pluginPreview = JSON.stringify(images.filter(url => !!url)) + } + return result + } +} + +// setTimeout(() => { +// ManagerPluginStore.publishInfo('AxxxdDddd', { +// version: '1.2.0', +// }) +// }, 3000) diff --git a/electron/mapi/manager/system/plugin/system.ts b/electron/mapi/manager/system/plugin/system.ts new file mode 100644 index 0000000..29cb912 --- /dev/null +++ b/electron/mapi/manager/system/plugin/system.ts @@ -0,0 +1,96 @@ +import {ActionTypeEnum, PluginRecord} from "../../../../../src/types/Manager"; +import {SystemIcons} from "../asset/icon"; + + +export const SystemPlugin: PluginRecord = { + name: 'system', + title: '系统设置', + version: '1.0.0', + logo: SystemIcons.pluginSystem, + description: '提供基础系统功能', + main: '/page/system.html', + preload: '', + actions: [ + { + name: "page-data", + title: "数据中心", + type: ActionTypeEnum.WEB, + icon: SystemIcons.database, + matches: [ + '数据中心', 'data' + ] as any + }, + { + name: "page-setting", + title: "功能设置", + type: ActionTypeEnum.WEB, + icon: SystemIcons.pluginSystem, + matches: [ + '功能设置', 'setting' + ] as any + }, + { + name: "page-plugin", + title: "插件管理", + type: ActionTypeEnum.WEB, + icon: SystemIcons.plugin, + matches: [ + '插件管理', 'plugin' + ] as any + }, + { + name: "page-action", + title: "指令管理", + type: ActionTypeEnum.WEB, + icon: SystemIcons.command, + matches: [ + '指令管理', 'action' + ] as any + }, + { + name: "page-file", + title: "文件启动", + type: ActionTypeEnum.WEB, + icon: SystemIcons.folder, + matches: [ + '文件启动', 'file' + ] as any + }, + { + name: "page-launch", + title: "快捷启动", + type: ActionTypeEnum.WEB, + icon: SystemIcons.thunder, + matches: [ + '快捷启动', 'launch' + ] as any + }, + { + name: 'page-about', + title: "关于我们", + type: ActionTypeEnum.WEB, + icon: SystemIcons.about, + matches: [ + '关于我们', 'about', + ] as any + }, + { + name: 'screenshot', + title: "截图", + type: ActionTypeEnum.CODE, + icon: SystemIcons.screenshot, + matches: [ + '截图', 'screenshot', 'snapshot' + ] as any + }, + { + name: 'guide', + title: "新手指引", + type: ActionTypeEnum.CODE, + icon: SystemIcons.guide, + matches: [ + '新手指引', 'guide' + ] as any + } + ] +} diff --git a/electron/mapi/manager/system/plugin/system/action.ts b/electron/mapi/manager/system/plugin/system/action.ts new file mode 100644 index 0000000..8afa0d0 --- /dev/null +++ b/electron/mapi/manager/system/plugin/system/action.ts @@ -0,0 +1,15 @@ +import {ActionTypeCodeData} from "../../../../../../src/types/Manager"; +import {screenCapture} from "../../../plugin/screenCapture"; +import {AppsMain} from "../../../../app/main"; +import {Page} from "../../../../../page"; + +export const SystemActionCode = { + "screenshot": async (focusany: FocusAnyApi, data: ActionTypeCodeData) => { + screenCapture((image: string) => { + AppsMain.setClipboardImage(image) + }) + }, + "guide": async (focusany: FocusAnyApi, data: ActionTypeCodeData) => { + Page.open('guide', {}).then() + } +} diff --git a/electron/mapi/manager/type.ts b/electron/mapi/manager/type.ts new file mode 100644 index 0000000..bb6c826 --- /dev/null +++ b/electron/mapi/manager/type.ts @@ -0,0 +1,15 @@ +import {BrowserView, BrowserWindow} from "electron"; +import {PluginRecord} from "../../../src/types/Manager"; + + +export type PluginContext = (BrowserView | {}) & { + _plugin: PluginRecord, + _window?: BrowserWindow, +} + +export type SearchQuery = { + keywords: string, + currentFiles?: ClipboardFileItem[], + currentImage?: string, + currentText?: string, +} diff --git a/electron/mapi/manager/window/index.ts b/electron/mapi/manager/window/index.ts new file mode 100644 index 0000000..d150efc --- /dev/null +++ b/electron/mapi/manager/window/index.ts @@ -0,0 +1,460 @@ +import {ActionRecord, PluginRecord, PluginState, PluginType} from "../../../../src/types/Manager"; +import {AppEnv, AppRuntime} from "../../env"; +import {preloadDefault, preloadPluginDefault, rendererIsUrl, rendererLoadPath} from "../../../lib/env-main"; +import {WindowConfig} from "../../../config/window"; +import {BrowserView, BrowserWindow, screen, session, shell, WebContents} from "electron"; +import {isMac} from "../../../lib/env"; +import * as remoteMain from "@electron/remote/main"; +import {executeDarkMode, executeHooks, executePluginHooks} from "../lib/hooks"; +import {DevToolsManager} from "../../../lib/devtools"; +import {ManagerPlugin} from "../plugin"; +import {Log} from "../../log/main"; +import {Events} from "../../event/main"; +import {ManagerSystem} from "../system"; + +const browserViews = new Map() +const detachWindows = new Map() +let mainWindowView: BrowserView | null = null + +const addBrowserViews = (view: BrowserView) => { + // console.log('addBrowserViews.value', view) + browserViews.set(view.webContents, view) +} + +const removeBrowserViews = (view: BrowserView) => { + browserViews.delete(view.webContents) +} + +const addDetachWindows = (win: BrowserWindow) => { + detachWindows.set(win.webContents, win) +} + +const removeDetachWindows = (win: BrowserWindow) => { + detachWindows.delete(win.webContents) +} + +export const ManagerWindow = { + listBrowserViews(): BrowserView[] { + return Array.from(browserViews.values()) + }, + listDetachWindows(): BrowserWindow[] { + return Array.from(detachWindows.values()) + }, + getViewByWebContents: (webContents: any) => { + // console.log('getViewByWebContents.value', webContents) + let view = browserViews.get(webContents) + if (view) { + return view + } + const iterator = browserViews.entries(); + while (true) { + const {value, done} = iterator.next() + if (done) { + break + } + // console.log('getViewByWebContents.value.start', value[1], value[1]._window) + if (value[1]._window.webContents === webContents) { + return value[1] + } + } + return null + }, + async openForCode(plugin: PluginRecord, action: ActionRecord, option?: { + codeData?: any + }) { + const { + nodeIntegration, + preloadBase, + preload, + main, + } = ManagerPlugin.getInfo(plugin) + // console.log('openForCode', {preload, main, height}) + const windowSession = session.fromPartition('<' + plugin.name + '>'); + if (preloadBase) { + windowSession.setPreloads([preloadBase]); + } + const view = new BrowserView({ + webPreferences: { + webSecurity: false, + nodeIntegration, + contextIsolation: false, + sandbox: false, + devTools: true, + webviewTag: true, + preload, + session: windowSession, + defaultFontSize: 14, + defaultFontFamily: { + standard: 'system-ui', + serif: 'system-ui', + }, + spellcheck: false, + }, + }); + addBrowserViews(view) + view._plugin = plugin + view._window = AppRuntime.mainWindow + remoteMain.enable(view.webContents) + AppRuntime.mainWindow.addBrowserView(view); + if (rendererIsUrl(main)) { + view.webContents.loadURL(main).then() + } else { + view.webContents.loadFile(main).then() + } + DevToolsManager.register(`MainCodeView.${plugin.name}`, view) + view.webContents.on('preload-error', (event, preloadPath, error) => { + Log.error('ManagerWindow.openForCode.preload-error', error) + }) + return new Promise((resolve, reject) => { + view.webContents.once('dom-ready', async () => { + DevToolsManager.autoShow(view) + view.setBounds({ + x: 0, + y: 0, + width: 0,//size[0], + height: 0, + }) + const evalJs = ` + (async()=>{ + const name = '${action.name}'; + if(window.exports && window.exports.code && (name in window.exports.code)) { + return await window.exports.code[name](${JSON.stringify(option.codeData)}) + }else{ + throw new Error('ActionCodeNotFound : ' + name) + } + })(); + `; + view.webContents?.executeJavaScript(evalJs).then(value => { + resolve(value) + }).catch(e => { + Log.error('ManagerWindow.openForCode.evalJs.error', e) + reject(e) + }).finally(() => { + setTimeout(() => { + // console.log('ManagerWindow.openForCode.evalJs.finally') + AppRuntime.mainWindow.removeBrowserView(view) + removeBrowserViews(view) + if (view._plugin.setting && view._plugin.setting.keepCodeDevTools) { + // 保留最后调试信息 + } else { + // @ts-ignore + view.webContents?.destroy() + } + }, 1000) + }) + }); + }) + }, + async open(plugin: PluginRecord, action: ActionRecord, option?: {}) { + let view = mainWindowView + if (view) { + await this.close(view._plugin) + view = null + } + const size = AppRuntime.mainWindow.getSize() + AppRuntime.mainWindow.setSize(size[0], WindowConfig.mainHeight); + const { + nodeIntegration, + preloadBase, + preload, + main, + width, + height, + autoDetach, + singleton, + zoom + } = ManagerPlugin.getInfo(plugin) + // console.log('ManagerWindow.open', {nodeIntegration, preload, main, width, height, autoDetach}) + if (singleton) { + for (const v of this.listBrowserViews()) { + if (v._plugin.name === plugin.name) { + v._window.show() + v._window.focus() + await executeHooks(AppRuntime.mainWindow, 'PluginAlreadyOpened', {}) + return + } + } + } + const windowSession = session.fromPartition('<' + plugin.name + '>'); + if (preloadBase) { + windowSession.setPreloads([preloadBase]); + } + // console.log('preload', {preloadPluginDefault, preload}) + view = new BrowserView({ + webPreferences: { + webSecurity: false, + nodeIntegration, + contextIsolation: false, + sandbox: false, + devTools: true, + webviewTag: true, + preload, + session: windowSession, + defaultFontSize: 14, + defaultFontFamily: { + standard: 'system-ui', + serif: 'system-ui', + }, + spellcheck: false, + }, + }); + addBrowserViews(view) + // browserViews.push(view) + view._plugin = plugin + view._window = AppRuntime.mainWindow + mainWindowView = view + remoteMain.enable(view.webContents) + AppRuntime.mainWindow.addBrowserView(view); + if (rendererIsUrl(main)) { + view.webContents.loadURL(main).then() + } else { + view.webContents.loadFile(main).then() + } + DevToolsManager.register(`MainView.${plugin.name}`, view) + view.webContents.once('did-finish-load', async () => { + await executeDarkMode(view, { + isSystem: ManagerSystem.match(plugin.name) + }) + Events.sendRaw(view.webContents, 'APP_READY', { + name: plugin.name, + AppEnv + }) + }) + view.webContents.once('did-frame-finish-load', () => { + // console.log('setZoomFactor', zoom / 100) + setTimeout(() => { + view.webContents.setZoomFactor(zoom / 100) + }, 0) + }) + view.webContents.once('dom-ready', async () => { + if (autoDetach) { + AppRuntime.mainWindow.setSize(size[0], WindowConfig.mainHeight) + view.setBounds({ + x: 0, + y: WindowConfig.mainHeight, + width: width, + height: height, + }) + } else { + AppRuntime.mainWindow.setSize(size[0], WindowConfig.mainHeight + height) + view.setBounds({ + x: 0, + y: WindowConfig.mainHeight, + width: size[0], + height: height, + }) + } + const pluginParam = {} + const pluginState: PluginState = { + value: '', + placeholder: '', + } + await executeHooks(AppRuntime.mainWindow, 'PluginInit', { + plugin: plugin, + state: pluginState, + param: pluginParam + }) + DevToolsManager.autoShow(view) + if (autoDetach) { + await this.detach() + } + }); + view.webContents.on('preload-error', (event, preloadPath, error) => { + console.error('MainView.preload-error', error) + }) + view.webContents.setWindowOpenHandler(({url}) => { + if (url.startsWith('https://') || url.startsWith('http://')) { + shell.openExternal(url) + } + return {action: 'deny'} + }) + view.setAutoResize({width: true, height: true}); + // mainWindowPlugin = plugin; + const readyData = {} + readyData['actionName'] = action.name + readyData['actionMatch'] = action.runtime?.match + readyData['requestId'] = action.runtime?.requestId + // console.log('open.readyData', readyData) + await executePluginHooks(view, 'PluginReady', readyData) + }, + async subInputChange(win: BrowserWindow, keywords: string) { + const view = win.getBrowserView() + await executePluginHooks(view, 'SubInputChange', keywords); + }, + async close(plugin?: PluginRecord, option?: {}) { + if (mainWindowView && (!plugin || mainWindowView._plugin.name === plugin.name)) { + await executePluginHooks(mainWindowView, 'PluginExit', null).then() + await executeHooks(AppRuntime.mainWindow, 'PluginExit', null).then() + removeBrowserViews(mainWindowView) + AppRuntime.mainWindow.removeBrowserView(mainWindowView); + // @ts-ignore + mainWindowView.webContents?.destroy(); + } else { + // detach的插件窗口 + //TODO + } + }, + async openMainPluginDevTools(plugin: PluginRecord, option?: {}) { + if (plugin) { + //TODO + } else { + mainWindowView.webContents.openDevTools({ + mode: 'detach', + activate: false, + title: `MainPluginView`, + }) + } + }, + async detach(option?: {}) { + // const bounds = AppRuntime.mainWindow.getBounds() + const view: BrowserView = mainWindowView + if (!view) { + throw 'MainViewNotFound' + } + const bounds = view.getBounds() + // console.log('view.bounds', view.getBounds()) + const viewWidth = bounds.width + const viewHeight = bounds.height + AppRuntime.mainWindow.removeBrowserView(view); + mainWindowView = null + const pluginState: PluginState = await executeHooks(AppRuntime.mainWindow, 'PluginState') + let alwaysOnTop = false; + let win = new BrowserWindow({ + height: viewHeight + WindowConfig.detachWindowTitleHeight, + minHeight: viewHeight + WindowConfig.detachWindowTitleHeight, + width: viewWidth, + autoHideMenuBar: true, + titleBarStyle: 'hidden', + trafficLightPosition: {x: 10, y: 11}, + title: view._plugin.title, + resizable: true, + frame: true, + show: false, + transparent: false, + enableLargerThanScreen: true, + backgroundColor: '#fff', + alwaysOnTop, + // x: bounds.x, + // y: bounds.y, + center: true, + webPreferences: { + webSecurity: false, + backgroundThrottling: false, + nodeIntegration: true, + contextIsolation: false, + webviewTag: true, + devTools: true, + navigateOnDragDrop: true, + spellcheck: false, + preload: preloadDefault, + }, + }); + // console.log('DetachWindow', win._name) + view._window = win + remoteMain.enable(win.webContents) + win.on('close', () => { + executePluginHooks(view, 'PluginExit', null); + removeBrowserViews(view) + removeDetachWindows(win) + }); + win.on('closed', () => { + // @ts-ignore + view.webContents?.destroy(); + win = undefined; + }); + win.on('focus', () => { + view && win.webContents?.focus(); + }); + DevToolsManager.register(`DetachWindow.${view._plugin.name}`, win) + const pluginJson = JSON.parse(JSON.stringify(view._plugin)) + win.once('ready-to-show', async () => { + await executeDarkMode(win, { + isSystem: true + }) + view.setAutoResize({width: true, height: true}); + win.setBrowserView(view); + view.setBounds({ + x: 0, + y: WindowConfig.detachWindowTitleHeight, + width: viewWidth, + height: viewHeight, + }); + DevToolsManager.autoShow(win) + const pluginParam = { + alwaysOnTop + }; + await executeHooks(win, 'PluginInit', {plugin: pluginJson, state: pluginState, param: pluginParam}) + await executeHooks(AppRuntime.mainWindow, 'PluginDetached') + win.show() + AppRuntime.mainWindow.hide() + }) + win.on('maximize', () => { + executeHooks(win, 'Maximize'); + const display = screen.getDisplayMatching(win.getBounds()); + view.setBounds({ + x: 0, + y: WindowConfig.detachWindowTitleHeight, + width: display.workArea.width, + height: display.workArea.height - WindowConfig.detachWindowTitleHeight, + }); + }) + win.on('unmaximize', () => { + executeHooks(win, 'Unmaximize'); + const bounds = win.getBounds(); + const display = screen.getDisplayMatching(bounds); + const width = (display.scaleFactor * bounds.width) % 1 == 0 ? bounds.width : bounds.width - 2; + const height = (display.scaleFactor * bounds.height) % 1 == 0 ? bounds.height : bounds.height - 2; + view.setBounds({ + x: 0, + y: WindowConfig.detachWindowTitleHeight, + width, + height: height - WindowConfig.detachWindowTitleHeight, + }); + }); + win.webContents.once('render-process-gone', () => { + win.close(); + }); + view.webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') return; + if (!(input.meta || input.control || input.shift || input.alt)) { + if (input.key === 'Escape') { + win.isFullScreen() && win.setFullScreen(false); + } + return; + } + }); + if (isMac) { + win.on('enter-full-screen', () => { + executeHooks(win, 'EnterFullScreen'); + }); + win.on('leave-full-screen', () => { + executeHooks(win, 'LeaveFullScreen'); + }); + } + win.webContents.on("will-navigate", (event) => { + event.preventDefault(); + }); + win.webContents.setWindowOpenHandler(() => { + return {action: "deny"}; + }); + rendererLoadPath(win, 'page/detachWindow.html') + addDetachWindows(win) + }, + async toggleDetachPluginAlwaysOnTop(view: BrowserView, alwaysOnTop: boolean, option?: {}) { + view._window.setAlwaysOnTop(alwaysOnTop) + return alwaysOnTop + }, + async setDetachPluginZoom(view: BrowserView, zoom: number, option?: {}) { + view.webContents.setZoomFactor(zoom / 100) + }, + async closeDetachPlugin(view: BrowserView, option?: {}) { + view._window.close() + }, + async openDetachPluginDevTools(view: BrowserView, option?: {}) { + view.webContents.openDevTools({ + mode: 'detach', + activate: false, + title: `DetachView.${view._plugin.name}`, + }) + } +} diff --git a/electron/mapi/misc/index.ts b/electron/mapi/misc/index.ts new file mode 100644 index 0000000..154474e --- /dev/null +++ b/electron/mapi/misc/index.ts @@ -0,0 +1,143 @@ +import fs from "node:fs"; + +import yauzl from "yauzl"; +import archiver from "archiver"; + +const getZipFileContent = async (path: string, pathInZip: string) => { + return new Promise((resolve, reject) => { + // console.log('getZipFileContent', path, pathInZip) + yauzl.open(path, {lazyEntries: true}, (err: any, zipfile: any) => { + if (err) { + // console.log('getZipFileContent err', err) + reject(err) + return + } + zipfile.on('error', function (err: any) { + // console.log('getZipFileContent error', err) + reject(err) + }) + zipfile.on("end", function () { + // console.log('getZipFileContent end') + reject("FileNotFound") + }) + zipfile.on("entry", function (entry: any) { + // console.log('getZipFileContent entry', entry.fileName) + if (entry.fileName === pathInZip) { + zipfile.openReadStream(entry, function (err: any, readStream: any) { + if (err) { + reject(err) + return + } + let chunks: any[] = [] + readStream.on("data", function (chunk: any) { + chunks.push(chunk) + }) + readStream.on("end", function () { + const bytes = Buffer.concat(chunks) + const text = bytes.toString('utf8') + resolve(text) + }) + }) + } else { + zipfile.readEntry() + } + }) + zipfile.readEntry() + }) + }) +} + +const unzip = async ( + zipPath: string, + dest: string, + option?: { + process: (type: 'start' | 'end', entry: any) => void + } +) => { + option = Object.assign({ + process: null + }, option) + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, {recursive: true}) + } + return new Promise((resolve, reject) => { + // console.log('unzip', zipPath, dest) + yauzl.open(zipPath, {lazyEntries: true}, (err: any, zipfile: any) => { + if (err) { + // console.log('unzip err', err) + reject(err) + return + } + zipfile.on('error', function (err: any) { + // console.log('unzip error', err) + reject(err) + }) + zipfile.on("end", function () { + // console.log('unzip end') + resolve(true) + }) + zipfile.on("entry", function (entry: any) { + if (option.process) { + option.process('start', entry) + } + // console.log('unzip entry', dest, entry.fileName) + const destPath = dest + '/' + entry.fileName + if (/\/$/.test(entry.fileName)) { + // console.log('unzip mkdir', destPath) + fs.mkdirSync(destPath, {recursive: true}) + zipfile.readEntry() + } else { + const dirname = destPath.replace(/\/[^/]+$/, '') + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, {recursive: true}) + } + zipfile.openReadStream(entry, function (err: any, readStream: any) { + if (err) { + reject(err) + return + } + readStream.on("end", function () { + if (option.process) { + option.process('end', entry) + } + zipfile.readEntry() + }) + readStream.pipe(fs.createWriteStream(destPath)) + }) + } + }) + zipfile.readEntry() + }) + }) +} + +const zip = async ( + zipPath: string, + sourceDir: string, + option?: {} +) => { + option = Object.assign({}, option) + return new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipPath) + const archive = archiver('zip', { + zlib: {level: 9} + }) + output.on('close', function () { + resolve(true) + }) + archive.on('error', function (err: any) { + reject(err) + }) + archive.pipe(output) + archive.directory(sourceDir, false) + archive.finalize() + }) +} + +export const Misc = { + getZipFileContent, + unzip, + zip, +} + +export default Misc diff --git a/electron/mapi/misc/main.ts b/electron/mapi/misc/main.ts new file mode 100644 index 0000000..2715e2d --- /dev/null +++ b/electron/mapi/misc/main.ts @@ -0,0 +1,18 @@ +import {ipcMain} from "electron"; + +import index from './index' + +ipcMain.handle('misc:getZipFileContent', async (_, path: string, pathInZip: string) => { + return await index.getZipFileContent(path, pathInZip) +}) +ipcMain.handle('misc:unzip', async (_, zipPath: string, dest: string) => { + return await index.unzip(zipPath, dest) +}) + +export default { + ...index, +} + +export const MiscMain = { + ...index +} diff --git a/electron/mapi/misc/render.ts b/electron/mapi/misc/render.ts new file mode 100644 index 0000000..3e07541 --- /dev/null +++ b/electron/mapi/misc/render.ts @@ -0,0 +1,5 @@ +import index from './index' + +export default { + ...index +} diff --git a/electron/mapi/render.ts b/electron/mapi/render.ts new file mode 100644 index 0000000..4059137 --- /dev/null +++ b/electron/mapi/render.ts @@ -0,0 +1,56 @@ +import {exposeContext} from "./util"; +import {AppEnv} from "./env"; + +import config from "./config/render"; +import log from "./log/render"; +import app from "./app/render"; +import storage from "./storage/render"; +import db from "./db/render"; +import file from "./file/render"; +import event from "./event/render"; +import ui from "./ui/render"; +import updater from "./updater/render"; +import statistics from "./statistics/render"; +import lang from "./lang/render"; +import user from "./user/render"; +import misc from "./misc/render"; + +import server from "./server/render"; +import manager from "./manager/render"; +import kvdb from "./kvdb/render"; + +export const MAPI = { + init(env: typeof AppEnv = null) { + if (!env) { + // expose context + exposeContext('$mapi', { + app, + log, + config, + storage, + db, + file, + event, + ui, + updater, + statistics, + lang, + user, + misc, + + server, + manager, + kvdb, + }) + db.init() + event.init() + ui.init() + } else { + // init context + AppEnv.appRoot = env.appRoot + AppEnv.appData = env.appData + AppEnv.userData = env.userData + AppEnv.isInit = true + } + }, +} diff --git a/electron/mapi/server/api.ts b/electron/mapi/server/api.ts new file mode 100644 index 0000000..44d1028 --- /dev/null +++ b/electron/mapi/server/api.ts @@ -0,0 +1,108 @@ +import {net} from 'electron' +import {Client, handle_file} from "@gradio/client"; +import {platformArch, platformName} from "../../lib/env"; +import {Events} from "../event/main"; +import {Apps} from "../app"; +import {Files} from "../file/main"; +import fs from 'node:fs' + +const request = async (url, data?: {}, option?: {}) => { + option = Object.assign({ + method: 'GET', + timeout: 60 * 1000, + headers: { + 'Content-Type': 'application/json' + }, + responseType: 'json' as 'json' + }) + if (option['method'] === 'GET') { + url += '?' + for (let key in data) { + url += `${key}=${data[key]}&` + } + } + return new Promise((resolve, reject) => { + const req = net.request({ + url, + method: option['method'], + headers: option['headers'], + }) + req.on('response', (response) => { + let body = '' + response.on('data', (chunk) => { + body += chunk.toString() + }) + response.on('end', () => { + if ('json' === option['responseType']) { + try { + resolve(JSON.parse(body)) + } catch (e) { + resolve({code: -1, msg: `ResponseError: ${body}`}) + } + } else { + resolve(body) + } + }) + }) + req.on('error', (err) => { + reject(err) + }) + req.end() + }) +} + +const requestPost = async (url, data?: {}, option?: {}) => { + option = Object.assign({ + method: 'POST', + }) + return request(url, data, option) +} + +const requestGet = async (url, data?: {}, option?: {}) => { + option = Object.assign({ + method: 'GET', + }) + return request(url, data, option) +} + +const requestPostSuccess = async (url, data?: {}, option?: {}) => { + const res = await requestPost(url, data, option) + if (res['code'] === 0) { + return res + } + throw new Error(res['msg']) +} + +const requestUrlFileToLocal = async (url, path) => { + return new Promise((resolve, reject) => { + const req = net.request(url) + req.on('response', (response) => { + const file = fs.createWriteStream(path) + // @ts-ignore + response.pipe(file) + file.on('finish', () => { + file.close() + resolve('x') + }) + }) + req.on('error', (err) => { + reject(err) + }) + req.end() + }) +} + +export default { + GradioClient: Client, + GradioHandleFile: handle_file, + event: Events, + file: Files, + app: Apps, + request, + requestPost, + requestGet, + requestPostSuccess, + requestUrlFileToLocal, + platformName: platformName(), + platformArch: platformArch(), +} diff --git a/electron/mapi/server/main.ts b/electron/mapi/server/main.ts new file mode 100644 index 0000000..3592d2d --- /dev/null +++ b/electron/mapi/server/main.ts @@ -0,0 +1,86 @@ +import ServerApi from './api' +import {ipcMain} from "electron"; +import {Log} from "../log/main"; + +const serverModule = {} + +const init = () => { + +} + +const getModule = async (serverInfo: ServerInfo) => { + // console.log('getModule', serverInfo) + if (!serverModule[serverInfo.localPath]) { + try { + const serverPath = `${serverInfo.localPath}/server.js` + const module = await import(`file://${serverPath}`) + // console.log('module', module) + await module.default.init(ServerApi) + serverModule[serverInfo.localPath] = module.default + } catch (e) { + Log.error('mapi.server.getModule.error', e) + throw new Error(e) + } + } + return serverModule[serverInfo.localPath] +} + +ipcMain.handle('server:start', async (event, serverInfo: ServerInfo) => { + const module = await getModule(serverInfo) + try { + return await module.start(serverInfo) + } catch (e) { + Log.error('mapi.server.start.error', e) + throw new Error(e) + } +}) + +ipcMain.handle('server:ping', async (event, serverInfo: ServerInfo) => { + const module = await getModule(serverInfo) + try { + return await module.ping() + } catch (e) { + Log.error('mapi.server.ping.error', e) + throw new Error(e) + } +}) + +ipcMain.handle('server:stop', async (event, serverInfo: ServerInfo) => { + const module = await getModule(serverInfo) + try { + return await module.stop(serverInfo) + } catch (e) { + Log.error('mapi.server.stop.error', e) + throw new Error(e) + } +}) + +ipcMain.handle('server:config', async (event, serverInfo: ServerInfo) => { + const module = await getModule(serverInfo) + try { + return await module.config() + } catch (e) { + Log.error('mapi.server.config.error', e) + throw new Error(e) + } +}) + +ipcMain.handle('server:callFunction', async (event, serverInfo: ServerInfo, method: string, data: any) => { + // console.log('getModule.before', serverInfo, method) + const module = await getModule(serverInfo) + // console.log('getModule.end', serverInfo, method, module) + const func = module[method] + if (!func) { + throw new Error(`MethodNotFound : ${method}`) + } + try { + return await func.bind(module)(serverInfo, data) + } catch (e) { + Log.error('mapi.server.callFunction.error', e) + throw new Error(e) + } +}) + +export default { + init +} diff --git a/electron/mapi/server/render.ts b/electron/mapi/server/render.ts new file mode 100644 index 0000000..8bd94b6 --- /dev/null +++ b/electron/mapi/server/render.ts @@ -0,0 +1,29 @@ +import {ipcRenderer} from 'electron' + +const start = async (serverInfo: ServerInfo) => { + return ipcRenderer.invoke('server:start', serverInfo) +} + +const ping = async (serverInfo: ServerInfo) => { + return ipcRenderer.invoke('server:ping', serverInfo) +} + +const stop = async (serverInfo: ServerInfo) => { + return ipcRenderer.invoke('server:stop', serverInfo) +} + +const config = async (serverInfo: ServerInfo) => { + return ipcRenderer.invoke('server:config', serverInfo) +} + +const callFunction = async (serverInfo: ServerInfo, method: string, data: any) => { + return ipcRenderer.invoke('server:callFunction', serverInfo, method, data) +} + +export default { + start, + ping, + stop, + config, + callFunction, +} diff --git a/electron/mapi/server/type.d.ts b/electron/mapi/server/type.d.ts new file mode 100644 index 0000000..9ce7d44 --- /dev/null +++ b/electron/mapi/server/type.d.ts @@ -0,0 +1,9 @@ +interface ServerInfo { + localPath: string, + name: string, + version: string, + setting: { + [key: string]: any, + }, + logFile: string, +} diff --git a/electron/mapi/statistics/render.ts b/electron/mapi/statistics/render.ts new file mode 100644 index 0000000..a9f3862 --- /dev/null +++ b/electron/mapi/statistics/render.ts @@ -0,0 +1,52 @@ +import {AppConfig} from "../../../src/config"; +import {platformArch, platformName, platformUUID, platformVersion} from "../../lib/env"; +import {post} from "../../lib/api"; + +let tickDataList = [] + +let tickSendTimer = null + +const tickSendAsync = () => { + if (tickSendTimer) { + clearTimeout(tickSendTimer) + tickSendTimer = null + } + if (!AppConfig.statisticsUrl) { + tickDataList = [] + return + } + tickSendTimer = setTimeout(async () => { + tickSendTimer = null + if (!tickDataList.length) { + return + } + // console.log('tickSend', JSON.stringify(tickDataList)) + post(AppConfig.statisticsUrl, { + data: tickDataList, + version: AppConfig.version, + uuid: platformUUID(), + platform: { + name: platformName(), + version: platformVersion(), + arch: platformArch(), + } + }).then(res => { + // console.log('tickSend', tickDataList, res) + }).catch(err => { + // console.error('tickSend', tickDataList, err) + }) + tickDataList = [] + }, 2000) +} + +const tick = (name: string, data: any) => { + tickDataList.push({ + name, + data, + }) + tickSendAsync() +} + +export default { + tick +} diff --git a/electron/mapi/storage/main.ts b/electron/mapi/storage/main.ts new file mode 100644 index 0000000..fb4d30d --- /dev/null +++ b/electron/mapi/storage/main.ts @@ -0,0 +1,82 @@ +import path from "node:path"; +import {AppEnv, waitAppEnvReady} from "../env"; +import fs from "node:fs"; +import {ipcMain} from "electron"; + +let data = {} + +const configRoot = () => { + return path.join(AppEnv.userData, 'storage') +} + +const configPath = (group: string) => { + return path.join(configRoot(), `${group}.json`) +} + +const load = (group: string) => { + try { + const p = configPath(group) + let json = fs.readFileSync(p).toString() + json = JSON.parse(json) + data[group] = json || {} + } catch (e) { + data[group] = {} + } +} + +const loadIfNeed = (group: string) => { + if (!(group in data)) { + load(group) + } +} + +const save = (group: string) => { + const path = configPath(group) + if (!fs.existsSync(configRoot())) { + fs.mkdirSync(configRoot(), {recursive: true}) + } + fs.writeFileSync(path, JSON.stringify(data[group], null, 4)) +} + +const all = async (group: string) => { + await waitAppEnvReady() + loadIfNeed(group) + return data[group] +} + +const get = async (group: string, key: string, defaultValue: any) => { + await waitAppEnvReady() + loadIfNeed(group) + if (!(key in data[group])) { + data[group][key] = defaultValue + save(group) + } + return data[group][key] +} + +const set = async (group: string, key: string, value: any) => { + await waitAppEnvReady() + loadIfNeed(group) + data[group][key] = value + save(group) +} + +ipcMain.handle('storage:all', async (event, group: string) => { + return await all(group) +}) + +ipcMain.handle('storage:get', async (event, group: string, key: string, defaultValue: any) => { + return await get(group, key, defaultValue) +}) + +ipcMain.handle('storage:set', async (event, group: string, key: string, value: any) => { + return await set(group, key, value) +}) + +export const StorageMain = { + all, + get, + set +} + +export default StorageMain diff --git a/electron/mapi/storage/render.ts b/electron/mapi/storage/render.ts new file mode 100644 index 0000000..bae9885 --- /dev/null +++ b/electron/mapi/storage/render.ts @@ -0,0 +1,20 @@ +import {ipcRenderer} from "electron"; + + +const all = async (group: string) => { + return ipcRenderer.invoke('storage:all', group) +} + +const get = async (group: string, key: string, defaultValue: any) => { + return ipcRenderer.invoke('storage:get', group, key, defaultValue) +} + +const set = async (group: string, key: string, value: any) => { + return ipcRenderer.invoke('storage:set', group, key, value) +} + +export default { + all, + get, + set +} diff --git a/electron/mapi/ui/index.ts b/electron/mapi/ui/index.ts new file mode 100644 index 0000000..b1c6ea4 --- /dev/null +++ b/electron/mapi/ui/index.ts @@ -0,0 +1 @@ +export default {} diff --git a/electron/mapi/ui/render.ts b/electron/mapi/ui/render.ts new file mode 100644 index 0000000..a965cdb --- /dev/null +++ b/electron/mapi/ui/render.ts @@ -0,0 +1,133 @@ +const init = () => { + // initLoaders() +} + +const initLoaders = () => { + function domReady(condition: DocumentReadyState[] = ['complete', 'interactive']) { + return new Promise((resolve) => { + if (condition.includes(document.readyState)) { + resolve(true) + } else { + document.addEventListener('readystatechange', () => { + if (condition.includes(document.readyState)) { + resolve(true) + } + }) + } + }) + } + + const safeDOM = { + append(parent: HTMLElement, child: HTMLElement) { + if (!Array.from(parent.children).find(e => e === child)) { + return parent.appendChild(child) + } + }, + remove(parent: HTMLElement, child: HTMLElement) { + if (Array.from(parent.children).find(e => e === child)) { + return parent.removeChild(child) + } + }, + } + + /** + * https://tobiasahlin.com/spinkit + * https://connoratherton.com/loaders + * https://projects.lukehaas.me/css-loaders + * https://matejkustec.github.io/SpinThatShit + */ + function useLoading() { + const className = `loaders-css__square-spin` + const styleContent = ` +@keyframes loading-spin { + 33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%} + 50%{background-size:calc(100%/3) 100%,calc(100%/3) 0% ,calc(100%/3) 100%} + 66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0% } +} +.${className} > div { + width: 60px; + aspect-ratio: 4; + --_g: no-repeat radial-gradient(circle closest-side,#cbd5e1 90%,#cbd5e100); + background: + var(--_g) 0% 50%, + var(--_g) 50% 50%, + var(--_g) 100% 50%; + background-size: calc(100%/3) 100%; + animation: loading-spin 1s infinite linear; +} +.app-loading-wrap { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #FFFFFF; + z-index: 10000; +} +[data-theme="dark"] .app-loading-wrap { + background: #17171A; +} +[data-theme="dark"] .${className} > div { + --_g: no-repeat radial-gradient(circle closest-side,#2D3748 90%,#2D374800); +} + ` + const oStyle = document.createElement('style') + const oDiv = document.createElement('div') + let hasLoading = false + let setLoadingTimer = null + + oStyle.id = 'app-loading-style' + oStyle.innerHTML = styleContent + oDiv.className = 'app-loading-wrap' + oDiv.innerHTML = `
` + + return { + appendLoading() { + setLoadingTimer = setTimeout(() => { + safeDOM.append(document.head, oStyle) + safeDOM.append(document.body, oDiv) + hasLoading = true + }, 1000) + }, + removeLoading() { + clearTimeout(setLoadingTimer) + if (hasLoading) { + safeDOM.remove(document.head, oStyle) + safeDOM.remove(document.body, oDiv) + hasLoading = false + } + }, + } + } + + const {appendLoading, removeLoading} = useLoading() + + const isMain = () => { + return true; + let l = window.location.href + if (l.indexOf('app.asar/dist/index.html') > 0) { + return true + } + if (l.indexOf('localhost') > 0 && l.indexOf('.html') === -1) { + return true + } + return false + } + + if (isMain()) { + domReady().then(appendLoading) + window.onmessage = (ev) => { + ev.data.payload === 'removeLoading' && removeLoading() + } + } + + setTimeout(removeLoading, 4999) +} + + +export default { + init +} diff --git a/electron/mapi/updater/render.ts b/electron/mapi/updater/render.ts new file mode 100644 index 0000000..2b40136 --- /dev/null +++ b/electron/mapi/updater/render.ts @@ -0,0 +1,31 @@ +import {AppConfig} from "../../../src/config"; +import {platformArch, platformName, platformUUID, platformVersion} from "../../lib/env"; + +const checkForUpdate = async () => { + try { + const res = await fetch(AppConfig.updaterUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + version: AppConfig.version, + uuid: platformUUID(), + platform: { + name: platformName(), + version: platformVersion(), + arch: platformArch(), + } + }) + }) + return await res.json() + } catch (e) { + return { + code: -1, + msg: `Failed to check update : ${e.message}` + } + } +} +export default { + checkForUpdate, +} diff --git a/electron/mapi/user/main.ts b/electron/mapi/user/main.ts new file mode 100644 index 0000000..9fcec85 --- /dev/null +++ b/electron/mapi/user/main.ts @@ -0,0 +1,184 @@ +import {ipcMain, shell} from "electron"; +import {AppConfig} from "../../../src/config"; +import {ResultType} from "../../lib/api"; +import {Events} from "../event/main"; +import {platformUUID} from "../../lib/env"; +import {AppsMain} from "../app/main"; +import Apps from "../app"; +import StorageMain from "../storage/main"; + +const init = () => { + setTimeout(() => { + refresh().then() + }, 1000) + return null +} + +const userData = { + isInit: false, + apiToken: '', + user: { + id: '', + name: '', + avatar: '', + }, + data: {}, + basic: {}, +} + +const get = async (): Promise<{ + apiToken: string, + user: object, + data: {}, + basic: {}, +}> => { + if (!userData.isInit) { + const userStorageData = await StorageMain.get('user', 'data', {}) + userData.apiToken = userStorageData.apiToken || '' + userData.user = userStorageData.user || {} + userData.data = userStorageData.data || {} + userData.basic = userStorageData.basic || {} + userData.isInit = true + } + userData.user.id = userData.user.id || '' + return { + apiToken: userData.apiToken, + user: userData.user, + data: userData.data, + basic: userData.basic, + } +} + +ipcMain.handle('user:get', async (event) => { + return get() +}) + +const save = async (data: { + apiToken: string, + user: any, + data: any, + basic: {}, +}) => { + userData.apiToken = data.apiToken || '' + userData.user = data.user || {} + userData.data = data.data || {} + userData.user.id = userData.user.id || '' + Events.broadcast('UserChange', {}) + await StorageMain.set('user', 'data', { + apiToken: data.apiToken, + user: data.user, + data: data.data, + basic: data.basic, + }) +} + +ipcMain.handle('user:save', async (event, data) => { + return save(data) +}) + +const refresh = async () => { + const result = await userInfoApi() + await save({ + apiToken: result.data.apiToken, + user: result.data.user, + data: result.data.data, + basic: result.data.basic, + }) +} + +ipcMain.handle('user:refresh', async (event) => { + return refresh() +}) + +const getApiToken = async (): Promise => { + await get() + return userData.apiToken +} + +ipcMain.handle('user:getApiToken', async (event) => { + return getApiToken() +}) + +const getWebEnterUrl = async (url: string) => { + let param = [] + const apiToken = await getApiToken() + if (apiToken) { + param.push(`api_token=${apiToken}`) + } + if (await AppsMain.shouldDarkMode()) { + param.push(`is_dark=1`) + } + param.push(`device_uuid=${platformUUID()}`) + param.push(`url=${encodeURIComponent(url)}`) + return `${AppConfig.apiBaseUrl}/app_manager/enter?${param.join('&')}` +} + +ipcMain.handle('user:getWebEnterUrl', async (event, url) => { + return getWebEnterUrl(url) +}) + +const openWebUrl = async (url: string) => { + url = await getWebEnterUrl(url) + await shell.openExternal(url) +} + +ipcMain.handle('user:openWebUrl', async (event, url) => { + return openWebUrl(url) +}) + +const apiPost = async (url: string, data: any) => { + return post(url, data) +} + +ipcMain.handle('user:apiPost', async (event, url, data) => { + return apiPost(url, data) +}) + +export const User = { + init, + get, + save, + getApiToken, + getWebEnterUrl, + openWebUrl, +} + +export default User + +const post = async (api: string, data: Record): Promise> => { + const url = `${AppConfig.apiBaseUrl}/${api}` + const apiToken = await User.getApiToken() + const res = await fetch(url, { + method: 'POST', + headers: { + 'User-Agent': Apps.getUserAgent(), + 'Content-Type': 'application/json', + 'Api-Token': apiToken, + }, + body: JSON.stringify(data) + }) + const json = await res.json() + // console.log('post', JSON.stringify({api, data, json}, null, 2)) + if (json.code) { + // 未登录或登录过期 + if (json.code === 1001) { + await refresh() + } + throw json.msg + } + return json +} + +const userInfoApi = async (): Promise> => { + return await post('app_manager/user_info', {}) +} + +export const UserApi = { + post, + userInfoApi +} diff --git a/electron/mapi/user/render.ts b/electron/mapi/user/render.ts new file mode 100644 index 0000000..e387eb9 --- /dev/null +++ b/electron/mapi/user/render.ts @@ -0,0 +1,41 @@ +import page from "../page/render"; +import {ipcRenderer} from "electron"; +import AppsRender from "../app/render"; + +const open = async (option: any) => { + await AppsRender.windowOpen('user', option) +} + +const get = async (): Promise => { + return ipcRenderer.invoke('user:get') +} + +const refresh = async () => { + return ipcRenderer.invoke('user:refresh') +} + +const getApiToken = async (): Promise => { + return ipcRenderer.invoke('user:getApiToken') +} + +const getWebEnterUrl = async (url: string) => { + return ipcRenderer.invoke('user:getWebEnterUrl', url) +} + +const openWebUrl = async (url: string) => { + return ipcRenderer.invoke('user:openWebUrl', url) +} + +const apiPost = async (url: string, data: any) => { + return ipcRenderer.invoke('user:apiPost', url, data) +} + +export default { + open, + get, + refresh, + getApiToken, + getWebEnterUrl, + openWebUrl, + apiPost, +} diff --git a/electron/mapi/util.ts b/electron/mapi/util.ts new file mode 100644 index 0000000..04f7e60 --- /dev/null +++ b/electron/mapi/util.ts @@ -0,0 +1,13 @@ +import {contextBridge} from 'electron' + +export function exposeContext(key, value) { + if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld(key, value) + } catch (error) { + console.error(error) + } + } else { + window[key] = value + } +} diff --git a/electron/page/about.ts b/electron/page/about.ts new file mode 100644 index 0000000..2f44878 --- /dev/null +++ b/electron/page/about.ts @@ -0,0 +1,34 @@ +import {BrowserWindow} from "electron"; +import {preloadDefault} from "../lib/env-main"; +import {AppRuntime} from "../mapi/env"; +import {t} from "../config/lang"; +import {Page} from "./index"; +import {WindowConfig} from "../config/window"; + +export const PageAbout = { + NAME: 'about', + open: async (option: any) => { + const win = new BrowserWindow({ + title: t('关于'), + parent: AppRuntime.mainWindow, + minWidth: WindowConfig.aboutWidth, + minHeight: WindowConfig.aboutHeight, + width: WindowConfig.aboutWidth, + height: WindowConfig.aboutHeight, + webPreferences: { + preload: preloadDefault, + // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production + nodeIntegration: true, + webSecurity: false, + webviewTag: true, + // Consider using contextBridge.exposeInMainWorld + // Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation + contextIsolation: false, + }, + show: true, + frame: false, + transparent: false, + }); + return Page.openWindow(PageAbout.NAME, win, "page/about.html"); + } +} diff --git a/electron/page/guide.ts b/electron/page/guide.ts new file mode 100644 index 0000000..d9ba556 --- /dev/null +++ b/electron/page/guide.ts @@ -0,0 +1,69 @@ +import {BrowserWindow} from "electron"; +import {preloadDefault, rendererLoadPath} from "../lib/env-main"; +import {Page} from "./index"; +import {AppConfig} from "../../src/config"; +import {icnsLogoPath, icoLogoPath, logoPath} from "../config/icon"; +import {isPackaged} from "../lib/env"; +import {WindowConfig} from "../config/window"; +import * as remoteMain from "@electron/remote/main"; +import {DevToolsManager} from "../lib/devtools"; + +export const PageGuide = { + NAME: 'guide', + open: async (option: any) => { + let icon = logoPath + if (process.platform === 'win32') { + icon = icoLogoPath + } else if (process.platform === 'darwin') { + icon = icnsLogoPath + } + const win = new BrowserWindow({ + show: true, + title: AppConfig.name, + ...(!isPackaged ? {icon} : {}), + frame: false, + transparent: false, + hasShadow: true, + center: true, + useContentSize: true, + minWidth: WindowConfig.guideWidth, + minHeight: WindowConfig.guideHeight, + width: WindowConfig.guideWidth, + height: WindowConfig.guideHeight, + skipTaskbar: true, + resizable: false, + maximizable: false, + backgroundColor: '#f1f5f9', + alwaysOnTop: false, + webPreferences: { + preload: preloadDefault, + // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production + nodeIntegration: true, + webSecurity: false, + webviewTag: true, + // Consider using contextBridge.exposeInMainWorld + // Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation + contextIsolation: false, + }, + }) + + win.on('closed', () => { + Page.unregisterWindow(PageGuide.NAME) + }) + + rendererLoadPath(win, 'page/guide.html'); + + remoteMain.enable(win.webContents) + + win.webContents.on('did-finish-load', () => { + Page.ready('guide') + DevToolsManager.autoShow(win) + }) + DevToolsManager.register('Guide', win) + // win.webContents.setWindowOpenHandler(({url}) => { + // if (url.startsWith('https:')) shell.openExternal(url) + // return {action: 'deny'} + // }) + Page.registerWindow(PageGuide.NAME, win) + } +} diff --git a/electron/page/index.ts b/electron/page/index.ts new file mode 100644 index 0000000..e8d305b --- /dev/null +++ b/electron/page/index.ts @@ -0,0 +1,73 @@ +import {Events} from "../mapi/event/main"; +import {AppEnv, AppRuntime} from "../mapi/env"; +import {PageUser} from "./user"; +import {BrowserWindow, shell} from "electron"; +import {rendererLoadPath} from "../lib/env-main"; +import {PageGuide} from "./guide"; +import {PageSetup} from "./setup"; +import {PageAbout} from "./about"; +import {DevToolsManager} from "../lib/devtools"; + +const Pages = { + 'user': PageUser, + 'guide': PageGuide, + 'setup': PageSetup, + 'about': PageAbout, +} + +export const Page = { + ready(name: string) { + Events.send(name, 'APP_READY', { + name, + AppEnv + }) + }, + openWindow: (name: string, win: BrowserWindow, fileName: string) => { + win.webContents.on("will-navigate", (event) => { + event.preventDefault(); + }); + win.webContents.setWindowOpenHandler(() => { + return {action: "deny"}; + }); + win.webContents.setWindowOpenHandler(({url}) => { + if (url.startsWith('https:') || url.startsWith('http:')) { + shell.openExternal(url).then() + } + return {action: 'deny'} + }) + win.on('close', () => { + delete AppRuntime.windows[name] + }) + const promise = new Promise((resolve, reject) => { + win.webContents.on("did-finish-load", () => { + Page.ready(name); + DevToolsManager.autoShow(win) + resolve(undefined); + }); + }); + rendererLoadPath(win, fileName); + DevToolsManager.register(`Page.${name}`, win) + AppRuntime.windows[name] = win + return promise + }, + open: async (name: string, option?: { + singleton?: boolean, + }) => { + option = Object.assign({ + singleton: true + }, option) + if (option.singleton && AppRuntime.windows[name]) { + AppRuntime.windows[name].show() + AppRuntime.windows[name].focus() + return + } + return Pages[name].open(option) + }, + registerWindow(name: string, win: BrowserWindow) { + AppRuntime.windows[name] = win + }, + unregisterWindow(name: string) { + delete AppRuntime.windows[name] + } +} + diff --git a/electron/page/setup.ts b/electron/page/setup.ts new file mode 100644 index 0000000..a7d0ce5 --- /dev/null +++ b/electron/page/setup.ts @@ -0,0 +1,70 @@ +import {BrowserWindow} from "electron"; +import {preloadDefault, rendererLoadPath} from "../lib/env-main"; +import {Page} from "./index"; +import {AppConfig} from "../../src/config"; +import {icnsLogoPath, icoLogoPath, logoPath} from "../config/icon"; +import {isPackaged} from "../lib/env"; +import {WindowConfig} from "../config/window"; +import * as remoteMain from "@electron/remote/main"; +import {DevToolsManager} from "../lib/devtools"; + +export const PageSetup = { + NAME: 'setup', + open: async (option: any) => { + let icon = logoPath + if (process.platform === 'win32') { + icon = icoLogoPath + } else if (process.platform === 'darwin') { + icon = icnsLogoPath + } + const win = new BrowserWindow({ + show: true, + title: AppConfig.name, + ...(!isPackaged ? {icon} : {}), + frame: false, + transparent: false, + hasShadow: true, + center: true, + useContentSize: true, + minWidth: WindowConfig.guideWidth, + minHeight: WindowConfig.guideHeight, + width: WindowConfig.guideWidth, + height: WindowConfig.guideHeight, + skipTaskbar: true, + resizable: false, + maximizable: false, + backgroundColor: '#f1f5f9', + alwaysOnTop: false, + webPreferences: { + preload: preloadDefault, + // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production + nodeIntegration: true, + webSecurity: false, + webviewTag: true, + // Consider using contextBridge.exposeInMainWorld + // Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation + contextIsolation: false, + // sandbox: false, + }, + }) + + win.on('closed', () => { + Page.unregisterWindow(PageSetup.NAME) + }) + + rendererLoadPath(win, 'page/setup.html'); + + remoteMain.enable(win.webContents) + + win.webContents.on('did-finish-load', () => { + Page.ready('setup') + DevToolsManager.autoShow(win) + }) + DevToolsManager.register('Setup', win) + // win.webContents.setWindowOpenHandler(({url}) => { + // if (url.startsWith('https:')) shell.openExternal(url) + // return {action: 'deny'} + // }) + Page.registerWindow(PageSetup.NAME, win) + } +} diff --git a/electron/page/user.ts b/electron/page/user.ts new file mode 100644 index 0000000..59a13a5 --- /dev/null +++ b/electron/page/user.ts @@ -0,0 +1,33 @@ +import {BrowserWindow, shell} from "electron"; +import {preloadDefault} from "../lib/env-main"; +import {AppRuntime} from "../mapi/env"; +import {t} from "../config/lang"; +import {Page} from "./index"; +import {AppConfig} from "../../src/config"; +import {User} from "../mapi/user/main"; + +export const PageUser = { + NAME: 'user', + open: async (option: any) => { + const win = new BrowserWindow({ + title: t('用户中心'), + parent: AppRuntime.mainWindow, + minWidth: 700, + minHeight: 500, + width: 700, + height: 500, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + webSecurity: false, + preload: preloadDefault, + webviewTag: true, + }, + show: true, + frame: false, + center: true, + transparent: false, + }); + return Page.openWindow(PageUser.NAME, win, "page/user.html"); + } +} diff --git a/electron/preload/focusany.ts b/electron/preload/focusany.ts new file mode 100644 index 0000000..da23507 --- /dev/null +++ b/electron/preload/focusany.ts @@ -0,0 +1,400 @@ +import os from "os"; +import electronRemote from "@electron/remote"; +import path from "path"; +import {EncodeUtil, FileUtil, StrUtil, TimeUtil} from "../lib/util"; +import {ipcRenderer, SaveDialogOptions, shell} from 'electron' + +const ipcSendSync = (type: string, data?: any) => { + executeHook('Log', `${type}`, data) + const result = ipcRenderer.sendSync('FocusAny.Plugin', { + type, + data, + }); + executeHook('Log', `${type}.result`, result) + if (result instanceof Error) throw result; + return result; +}; + +const ipcSend = (type: string, data?: any) => { + ipcRenderer.send('FocusAny.Plugin', { + type, + data, + }); + executeHook('Log', `${type}`, data) +}; + +const ipcSendToHost = (type: string, data?: any, hasResult?: boolean): Promise => { + hasResult = hasResult || false + const id = StrUtil.randomString(16) + return new Promise((resolve, reject) => { + if (hasResult) { + const timeoutTimer = setTimeout(() => { + executeHook('Log', `${type}.timeout`) + ipcRenderer.removeAllListeners(`FocusAny.FastPanel.${id}`) + reject(new Error('timeout')) + }, 60 * 1000) + ipcRenderer.once(`FocusAny.FastPanel.${id}`, (_event, result) => { + executeHook('Log', `${type}.result`, result) + clearTimeout(timeoutTimer) + resolve(result) + }) + } + ipcRenderer.sendToHost('FocusAny.FastPanel', { + id, + type, + data, + }); + executeHook('Log', `${type}`, data) + if (!hasResult) { + resolve(null) + } + }) +} + +const executeHook = (hook: string, ...data: any[]) => { + hook = `on${hook}` + if (FocusAny.hooks[hook]) { + FocusAny.hooks[hook](...data) + } +} + +export const FocusAny = { + hooks: {} as any, + onPluginReady(cb: Function) { + FocusAny.hooks.onPluginReady = cb + }, + onPluginExit(cb: Function) { + FocusAny.hooks.onPluginExit = cb + }, + onLog(cb: Function) { + FocusAny.hooks.onLog = cb + }, + isMacOs() { + return os.type() === 'Darwin'; + }, + isWindows() { + return os.type() === 'Windows_NT'; + }, + isLinux() { + return os.type() === 'Linux'; + }, + getPlatformArch() { + return ipcSendSync('getPlatformArch'); + }, + isMainWindowShown(): boolean { + return ipcSendSync('isMainWindowShown'); + }, + hideMainWindow() { + ipcSend('hideMainWindow', {}) + }, + showMainWindow() { + ipcSend('showMainWindow', {}) + }, + isFastPanelWindowShown() { + return ipcSendSync('isFastPanelWindowShown'); + }, + showFastPanelWindow() { + ipcSend('showFastPanelWindow', {}) + }, + hideFastPanelWindow() { + ipcSend('hideFastPanelWindow', {}) + }, + showOpenDialog(options: { + title?: string, + defaultPath?: string, + buttonLabel?: string, + filters?: { name: string, extensions: string[] }[], + properties?: Array<'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles' | 'createDirectory' | 'promptToCreate' | 'noResolveAliases' | 'treatPackageAsDirectory' | 'dontAddToRecent'>, + message?: string, + securityScopedBookmarks?: boolean + }): (string[]) | (undefined) { + return ipcSendSync('showOpenDialog', options); + }, + showSaveDialog(options: SaveDialogOptions) { + return ipcSendSync('showSaveDialog', options); + }, + setExpendHeight(height: number) { + ipcSend('setExpendHeight', height); + }, + setSubInput(onChange: Function, placeholder: string = '', isFocus: boolean = true) { + if (typeof onChange === 'function') { + FocusAny.hooks.onSubInputChange = onChange + } + ipcSendSync('setSubInput', { + placeholder, + isFocus, + }); + }, + removeSubInput() { + delete FocusAny.hooks.onSubInputChange + ipcSendSync('removeSubInput'); + }, + setSubInputValue(text: string) { + ipcSendSync('setSubInputValue', {text}); + }, + subInputBlur() { + ipcSendSync('subInputBlur'); + }, + getPluginRoot() { + return ipcSendSync('getPluginRoot'); + }, + getPluginConfig() { + return ipcSendSync('getPluginConfig'); + }, + getPluginInfo() { + return ipcSendSync('getPluginInfo'); + }, + getPluginEnv(): 'dev' | 'prod' { + return ipcSendSync('getPluginEnv'); + }, + getQuery(requestId: string): SearchQuery { + return ipcSendSync('getQuery', {requestId}); + }, + getPath(name: 'home' | 'appData' | 'userData' | 'temp' | 'exe' | 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos' | 'logs') { + return ipcSendSync('getPath', {name}); + }, + showToast(body: string, options?: { + duration?: number, + status?: 'info' | 'success' | 'error' + }): void { + ipcSend('showToast', {body, options}); + }, + showNotification(body: string, clickActionName?: string) { + ipcSend('showNotification', {body, clickActionName}); + }, + showMessageBox(message: string, options: { + title?: string, + yes?: string, + no?: string, + }) { + options = options || {} + return ipcSendSync('showMessageBox', { + message, ...options + }); + }, + + copyImage(img: string) { + return ipcSendSync('copyImage', {img}); + }, + copyText(text: string) { + return ipcSendSync('copyText', {text}); + }, + copyFile(file: string | string[]) { + return ipcSendSync('copyFile', {file}); + }, + getClipboardText() { + return ipcSendSync('getClipboardText', {}); + }, + getClipboardImage() { + return ipcSendSync('getClipboardImage', {}); + }, + getClipboardFiles(): { + name: string, + pathname: string, + isDirectory: boolean, + size: number, + lastModified: number, + }[] { + return ipcSendSync('getClipboardFiles'); + }, + shellOpenExternal(url: string) { + shell.openExternal(url).then() + }, + shellOpenPath(path: string) { + shell.openPath(path).then() + }, + shellShowItemInFolder(path: string) { + ipcSend('shellShowItemInFolder', {path}); + }, + shellBeep() { + ipcSend('shellBeep'); + }, + getFileIcon(path: string) { + return ipcSendSync('getFileIcon', {path}); + }, + simulateKeyboardTap(key: string, ...modifier: any[]) { + ipcSend('simulateKeyboardTap', {key, modifier}); + }, + getCursorScreenPoint() { + return electronRemote.screen.getCursorScreenPoint(); + }, + getDisplayNearestPoint(point: { + x: number, + y: number + }) { + return electronRemote.screen.getDisplayNearestPoint(point); + }, + createBrowserWindow(url: string, options: BrowserWindow.InitOptions, callback?: () => void) { + // console.log('createBrowserWindow', JSON.stringify(url)) + const pluginRoot = this.getPluginRoot(); + // console.log('createBrowserWindow', JSON.stringify(url)) + let preloadPath = null + options = (options || {}) as BrowserWindow.InitOptions; + if (options.webPreferences && options.webPreferences.preload) { + preloadPath = path.join(pluginRoot, options.webPreferences.preload) + } + let win = new electronRemote.BrowserWindow({ + useContentSize: true, + resizable: true, + title: options.title || '新窗口', + show: true, + backgroundColor: '#fff', + ...options, + webPreferences: { + webSecurity: false, + backgroundThrottling: false, + contextIsolation: false, + webviewTag: true, + nodeIntegration: true, + spellcheck: false, + partition: null, + ...(options.webPreferences || {}), + preload: preloadPath, + }, + }); + if (url.startsWith('file://') || url.startsWith('http://') || url.startsWith('https://')) { + win.loadURL(url); + } else { + win.loadFile(url); + } + win.on('closed', () => { + win = undefined; + }); + win.once('ready-to-show', () => { + win.show(); + }); + win.webContents.on('dom-ready', () => { + callback && callback(); + }); + return win; + }, + screenCapture(cb: (imgBase64: string) => void): void { + FocusAny.hooks.onScreenCapture = (data: { + image: string + }) => { + // console.log('onScreenCapture', data) + cb && cb(data.image); + }; + ipcSendSync('screenCapture'); + }, + getNativeId(): string { + return ipcSendSync('getNativeId'); + }, + getAppVersion(): string { + return ipcSendSync('getAppVersion'); + }, + outPlugin() { + ipcSend('outPlugin'); + }, + isDarkColors() { + return ipcSendSync('isDarkColors'); + }, + redirect(keywordsOrAction: string | string[], query?: SearchQuery): void { + ipcSend('redirect', {keywordsOrAction, query}) + }, + getActions(names?: string[]): PluginAction[] { + return ipcSendSync('getActions', {names}); + }, + setAction(action: PluginAction | PluginAction[]): boolean { + return ipcSendSync('setAction', {action}); + }, + removeAction(name: string) { + return ipcSendSync('removeAction', {name}); + }, + + sendBackendEvent(event: string, data?: any, option?: { + timeout: number + }): Promise { + option = Object.assign({ + timeout: 10 * 1000, + }) + return new Promise((resolve, reject) => { + const id = StrUtil.randomString(16) + const timeoutTimer = setTimeout(() => { + ipcRenderer.removeAllListeners(`FocusAny.Event.${id}`) + reject(new Error('timeout')) + }, option.timeout) + ipcRenderer.once(`FocusAny.Event.${id}`, (_event, result) => { + clearTimeout(timeoutTimer) + resolve(result) + }) + ipcRenderer.send('FocusAny.Event', { + id, + event, + data, + }); + }) + }, + + db: { + put(doc: DbDoc) { + return ipcSendSync('dbPut', {doc}) + }, + get>(id: string): DbDoc | null { + return ipcSendSync('dbGet', {id}) + }, + remove(doc: string | DbDoc): DbReturn { + return ipcSendSync('dbRemove', {doc}) + }, + bulkDocs(docs: DbDoc[]): DbReturn[] { + return ipcSendSync('dbBulkDocs', {docs}) + }, + allDocs>(key?: string): DbDoc[] { + return ipcSendSync('dbAllDocs', {key}) + }, + postAttachment(docId: string, attachment: Buffer | Uint8Array, type: string): DbReturn { + return ipcSendSync('dbPostAttachment', { + docId, + attachment, + type + }) + }, + getAttachment(docId: string): Uint8Array | null { + return ipcSendSync('dbGetAttachment', {docId}) + }, + getAttachmentType(docId: string): string | null { + return ipcSendSync('dbGetAttachmentType', {docId}) + }, + }, + dbStorage: { + setItem(key: string, value: any) { + return ipcSendSync('dbStorageSetItem', {key, value}); + }, + getItem(key: string) { + return ipcSendSync('dbStorageGetItem', {key}); + }, + removeItem(key: string) { + return ipcSendSync('dbStorageRemoveItem', {key}); + }, + }, + + fastPanel: { + setHeight(height: number) { + ipcSendToHost('fastPanel.setHeight', {height}).then() + }, + getHeight(): Promise { + return ipcSendToHost('fastPanel.getHeight', {}, true) + } + }, + + util: { + randomString(length: number): string { + return StrUtil.randomString(length) + }, + bufferToBase64(buffer: Buffer): string { + return FileUtil.bufferToBase64(buffer) + }, + datetimeString(): string { + return TimeUtil.datetimeString() + }, + base64Encode(data: any): string { + return EncodeUtil.base64Encode(data) + }, + base64Decode(data: string): any { + return EncodeUtil.base64Decode(data) + }, + md5(data: string): string { + return EncodeUtil.md5(data) + } + }, +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts new file mode 100644 index 0000000..6fe2445 --- /dev/null +++ b/electron/preload/index.ts @@ -0,0 +1,162 @@ +import {ipcRenderer, webFrame} from 'electron' +import {MAPI} from "../mapi/render"; +import {FocusAny} from "./focusany"; + +webFrame.setZoomLevel(1) +webFrame.setVisualZoomLevelLimits(1, 1) +webFrame.setZoomFactor(1) + +// @ts-ignore +window['focusany'] = FocusAny + +MAPI.init() + +window['__page'] = { + hooks: {}, + onShow: (cb: Function) => { + window['__page'].hooks.onShow = cb + }, + onHide: (cb: Function) => { + window['__page'].hooks.onHide = cb + }, + onMaximize: (cb: Function) => { + window['__page'].hooks.onMaximize = cb + }, + onUnmaximize: (cb: Function) => { + window['__page'].hooks.onUnmaximize = cb + }, + onEnterFullScreen: (cb: Function) => { + window['__page'].hooks.onEnterFullScreen = cb + }, + onLeaveFullScreen: (cb: Function) => { + window['__page'].hooks.onLeaveFullScreen = cb + }, + broadcastListeners: {}, + onBroadcast: (type: string, cb: (data: any) => void) => { + if (!(type in window['__page'].broadcastListeners)) { + window['__page'].broadcastListeners[type] = [] + } + window['__page'].broadcastListeners[type].push(cb) + }, + offBroadcast: (type: string, cb: (data: any) => void) => { + if (!(type in window['__page'].broadcastListeners)) { + return + } + window['__page'].broadcastListeners[type] = window['__page'].broadcastListeners[type].filter(c => c !== cb) + }, + callPage: {}, + registerCallPage: ( + name: string, + cb: ( + resolve: (data: any) => void, + reject: (error: string) => void, + data: any + ) => void + ) => { + window['__page'].callPage[name] = cb + }, + channel: {}, + createChannel: (cb: (data: any) => void) => { + const channel = Math.random().toString(36).substring(2) + window['__page'].channel[channel] = cb + return channel + }, + destroyChannel: (channel: string) => { + delete window['__page'].channel[channel] + }, + + // + onPluginInit: (cb: Function) => { + window['__page'].hooks.onPluginInit = cb + }, + onPluginAlreadyOpened: (cb: Function) => { + window['__page'].hooks.onPluginAlreadyOpened = cb + }, + onPluginExit: (cb: Function) => { + window['__page'].hooks.onPluginExit = cb + }, + onPluginDetached: (cb: Function) => { + window['__page'].hooks.onPluginDetached = cb + }, + onPluginState: (cb: Function) => { + window['__page'].hooks.onPluginState = cb + }, + onSetSubInput: (cb: Function) => { + window['__page'].hooks.onSetSubInput = cb + }, + onRemoveSubInput: (cb: Function) => { + window['__page'].hooks.onRemoveSubInput = cb + }, + onSetSubInputValue: (cb: Function) => { + window['__page'].hooks.onSetSubInputValue = cb + }, +} + +ipcRenderer.removeAllListeners('MAIN_PROCESS_MESSAGE') +ipcRenderer.on('MAIN_PROCESS_MESSAGE', (_event: any, payload: any) => { + if ('APP_READY' === payload.type) { + MAPI.init(payload.data.AppEnv) + } else if ('CALL_PAGE' === payload.type) { + let {type, data, option} = payload.data + option = Object.assign({ + waitReadyTimeout: 10 * 1000, + }, option) + // console.log('CALL_PAGE', type, {type, data, option}) + const resultEventName = `event:callPage:${payload.id}` + const send = (code: number, msg: string, data?: any) => { + ipcRenderer.send(resultEventName, {code, msg, data}) + } + if (!window['__page'].callPage) { + send(-1, 'error') + return + } + const callPageExecute = () => { + window['__page'].callPage[type]( + (resultData: any) => send(0, 'ok', resultData), + (error: string) => send(-1, error), + data + ) + } + if (!window['__page'].callPage[type]) { + if (option.waitReadyTimeout > 0) { + const start = Date.now() + const monitor = () => { + setTimeout(() => { + if (!window['__page'].callPage[type]) { + if (Date.now() - start > option.waitReadyTimeout) { + send(-1, 'timeout') + return + } else { + monitor() + return + } + } else { + callPageExecute() + } + }, 10) + } + monitor() + return + } + send(-1, 'event not found') + return + } + callPageExecute() + } else if ('CHANNEL' === payload.type) { + const {channel, data} = payload.data + if (!window['__page'].channel || !window['__page'].channel[channel]) { + return + } + window['__page'].channel[channel](data) + } else if ('BROADCAST' === payload.type) { + const {type, data} = payload.data + if (window['__page'].broadcastListeners[type]) { + window['__page'].broadcastListeners[type].forEach((cb: Function) => { + cb(data) + }) + } + } else { + console.warn('UnknownMainProcessMessage', JSON.stringify(payload)) + } +}) + diff --git a/electron/preload/plugin.ts b/electron/preload/plugin.ts new file mode 100644 index 0000000..bad158f --- /dev/null +++ b/electron/preload/plugin.ts @@ -0,0 +1,132 @@ +import {FocusAny} from "./focusany"; +import {ipcRenderer, webFrame} from "electron"; + +webFrame.setZoomLevel(1) +webFrame.setVisualZoomLevelLimits(1, 1) +webFrame.setZoomFactor(1) + +// @ts-ignore +window['focusany'] = FocusAny + +window['__page'] = { + hooks: {}, + onShow: (cb: Function) => { + window['__page'].hooks.onShow = cb + }, + onHide: (cb: Function) => { + window['__page'].hooks.onHide = cb + }, + onMaximize: (cb: Function) => { + window['__page'].hooks.onMaximize = cb + }, + onUnmaximize: (cb: Function) => { + window['__page'].hooks.onUnmaximize = cb + }, + onEnterFullScreen: (cb: Function) => { + window['__page'].hooks.onEnterFullScreen = cb + }, + onLeaveFullScreen: (cb: Function) => { + window['__page'].hooks.onLeaveFullScreen = cb + }, + broadcastListeners: {}, + onBroadcast: (type: string, cb: (data: any) => void) => { + if (!(type in window['__page'].broadcastListeners)) { + window['__page'].broadcastListeners[type] = [] + } + window['__page'].broadcastListeners[type].push(cb) + }, + offBroadcast: (type: string, cb: (data: any) => void) => { + if (!(type in window['__page'].broadcastListeners)) { + return + } + window['__page'].broadcastListeners[type] = window['__page'].broadcastListeners[type].filter(c => c !== cb) + }, + callPage: {}, + registerCallPage: ( + name: string, + cb: ( + resolve: (data: any) => void, + reject: (error: string) => void, + data: any + ) => void + ) => { + window['__page'].callPage[name] = cb + }, + channel: {}, + createChannel: (cb: (data: any) => void) => { + const channel = Math.random().toString(36).substring(2) + window['__page'].channel[channel] = cb + return channel + }, + destroyChannel: (channel: string) => { + delete window['__page'].channel[channel] + }, + +} + +ipcRenderer.removeAllListeners('MAIN_PROCESS_MESSAGE') +ipcRenderer.on('MAIN_PROCESS_MESSAGE', (_event: any, payload: any) => { + if ('APP_READY' === payload.type) { + } else if ('CALL_PAGE' === payload.type) { + let {type, data, option} = payload.data + option = Object.assign({ + waitReadyTimeout: 10 * 1000, + }, option) + // console.log('CALL_PAGE', type, {type, data, option}) + const resultEventName = `event:callPage:${payload.id}` + const send = (code: number, msg: string, data?: any) => { + ipcRenderer.send(resultEventName, {code, msg, data}) + } + if (!window['__page'].callPage) { + send(-1, 'error') + return + } + const callPageExecute = () => { + window['__page'].callPage[type]( + (resultData: any) => send(0, 'ok', resultData), + (error: string) => send(-1, error), + data + ) + } + if (!window['__page'].callPage[type]) { + if (option.waitReadyTimeout > 0) { + const start = Date.now() + const monitor = () => { + setTimeout(() => { + if (!window['__page'].callPage[type]) { + if (Date.now() - start > option.waitReadyTimeout) { + send(-1, 'timeout') + return + } else { + monitor() + return + } + } else { + callPageExecute() + } + }, 10) + } + monitor() + return + } + send(-1, 'event not found') + return + } + callPageExecute() + } else if ('CHANNEL' === payload.type) { + const {channel, data} = payload.data + if (!window['__page'].channel || !window['__page'].channel[channel]) { + return + } + window['__page'].channel[channel](data) + } else if ('BROADCAST' === payload.type) { + const {type, data} = payload.data + if (window['__page'].broadcastListeners[type]) { + window['__page'].broadcastListeners[type].forEach((cb: Function) => { + cb(data) + }) + } + } else { + console.warn('UnknownMainProcessMessage', JSON.stringify(payload)) + } +}) diff --git a/electron/resources/build/appx/Square150x150Logo.png b/electron/resources/build/appx/Square150x150Logo.png new file mode 100644 index 0000000..7941cb4 Binary files /dev/null and b/electron/resources/build/appx/Square150x150Logo.png differ diff --git a/electron/resources/build/appx/Square44x44Logo.png b/electron/resources/build/appx/Square44x44Logo.png new file mode 100644 index 0000000..fc75e10 Binary files /dev/null and b/electron/resources/build/appx/Square44x44Logo.png differ diff --git a/electron/resources/build/appx/StoreLogo.png b/electron/resources/build/appx/StoreLogo.png new file mode 100644 index 0000000..4e6d399 Binary files /dev/null and b/electron/resources/build/appx/StoreLogo.png differ diff --git a/electron/resources/build/appx/Wide310x150Logo.png b/electron/resources/build/appx/Wide310x150Logo.png new file mode 100644 index 0000000..f60f37c Binary files /dev/null and b/electron/resources/build/appx/Wide310x150Logo.png differ diff --git a/electron/resources/build/entitlements.mac.plist b/electron/resources/build/entitlements.mac.plist new file mode 100644 index 0000000..273c351 --- /dev/null +++ b/electron/resources/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + + \ No newline at end of file diff --git a/electron/resources/build/logo-gray.png b/electron/resources/build/logo-gray.png new file mode 100644 index 0000000..259b821 Binary files /dev/null and b/electron/resources/build/logo-gray.png differ diff --git a/electron/resources/build/logo.icns b/electron/resources/build/logo.icns new file mode 100644 index 0000000..f7c0a7f Binary files /dev/null and b/electron/resources/build/logo.icns differ diff --git a/electron/resources/build/logo.ico b/electron/resources/build/logo.ico new file mode 100644 index 0000000..71959ba Binary files /dev/null and b/electron/resources/build/logo.ico differ diff --git a/electron/resources/build/logo.png b/electron/resources/build/logo.png new file mode 100644 index 0000000..4e6d399 Binary files /dev/null and b/electron/resources/build/logo.png differ diff --git a/electron/resources/build/logo_1024x1024.png b/electron/resources/build/logo_1024x1024.png new file mode 100644 index 0000000..72ed378 Binary files /dev/null and b/electron/resources/build/logo_1024x1024.png differ diff --git a/electron/resources/build/logo_1024x1024.psd b/electron/resources/build/logo_1024x1024.psd new file mode 100644 index 0000000..b64460f Binary files /dev/null and b/electron/resources/build/logo_1024x1024.psd differ diff --git a/electron/resources/build/logo_1024x1024.svg b/electron/resources/build/logo_1024x1024.svg new file mode 100644 index 0000000..37b7915 --- /dev/null +++ b/electron/resources/build/logo_1024x1024.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/electron/resources/extra/common/tray/icon.ico b/electron/resources/extra/common/tray/icon.ico new file mode 100644 index 0000000..71959ba Binary files /dev/null and b/electron/resources/extra/common/tray/icon.ico differ diff --git a/electron/resources/extra/common/tray/icon.png b/electron/resources/extra/common/tray/icon.png new file mode 100644 index 0000000..4e6d399 Binary files /dev/null and b/electron/resources/extra/common/tray/icon.png differ diff --git a/electron/resources/extra/mac/tray/iconTemplate.png b/electron/resources/extra/mac/tray/iconTemplate.png new file mode 100644 index 0000000..1fa6842 Binary files /dev/null and b/electron/resources/extra/mac/tray/iconTemplate.png differ diff --git a/electron/resources/extra/mac/tray/iconTemplate@2x.png b/electron/resources/extra/mac/tray/iconTemplate@2x.png new file mode 100644 index 0000000..184169e Binary files /dev/null and b/electron/resources/extra/mac/tray/iconTemplate@2x.png differ diff --git a/electron/resources/extra/mac/tray/iconTemplate@4x.png b/electron/resources/extra/mac/tray/iconTemplate@4x.png new file mode 100644 index 0000000..50fc24f Binary files /dev/null and b/electron/resources/extra/mac/tray/iconTemplate@4x.png differ diff --git a/electron/resources/extra/win/ScreenCapture.exe b/electron/resources/extra/win/ScreenCapture.exe new file mode 100644 index 0000000..af2e900 Binary files /dev/null and b/electron/resources/extra/win/ScreenCapture.exe differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..c8a0c86 --- /dev/null +++ b/index.html @@ -0,0 +1,37 @@ + + + + + + + %name% + + + + +
+
+
+ +
+
+
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..a98a39a --- /dev/null +++ b/package.json @@ -0,0 +1,114 @@ +{ + "name": "focusany-pro", + "version": "0.1.0-beta", + "main": "dist-electron/main/index.js", + "description": "FocusAnyPro", + "author": "ModStartLib", + "license": "AGPL-3.0", + "private": true, + "keywords": [ + "electron", + "rollup", + "vite", + "vue3", + "vue" + ], + "debug": { + "env": { + "VITE_DEV_SERVER_URL": "http://127.0.0.1:3344/" + } + }, + "type": "module", + "scripts": { + "dev": "vite", + "dev:pre": "export ELECTRON_ENV_PROD=1 && vite", + "dev:win": "chcp 65001 && vite", + "dev:debug": "vite --debug", + "build": "vite build && electron-builder", + "build:win": "vite build && electron-builder --win", + "build:mac": "vite build && electron-builder --mac", + "build:linux": "vite build && electron-builder --linux", + "preview": "vite preview", + "postinstall": "electron-builder install-app-deps", + "postuninstall": "electron-builder install-app-deps" + }, + "devDependencies": { + "@arco-design/web-vue": "^2.55.3", + "@types/lodash-es": "^4.17.12", + "@types/pouchdb": "^6.4.2", + "@types/splitpanes": "^2.2.6", + "@vitejs/plugin-vue": "^5.0.4", + "autoprefixer": "^10.4.19", + "electron": "^29.1.1", + "electron-builder": "^24.13.3", + "less": "^4.2.0", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.2", + "vite": "^5.1.5", + "vite-plugin-electron": "^0.28.4", + "vite-plugin-electron-renderer": "^0.14.5", + "vite-plugin-html": "^3.2.2", + "vue": "^3.4.21", + "vue-tsc": "^2.0.6" + }, + "dependencies": { + "@babel/runtime": "^7.24.8", + "@codemirror/commands": "^6.1.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-python": "^6.1.6", + "@codemirror/state": "^6.4.1", + "@devicefarmer/adbkit": "^3.2.6", + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/utils": "^3.0.0", + "@electron/remote": "^2.1.2", + "@gradio/client": "^1.7.0", + "@types/showdown": "^2.0.6", + "@uiw/codemirror-theme-dracula": "^4.23.0", + "@uiw/codemirror-theme-quietlight": "^4.23.0", + "@vue-flow/core": "^1.41.5", + "@vue-js-cron/light": "^4.0.9", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "axios": "^1.7.2", + "chardet": "^2.0.0", + "codemirror": "^6.0.1", + "crypto": "^1.0.1", + "date-and-time": "^3.4.1", + "dayjs": "^1.11.12", + "electron-clipboard-ex": "^1.3.3", + "electron-context-menu": "^4.0.4", + "extract-file-icon": "^0.3.2", + "ffmpeg-static": "^5.2.0", + "fix-path": "^4.0.0", + "get-windows": "^9.2.0", + "iconv": "^3.0.1", + "iconv-lite": "^0.6.3", + "js-base64": "^3.7.7", + "lodash-es": "^4.17.21", + "memorystream": "^0.3.1", + "node-window-manager": "^2.2.4", + "nodejs-base64": "^2.0.0", + "original-fs": "^1.2.0", + "pinia": "^2.1.7", + "pinyin-match": "^1.2.6", + "pouchdb": "^9.0.0", + "pouchdb-load": "^1.4.6", + "pouchdb-replication-stream": "^1.2.9", + "showdown": "^2.1.0", + "splitpanes": "^3.1.5", + "sqlite3": "^5.1.7", + "tiny-emitter": "^2.1.0", + "uiohook-napi": "^1.5.4", + "vue-command": "^35.2.1", + "vue-i18n": "^9.13.1", + "vue-router": "^4.4.0", + "wavesurfer.js": "^7.8.6", + "webdav": "^5.7.1", + "xgplayer": "^3.0.20", + "yauzl": "^3.1.3" + }, + "optionalDependencies": { + "node-mac-permissions": "^2.4.0" + } +} diff --git a/page/about.html b/page/about.html new file mode 100644 index 0000000..06227f5 --- /dev/null +++ b/page/about.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/detachWindow.html b/page/detachWindow.html new file mode 100644 index 0000000..6aa37fe --- /dev/null +++ b/page/detachWindow.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/fastPanel.html b/page/fastPanel.html new file mode 100644 index 0000000..825f23d --- /dev/null +++ b/page/fastPanel.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/guide.html b/page/guide.html new file mode 100644 index 0000000..6a5c958 --- /dev/null +++ b/page/guide.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/setup.html b/page/setup.html new file mode 100644 index 0000000..f1b6eb8 --- /dev/null +++ b/page/setup.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/store.html b/page/store.html new file mode 100644 index 0000000..2e0efd3 --- /dev/null +++ b/page/store.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/system.html b/page/system.html new file mode 100644 index 0000000..ae030bd --- /dev/null +++ b/page/system.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/user.html b/page/user.html new file mode 100644 index 0000000..e093e85 --- /dev/null +++ b/page/user.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/page/workflow.html b/page/workflow.html new file mode 100644 index 0000000..29e4aab --- /dev/null +++ b/page/workflow.html @@ -0,0 +1,14 @@ + + + + + + + %name% + + + +
+ + + diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..d41ad63 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/iconfont/iconfont.css b/public/iconfont/iconfont.css new file mode 100644 index 0000000..800a0ea --- /dev/null +++ b/public/iconfont/iconfont.css @@ -0,0 +1,71 @@ +@font-face { + font-family: "iconfont"; /* Project id 4733566 */ + src: url('iconfont.woff2?t=1732685155412') format('woff2'), + url('iconfont.woff?t=1732685155412') format('woff'), + url('iconfont.ttf?t=1732685155412') format('truetype'); +} + +.iconfont { + font-family: "iconfont" !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-text:before { + content: "\e959"; +} + +.icon-close-o:before { + content: "\e6a7"; +} + +.icon-pin:before { + content: "\e863"; +} + +.icon-store:before { + content: "\e670"; +} + +.icon-sound:before { + content: "\e62a"; +} + +.icon-desktop:before { + content: "\e8e9"; +} + +.icon-network:before { + content: "\e675"; +} + +.icon-avatar:before { + content: "\e604"; +} + +.icon-empty-box:before { + content: "\e620"; +} + +.icon-github:before { + content: "\e732"; +} + +.icon-gitee:before { + content: "\e601"; +} + +.icon-close:before { + content: "\e61b"; +} + +.icon-min:before { + content: "\e67a"; +} + +.icon-max:before { + content: "\e665"; +} + diff --git a/public/iconfont/iconfont.js b/public/iconfont/iconfont.js new file mode 100644 index 0000000..b16d752 --- /dev/null +++ b/public/iconfont/iconfont.js @@ -0,0 +1 @@ +window._iconfont_svg_string_4733566='',(t=>{var a=(l=(l=document.getElementsByTagName("script"))[l.length-1]).getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var c,e,i,o,s,n=function(a,l){l.parentNode.insertBefore(a,l)};if(a&&!t.__iconfont__svg__cssinject__){t.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}c=function(){var a,l=document.createElement("div");l.innerHTML=t._iconfont_svg_string_4733566,(l=l.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",l=l,(a=document.body).firstChild?n(l,a.firstChild):a.appendChild(l))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(c,0):(e=function(){document.removeEventListener("DOMContentLoaded",e,!1),c()},document.addEventListener("DOMContentLoaded",e,!1)):document.attachEvent&&(i=c,o=t.document,s=!1,d(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,h())})}function h(){s||(s=!0,i())}function d(){try{o.documentElement.doScroll("left")}catch(a){return void setTimeout(d,50)}h()}})(window); \ No newline at end of file diff --git a/public/iconfont/iconfont.json b/public/iconfont/iconfont.json new file mode 100644 index 0000000..697e51f --- /dev/null +++ b/public/iconfont/iconfont.json @@ -0,0 +1,107 @@ +{ + "id": "4733566", + "name": "FocusAny", + "font_family": "iconfont", + "css_prefix_text": "icon-", + "description": "", + "glyphs": [ + { + "icon_id": "924549", + "name": "text", + "font_class": "text", + "unicode": "e959", + "unicode_decimal": 59737 + }, + { + "icon_id": "257406", + "name": "close-o", + "font_class": "close-o", + "unicode": "e6a7", + "unicode_decimal": 59047 + }, + { + "icon_id": "34453257", + "name": "pin", + "font_class": "pin", + "unicode": "e863", + "unicode_decimal": 59491 + }, + { + "icon_id": "32804187", + "name": "store", + "font_class": "store", + "unicode": "e670", + "unicode_decimal": 58992 + }, + { + "icon_id": "663540", + "name": "sound", + "font_class": "sound", + "unicode": "e62a", + "unicode_decimal": 58922 + }, + { + "icon_id": "924409", + "name": "desktop", + "font_class": "desktop", + "unicode": "e8e9", + "unicode_decimal": 59625 + }, + { + "icon_id": "6537202", + "name": "network", + "font_class": "network", + "unicode": "e675", + "unicode_decimal": 58997 + }, + { + "icon_id": "30808030", + "name": "avatar", + "font_class": "avatar", + "unicode": "e604", + "unicode_decimal": 58884 + }, + { + "icon_id": "14027553", + "name": "empty-box", + "font_class": "empty-box", + "unicode": "e620", + "unicode_decimal": 58912 + }, + { + "icon_id": "7239764", + "name": "github", + "font_class": "github", + "unicode": "e732", + "unicode_decimal": 59186 + }, + { + "icon_id": "39287937", + "name": "gitee", + "font_class": "gitee", + "unicode": "e601", + "unicode_decimal": 58881 + }, + { + "icon_id": "1115039", + "name": "close", + "font_class": "close", + "unicode": "e61b", + "unicode_decimal": 58907 + }, + { + "icon_id": "1649166", + "name": "min", + "font_class": "min", + "unicode": "e67a", + "unicode_decimal": 59002 + }, + { + "icon_id": "1818719", + "name": "max", + "font_class": "max", + "unicode": "e665", + "unicode_decimal": 58981 + } + ] +} diff --git a/public/iconfont/iconfont.ttf b/public/iconfont/iconfont.ttf new file mode 100644 index 0000000..93badb3 Binary files /dev/null and b/public/iconfont/iconfont.ttf differ diff --git a/public/iconfont/iconfont.woff b/public/iconfont/iconfont.woff new file mode 100644 index 0000000..ec0e14d Binary files /dev/null and b/public/iconfont/iconfont.woff differ diff --git a/public/iconfont/iconfont.woff2 b/public/iconfont/iconfont.woff2 new file mode 100644 index 0000000..d62f40b Binary files /dev/null and b/public/iconfont/iconfont.woff2 differ diff --git a/public/loading.svg b/public/loading.svg new file mode 100644 index 0000000..2d82ccc --- /dev/null +++ b/public/loading.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/logo-white.svg b/public/logo-white.svg new file mode 100644 index 0000000..1c94c2f --- /dev/null +++ b/public/logo-white.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..1c94c2f --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/public/setup/accessibility.png b/public/setup/accessibility.png new file mode 100644 index 0000000..6926ecc Binary files /dev/null and b/public/setup/accessibility.png differ diff --git a/public/setup/screen.png b/public/setup/screen.png new file mode 100644 index 0000000..c9379cd Binary files /dev/null and b/public/setup/screen.png differ diff --git a/release/entitlements.mac.plist b/release/entitlements.mac.plist new file mode 100644 index 0000000..e370213 --- /dev/null +++ b/release/entitlements.mac.plist @@ -0,0 +1,24 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.accessibility + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.input-monitor + + com.apple.security.automation.apple-events + + com.apple.security.device.audio-input + + + diff --git a/screenshots/cn/home.png b/screenshots/cn/home.png new file mode 100644 index 0000000..b917165 Binary files /dev/null and b/screenshots/cn/home.png differ diff --git a/scripts/build_optimize.cjs b/scripts/build_optimize.cjs new file mode 100644 index 0000000..a4ed5c8 --- /dev/null +++ b/scripts/build_optimize.cjs @@ -0,0 +1,73 @@ +const fs = require("fs"); +const {resolve} = require("node:path"); + +const platformName = () => { + switch (process.platform) { + case "darwin": + return "mac"; + case "win32": + return "win"; + case "linux": + return "linux"; + } + return null; +} + +const platformArch = () => { + switch (process.arch) { + case "x64": + return "x64"; + case "arm64": + return "arm64"; + } + return null; +} + +const listFiles = (dir, recursive) => { + recursive = recursive || false + const files = fs.readdirSync(dir); + const list = []; + for (let f of files) { + const p = resolve(dir, f); + const stat = fs.statSync(p); + list.push({ + isDir: stat.isDirectory(), + path: p + }); + if (recursive && stat.isDirectory()) { + list.push(...listFiles(p, recursive)); + } + } + return list; +} + +console.log('BuildOptimize', { + name: platformName(), + arch: platformArch(), +}); + +exports.default = async function (context) { + console.log('BuildOptimize.output', { + context: context, + root: context.appOutDir + }) + listFiles(context.appOutDir, true).forEach((p) => { + // console.log('BuildOptimize.path', (p.isDir ? 'D:' : 'F:') + p.path); + }) + // const localeDir = context.appOutDir + "/FocusAny.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Resources/"; + // console.log(`localeDir: ${localeDir}`); + // fs.readdir(localeDir, function (err, files) { + // if (!(files && files.length)) { + // return; + // } + // for (let f of files) { + // if (f.endsWith('.lproj')) { + // if (!(f.startsWith("en") || f.startsWith("zh"))) { + // const p = localeDir + f; + // console.log(`removeFile: ${p}`); + // fs.rmdirSync(p, {recursive: true}); + // } + // } + // } + // }); +}; diff --git a/scripts/icon_convert.sh b/scripts/icon_convert.sh new file mode 100755 index 0000000..a260dd6 --- /dev/null +++ b/scripts/icon_convert.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# prepare +# brew install --cask inkscape + +echo "Convert icon" + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT=$(realpath "${DIR}/..") +echo "PROJECT_ROOT: ${PROJECT_ROOT}" + +path_build="${PROJECT_ROOT}/electron/resources/build" +path_extra="${PROJECT_ROOT}/electron/resources/extra" +path_source_png="${path_build}/logo_1024x1024.png" + +size=(16 32 44 48 64 128 150 256 512) +for i in "${size[@]}"; do + path_png="${path_build}/logo@${i}x$i.png" + echo "Generate: logo@${i}x$i.png" + inkscape --export-type="png" --export-filename="${path_png}" -w $i -h $i "${path_source_png}" +done + +path_ico="${path_build}/logo.ico" +echo "Generate: logo.ico" +magick "${path_source_png}" -define icon:auto-resize=256,48,32,16 "${path_ico}" + +echo "Generate: logo.png" +rm -rf "${path_build}/logo.png" +cp -a "${path_build}/logo@256x256.png" "${path_build}/logo.png" + +echo "Generate: logo.icns" +path_iconset="${path_build}/icon.iconset" +rm -rf "${path_iconset}" +mkdir -p "${path_iconset}" +cp -a "${path_build}/logo@256x256.png" "${path_iconset}/icon_256x256.png" +cp -a "${path_build}/logo@32x32.png" "${path_iconset}/icon_32x32.png" +cp -a "${path_build}/logo@16x16.png" "${path_iconset}/icon_16x16.png" +iconutil -c icns "${path_iconset}" -o "${path_build}/logo.icns" + +echo "Generate: appx/StoreLogo.png" +cp -a "${path_build}/logo@256x256.png" "${path_build}/appx/StoreLogo.png" +echo "Generate: appx/Square44x44Logo.png" +cp -a "${path_build}/logo@44x44.png" "${path_build}/appx/Square44x44Logo.png" +echo "Generate: appx/Square150x150Logo.png" +cp -a "${path_build}/logo@150x150.png" "${path_build}/appx/Square150x150Logo.png" +echo "Generate: appx/Wide310x150Logo.png" +magick "${path_build}/logo@150x150.png" -resize 310x150 -background none -gravity center -extent 310x150 "${path_build}/appx/Wide310x150Logo.png" + +echo "Generate: common/tray/icon.png" +mkdir -p "${path_extra}/common/tray" +cp -a "${path_build}/logo@256x256.png" "${path_extra}/common/tray/icon.png" +echo "Generate: common/tray/icon.ico" +cp -a "${path_build}/logo.ico" "${path_extra}/common/tray/icon.ico" + +echo "Generate: mac/tray/iconTemplate.png" +mkdir -p "${path_extra}/mac/tray" +magick "${path_build}/logo-gray.png" -resize 16x16 -background none -gravity center "${path_extra}/mac/tray/iconTemplate.png" +echo "Generate: mac/tray/iconTemplate@2x.png" +magick "${path_build}/logo-gray.png" -resize 32x32 -background none -gravity center "${path_extra}/mac/tray/iconTemplate@2x.png" +echo "Generate: mac/tray/iconTemplate@4x.png" +magick "${path_build}/logo-gray.png" -resize 64x64 -background none -gravity center "${path_extra}/mac/tray/iconTemplate@4x.png" + +rm -rf "${path_iconset}" +rm -rf ${path_build}/logo@* diff --git a/scripts/notarize.cjs b/scripts/notarize.cjs new file mode 100644 index 0000000..66b9f6a --- /dev/null +++ b/scripts/notarize.cjs @@ -0,0 +1,18 @@ +const {notarize} = require('electron-notarize'); + +exports.default = async function notarizing(context) { + const {electronPlatformName, appOutDir} = context; + + if (electronPlatformName !== 'darwin') { + return; + } + + const appName = context.packager.appInfo.productFilename; + + return await notarize({ + appBundleId: 'com.focusany', + appPath: `${appOutDir}/${appName}.app`, + appleId: 'your-apple-id@example.com', + appleIdPassword: 'your-app-specific-password', + }); +}; diff --git a/sdk/.gitignore b/sdk/.gitignore new file mode 100644 index 0000000..aa1ec1e --- /dev/null +++ b/sdk/.gitignore @@ -0,0 +1 @@ +*.tgz diff --git a/sdk/.npmignore b/sdk/.npmignore new file mode 100644 index 0000000..7e47aaa --- /dev/null +++ b/sdk/.npmignore @@ -0,0 +1,2 @@ +example/ +*.tgz diff --git a/sdk/config.schema.json b/sdk/config.schema.json new file mode 100644 index 0000000..f65b477 --- /dev/null +++ b/sdk/config.schema.json @@ -0,0 +1,261 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://focusany.com/doc/config.json", + "title": "FocusAny 插件配置文件", + "description": "FocusAny 插件配置文件,用于描述插件的基本信息,以及插件的入口文件。", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "插件名称,此为必选项,且插件应用内不可重复。" + }, + "version": { + "type": "string", + "description": "插件版本,此为必选项。" + }, + "platform": { + "type": "string", + "description": "支持的平台,此为选填,留空表示支持所有平台。", + "enum": [ + "win", + "osx", + "linux" + ] + }, + "versionRequire": { + "type": "string", + "description": "FocusAny 版本要求,如 * 或 >=1.0.0 或 <=1.0.0 或 >1.0.0 或 <1.0.0,此为选填,留空表示不限制 FocusAny 版本。" + }, + "title": { + "type": "string", + "description": "插件标题,此为必选项。" + }, + "logo": { + "type": "string", + "description": "插件图标,支持png,jpg,svg格式" + }, + "description": { + "type": "string", + "description": "插件描述" + }, + "main": { + "type": "string", + "description": "入口文件,当该配置为空时,表示插件应用为模板插件应用。 main 与 preload 至少存在其一。" + }, + "mainFastPanel": { + "type": "string", + "description": "快速面板入口文件,当该配置为空时,使用主入口文件。" + }, + "preload": { + "type": "string", + "description": "这是一个关键文件,你可以在此文件内调用 FocusAny、 nodejs、 electron 提供的 api。 main 与 preload 至少存在其一。" + }, + "author": { + "type": "string", + "description": "插件作者" + }, + "homepage": { + "type": "string", + "description": "插件主页" + }, + "actions": { + "type": "array", + "description": "actions 描述了当 FocusAny 主输入框内容产生变化时,此插件应用是否显示在搜索结果列表中,一个插件应用可以有多个功能,一个功能可以提供多个命令供用户搜索。", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "插件应用提供的某个功能的唯一标示,此为必选项,且插件应用内不可重复。" + }, + "title": { + "type": "string", + "description": "对此功能的说明,将在搜索列表对应位置中显示。" + }, + "icon": { + "type": "string", + "description": "此功能的图标,支持png,jpg,svg格式。" + }, + "type": { + "type": "string", + "description": "此功能的类型", + "enum": [ + "command", + "web", + "code", + "backend", + "view" + ] + }, + "platform": { + "type": "array", + "description": "支持的平台,此为选填,留空表示支持所有平台。", + "items": { + "type": "string", + "enum": [ + "win", + "osx", + "linux" + ] + } + }, + "matches": { + "type": "array", + "description": "该功能下可响应的命令集,支持 6 种类型,由 matches 的类型或 matches.type 决定。", + "items": { + "properties": { + "type": { + "type": "string", + "description": "类型", + "enum": [ + "text", + "key", + "regex", + "file", + "image", + "window" + ] + }, + "name": { + "type": "string", + "description": "匹配名称" + }, + "text": { + "type": "string", + "description": "匹配文本" + }, + "key": { + "type": "string", + "description": "匹配键值" + }, + "regex": { + "type": "string", + "description": "匹配正则" + }, + "minLength": { + "type": "number", + "description": "最小匹配长度", + "minimum": 1 + }, + "maxLength": { + "type": "number", + "description": "最大匹配长度", + "minimum": 1, + "maximum": 10000 + }, + "minCount": { + "type": "number", + "description": "最小匹配数量", + "minimum": 1 + }, + "maxCount": { + "type": "number", + "description": "最大匹配数量", + "minimum": 1, + "maximum": 10000 + }, + "filterFileType": { + "type": "string", + "description": "文件类型", + "enum": [ + "file", + "directory" + ] + }, + "filterExtensions": { + "type": "array", + "description": "文件扩展名", + "items": { + "type": "string" + } + }, + "nameRegex": { + "type": "string", + "description": "匹配正则" + }, + "titleRegex": { + "type": "string", + "description": "匹配正则" + }, + "attrRegex": { + "type": "object", + "description": "匹配正则" + } + } + } + } + } + } + }, + "development": { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "开发环境,prod 表示生产环境,dev 表示开发环境,prod 环境会忽略 development 的所有配置。", + "enum": [ + "prod", + "dev" + ] + }, + "main": { + "type": "string", + "description": "入口文件,当该配置为空时,表示插件应用为模板插件应用。 main 与 preload 至少存在其一。" + }, + "mainFastPanel": { + "type": "string", + "description": "快速面板入口文件,当该配置为空时,使用主入口文件。" + }, + "releaseDoc": { + "type": "string", + "description": "更新日志文档,参照插件选择根目录,使用 markdown 格式,格式为【## x.x.x 功能特性[换行][换行]更新内容详情】使用 --- 分割多个。" + }, + "contentDoc": { + "type": "string", + "description": "插件内容文档,参照插件选择根目录,使用 markdown 格式。" + }, + "previewDoc": { + "type": "string", + "description": "插件预览文档,参照插件选择根目录,使用 markdown 格式,每行一个图片链接。" + } + } + }, + "setting": { + "type": "object", + "properties": { + "autoDetach": { + "type": "boolean", + "description": "是否默认分离模式打开" + }, + "height": { + "type": "string", + "description": "窗口高度,支持 数字 或 百分比,设置后窗口大小将默认为分离模式" + }, + "width": { + "type": "string", + "description": "窗口宽度,支持 数字 或 百分比,设置后窗口大小将默认为分离模式" + }, + "keepCodeDevTools": { + "type": "boolean", + "description": "是否在执行完成后保留开发者窗口(便于调试)" + }, + "singleton": { + "type": "boolean", + "description": "是否只允许打开一个窗口" + }, + "zoom": { + "type": "number", + "description": "窗口缩放比例,100表示原始大小" + }, + "preloadBase": { + "type": "string", + "description": "基础预加载文件,普通插件应用不需要设置" + }, + "nodeIntegration": { + "type": "boolean", + "description": "是否启用 nodejs,普通应用不需要设置" + } + } + } + } +} diff --git a/sdk/electron-browser-window.d.ts b/sdk/electron-browser-window.d.ts new file mode 100644 index 0000000..75047df --- /dev/null +++ b/sdk/electron-browser-window.d.ts @@ -0,0 +1,211 @@ +declare module BrowserWindow { + interface WebPreferences { + devTools?: boolean + preload?: string + zoomFactor: number + [key: string]: any + } + + interface InitOptions { + width?: number, + height?: number, + webPreferences: WebPreferences + show?: boolean + title?: string + x?: number + y?: number + center?: boolean + resizable?: boolean + fullscreen?: boolean + fullscreenable?: boolean + skipTaskbar?: true + closable?: boolean + frame?: boolean + alwayOnTop?: boolean + [key: string]: any + } + + interface NativeImage { + toPng: (options?: { scaleFator?: number }) => Uint8Array + toJPEG: (options?: { quality?: number }) => Uint8Array + isEmpty: () => boolean + [key: string]: any + } + + interface PrinterSync { + description: string + displayName: string + isDefault: boolean + status: number + options?: { + 'printer-location'?: string + 'printer-make-and-model'?: string + 'system_driverinfo'?: string + } + } + + type WebRTCIPHandlingPolicy = + | 'default' + | 'default_public_interface_only' + | 'default_public_and_private_interfaces' + | 'disable_non_proxied_udp' + + interface WebContents { + id: number + capturePage: () => Promise + closeDevTools: () => void + copy: () => void + copyImageAt: (x: number, y: number) => void + cut: () => void + /** + * @deprecated + */ + decrementCapturerCount: () => any + delete: () => void + disableDeviceEmulation: () => void + enableDeviceEmulation: () => void + executeJavaScript: (code: string, userGesture?: boolean) => Promise + findInPage: (text: string, options?: { + forward?: boolean + findNext?: boolean + matchCase?: boolean + }) => number + focus: () => void + getBackgroundThrottling: () => boolean + getFrameRate: () => number + getOSProcessId: () => number + getPrinters: () => PrinterSync[] + getProcessId: () => number + getUserAgent: () => string + getWebRTCIPHandlingPolicy: () => WebRTCIPHandlingPolicy + getZoomFactor: () => number + /** + * @deprecated + */ + incrementCapturerCount: () => any + insertCSS: (css: string, options?: { + /** + * @default 'author' + */ + cssOrigin?: 'user' | 'author' + }) => Promise + insertText: (text: string) => Promise + invalidate: () => void + isAudioMuted: () => boolean + isBeingCaptured: () => boolean + isCrashed: () => boolean + isCurrentlyAudible: () => boolean + isDestroyed: () => boolean + isDevToolsFocused: () => boolean + isDevToolsOpened: () => boolean + isFocused: () => boolean + isLoading: () => boolean + isLoadingMainFrame: () => boolean + isOffscreen: () => boolean + isPainting: () => void + isWaitingForResponse: () => boolean + openDevTools: (options?: { + mode: 'left' | 'right' | 'bottom' | 'undocked' | 'detach' + activate?: boolean + title?: string + }) => void + paste: () => void + pasteAndMatchStyle: () => void + print: (options?: Record, callback?: (success: boolean, errorType?: string) => void) => void + printToPDF: (options: Record) => Promise + redo: () => void + removeInsertedCSS: (key: string) => Promise + replace: (text: string) => void + replaceMisspelling: (text: string) => void + savePage: (fullPath: string, saveType: 'HTMLOnly' | 'HTMLComplete' | 'MHTML') => Promise + selectAll: () => void + sendInputEvent: (e: any) => void + setAudioMuted: (muted: boolean) => void + setBackgroundThrottling: (allowed: boolean) => void + setFrameRate: (fps: number) => void + setIgnoreMenuShortcuts: (ignore: boolean) => void + setUserAgent: (userAgent: string) => void + setWebRTCIPHandlingPolicy: (policy: WebRTCIPHandlingPolicy) => void + setZoomFactor: (factor: number) => void + startPainting: () => void + stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void + stopPainting: () => void + takeHeapSnapshot: (filePath: string) => Promise + toggleDevTools: () => void + undo: () => void + unselect: () => void + + [key: string]: any + } + + interface Rectangle { + x: number + y: number + width: number + height: number + } + + interface WindowInstance { + id: number + webContents: WebContents + show: () => void + hide: () => void + destory: () => void + close: () => void + isFocused: () => boolean + isDestroyed: () => boolean + setResizable: (resizable: boolean) => void + setSize: (width: number, height: number) => void + getSize: () => [width: number, height: number] + isVisible: () => boolean + maximize: () => void + unmaximize: () => void + isMaximized: () => void + minimize: () => void + restore: () => void + isMinimized: () => boolean + setFullScreen: (flag: boolean) => void + isFullScreen: () => boolean + isNormal: () => boolean + setAspectRatio: (aspectiRotio: number) => void + setBackgroundColor: (backgroundColor: string) => void + getBounds: () => Rectangle + getBackgroundColor: () => string + setContentBounds: (bounds: Rectangle) => void + getContentBounds: () => Rectangle + getNormalBounds: () => Rectangle + setEnabled: (enable: boolean) => void + isEnabled: () => boolean + setContentSize: (width: number, height: number) => void + getContentSize: () => [width: number, height: number] + setMinimumSize: (width: number, height: number) => void + getMinimumSize: () => [width: number, height: number] + setMaximumSize: (width: number, height: number) => void + getMaximumSize: () => [width: number, height: number] + isResizable: () => boolean + setFullScreenable: (fullscreenable: boolean) => void + isFullScreenable: () => boolean + setClosable: (closable: boolean) => void + isClosable: () => boolean + setAlwaysOnTop: (flag: boolean) => void + isAlwaysOnTop: () => boolean + moveTop: () => void + setPosition: (x: number, y: number) => void + getPosition: () => [x: number, y: number] + setTitle: (title: string) => void + getTitle: () => string + flashFrame: (flag: boolean) => void + setKiosk: (flag: boolean) => void + isKiosk: () => boolean + focusOnWebView: () => void + blurWebView: () => void + capturePage: (rect?: Rectangle, options?: { + stayHidden?: boolean + stayAwake?: boolean + }) => Promise + reload: () => void + + [key: string]: any + } +} + diff --git a/sdk/electron.d.ts b/sdk/electron.d.ts new file mode 100644 index 0000000..fd7500b --- /dev/null +++ b/sdk/electron.d.ts @@ -0,0 +1,66 @@ +declare module "electron" { + type ClipboardType = 'selection' | 'clipboard' + module clipboard { + function availableFormats(type?: ClipboardType): void + function clear(type?: ClipboardType): void + function has(fmt: string, type?: ClipboardType): boolean + function read(fmt: string): string + function readBookmark(): { + title: string + url: string + } + + function readBuffer(fmt: string): Uint8Array + function readHTML(type?: ClipboardType): string + function readImage(type?: ClipboardType): BrowserWindow.NativeImage + function readRTF(type?: ClipboardType): string + function readText(type?: ClipboardType): string + function write(data: { + text?: string + html?: string + image?: BrowserWindow.NativeImage + rtf?: string + bookmark?: string + }, type?: ClipboardType): void + function writeBookmark(title: string, url: string, type?: ClipboardType): void + function writeBuffer(fmt: string, buffer: Uint8Array, type?: ClipboardType): void + function writeHTML(markup: string, type?: ClipboardType): void + function writeImage(img: BrowserWindow.NativeImage, type?: ClipboardType): void + function writeRTF(text: string, type?: ClipboardType): void + function writeText(text: string, type?: ClipboardType): void + } + interface UIpcSendEventInit { + senderId: number + } + type UIpcSendEventListener = (event: UIpcSendEventInit, ...args: T) => void + module ipcRenderer { + function on(channel: string, listener: UIpcSendEventListener): void + function once(channel: string, listener: UIpcSendEventListener): void + function off(channel: string, listener: UIpcSendEventListener): void + function sendTo(id: number, channel: string, ...args: T): void + } + + module contextBridge { } + + module webFrame { } + + module shell { } + + module nativeImage { + type NativeImage = BrowserWindow.NativeImage + function createEmpty(): NativeImage + function createFromPath(path: string): NativeImage + function createFromBitmap(buffer: Uint8Array, options: { + width: number + height: number + scaleFator?: number + }): NativeImage + function createFromBuffer(buffer: Uint8Array, options?: { + width?: number + height?: number + scaleFator?: number + }): NativeImage + function createFromDataURL(dataURL: string): NativeImage + + } +} diff --git a/sdk/focusany.d.ts b/sdk/focusany.d.ts new file mode 100644 index 0000000..b560215 --- /dev/null +++ b/sdk/focusany.d.ts @@ -0,0 +1,277 @@ +/// +/// + +declare interface Window { + focusany: FocusAnyApi +} + + +type DbDoc> = { + _id: string, + _rev?: string, +} & T + +interface DbReturn { + id: string, + rev?: string, + ok?: boolean, + error?: boolean, + name?: string, + message?: string +} + +declare type PlatformType = 'win' | 'osx' | 'linux' + +declare type ActionMatch = ( + ActionMatchText + | ActionMatchKey + | ActionMatchRegex + | ActionMatchFile + | ActionMatchImage + | ActionMatchWindow + ) + +declare enum ActionMatchTypeEnum { + TEXT = 'text', + KEY = 'key', + REGEX = 'regex', + IMAGE = 'image', + FILE = 'file', + WINDOW = 'window', +} + +type SearchQuery = { + keywords: string, + currentFiles?: ClipboardFileItem[], + currentImage?: string, + currentText?: string, +} + +type ClipboardFileItem = { + name: string, + isDirectory: boolean, + isFile: boolean, + path: string, + fileExt: string, +} + +declare type ActionMatchBase = { + type: ActionMatchTypeEnum, + name?: string, +} + +declare type ActionMatchText = ActionMatchBase & { + text: string, + minLength: number, + maxLength: number, +} + +declare type ActionMatchKey = ActionMatchBase & { + key: string, +} + +declare type ActionMatchRegex = ActionMatchBase & { + regex: string, + title: string, + minLength: number, + maxLength: number, +} + +declare type ActionMatchFile = ActionMatchBase & { + title: string, + minCount: number, + maxCount: number, + filterFileType: 'file' | 'directory', + filterExtensions: string[], +} + +declare type ActionMatchImage = ActionMatchBase & { + title: string, +} + +declare type ActionMatchWindow = ActionMatchBase & { + title: string, + titleRegex: string, +} + +interface PluginAction { + fullName?: string, + name: string, + title: string, + matches: ActionMatch[], + platform?: PlatformType[], + icon?: string, + type?: 'command' | 'web' | 'code' | 'backend', +} + +interface FocusAnyApi { + onPluginReady( + callback: (data: { + actionName: string, + actionMatch: ActionMatch | null, + requestId: string, + }) => void + ): void; + + onPluginExit(callback: Function): void; + + isMainWindowShown(): boolean; + + hideMainWindow(): boolean; + + showMainWindow(): boolean; + + isFastPanelWindowShown(): boolean; + + showFastPanelWindow(): boolean; + + hideFastPanelWindow(): boolean; + + setExpendHeight(height: number): void; + + setSubInput(onChange: (keywords: string) => void, placeholder?: string, isFocus?: boolean): boolean; + + removeSubInput(): boolean; + + setSubInputValue(value: string): boolean; + + subInputBlur(): boolean; + + getPluginRoot(): string; + + getPluginConfig(): any; + + getPluginInfo(): any; + + getPluginEnv(): 'dev' | 'prod'; + + getQuery(requestId: string): SearchQuery; + + createBrowserWindow(url: string, options: BrowserWindow.InitOptions, callback?: () => void): BrowserWindow.WindowInstance; + + outPlugin(): boolean; + + isDarkColors(): boolean; + + setAction(action: PluginAction | PluginAction[]): boolean; + + removeAction(name: string): boolean; + + getActions(names?: string[]): PluginAction[]; + + redirect(keywordsOrAction: string | string[], query?: SearchQuery): void; + + showToast(body: string, options?: { + duration?: number, + status?: 'info' | 'success' | 'error' + }): void; + + showNotification(body: string, clickActionName?: string): void; + + showMessageBox(message: string, options: { + title?: string, + yes?: string, + no?: string, + }): void; + + showOpenDialog(options: { + title?: string, + defaultPath?: string, + buttonLabel?: string, + filters?: { name: string, extensions: string[] }[], + properties?: Array<'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles' | 'createDirectory' | 'promptToCreate' | 'noResolveAliases' | 'treatPackageAsDirectory' | 'dontAddToRecent'>, + message?: string, + securityScopedBookmarks?: boolean + }): (string[]) | (undefined) + + + showSaveDialog(options: any): any; + + screenCapture(callback: (imgBase64: string) => void): void; + + getNativeId(): string; + + getAppVersion(): string; + + getPath(name: 'home' | 'appData' | 'userData' | 'temp' | 'exe' | 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos' | 'logs'): string; + + getFileIcon(path: string): string; + + copyFile(file: string | string[]): boolean; + + copyImage(img: string): boolean; + + copyText(text: string): boolean; + + getClipboardText(): string; + + getClipboardImage(): string; + + getClipboardFiles(): ClipboardFileItem[]; + + shellOpenPath(fullPath: string): void; + + shellShowItemInFolder(fullPath: string): void; + + shellOpenExternal(url: string): void; + + shellBeep(): void; + + simulateKeyboardTap(key: string, ...modifier: ('control' | 'ctrl' | 'shift' | 'option' | 'alt' | 'command' | 'super')[]): void; + + getCursorScreenPoint(): { x: number, y: number }; + + getDisplayNearestPoint(point: { x: number, y: number }): any; + + isMacOs(): boolean; + + isWindows(): boolean; + + isLinux(): boolean; + + getPlatformArch(): string; + + sendBackendEvent(event: string, data?: any, option?: { + timeout: number + }): Promise; + + db: { + put(doc: DbDoc): DbReturn; + get>(id: string): DbDoc | null; + remove(doc: string | DbDoc): DbReturn; + bulkDocs(docs: DbDoc[]): DbReturn[]; + allDocs>(key?: string): DbDoc[]; + postAttachment(docId: string, attachment: Uint8Array, type: string): DbReturn; + getAttachment(docId: string): Uint8Array | null; + getAttachmentType(docId: string): string | null; + }; + + dbStorage: { + setItem(key: string, value: any): void; + getItem(key: string): T; + removeItem(key: string): void; + }; + + fastPanel: { + /** + * 设置超级面板当前插件渲染区域的高度 + * @param height + */ + setHeight(height: number): void; + /** + * 获取超级面板当前插件渲染区域的高度 + */ + getHeight(): Promise; + }, + + util: { + randomString(length: number): string; + bufferToBase64(buffer: Uint8Array): string; + datetimeString(): string; + base64Encode(data: any): string; + base64Decode(data: string): any; + md5(data: string): string; + }; +} + +declare var focusany: FocusAnyApi; diff --git a/sdk/index.d.ts b/sdk/index.d.ts new file mode 100644 index 0000000..f4c976d --- /dev/null +++ b/sdk/index.d.ts @@ -0,0 +1,2 @@ +/// +export = focusany; diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..bdb39be --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,23 @@ +{ + "name": "focusany-sdk", + "version": "0.1.0", + "description": "TypeScript definitions for FocusAny", + "repository": { + "type": "git", + "url": "https://github.com/modstart-lib/focusany-sdk" + }, + "keywords": [ + "FocusAny" + ], + "author": "ModStartLib", + "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/modstart-lib/focusany-sdk/issues" + }, + "homepage": "https://github.com/modstart-lib/focusany-sdk#readme", + "exports": { + "default": { + "types": "./focusany.d.ts" + } + } +} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000..e86eeef --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "lib": [ + "ES6", + "DOM" + ], + "target": "ES6", + "noImplicitAny": true, + "noImplicitThis": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "types": [], + "noEmit": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.d.ts", + "focusany.d.ts" + ] +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..a0e8759 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/src/api/types/base.ts b/src/api/types/base.ts new file mode 100644 index 0000000..3ed505a --- /dev/null +++ b/src/api/types/base.ts @@ -0,0 +1,5 @@ +interface ApiResult { + code: number + msg: string + data: T +} diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..003868f --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,13 @@ +import {request,} from "../lib/api"; + +export function userInfoApi(): Promise> { + return request({ + url: "app_manager/user_info", + method: "post" + }) +} diff --git a/src/app/dragWindow.ts b/src/app/dragWindow.ts new file mode 100644 index 0000000..b0823e6 --- /dev/null +++ b/src/app/dragWindow.ts @@ -0,0 +1,50 @@ +export const useDragWindow = ( + { + name, + }: { + name: string | null; + } +) => { + name = name || null + let animationId: number; + let mouseX: number; + let mouseY: number; + let clientWidth = 0; + let clientHeight = 0; + let draggable = true; + + const onDragWindowMouseDown = (e) => { + // 右击不移动 + if (e.button === 2) return; + draggable = true; + mouseX = e.clientX; + mouseY = e.clientY; + if (Math.abs(document.body.clientWidth - clientWidth) > 5) { + clientWidth = document.body.clientWidth; + } + if (Math.abs(document.body.clientHeight - clientHeight) > 5) { + clientHeight = document.body.clientHeight; + } + document.addEventListener('mouseup', onMouseUp); + animationId = requestAnimationFrame(moveWindow); + }; + + const onMouseUp = () => { + draggable = false; + document.removeEventListener('mouseup', onMouseUp); + cancelAnimationFrame(animationId); + }; + + const moveWindow = () => { + window.$mapi.app.windowMove(name, {mouseX, mouseY, width: clientWidth, height: clientHeight}).then(() => { + if (draggable) { + animationId = requestAnimationFrame(moveWindow); + } + }) + }; + + return { + onDragWindowMouseDown, + }; +}; + diff --git a/src/app/locale.ts b/src/app/locale.ts new file mode 100644 index 0000000..ff57777 --- /dev/null +++ b/src/app/locale.ts @@ -0,0 +1,18 @@ +import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn'; +import enUS from '@arco-design/web-vue/es/locale/lang/en-us'; +import {onLocaleChange} from "../lang" +import {ref} from "vue"; + +export const useLocale = () => { + const locales = { + 'zh-CN': zhCN, + 'en-US': enUS, + }; + const locale = ref(zhCN); + onLocaleChange((newLocale) => { + locale.value = locales[newLocale]; + }); + return { + locale: locale, + } +} diff --git a/src/assets/image/avatar.svg b/src/assets/image/avatar.svg new file mode 100644 index 0000000..58e37b1 --- /dev/null +++ b/src/assets/image/avatar.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/image/gitee.svg b/src/assets/image/gitee.svg new file mode 100644 index 0000000..659c5de --- /dev/null +++ b/src/assets/image/gitee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/image/github.svg b/src/assets/image/github.svg new file mode 100644 index 0000000..a64c150 --- /dev/null +++ b/src/assets/image/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/image/logo.svg b/src/assets/image/logo.svg new file mode 100644 index 0000000..22343b7 --- /dev/null +++ b/src/assets/image/logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/image/no-record-dark.svg b/src/assets/image/no-record-dark.svg new file mode 100644 index 0000000..a2d27dd --- /dev/null +++ b/src/assets/image/no-record-dark.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/image/no-record.svg b/src/assets/image/no-record.svg new file mode 100644 index 0000000..46cb4ea --- /dev/null +++ b/src/assets/image/no-record.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/image/plugin.svg b/src/assets/image/plugin.svg new file mode 100644 index 0000000..c63da30 --- /dev/null +++ b/src/assets/image/plugin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/image/search-icon.svg b/src/assets/image/search-icon.svg new file mode 100644 index 0000000..22343b7 --- /dev/null +++ b/src/assets/image/search-icon.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/src/components/AppQuitConfirm.vue b/src/components/AppQuitConfirm.vue new file mode 100644 index 0000000..b18b13c --- /dev/null +++ b/src/components/AppQuitConfirm.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/components/PageNav.vue b/src/components/PageNav.vue new file mode 100644 index 0000000..413b627 --- /dev/null +++ b/src/components/PageNav.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/src/components/Setting/SettingAbout.vue b/src/components/Setting/SettingAbout.vue new file mode 100644 index 0000000..abe60fd --- /dev/null +++ b/src/components/Setting/SettingAbout.vue @@ -0,0 +1,73 @@ + + + + diff --git a/src/components/Setting/SettingBasic.vue b/src/components/Setting/SettingBasic.vue new file mode 100644 index 0000000..ec005bb --- /dev/null +++ b/src/components/Setting/SettingBasic.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/components/Setting/SettingEnv.vue b/src/components/Setting/SettingEnv.vue new file mode 100644 index 0000000..7ffe83a --- /dev/null +++ b/src/components/Setting/SettingEnv.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/components/common/AudioPlayer.vue b/src/components/common/AudioPlayer.vue new file mode 100644 index 0000000..17367f8 --- /dev/null +++ b/src/components/common/AudioPlayer.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/src/components/common/CodeViewer.vue b/src/components/common/CodeViewer.vue new file mode 100644 index 0000000..0381422 --- /dev/null +++ b/src/components/common/CodeViewer.vue @@ -0,0 +1,80 @@ + + + + + + diff --git a/src/components/common/CodeViewerDialog.vue b/src/components/common/CodeViewerDialog.vue new file mode 100644 index 0000000..4e399db --- /dev/null +++ b/src/components/common/CodeViewerDialog.vue @@ -0,0 +1,81 @@ + + + + + + diff --git a/src/components/common/DragPasteContainer.vue b/src/components/common/DragPasteContainer.vue new file mode 100644 index 0000000..9d7883c --- /dev/null +++ b/src/components/common/DragPasteContainer.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/src/components/common/FileExt.vue b/src/components/common/FileExt.vue new file mode 100644 index 0000000..e61ecc9 --- /dev/null +++ b/src/components/common/FileExt.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/common/FileExtAssets/ai.svg b/src/components/common/FileExtAssets/ai.svg new file mode 100644 index 0000000..80c5afe --- /dev/null +++ b/src/components/common/FileExtAssets/ai.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/apk.svg b/src/components/common/FileExtAssets/apk.svg new file mode 100644 index 0000000..96bef1a --- /dev/null +++ b/src/components/common/FileExtAssets/apk.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/chm.svg b/src/components/common/FileExtAssets/chm.svg new file mode 100644 index 0000000..8432530 --- /dev/null +++ b/src/components/common/FileExtAssets/chm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/css.svg b/src/components/common/FileExtAssets/css.svg new file mode 100644 index 0000000..94361c7 --- /dev/null +++ b/src/components/common/FileExtAssets/css.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/doc.svg b/src/components/common/FileExtAssets/doc.svg new file mode 100644 index 0000000..30dd860 --- /dev/null +++ b/src/components/common/FileExtAssets/doc.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/docx.svg b/src/components/common/FileExtAssets/docx.svg new file mode 100644 index 0000000..30dd860 --- /dev/null +++ b/src/components/common/FileExtAssets/docx.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/dwg.svg b/src/components/common/FileExtAssets/dwg.svg new file mode 100644 index 0000000..e7eff1a --- /dev/null +++ b/src/components/common/FileExtAssets/dwg.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + dwg + + + diff --git a/src/components/common/FileExtAssets/folder.svg b/src/components/common/FileExtAssets/folder.svg new file mode 100644 index 0000000..02e8edc --- /dev/null +++ b/src/components/common/FileExtAssets/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/FileExtAssets/gif.svg b/src/components/common/FileExtAssets/gif.svg new file mode 100644 index 0000000..6b74924 --- /dev/null +++ b/src/components/common/FileExtAssets/gif.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/html.svg b/src/components/common/FileExtAssets/html.svg new file mode 100644 index 0000000..2935849 --- /dev/null +++ b/src/components/common/FileExtAssets/html.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/jpeg.svg b/src/components/common/FileExtAssets/jpeg.svg new file mode 100644 index 0000000..d951ef4 --- /dev/null +++ b/src/components/common/FileExtAssets/jpeg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/jpg.svg b/src/components/common/FileExtAssets/jpg.svg new file mode 100644 index 0000000..b3bcb68 --- /dev/null +++ b/src/components/common/FileExtAssets/jpg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/log.svg b/src/components/common/FileExtAssets/log.svg new file mode 100644 index 0000000..f1f9236 --- /dev/null +++ b/src/components/common/FileExtAssets/log.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/mp3.svg b/src/components/common/FileExtAssets/mp3.svg new file mode 100644 index 0000000..6cc0e35 --- /dev/null +++ b/src/components/common/FileExtAssets/mp3.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/mp4.svg b/src/components/common/FileExtAssets/mp4.svg new file mode 100644 index 0000000..20c579d --- /dev/null +++ b/src/components/common/FileExtAssets/mp4.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/pdf.svg b/src/components/common/FileExtAssets/pdf.svg new file mode 100644 index 0000000..335b9f7 --- /dev/null +++ b/src/components/common/FileExtAssets/pdf.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/png.svg b/src/components/common/FileExtAssets/png.svg new file mode 100644 index 0000000..4f147d9 --- /dev/null +++ b/src/components/common/FileExtAssets/png.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/ppt.svg b/src/components/common/FileExtAssets/ppt.svg new file mode 100644 index 0000000..4ea923e --- /dev/null +++ b/src/components/common/FileExtAssets/ppt.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/pptx.svg b/src/components/common/FileExtAssets/pptx.svg new file mode 100644 index 0000000..4ea923e --- /dev/null +++ b/src/components/common/FileExtAssets/pptx.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/psd.svg b/src/components/common/FileExtAssets/psd.svg new file mode 100644 index 0000000..52fa08c --- /dev/null +++ b/src/components/common/FileExtAssets/psd.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/rar.svg b/src/components/common/FileExtAssets/rar.svg new file mode 100644 index 0000000..2541fec --- /dev/null +++ b/src/components/common/FileExtAssets/rar.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/components/common/FileExtAssets/svg.svg b/src/components/common/FileExtAssets/svg.svg new file mode 100644 index 0000000..8f7f37c --- /dev/null +++ b/src/components/common/FileExtAssets/svg.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/torrent.svg b/src/components/common/FileExtAssets/torrent.svg new file mode 100644 index 0000000..6429687 --- /dev/null +++ b/src/components/common/FileExtAssets/torrent.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/txt.svg b/src/components/common/FileExtAssets/txt.svg new file mode 100644 index 0000000..5b4c797 --- /dev/null +++ b/src/components/common/FileExtAssets/txt.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/unknown.svg b/src/components/common/FileExtAssets/unknown.svg new file mode 100644 index 0000000..214a6f3 --- /dev/null +++ b/src/components/common/FileExtAssets/unknown.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/xls.svg b/src/components/common/FileExtAssets/xls.svg new file mode 100644 index 0000000..e4bd05f --- /dev/null +++ b/src/components/common/FileExtAssets/xls.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/xlsx.svg b/src/components/common/FileExtAssets/xlsx.svg new file mode 100644 index 0000000..e4bd05f --- /dev/null +++ b/src/components/common/FileExtAssets/xlsx.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/components/common/FileExtAssets/zip.svg b/src/components/common/FileExtAssets/zip.svg new file mode 100644 index 0000000..2541fec --- /dev/null +++ b/src/components/common/FileExtAssets/zip.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/components/common/FileLogViewer.vue b/src/components/common/FileLogViewer.vue new file mode 100644 index 0000000..8130eeb --- /dev/null +++ b/src/components/common/FileLogViewer.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/components/common/HtmlViewer.vue b/src/components/common/HtmlViewer.vue new file mode 100644 index 0000000..124f7f5 --- /dev/null +++ b/src/components/common/HtmlViewer.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/src/components/common/InputInlineEditor.vue b/src/components/common/InputInlineEditor.vue new file mode 100644 index 0000000..a10c401 --- /dev/null +++ b/src/components/common/InputInlineEditor.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/components/common/LogViewer.vue b/src/components/common/LogViewer.vue new file mode 100644 index 0000000..b023b97 --- /dev/null +++ b/src/components/common/LogViewer.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/components/common/LogViewerDialog.vue b/src/components/common/LogViewerDialog.vue new file mode 100644 index 0000000..da0a238 --- /dev/null +++ b/src/components/common/LogViewerDialog.vue @@ -0,0 +1,50 @@ + + + + diff --git a/src/components/common/MEmpty.vue b/src/components/common/MEmpty.vue new file mode 100644 index 0000000..2264b6e --- /dev/null +++ b/src/components/common/MEmpty.vue @@ -0,0 +1,17 @@ + + diff --git a/src/components/common/MLoading.vue b/src/components/common/MLoading.vue new file mode 100644 index 0000000..05603e9 --- /dev/null +++ b/src/components/common/MLoading.vue @@ -0,0 +1,9 @@ + + diff --git a/src/components/common/PageWebviewStatus.vue b/src/components/common/PageWebviewStatus.vue new file mode 100644 index 0000000..8289474 --- /dev/null +++ b/src/components/common/PageWebviewStatus.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/components/common/ParamForm.vue b/src/components/common/ParamForm.vue new file mode 100644 index 0000000..ed743af --- /dev/null +++ b/src/components/common/ParamForm.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/components/common/SettingItemYesNo.vue b/src/components/common/SettingItemYesNo.vue new file mode 100644 index 0000000..ee78bff --- /dev/null +++ b/src/components/common/SettingItemYesNo.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/src/components/common/SettingItemYesNoDefault.vue b/src/components/common/SettingItemYesNoDefault.vue new file mode 100644 index 0000000..7b92b4a --- /dev/null +++ b/src/components/common/SettingItemYesNoDefault.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/src/components/common/TaskBizStatus.vue b/src/components/common/TaskBizStatus.vue new file mode 100644 index 0000000..f0e9362 --- /dev/null +++ b/src/components/common/TaskBizStatus.vue @@ -0,0 +1,46 @@ + + + + diff --git a/src/components/common/UpdaterButton.vue b/src/components/common/UpdaterButton.vue new file mode 100644 index 0000000..4da75d4 --- /dev/null +++ b/src/components/common/UpdaterButton.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/common/VideoPlayer.vue b/src/components/common/VideoPlayer.vue new file mode 100644 index 0000000..92bf34a --- /dev/null +++ b/src/components/common/VideoPlayer.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/components/common/WebFileSelectButton.vue b/src/components/common/WebFileSelectButton.vue new file mode 100644 index 0000000..15599f0 --- /dev/null +++ b/src/components/common/WebFileSelectButton.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/src/components/common/index.ts b/src/components/common/index.ts new file mode 100644 index 0000000..4ac2128 --- /dev/null +++ b/src/components/common/index.ts @@ -0,0 +1,9 @@ +import MLoading from "./MLoading.vue"; +import MEmpty from "./MEmpty.vue"; + +export const CommonComponents = { + install(Vue: any) { + Vue.component('m-loading', MLoading); + Vue.component('m-empty', MEmpty); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..545d730 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,29 @@ +import packageJson from '../package.json'; +import dayjs from "dayjs"; + +const BASE_URL = 'https://focusany.com'; + +let version = packageJson.version +if (version.includes('-beta')) { + version = version.replace('-beta', `-beta-${dayjs().format('YYYYMMDD_HHmmss')}`); +} + +export const AppConfig = { + name: 'FocusAny', + slogan: '专注提效的AI工具条', + version: version, + website: `${BASE_URL}`, + websiteGithub: 'https://github.com/modstart-lib/focusany', + websiteGitee: 'https://gitee.com/modstart-lib/focusany', + apiBaseUrl: `${BASE_URL}/api`, + updaterUrl: `${BASE_URL}/app_manager/updater`, + downloadUrl: `${BASE_URL}/app_manager/download`, + feedbackUrl: `${BASE_URL}/feedback`, + statisticsUrl: `${BASE_URL}/app_manager/collect`, + guideUrl: `${BASE_URL}/app_manager/guide`, + helpUrl: `${BASE_URL}/app_manager/help`, + basic: { + userEnable: false, + }, +} + diff --git a/src/declarations/svg.d.ts b/src/declarations/svg.d.ts new file mode 100644 index 0000000..44350b3 --- /dev/null +++ b/src/declarations/svg.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: string; + export default content; +} diff --git a/src/declarations/type.d.ts b/src/declarations/type.d.ts new file mode 100644 index 0000000..1e47504 --- /dev/null +++ b/src/declarations/type.d.ts @@ -0,0 +1,321 @@ +declare interface Window { + __page: { + onShow: (cb: Function) => void, + onHide: (cb: Function) => void, + onMaximize: (cb: Function) => void, + onUnmaximize: (cb: Function) => void, + onEnterFullScreen: (cb: Function) => void, + onLeaveFullScreen: (cb: Function) => void, + onBroadcast: (type: string, cb: (data: any) => void) => void, + offBroadcast: (type: string, cb: (data: any) => void) => void, + registerCallPage: ( + name: string, + cb: ( + resolve: (data: any) => void, + reject: (error: string) => void, + data: any + ) => void + ) => void, + createChannel: (cb: (data: any) => void) => string, + destroyChannel: (channel: string) => void, + + onPluginInit: (cb: Function) => void, + onPluginAlreadyOpened: (cb: Function) => void, + onPluginExit: (cb: Function) => void, + onPluginDetached: (cb: Function) => void, + onPluginState: (cb: Function) => void, + onSetSubInput: (cb: Function) => void, + onRemoveSubInput: (cb: Function) => void, + onSetSubInputValue: (cb: Function) => void, + }, + focusany: FocusAnyApi, + $mapi: { + app: { + getPreload: () => Promise, + resourcePathResolve: (filePath: string) => Promise, + extraPathResolve: (filePath: string) => Promise, + platformName: () => 'win' | 'osx' | 'linux' | null, + platformArch: () => 'x86' | 'arm64' | null, + isPlatform: (platform: 'win' | 'osx' | 'linux') => boolean, + quit: () => Promise, + restart: () => Promise, + windowMin: (name?: string) => Promise, + windowMax: (name?: string) => Promise, + windowSetSize: (name: string | null, width: number, height: number, option?: { + includeMinimumSize?: boolean, + center?: boolean + }) => Promise, + windowOpen: (name: string, option?: any) => Promise, + windowHide: (name?: string) => Promise, + windowClose: (name?: string) => Promise, + windowMove: (name: string | null, data: { + mouseX: number, + mouseY: number, + width: number, + height: number + }) => Promise, + openExternalWeb: (url: string) => Promise, + appEnv: () => Promise, + isDarkMode: () => Promise, + shell: (command: string, option?: { + cwd?: string, + outputEncoding?: string, + }) => Promise, + spawnShell: (command: string | string[], option: { + stdout?: (data: string, process: any) => void, + stderr?: (data: string, process: any) => void, + success?: (process: any) => void, + error?: (msg: string, exitCode: number, process: any) => void, + cwd?: string, + outputEncoding?: string, + env?: Record, + } | null) => Promise<{ + stop: () => void, + send: (data: any) => void, + result: () => Promise + }>, + availablePort: (start: number, lockKey?: string, lockTime?: number) => Promise, + fixExecutable: (executable: string) => Promise, + getClipboardText: () => Promise, + setClipboardText: (text: string) => Promise, + getClipboardImage: () => Promise, + setClipboardImage: (image: string) => Promise, + getUserAgent: () => string, + toast: (msg: string, option?: { + duration?: number, + status?: 'success' | 'error' + }) => Promise, + setupList: () => Promise<{ + name: string, + title: string, + status: 'success' | 'fail', + desc: string, + steps: { + title: string, + image: string, + }[] + }[]>, + setupOpen: (name: string) => Promise, + setupIsOk: () => Promise, + }, + config: { + get: (key: string, defaultValue: any = null) => Promise, + set: (key: string, value: any) => Promise, + all: () => Promise, + }, + log: { + root: () => string, + info: (msg: string, data: any = null) => Promise, + error: (msg: string, data: any = null) => Promise, + }, + storage: { + all: () => Promise, + get: (group: string, key: string, defaultValue: any) => Promise, + set: (group: string, key: string, value: any) => Promise, + }, + db: { + execute: (sql: string, params: any = []) => Promise, + insert: (sql: string, params: any = []) => Promise, + first: (sql: string, params: any = []) => Promise, + select: (sql: string, params: any = []) => Promise, + update: (sql: string, params: any = []) => Promise, + delete: (sql: string, params: any = []) => Promise, + }, + kvdb: { + put: (name: string, data: Doc) => Promise, + putForce: (name: string, data: Doc) => Promise, + get: (name: string, id: string) => Promise, + remove: (name: string, doc: Doc | string) => Promise, + bulkDocs: (name: string, docs: any[]) => Promise, + allDocs: (name: string, key: string) => Promise, + allKeys: (name: string, key: string) => Promise, + count: (name: string, key: string) => Promise, + postAttachment: (name: string, docId: string, attachment: any, type: string) => Promise, + getAttachment: (name: string, docId: string) => Promise, + getAttachmentType: (name: string, docId: string) => Promise, + dumpToFile: (file: string) => Promise, + importFromFile: (file: string) => Promise, + testWebdav: (option: { + url: string, + username: string, + password: string, + }) => Promise, + dumpToWebDav: (file: string, option: { + url: string, + username: string, + password: string + }) => Promise, + importFromWebDav: (file: string, option: { + url: string, + username: string, + password: string + }) => Promise, + listWebDav: (dir: string, option: { + url: string, + username: string, + password: string + }) => Promise, + }, + file: { + fullPath: (path: string) => Promise, + absolutePath: (path: string) => string, + exists: (path: string, option?: { isFullPath?: boolean, }) => Promise, + isDirectory: (path: string, option?: { isFullPath?: boolean, }) => Promise, + mkdir: (path: string, option?: { isFullPath?: boolean, }) => Promise, + list: (path: string, option?: { isFullPath?: boolean, }) => Promise, + listAll: (path: string, option?: { isFullPath?: boolean, }) => Promise, + write: (path: string, data: any, option?: { isFullPath?: boolean, }) => Promise, + writeBuffer: (path: string, data: any, option?: { isFullPath?: boolean, }) => Promise, + read: (path: string, option?: { isFullPath?: boolean, }) => Promise, + readBuffer: (path: string, option?: { isFullPath?: boolean, }) => Promise, + deletes: (path: string, option?: { isFullPath?: boolean, }) => Promise, + rename: (pathOld: string, pathNew: string, option?: { + isFullPath?: boolean, + overwrite?: boolean + }) => Promise, + copy: (pathOld: string, pathNew: string, option?: { isFullPath?: boolean, }) => Promise, + temp: (ext: string = 'tmp', prefix: string = 'file') => Promise, + tempDir: (prefix: string = 'dir') => Promise, + watchText: (path: string, callback: (data: {}) => void, option?: { + isFullPath?: boolean, + limit?: number, + }) => Promise<{ + stop: Function, + }>, + appendText: (path: string, data: any, option?: { isFullPath?: boolean, }) => Promise, + openFile: (options: {} = {}) => Promise, + openDirectory: (options: {} = {}) => Promise, + openSave: (options: {} = {}) => Promise, + openPath: (path: string, options: {} = {}) => Promise, + }, + updater: { + checkForUpdate: () => Promise>, + }, + statistics: { + tick: (name: string, data: any = null) => Promise, + }, + lang: { + writeSourceKey: (key: string) => Promise, + writeSourceKeyUse: (key: string) => Promise, + }, + event: { + send: (name: string, type: string, data: any) => void, + callPage: (name: string, type: string, data?: any, option?: { + waitReadyTimeout?: number, + timeout?: number + }) => Promise>, + channelSend: (channel: string, data: any) => Promise, + }, + user: { + open: (option?: any) => Promise, + get: () => Promise<{ + apiToken: string, + user: { + id: string, + name: string, + avatar: string, + }, + data: {}, + basic: {}, + }>, + refresh: () => Promise, + getApiToken: () => Promise, + getWebEnterUrl: (url: string) => Promise, + openWebUrl: (url: string) => Promise, + apiPost: (url: string, data?: any) => Promise, + }, + misc: { + getZipFileContent: (path: string, pathInZip: string) => Promise, + unzip: (zipPath: string, dest: string, option?: { process: Function }) => Promise, + }, + + ffmpeg: { + version: () => Promise, + run: (args: string[]) => Promise, + }, + server: { + start: (serverInfo: ServerInfo) => Promise, + ping: (serverInfo: ServerInfo) => Promise, + stop: (serverInfo: ServerInfo) => Promise, + config: (serverInfo: ServerInfo) => Promise, + callFunction: (serverInfo: ServerInfo, method: string, data: any) => Promise, + }, + manager: { + + getConfig: () => Promise, + setConfig: (config: ConfigRecord) => Promise, + + show: () => Promise, + hide: () => Promise, + + getClipboardFiles: () => Promise, + getSelectedContent: () => Promise, + + searchFastPanelAction: (query: { + currentFiles: any[], + currentImage: string, + }, option?: {}) => Promise<{ + searchActions: ActionRecord[], + }>, + searchAction: (query: { + keywords: string, + currentFiles: any[], + currentImage: string, + }, option?: {}) => Promise<{ + searchActions: ActionRecord[], + matchActions: ActionRecord[], + historyActions: ActionRecord[], + pinActions: ActionRecord[], + }>, + subInputChange: (keywords: string, option?: {}) => void, + + openPlugin: (pluginName: string, option?: {}) => Promise, + openAction: (action: ActionRecord, option?: {}) => Promise, + + closeMainPlugin: (plugin?: PluginRecord, option?: {}) => Promise, + openMainPluginDevTools: (plugin?: PluginRecord, option?: {}) => Promise, + detachPlugin: (option?: {}) => Promise, + listPlugin: (option?: {}) => Promise, + installPlugin: (fileOrPath: string, option?: {}) => Promise, + refreshInstallPlugin: (pluginName: string, option?: {}) => Promise, + uninstallPlugin: (pluginName: string, option?: {}) => Promise, + getPluginInstalledVersion: (pluginName: string, option?: {}) => Promise, + listDisabledActionMatch: (option?: {}) => Promise, + toggleDisabledActionMatch: (pluginName: string, actionName: string, matchName: string, option?: {}) => Promise, + listPinAction: (option?: {}) => Promise, + togglePinAction: (actionName: string, option?: {}) => Promise, + clearCache: (option?: {}) => Promise, + hotKeyWatch: (option?: {}) => Promise, + hotKeyUnwatch: (option?: {}) => Promise, + + toggleDetachPluginAlwaysOnTop: (alwaysOnTop: boolean, option?: {}) => Promise, + setDetachPluginZoom: (zoom: number, option?: {}) => Promise, + closeDetachPlugin: (option?: {}) => Promise, + openDetachPluginDevTools: (option?: {}) => Promise, + setPluginAutoDetach: (autoDetach: boolean, option?: {}) => Promise, + getPluginConfig: (pluginName: string, option?: {}) => Promise, + + listFilePluginRecords: (option?: {}) => Promise, + updateFilePluginRecords: (records: FilePluginRecord[], option?: {}) => Promise, + listLaunchRecords: (option?: {}) => Promise, + updateLaunchRecords: (records: LaunchRecord[], option?: {}) => Promise, + + storeInstall: (pluginName: string, option?: { + version?: string, + }) => Promise, + storePublish: (pluginName: string, option?: { + version?: string, + }) => Promise, + storePublishInfo: (pluginName: string, option?: { + version?: string, + }) => Promise, + + clipboardList: (option?: {}) => Promise, + clipboardClear: (option?: {}) => Promise, + clipboardDelete: (timestamp: number, option?: {}) => Promise, + + } + } +} + + diff --git a/src/entry/Page.vue b/src/entry/Page.vue new file mode 100644 index 0000000..ef34719 --- /dev/null +++ b/src/entry/Page.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/entry/about.ts b/src/entry/about.ts new file mode 100644 index 0000000..87dbf31 --- /dev/null +++ b/src/entry/about.ts @@ -0,0 +1,34 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import Page from "./Page.vue"; +import PageAbout from "../pages/PageAbout.vue"; + +const app = createApp(Page, { + name: 'about', + title: t('关于'), + page: PageAbout +}) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/detachWindow.ts b/src/entry/detachWindow.ts new file mode 100644 index 0000000..3af5fd9 --- /dev/null +++ b/src/entry/detachWindow.ts @@ -0,0 +1,29 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import PageDetachWindow from "../pages/PageDetachWindow.vue"; + +const app = createApp(PageDetachWindow) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/fastPanel.ts b/src/entry/fastPanel.ts new file mode 100644 index 0000000..65d34ff --- /dev/null +++ b/src/entry/fastPanel.ts @@ -0,0 +1,29 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import PageFastPanel from "../pages/PageFastPanel.vue"; + +const app = createApp(PageFastPanel) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/guide.ts b/src/entry/guide.ts new file mode 100644 index 0000000..e668d05 --- /dev/null +++ b/src/entry/guide.ts @@ -0,0 +1,34 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import Page from "./Page.vue"; +import PageGuide from "../pages/PageGuide.vue"; + +const app = createApp(Page, { + name: 'guide', + title: t('新手指引'), + page: PageGuide +}) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/setup.ts b/src/entry/setup.ts new file mode 100644 index 0000000..7328ada --- /dev/null +++ b/src/entry/setup.ts @@ -0,0 +1,34 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import Page from "./Page.vue"; +import PageSetup from "../pages/PageSetup.vue"; + +const app = createApp(Page, { + name: 'setup', + title: '请按照说明完成软件配置', + page: PageSetup +}) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/store.ts b/src/entry/store.ts new file mode 100644 index 0000000..807de0b --- /dev/null +++ b/src/entry/store.ts @@ -0,0 +1,29 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import PageStore from "../pages/PageStore.vue"; + +const app = createApp(PageStore) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/system.ts b/src/entry/system.ts new file mode 100644 index 0000000..e74550a --- /dev/null +++ b/src/entry/system.ts @@ -0,0 +1,29 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import PageSystem from "../pages/PageSystem.vue"; + +const app = createApp(PageSystem) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/user.ts b/src/entry/user.ts new file mode 100644 index 0000000..1c3f7bf --- /dev/null +++ b/src/entry/user.ts @@ -0,0 +1,34 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import Page from "./Page.vue"; +import PageUser from "../pages/PageUser.vue"; + +const app = createApp(Page, { + name: 'user', + title: t('用户中心'), + page: PageUser +}) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/entry/workflow.ts b/src/entry/workflow.ts new file mode 100644 index 0000000..91b0a74 --- /dev/null +++ b/src/entry/workflow.ts @@ -0,0 +1,29 @@ +import {createApp} from 'vue' +import store from "../store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "../lang"; + +import '../style.less' +import {Dialog} from "../lib/dialog"; + +import {CommonComponents} from "../components/common"; +import PageWorkflow from "../pages/PageWorkflow.vue"; + +const app = createApp(PageWorkflow) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/lang/en-US.json b/src/lang/en-US.json new file mode 100644 index 0000000..d27c741 --- /dev/null +++ b/src/lang/en-US.json @@ -0,0 +1,219 @@ +{ + "0000503c": "Value", + "00005feb": "Fast", + "00006162": "Slow", + "000079d2": "Second", + "0009c256": "Upload", + "000a02d2": "Download", + "000a071b": "Save", + "000a0cae": "Info", + "000a2b7b": "About", + "000a37e7": "Fullscreen", + "000a4cdd": "Cut", + "000a5a59": "Refresh", + "000a6ba1": "Select All", + "000a72da": "Close", + "000a823e": "Function", + "000a8359": "分钟", + "000a8844": "Delete", + "000a9472": "Cancel", + "000aa783": "Name", + "000ad991": "Image", + "000b1ac9": "Copy", + "000b2b1e": "License", + "000b5a14": "Failure", + "000b5d03": "Sound", + "000b8bc7": "小时", + "000b96b9": "Website", + "000c328f": "Success", + "000c3f65": "Screenshot", + "000c842f": "Operate", + "000c8f6a": "Hint", + "000c9ba9": "Zoom In", + "000ca601": "Desc", + "000caa60": "明亮", + "000cb692": "Log", + "000cc4dc": "Undo", + "000ccd34": "Service", + "000ccf01": "Collapse", + "000d0b1a": "暗黑", + "000d249f": "ID", + "000d318a": "Model", + "000d5f35": "Normal", + "000da405": "Add", + "000e3de4": "Version", + "000ec627": "目录", + "000ef0ec": "Confirm", + "000f36d4": "Port", + "000f6a70": "Type", + "000fa39c": "Paste", + "000fc206": "Zoom Out", + "000ff33b": "Edit", + "00100dfe": "Auto", + "001105f8": "View", + "00114509": "Device", + "0011478b": "Video", + "00116b70": "Setting", + "00117bb3": "Language", + "0011c18a": "返回", + "0011c1fa": "Exit", + "0011f82d": "Redo", + "0011fc02": "Restart", + "0012018b": "Platform", + "001299f6": "Error", + "0012c13f": "Hide", + "0012e8df": "Speaker", + "0012f68c": "Sonic Speed", + "001fbc6f": "CUDA", + "002d4177": "IP Address", + "00e5b339": "Server not started", + "013af867": "Stopping", + "014654f4": "Starting", + "0158d357": "Download model files locally", + "016acff8": "Stopped", + "017278f9": "Connected", + "018827b3": "Digital Human", + "01884300": "In Queue", + "018dc903": "Not Started", + "021f3e37": "Please select", + "02261f68": "Cross language", + "02286f86": "Connecting", + "022cab31": "Running", + "03537548": "Confirm delete {title} v{version} ?", + "041806bc": "GPU Priority", + "041b5a9a": "GPU Mode", + "04f71f68": "Server", + "07f6f37e": "加载中...", + "08ac1686": "Please select sound", + "09e64baa": "Started at {time}", + "0b2004f2": "Are you sure you want to exit the software?", + "0c7430b5": "Local Service directory", + "0cc262e2": "No recording device detected", + "0eed4342": "Task submitted successfully, awaiting video generation completion", + "138cdd85": "How to connect?", + "14a7906b": "Load Local Model", + "160fdc0e": "Remember my choice", + "18b4d0cc": "wav/mp3 supported", + "19282801": "The model one-click startup package includes the configuration files for the model service and the executable files for the model service.", + "195c95f2": "The model service directory contains the configuration files for the model service and the executable files for the model service.", + "1bb341a5": "This product is open-source software under the AGPL-3.0 license.", + "1e2bcd5b": "Confirm delete ?", + "216e470a": "Visit the official model page to download available models locally", + "23dcec1c": "sound length duration greater than 3s", + "259ff066": "Download Failed", + "25a0c8e1": "Download Successful", + "25aba9c4": "View Code", + "25e596a4": "Feedback", + "2615bdd6": "Where to find model files?", + "26276a3a": "Stop Service", + "263ee622": "About", + "26aa0dba": "已经是最新版本", + "26ba5101": "Extracting %s", + "26d1d59f": "Select model service directory", + "26e92d2b": "Show Main Interface", + "272e273c": "Show All", + "27840925": "File Extraction Completed", + "27855c20": "Start Command", + "27879f4d": "Start Service", + "27fee544": "Prompt sound", + "28002ef1": "Prompt text", + "2986f693": "Online Documentation", + "29d2c176": "Basic", + "2a3bec13": "Timeout", + "2ab296de": "Voice Clone", + "2ab2b74b": "Text to Speech", + "2ab2ccc4": "Reference Voice", + "2ba171ec": "Leave empty to use a random port", + "2ba96070": "Install App", + "2be4d86f": "Actual Size", + "2bf347f6": "Leave empty to use the default start command", + "2c3ba30e": "No models available yet, please add one~", + "2c8ac2a6": "Start Clone", + "2c8ae313": "Start Synthesis", + "2c90728c": "Force Reload", + "2c9d803f": "Record Audio", + "2e9afa35": "Model directory recognition failed; please select the correct model directory", + "2eb00a0b": "Open Log Folder", + "2f5eaf14": "File Management", + "2fb6d4c9": "新手指引", + "2fc665d8": "Log Viewer", + "301d8708": "Service Port", + "302416b0": "No Logs Available", + "3028a9c3": "暂无记录", + "303ab60e": "Local Model", + "31450307": "Check for Updates", + "319103b8": "Model Info", + "32226b29": "Deleting", + "32a57afe": "Ask Every Time", + "3345e9a5": "Add Role", + "338045e9": "Command Line Tool", + "33d94d01": "Clear History", + "35f3e924": "Environment Settings", + "36bc2185": "用户中心", + "3757d9f4": "Random inference seed", + "379a84eb": "Exit Directly", + "37ce23fb": "Connect to Network Device", + "37f08659": "The complete text content of the reference voice needs to be entered.", + "382747b5": "Confirm Cropping", + "38d6305f": "Confirm import", + "39600ad2": "Enter the voice content to start cloning", + "39602b3f": "Enter voice content to start synthesis", + "39f24859": "Manage Apps", + "3a8639d2": "The task has been submitted successfully, waiting for the synthesis to complete", + "3acfb9f5": "Online Model", + "3afff2ff": "The task has been submitted successfully. Waiting for cloning to complete.", + "3c52458c": "Auto Scroll", + "3d329aff": "Default Port {port}", + "3d718795": "For more information, see", + "3f729287": "Crop Audio", + "409f6765": "Voice Role", + "40e81fd3": "Video Generate", + "40ead249": "Video template", + "40eadd12": "Video template", + "40ec219c": "Video Generate", + "4148da61": "Request Error", + "41c4d108": "访问官网", + "41dff64a": "Extracting {name}", + "41e0472e": "TTS", + "42c281d4": "跟随系统", + "42ed4ed0": "Connect Device", + "42fa058a": "Select Model", + "42fe06e0": "Select character", + "42fe6375": "Select Path", + "43cf49a0": "Reload", + "43d09644": "Re-record", + "43d68cc3": "Re-select", + "43d76c93": "File Not Found", + "43fae06d": "File Already Exists", + "447a6567": "Start generating video", + "4602bc9c": "Generate Randomly", + "4671999f": "Hide Others", + "467699ab": "Hide Window", + "4b9efbfc": "Extracting File", + "4ba41237": "Model already exists, please delete it first", + "4dafbba2": "Start Extracting File", + "50e26377": "How to Find Available Models?", + "512b163b": "Please enter the synthesis content", + "5232d376": "Using the same seed ensures consistent generated results each time", + "55517ea8": "Use Online Service", + "58055d93": "Some models require special handling when performing cross-language cloning, so it’s necessary to indicate whether it is a cross-language cloning operation.", + "5affe2ba": "When clicking Close", + "5b0c1880": "A model with the same version already exists.", + "5b574e11": "Model file imported successfully", + "5c572228": "Model Service Not Running", + "5dc07189": "Add Model", + "5e1bac63": "Enter keyword to filter", + "62bef086": "Developer Tools", + "65baa0c4": "Choose a voice role.", + "65bc9692": "Select a voice file", + "6a7ee8a1": "Use CPU", + "6c9b6559": "Select Model File", + "702bab32": "Are you sure you want to delete the model {title}?", + "720a177b": "Please select a voice reference", + "72d74fad": "OpenCL", + "73b1d37a": "Add Online Model", + "79ccebce": "Add video template", + "7bf1ff1a": "Select video file", + "7fca1c6b": "Import a model zip file on this page", + "a0cf9088": "本产品为开源软件,遵循 GPL-3.0 license 协议。" +} \ No newline at end of file diff --git a/src/lang/index.ts b/src/lang/index.ts new file mode 100644 index 0000000..869a9fe --- /dev/null +++ b/src/lang/index.ts @@ -0,0 +1,118 @@ +import {createI18n} from "vue-i18n"; + +import {isDev} from "../lib/env"; +import source from "./source.json"; +import enUS from "./en-US.json"; +import zhCN from "./zh-CN.json"; + +let localeInit = false +export const defaultLocale = 'zh-CN' + +export const messageList = [ + { + name: 'en-US', + label: 'English', + messages: enUS + }, + { + name: 'zh-CN', + label: '简体中文', + messages: zhCN + }, +] + +const buildMessages = (): any => { + let messages = {} + for (let m of messageList) { + let msgList = {} + for (let k in source) { + const v = source[k] + if (m.messages[v]) { + msgList[k] = m.messages[v] + } + } + messages[m.name] = msgList + } + return messages +} + +const messages = buildMessages() + +export const i18n = createI18n({ + locale: defaultLocale, + legacy: false, + globalInjection: true, + messages +}); + +if (window.$mapi) { + window.$mapi.config.get('lang', defaultLocale).then((lang: string) => { + i18n.global.locale.value = lang as any + localeInit = true + fireLocaleChange(lang) + }) +} + +export type LocaleItem = { + name: string, + label: string, + active?: boolean +} + +export const listLocales = () => { + let list: LocaleItem[] = messageList + list.forEach((item) => { + item.active = i18n.global.locale.value === item.name + }) + return list +} + +export const getLocale = async () => { + return new Promise((resolve) => { + if (localeInit) { + resolve(i18n.global.locale.value) + } else { + setTimeout(() => { + resolve(getLocale()) + }, 100) + } + }) +} + +let localeChangeListener: Array<(locale: string) => void> = [] + +export const onLocaleChange = (callback: (lang: string) => void) => { + localeChangeListener.push(callback) +} + +const fireLocaleChange = (lang: string) => { + localeChangeListener.forEach((callback) => { + callback(lang) + }) +} + +export const changeLocale = (lang: string) => { + i18n.global.locale.value = lang as any + window.$mapi.config.set('lang', lang).then(() => { + fireLocaleChange(lang) + }) +} + +export const t = (key: string, param: object | null = null) => { + if (isDev) { + getLocale().then((locale) => { + if (!messages[locale][key]) { + console.warn('key not found, writing', locale, key, messages) + window.$mapi.lang.writeSourceKey(key).then(() => { + console.info('writeSourceKey.success', locale, key) + }).catch((e) => { + console.error('writeSourceKey.error', locale, key, e) + }) + } + }) + window.$mapi.lang.writeSourceKeyUse(key).then(() => { + }) + } + // @ts-ignore + return i18n.global.t(key, param as any) +} diff --git a/src/lang/source.json b/src/lang/source.json new file mode 100644 index 0000000..083bf4b --- /dev/null +++ b/src/lang/source.json @@ -0,0 +1,215 @@ +{ + "CUDA": "001fbc6f", + "GPU优先": "041806bc", + "GPU模式": "041b5a9a", + "IP地址": "002d4177", + "OpenCL": "72d74fad", + "上传": "0009c256", + "下载": "000a02d2", + "下载失败": "259ff066", + "下载成功": "25a0c8e1", + "下载模型文件到本地": "0158d357", + "代码查看": "25aba9c4", + "任务已经提交成功,等待克隆完成": "3afff2ff", + "任务已经提交成功,等待合成完成": "3a8639d2", + "任务已经提交成功,等待视频生成完成": "0eed4342", + "使用CPU": "6a7ee8a1", + "使用反馈": "25e596a4", + "使用线上服务": "55517ea8", + "保存": "000a071b", + "信息": "000a0cae", + "值": "0000503c", + "停止中": "013af867", + "停止服务": "26276a3a", + "全屏": "000a37e7", + "全选": "000a6ba1", + "全部显示": "272e273c", + "关于": "000a2b7b", + "关于软件": "263ee622", + "关闭": "000a72da", + "分钟": "000a8359", + "删除": "000a8844", + "刷新": "000a5a59", + "剪切": "000a4cdd", + "功能": "000a823e", + "加载中...": "07f6f37e", + "加载本地模型": "14a7906b", + "参考声音": "27fee544", + "参考声音需要大于 3s,保证声音清晰可见": "23dcec1c", + "参考文字": "28002ef1", + "取消": "000a9472", + "名称": "000aa783", + "启动中": "014654f4", + "启动命令": "27855c20", + "启动服务": "27879f4d", + "命令行工具": "338045e9", + "图像": "000ad991", + "在哪里找到模型文件?": "2615bdd6", + "在本页面导入模型压缩包 zip 文件": "7fca1c6b", + "在线文档": "2986f693", + "基础设置": "29d2c176", + "声明": "000b2b1e", + "声音": "000b5d03", + "声音克隆": "2ab296de", + "声音合成": "2ab2b74b", + "声音角色": "409f6765", + "处理超时": "2a3bec13", + "复制": "000b1ac9", + "失败": "000b5a14", + "如何找到可用模型?": "50e26377", + "如何连接?": "138cdd85", + "安装应用": "2ba96070", + "官网": "000b96b9", + "实际大小": "2be4d86f", + "小时": "000b8bc7", + "已停止": "016acff8", + "已启动 {time}": "09e64baa", + "已经是最新版本": "26aa0dba", + "已连接": "017278f9", + "开发者工具": "62bef086", + "开始克隆": "2c8ac2a6", + "开始合成": "2c8ae313", + "开始生成视频": "447a6567", + "开始解压文件": "4dafbba2", + "强制重载": "2c90728c", + "录制音频": "2c9d803f", + "快": "00005feb", + "慢": "00006162", + "成功": "000c328f", + "截屏": "000c3f65", + "打开日志文件夹": "2eb00a0b", + "排队中": "01884300", + "描述": "000ca601", + "提示": "000c8f6a", + "撤销": "000cc4dc", + "操作": "000c842f", + "支持 wav/mp3 格式": "18b4d0cc", + "收起": "000ccf01", + "放大": "000c9ba9", + "数字人": "018827b3", + "文件已存在": "43fae06d", + "文件未找到": "43d76c93", + "文件管理": "2f5eaf14", + "文件解压完成": "27840925", + "新手指引": "2fb6d4c9", + "日志": "000cb692", + "日志查看": "2fc665d8", + "明亮": "000caa60", + "显示主界面": "26e92d2b", + "暂无日志": "302416b0", + "暂无记录": "3028a9c3", + "暂时还没有模型,请添加模型~": "2c3ba30e", + "暗黑": "000d0b1a", + "更多内容,请查看": "3d718795", + "服务": "000ccd34", + "服务端口": "301d8708", + "未启动": "018dc903", + "未检测到录音设备": "0cc262e2", + "本产品为开源软件,遵循 AGPL-3.0 license 协议。": "1bb341a5", + "本产品为开源软件,遵循 GPL-3.0 license 协议。": "a0cf9088", + "本地模型": "303ab60e", + "本地模型目录": "0c7430b5", + "标识": "000d249f", + "检测更新": "31450307", + "模型": "000d318a", + "模型一键启动压缩包,包含模型服务的配置文件和模型服务程序文件。": "19282801", + "模型信息": "319103b8", + "模型已存在,请先删除": "4ba41237", + "模型服务未运行": "5c572228", + "模型服务目录,包含模型服务的配置文件和模型服务程序文件。": "195c95f2", + "模型未启动": "00e5b339", + "模型添加成功": "5b574e11", + "模型目录识别失败,请选择正确的模型目录": "2e9afa35", + "模型相同版本已存在": "5b0c1880", + "正在删除": "32226b29", + "正在解压 %s": "26ba5101", + "正在解压 {name}": "41dff64a", + "正在解压文件": "4b9efbfc", + "正常": "000d5f35", + "每次询问": "32a57afe", + "添加": "000da405", + "添加模型服务": "5dc07189", + "添加线上模型": "73b1d37a", + "添加视频模板": "79ccebce", + "添加角色": "3345e9a5", + "清空历史": "33d94d01", + "点击关闭时": "5affe2ba", + "版本": "000e3de4", + "环境设置": "35f3e924", + "用户中心": "36bc2185", + "留空会检测使用随机端口": "2ba171ec", + "留空使用默认启动命令": "2bf347f6", + "目录": "000ec627", + "直接退出": "379a84eb", + "相同的种子可以确保每次生成结果数据一致": "5232d376", + "确定": "000ef0ec", + "确定删除模型 {title} v{version} 吗?": "03537548", + "确定裁剪": "382747b5", + "确定退出软件?": "0b2004f2", + "确认删除?": "1e2bcd5b", + "确认提交": "38d6305f", + "秒": "000079d2", + "端口": "000f36d4", + "类型": "000f6a70", + "粘贴": "000fa39c", + "线上模型": "3acfb9f5", + "编辑": "000ff33b", + "缩小": "000fc206", + "自动": "00100dfe", + "自动滚动": "3c52458c", + "裁剪音频": "3f729287", + "视图": "001105f8", + "视频": "0011478b", + "视频合成": "40e81fd3", + "视频模板": "40ead249", + "视频模版": "40eadd12", + "视频生成": "40ec219c", + "记住我的选择": "160fdc0e", + "设备": "00114509", + "设置": "00116b70", + "访问官方提供的可用模型页面,下载模型文件到本地": "216e470a", + "访问官网": "41c4d108", + "语言": "00117bb3", + "语音合成": "41e0472e", + "请求错误": "4148da61", + "请输入合成内容": "512b163b", + "请选择": "021f3e37", + "请选择声音": "08ac1686", + "请选择声音角色": "720a177b", + "跟随系统": "42c281d4", + "跨语种": "02261f68", + "输入关键词过滤": "5e1bac63", + "输入语音内容开始克隆": "39600ad2", + "输入语音内容开始合成": "39602b3f", + "运行中": "022cab31", + "返回": "0011c18a", + "连接中": "02286f86", + "连接网络设备": "37ce23fb", + "连接设备": "42ed4ed0", + "退出": "0011c1fa", + "适配": "0012018b", + "选择声音文件": "65bc9692", + "选择声音角色": "65baa0c4", + "选择模型": "42fa058a", + "选择模型ZIP文件": "6c9b6559", + "选择模型服务目录": "26d1d59f", + "选择视频文件": "7bf1ff1a", + "选择角色": "42fe06e0", + "选择路径": "42fe6375", + "部分模型在跨语种克隆时需要特殊处理,因此需要标记是否为跨语种克隆": "58055d93", + "重做": "0011f82d", + "重启": "0011fc02", + "重新加载": "43cf49a0", + "重新录制": "43d09644", + "重新选择": "43d68cc3", + "错误": "001299f6", + "随机推理种子": "3757d9f4", + "随机生成": "4602bc9c", + "隐藏": "0012c13f", + "隐藏其他": "4671999f", + "隐藏窗口": "467699ab", + "需要输入参考声音的完整文字内容": "37f08659", + "音色": "0012e8df", + "音速": "0012f68c", + "默认端口 {port}": "3d329aff" +} \ No newline at end of file diff --git a/src/lang/zh-CN.json b/src/lang/zh-CN.json new file mode 100644 index 0000000..4facab1 --- /dev/null +++ b/src/lang/zh-CN.json @@ -0,0 +1,217 @@ +{ + "0000503c": "值", + "00005feb": "快", + "00006162": "慢", + "000079d2": "秒", + "0009c256": "上传", + "000a02d2": "下载", + "000a071b": "保存", + "000a0cae": "信息", + "000a2b7b": "关于", + "000a37e7": "全屏", + "000a4cdd": "剪切", + "000a5a59": "刷新", + "000a6ba1": "全选", + "000a72da": "关闭", + "000a823e": "功能", + "000a8359": "分钟", + "000a8844": "删除", + "000a9472": "取消", + "000aa783": "名称", + "000ad991": "图像", + "000b1ac9": "复制", + "000b2b1e": "声明", + "000b5a14": "失败", + "000b5d03": "声音", + "000b8bc7": "小时", + "000b96b9": "官网", + "000c328f": "成功", + "000c3f65": "截屏", + "000c842f": "操作", + "000c8f6a": "提示", + "000c9ba9": "放大", + "000ca601": "描述", + "000caa60": "明亮", + "000cb692": "日志", + "000cc4dc": "撤销", + "000ccd34": "服务", + "000ccf01": "收起", + "000d0b1a": "暗黑", + "000d249f": "标识", + "000d318a": "模型", + "000d5f35": "正常", + "000da405": "添加", + "000e3de4": "版本", + "000ec627": "目录", + "000ef0ec": "确定", + "000f36d4": "端口", + "000f6a70": "类型", + "000fa39c": "粘贴", + "000fc206": "缩小", + "000ff33b": "编辑", + "00100dfe": "自动", + "001105f8": "视图", + "00114509": "设备", + "0011478b": "视频", + "00116b70": "设置", + "00117bb3": "语言", + "0011c18a": "返回", + "0011c1fa": "退出", + "0011f82d": "重做", + "0011fc02": "重启", + "0012018b": "适配", + "001299f6": "错误", + "0012c13f": "隐藏", + "0012e8df": "音色", + "0012f68c": "音速", + "001fbc6f": "CUDA", + "002d4177": "IP地址", + "00e5b339": "模型未启动", + "013af867": "停止中", + "014654f4": "启动中", + "0158d357": "下载模型文件到本地", + "016acff8": "已停止", + "017278f9": "已连接", + "018827b3": "数字人", + "01884300": "排队中", + "018dc903": "未启动", + "021f3e37": "请选择", + "02261f68": "跨语种", + "02286f86": "连接中", + "022cab31": "运行中", + "03537548": "确定删除模型 {title} v{version} 吗?", + "041806bc": "GPU优先", + "041b5a9a": "GPU模式", + "04f71f68": "服务", + "07f6f37e": "加载中...", + "08ac1686": "请选择声音", + "09e64baa": "已启动 {time}", + "0b2004f2": "确定退出软件?", + "0c7430b5": "本地模型目录", + "0cc262e2": "未检测到录音设备", + "0eed4342": "任务已经提交成功,等待视频生成完成", + "138cdd85": "如何连接?", + "14a7906b": "加载本地模型", + "160fdc0e": "记住我的选择", + "18b4d0cc": "支持 wav/mp3 格式", + "19282801": "模型一键启动压缩包,包含模型服务的配置文件和模型服务程序文件。", + "195c95f2": "模型服务目录,包含模型服务的配置文件和模型服务程序文件。", + "1bb341a5": "本产品为开源软件,遵循 AGPL-3.0 license 协议。", + "1e2bcd5b": "确认删除?", + "216e470a": "访问官方提供的可用模型页面,下载模型文件到本地", + "23dcec1c": "参考声音需要大于 3s,保证声音清晰可见", + "259ff066": "下载失败", + "25a0c8e1": "下载成功", + "25aba9c4": "代码查看", + "25e596a4": "使用反馈", + "2615bdd6": "在哪里找到模型文件?", + "26276a3a": "停止服务", + "263ee622": "关于软件", + "26aa0dba": "已经是最新版本", + "26ba5101": "正在解压 %s", + "26d1d59f": "选择模型服务目录", + "26e92d2b": "显示主界面", + "272e273c": "全部显示", + "27840925": "文件解压完成", + "27855c20": "启动命令", + "27879f4d": "启动服务", + "27fee544": "参考声音", + "28002ef1": "参考文字", + "2986f693": "在线文档", + "29d2c176": "基础设置", + "2a3bec13": "处理超时", + "2ab296de": "声音克隆", + "2ab2b74b": "声音合成", + "2ab2ccc4": "声音角色", + "2ba171ec": "留空会检测使用随机端口", + "2ba96070": "安装应用", + "2be4d86f": "实际大小", + "2bf347f6": "留空使用默认启动命令", + "2c3ba30e": "暂时还没有模型,请添加模型~", + "2c8ac2a6": "开始克隆", + "2c8ae313": "开始合成", + "2c90728c": "强制重载", + "2c9d803f": "录制音频", + "2e9afa35": "模型目录识别失败,请选择正确的模型目录", + "2eb00a0b": "打开日志文件夹", + "2f5eaf14": "文件管理", + "2fb6d4c9": "新手指引", + "2fc665d8": "日志查看", + "301d8708": "服务端口", + "302416b0": "暂无日志", + "3028a9c3": "暂无记录", + "303ab60e": "本地模型", + "31450307": "检测更新", + "319103b8": "模型信息", + "32226b29": "正在删除", + "32a57afe": "每次询问", + "3345e9a5": "添加角色", + "338045e9": "命令行工具", + "33d94d01": "清空历史", + "35f3e924": "环境设置", + "36bc2185": "用户中心", + "3757d9f4": "随机推理种子", + "379a84eb": "直接退出", + "37ce23fb": "连接网络设备", + "37f08659": "需要输入参考声音的完整文字内容", + "382747b5": "确定裁剪", + "38d6305f": "确认提交", + "39600ad2": "输入语音内容开始克隆", + "39602b3f": "输入语音内容开始合成", + "3a8639d2": "任务已经提交成功,等待合成完成", + "3acfb9f5": "线上模型", + "3afff2ff": "任务已经提交成功,等待克隆完成", + "3c52458c": "自动滚动", + "3d329aff": "默认端口 {port}", + "3d718795": "更多内容,请查看", + "3f729287": "裁剪音频", + "409f6765": "声音角色", + "40e81fd3": "视频合成", + "40ead249": "视频模板", + "40eadd12": "视频模版", + "40ec219c": "视频生成", + "4148da61": "请求错误", + "41c4d108": "访问官网", + "41dff64a": "正在解压 {name}", + "41e0472e": "语音合成", + "42c281d4": "跟随系统", + "42ed4ed0": "连接设备", + "42fa058a": "选择模型", + "42fe06e0": "选择角色", + "42fe6375": "选择路径", + "43cf49a0": "重新加载", + "43d09644": "重新录制", + "43d68cc3": "重新选择", + "43d76c93": "文件未找到", + "43fae06d": "文件已存在", + "447a6567": "开始生成视频", + "4602bc9c": "随机生成", + "4671999f": "隐藏其他", + "467699ab": "隐藏窗口", + "4b9efbfc": "正在解压文件", + "4ba41237": "模型已存在,请先删除", + "4dafbba2": "开始解压文件", + "50e26377": "如何找到可用模型?", + "512b163b": "请输入合成内容", + "5232d376": "相同的种子可以确保每次生成结果数据一致", + "55517ea8": "使用线上服务", + "58055d93": "部分模型在跨语种克隆时需要特殊处理,因此需要标记是否为跨语种克隆", + "5affe2ba": "点击关闭时", + "5b0c1880": "模型相同版本已存在", + "5b574e11": "模型添加成功", + "5c572228": "模型服务未运行", + "5dc07189": "添加模型服务", + "5e1bac63": "输入关键词过滤", + "62bef086": "开发者工具", + "65baa0c4": "选择声音角色", + "65bc9692": "选择声音文件", + "6a7ee8a1": "使用CPU", + "6c9b6559": "选择模型ZIP文件", + "720a177b": "请选择声音角色", + "72d74fad": "OpenCL", + "73b1d37a": "添加线上模型", + "79ccebce": "添加视频模板", + "7bf1ff1a": "选择视频文件", + "7fca1c6b": "在本页面导入模型压缩包 zip 文件", + "a0cf9088": "本产品为开源软件,遵循 GPL-3.0 license 协议。" +} \ No newline at end of file diff --git a/src/layouts/Main.vue b/src/layouts/Main.vue new file mode 100644 index 0000000..a07e9dc --- /dev/null +++ b/src/layouts/Main.vue @@ -0,0 +1,61 @@ + + diff --git a/src/layouts/Raw.vue b/src/layouts/Raw.vue new file mode 100644 index 0000000..6882610 --- /dev/null +++ b/src/layouts/Raw.vue @@ -0,0 +1,34 @@ + + diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..d4f80a9 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,89 @@ +import axios, {type AxiosInstance, type AxiosRequestConfig} from "axios" +import {merge} from "lodash-es" +import {Dialog} from "./dialog"; +import {AppConfig} from "../config"; +import {user} from "../store/modules/user"; + +function createService() { + const service = axios.create() + service.interceptors.request.use( + (config) => config, + (error) => Promise.reject(error) + ) + service.interceptors.response.use( + (response) => { + const apiData = response.data + const responseType = response.request?.responseType + if (responseType === "blob" || responseType === "arraybuffer") return apiData + const code = apiData.code + // if (code === undefined) { + // ElMessage.error("非本系统的接口") + // return Promise.reject(new Error("非本系统的接口")) + // } + // switch (code) { + // case 0: + // // 本系统采用 code === 0 来表示没有业务错误 + // return apiData + // case 401: + // // Token 过期时 + // return logout() + // default: + // // 不是正确的 code + // ElMessage.error(apiData.message || "Error") + // return Promise.reject(new Error("Error")) + // } + return apiData + }, + (error) => { + return Promise.reject(error) + } + ) + return service +} + +function createRequest(service: AxiosInstance) { + return function (config: AxiosRequestConfig): Promise { + const defaultConfig = { + headers: { + 'User-Agent': window.$mapi.app.getUserAgent(), + 'Api-Token': user.apiToken ? user.apiToken : undefined, + 'Content-Type': 'application/json', + }, + timeout: 60 * 1000, + baseURL: AppConfig.apiBaseUrl, + data: {} + } + // 将默认配置 defaultConfig 和传入的自定义配置 config 进行合并成为 mergeConfig + const mergeConfig = merge(defaultConfig, config) + return service(mergeConfig).then(response => response as T) + } +} + +const service = createService() + +export const request = createRequest(service) + + +export const defaultResponseProcessor = (res: ApiResult, success: Function | null = null, error: Function | null = null) => { + if (res.code) { + if (error) { + if (!error(res)) { + Dialog.tipError(res.msg) + } + } else { + Dialog.tipError(res.msg) + } + } else { + if (success) { + if (success(res)) { + if (res.msg) { + Dialog.tipSuccess(res.msg) + } + } + } else { + if (res.msg) { + Dialog.tipSuccess(res.msg) + } + } + } +} diff --git a/src/lib/audio.ts b/src/lib/audio.ts new file mode 100644 index 0000000..0c257af --- /dev/null +++ b/src/lib/audio.ts @@ -0,0 +1,123 @@ +export const AudioUtil = { + audioBufferEmpty() { + const emptyLength = 1024 * 100; + const buffer = new AudioBuffer({ + length: emptyLength, + numberOfChannels: 2, + sampleRate: 8000 + }) + for (let channel = 0; channel < 2; channel++) { + const data = buffer.getChannelData(channel) + for (let i = 0; i < emptyLength; i++) { + data[i] = 0 + } + } + return buffer + }, + audioBufferCut(buffer: AudioBuffer, start: number, end: number) { + const numChannels = buffer.numberOfChannels + const sampleRate = buffer.sampleRate + const length = buffer.length + const startOffset = Math.floor(start * sampleRate) + const endOffset = Math.floor(end * sampleRate) + const targetLength = endOffset - startOffset + const targetBuffer = new AudioBuffer({ + length: targetLength, + numberOfChannels: numChannels, + sampleRate: sampleRate + }) + for (let channel = 0; channel < numChannels; channel++) { + const sourceChannel = buffer.getChannelData(channel) + const targetChannel = targetBuffer.getChannelData(channel) + for (let i = 0; i < targetLength; i++) { + targetChannel[i] = sourceChannel[startOffset + i] + } + } + return targetBuffer + }, + audioBufferConvert(buffer: AudioBuffer, targetSampleRate: number, targetChannelNum: number) { + targetChannelNum = targetChannelNum || buffer.numberOfChannels + const numChannels = buffer.numberOfChannels + const sampleRate = buffer.sampleRate + const length = buffer.length + const targetLength = Math.floor(length * targetSampleRate / sampleRate) + const targetBuffer = new AudioBuffer({ + length: targetLength, + numberOfChannels: targetChannelNum, + sampleRate: targetSampleRate + }) + for (let channel = 0; channel < targetChannelNum; channel++) { + const sourceChannel = buffer.getChannelData(channel % numChannels) + const targetChannel = targetBuffer.getChannelData(channel) + for (let i = 0; i < targetLength; i++) { + const sourceIndex = Math.floor(i * sampleRate / targetSampleRate) + targetChannel[i] = sourceChannel[sourceIndex] + } + } + return targetBuffer + }, + audioBufferToWav(buffer: AudioBuffer) { + const numChannels = buffer.numberOfChannels + const sampleRate = buffer.sampleRate + const format = 1 + const bitDepth = 16 + const bytesPerSample = bitDepth / 8 + const blockAlign = numChannels * bytesPerSample + const dataSize = buffer.length * blockAlign + const view = new DataView(new ArrayBuffer(44 + dataSize)) + view.setUint32(0, 1380533830, false) + view.setUint32(4, 44 + dataSize - 8, true) + view.setUint32(8, 1463899717, false) + view.setUint32(12, 1718449184, false) + view.setUint32(16, 16, true) + view.setUint16(20, format, true) + view.setUint16(22, numChannels, true) + view.setUint32(24, sampleRate, true) + view.setUint32(28, sampleRate * blockAlign, true) + view.setUint16(32, blockAlign, true) + view.setUint16(34, bitDepth, true) + view.setUint32(36, 1635017060, true) + view.setUint32(40, dataSize, true) + let offset = 44; + for (let i = 0; i < buffer.length; i++) { + for (let channel = 0; channel < numChannels; channel++) { + const sample = buffer.getChannelData(channel)[i]; + const intSample = Math.max(-1, Math.min(1, sample)); + view.setInt16(offset, Math.round(intSample < 0 ? intSample * 0x8000 : intSample * 0x7FFF), true); + offset += 2; + } + } + return new Uint8Array(view.buffer) + }, + audioBufferToWavBlob(buffer: AudioBuffer) { + return new Blob([this.audioBufferToWav(buffer)], {type: 'audio/wav'}) + }, + fileToAudioBuffer(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const arrayBuffer = reader.result as ArrayBuffer + const context = new AudioContext() + context.decodeAudioData(arrayBuffer, resolve, reject) + } + reader.readAsArrayBuffer(file) + }) + }, + parseAudioFile(file: File) { + return new Promise<{ + duration: number, + sampleRate: number, + numberOfChannels: number + }>((resolve, reject) => { + this.fileToAudioBuffer(file) + .then(buffer => { + resolve({ + duration: buffer.duration, + sampleRate: buffer.sampleRate, + numberOfChannels: buffer.numberOfChannels, + }) + }) + .catch(reject) + }) + } +} diff --git a/src/lib/components/Prompt.vue b/src/lib/components/Prompt.vue new file mode 100644 index 0000000..8a556c3 --- /dev/null +++ b/src/lib/components/Prompt.vue @@ -0,0 +1,21 @@ + + + + diff --git a/src/lib/dialog.ts b/src/lib/dialog.ts new file mode 100644 index 0000000..21a69ad --- /dev/null +++ b/src/lib/dialog.ts @@ -0,0 +1,100 @@ +import {Message, MessageReturn, Modal} from '@arco-design/web-vue'; +import Prompt from "./components/Prompt.vue"; +import {h} from "vue"; +import {i18n, t} from "../lang"; + +let loadingLayers: MessageReturn[] = [] + +export const Dialog = { + tipSuccess: (msg: string) => { + Message.success(msg); + }, + tipError: (msg: string) => { + Message.error(msg); + }, + confirm: (content: string, title: string | null = null): Promise => { + title = title || t('提示') + return new Promise((resolve, reject) => { + Modal.confirm({ + title, + content, + titleAlign: 'start', + simple: false, + width: '25rem', + modalClass: 'arco-modal-confirm', + okText: t('确定'), + cancelText: t('取消'), + onOk: () => { + resolve(); + }, + onCancel: () => { + // reject(); + } + }); + }); + }, + alertSuccess: (content: string, title: string | null = null): Promise => { + title = title || t('提示') + return new Promise((resolve) => { + Modal.confirm({ + title, + content, + simple: false, + width: '25rem', + onOk: () => { + resolve(); + } + }); + }); + }, + alertError: (content: string, title: string | null = null): Promise => { + title = title || t('提示') + return new Promise((resolve) => { + Modal.confirm({ + title, + content, + simple: false, + width: '25rem', + onOk: () => { + resolve(); + } + }); + }); + }, + loadingOn: (content: string | null = null) => { + content = content || t('加载中...') + const loading = Message.loading({ + content, + duration: 0 + }); + loadingLayers.push(loading) + }, + loadingOff: () => { + const loading = loadingLayers.pop() + if (loading) { + loading.close() + } + }, + prompt: (content: string, defaultValue: string = ''): Promise => { + return new Promise((resolve) => { + let inputValue = defaultValue + Modal.open({ + title: content, + simple: false, + titleAlign: 'start', + content: () => { + return h(Prompt, { + value: defaultValue, + onChange: (value: string) => { + inputValue = value + } + }) + }, + width: '25rem', + onOk: () => { + resolve(inputValue); + } + }); + }); + } +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..f793ce5 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1 @@ +export const isDev = process.env.NODE_ENV === "development"; diff --git a/src/lib/error.ts b/src/lib/error.ts new file mode 100644 index 0000000..fa0da25 --- /dev/null +++ b/src/lib/error.ts @@ -0,0 +1,19 @@ +export function mapError(msg: any) { + if (typeof msg !== 'string') { + msg = msg.toString() + } + const map = { + 'PublishVersionNotMatch': '插件版本不匹配', + 'PluginNotExists': '插件不存在', + 'PluginFormatError': '插件格式错误', + 'PluginAlreadyExists': '插件已存在', + 'PluginNotSupportPlatform': '插件不支持当前平台', + 'PluginVersionNotMatch': 'FocusAny版本不满足插件要求', + } + for (let key in map) { + if (msg.includes(key)) { + return map[key] + } + } + return msg +} diff --git a/src/lib/event.ts b/src/lib/event.ts new file mode 100644 index 0000000..d232fb4 --- /dev/null +++ b/src/lib/event.ts @@ -0,0 +1,20 @@ +import {EventType} from "../types/Event.js"; +import {TinyEmitter} from "tiny-emitter"; + +const emitter = new TinyEmitter(); + +export const GlobalEvent = { + on: function (event: EventType, callback: Function) { + emitter.on(event, callback) + }, + once: function (event: EventType, callback: Function) { + emitter.once(event, callback) + }, + off: function (event: EventType, callback: Function) { + emitter.off(event, callback) + }, + emit: function (event: EventType, ...args: any[]) { + emitter.emit(event, ...args) + }, +} + diff --git a/src/lib/file.ts b/src/lib/file.ts new file mode 100644 index 0000000..0f0935e --- /dev/null +++ b/src/lib/file.ts @@ -0,0 +1,43 @@ +export const FileUtil = { + extensionToType(extension: string) { + const mime = { + 'mp3': 'audio/mpeg', + 'wav': 'audio/wav', + } + return mime[extension] || '' + }, + bufferToBlob(buffer: ArrayBuffer, type: string) { + if (!type.indexOf('/')) { + type = this.extensionToType(type) + } + return new Blob([buffer], {type: type}) + }, + blobToFile(blob: Blob, name: string) { + return new File([blob], name) + }, + getExt(path: string) { + const ext = path.lastIndexOf('.') + if (ext >= 0) { + return path.substring(ext + 1) + } + return '' + }, + getBaseName(path: string, withExt: boolean = false) { + // windows + if (path.includes('\\')) { + path = path.replace(/\\/g, '/') + } + const last = path.lastIndexOf('/') + if (last >= 0) { + path = path.substring(last + 1) + } + if (!withExt) { + const ext = path.lastIndexOf('.') + if (ext >= 0) { + path = path.substring(0, ext) + } + return path + } + return path + } +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000..4b5f1f2 --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,10 @@ +import Showdown from "showdown" + + +const converter = new Showdown.Converter() + +export const MarkdownUtil = { + toHtml(markdown: string): string { + return converter.makeHtml(markdown) + }, +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..5b30c42 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,75 @@ +export const StorageUtil = { + /** + * @Util 存储数据 + * @param key String 键 + * @param value String|Object|Array 值 + */ + set: function (key: string, value: any): void { + window.localStorage.setItem(key, JSON.stringify(value)) + }, + /** + * @Util 获取数据 + * @param key String 键 + * @param defaultValue String|Object|Array 默认值 + * @return String|Object|Array 返回值 + */ + get: function (key: string, defaultValue: any): any { + let value = window.localStorage.getItem(key) + if (null === value) { + return defaultValue + } + try { + return JSON.parse(value); + } catch (e) { + } + return defaultValue + }, + /** + * @Util 获取数组数据 + * @param key String 键 + * @param defaultValue Array 默认值 + * @return Array 返回值 + */ + getArray: function (key: string, defaultValue?: any): any { + defaultValue = defaultValue || [] + let value = window.localStorage.getItem(key) + if (!value) { + return defaultValue + } + try { + value = JSON.parse(value) + if (!Array.isArray(value)) { + return defaultValue + } + return value + } catch (e) { + } + return defaultValue + }, + /** + * @Util 获取对象数据 + * @param key String 键 + * @param defaultValue Object 默认值 + * @return Array 返回值 + */ + getObject: function (key: string, defaultValue?: any): any { + defaultValue = defaultValue || {} + let value = window.localStorage.getItem(key) + if (!value) { + return defaultValue + } + try { + value = JSON.parse(value) + if (null === value) { + return defaultValue + } + if (!Array.isArray(value) && (typeof value === 'object')) { + return value + } + return defaultValue + } catch (e) { + } + return defaultValue + }, +} + diff --git a/src/lib/ui.ts b/src/lib/ui.ts new file mode 100644 index 0000000..9afc004 --- /dev/null +++ b/src/lib/ui.ts @@ -0,0 +1,155 @@ +type DomListener = { + dom: HTMLElement + callback: (width: number, height: number) => void +} +let domListeners: DomListener[] = [] +const resizeObserver = new ResizeObserver((entries) => { + entries.forEach(entry => { + domListeners.forEach(item => { + if (item.dom === entry.target) { + const {width, height} = entry.contentRect + item.callback(width, height) + } + }) + }) +}) + +type WindowListener = { + callback: (width: number, height: number) => void +} +let windowListeners: WindowListener[] = [] +window.addEventListener('resize', () => { + windowListeners.forEach(item => { + item.callback(window.innerWidth, window.innerHeight) + }) +}) + +export const UI = { + onWindowResize(callback: (width: number, height: number) => void) { + windowListeners.push({callback}) + }, + offWindowResize(callback: (width: number, height: number) => void) { + windowListeners = windowListeners.filter(item => item.callback !== callback) + }, + onResize(dom: HTMLElement | null, callback: (width: number, height: number) => void) { + if (!dom) return + domListeners.push({dom, callback}) + resizeObserver.observe(dom) + }, + offResize(dom: HTMLElement | null) { + if (!dom) return + domListeners = domListeners.filter(item => item.dom !== dom) + resizeObserver.unobserve(dom) + }, + fireResize(dom: HTMLElement) { + domListeners.forEach(item => { + if (item.dom === dom) { + const {width, height} = dom.getBoundingClientRect() + item.callback(width, height) + } + }) + } +} + + +export class TabContentScroller { + private option: { + activeClass: string + } + private tabContainer: HTMLElement + private contentContainer: HTMLElement + private isScrolling = false + private scrollEndTimer: any | null = null + private scrollEndCallback: (() => void) | null = null + + constructor(tabContainer: HTMLElement, contentContainer: HTMLElement, option: {} = {}) { + this.option = Object.assign({ + activeClass: 'active', + }, option) || {} + this.tabContainer = tabContainer + this.contentContainer = contentContainer + this.init() + } + + + init() { + this.tabContainer.addEventListener('click', this.onTabClickEvent.bind(this)) + this.contentContainer.addEventListener('scroll', this.onContentScrollEvent.bind(this)) + } + + destroy() { + this.tabContainer.removeEventListener('click', this.onTabClickEvent.bind(this)) + this.contentContainer.removeEventListener('scroll', this.onContentScrollEvent.bind(this)) + } + + onTabClickEvent(e: MouseEvent) { + const parentSection = (e.target as HTMLElement).closest('[data-section]') + const name = parentSection?.getAttribute('data-section') + if (name) { + this.scrollTo(name) + this.scrollEndCallback = () => { + this.forceActiveTab(name) + } + } + } + + onContentScrollEvent(e: Event) { + this.isScrolling = true + if (this.scrollEndTimer) { + clearTimeout(this.scrollEndTimer) + } + this.scrollEndTimer = setTimeout(() => { + this.isScrolling = false + this.scrollEndTimer = null + if (this.scrollEndCallback) { + this.scrollEndCallback() + this.scrollEndCallback = null + } + }, 100) + const tabs = this.tabContainer.querySelectorAll('[data-section]') + for (let i = 0; i < tabs.length; i++) { + const tab = tabs[i] + tab.classList.remove(this.option.activeClass) + } + const sections = this.contentContainer.querySelectorAll('[data-section]') + for (let i = 0; i < sections.length; i++) { + const section = sections[i] + const rect = section.getBoundingClientRect() + if (rect.top < 100 && rect.bottom > 100) { + const name = section.getAttribute('data-section') || '' + const tab = this.tabContainer.querySelector(`[data-section="${name}"]`) + if (tab) { + tab.classList.add(this.option.activeClass) + } + break + } + } + } + + forceActiveTab(name: string) { + const tabs = this.tabContainer.querySelectorAll('[data-section]') + for (let i = 0; i < tabs.length; i++) { + const tab = tabs[i] + const tabName = tab.getAttribute('data-section') || '' + if (tabName === name) { + tab.classList.add(this.option.activeClass) + } else { + tab.classList.remove(this.option.activeClass) + } + } + } + + scrollTo(name: string) { + const tab = this.tabContainer.querySelector(`[data-section="${name}"]`) + if (!tab) { + return + } + const content = this.contentContainer.querySelector(`[data-section="${name}"]`) + if (!content) { + return + } + content.scrollIntoView({ + behavior: 'smooth' + }) + } +} diff --git a/src/lib/util.ts b/src/lib/util.ts new file mode 100644 index 0000000..e82db60 --- /dev/null +++ b/src/lib/util.ts @@ -0,0 +1,156 @@ +import {Base64} from 'js-base64'; +import dayjs from "dayjs"; +import {t} from "../lang"; + +export const sleep = (time = 1000) => { + return new Promise((resolve) => { + setTimeout(() => resolve(true), time) + }) +} + +export const StringUtil = { + random(length: number = 16) { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result + }, + uuid: () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) + } +} + +export const TimeUtil = { + timestampMS() { + return new Date().getTime() + }, + format(time: number, format: string = 'YYYY-MM-DD HH:mm:ss') { + return dayjs(time).format(format) + }, + formatDate(time: number) { + return dayjs(time).format('YYYY-MM-DD') + }, + dateString() { + return dayjs().format('YYYYMMDD') + }, + datetimeString() { + return dayjs().format('YYYYMMDD_HHmmss') + }, + secondsToTime(seconds: number) { + seconds = parseInt(seconds.toString()) + let h: any = Math.floor(seconds / 3600) + let m: any = Math.floor(seconds % 3600 / 60) + let s: any = Math.floor(seconds % 60) + if (h < 10) h = '0' + h + if (m < 10) m = '0' + m + if (s < 10) s = '0' + s + return '00' == h ? `${m}:${s}` : `${h}:${m}:${s}` + }, + secondsToHuman(seconds: number) { + seconds = parseInt(seconds.toString()) + let h: any = Math.floor(seconds / 3600) + let m: any = Math.floor(seconds % 3600 / 60) + let s: any = Math.floor(seconds % 60) + const result: string[] = [] + if (h > 0) result.push(`${h}${t('小时')}`) + if (m > 0) result.push(`${m}${t('分钟')}`) + if (s > 0) result.push(`${s}${t('秒')}`) + return result.join('') + }, + replacePattern(text: string) { + return text.replaceAll('{year}', dayjs().format('YYYY')) + .replaceAll('{month}', dayjs().format('MM')) + .replaceAll('{day}', dayjs().format('DD')) + .replaceAll('{hour}', dayjs().format('HH')) + .replaceAll('{minute}', dayjs().format('mm')) + .replaceAll('{second}', dayjs().format('ss')) + } +} + +export const EncodeUtil = { + base64Encode(str: string) { + return Base64.encode(str) + }, + base64Decode(str: string) { + return Base64.decode(str) + } +} + +export const VersionUtil = { + /** + * 检测版本是否匹配 + * @param v string + * @param match string 如 * 或 >=1.0.0 或 >1.0.0 或 <1.0.0 或 <=1.0.0 或 1.0.0 + */ + match(v: string, match: string) { + if (match === '*') { + return true + } + if (match.startsWith('>=') && this.ge(v, match.substring(2))) { + return true + } + if (match.startsWith('>') && this.gt(v, match.substring(1))) { + return true + } + if (match.startsWith('<=') && this.le(v, match.substring(2))) { + return true + } + if (match.startsWith('<') && this.lt(v, match.substring(1))) { + return true + } + return this.eq(v, match) + }, + compare(v1: string, v2: string) { + const v1Arr = v1.split('.') + const v2Arr = v2.split('.') + for (let i = 0; i < v1Arr.length; i++) { + const v1Num = parseInt(v1Arr[i]) + const v2Num = parseInt(v2Arr[i]) + if (v1Num > v2Num) { + return 1 + } else if (v1Num < v2Num) { + return -1 + } + } + return 0 + }, + gt(v1: string, v2: string) { + return VersionUtil.compare(v1, v2) > 0 + }, + ge(v1: string, v2: string) { + return VersionUtil.compare(v1, v2) >= 0 + }, + lt(v1: string, v2: string) { + return VersionUtil.compare(v1, v2) < 0 + }, + le: (v1: string, v2: string) => { + return VersionUtil.compare(v1, v2) <= 0 + }, + eq: (v1: string, v2: string) => { + return VersionUtil.compare(v1, v2) === 0 + } +} + +export const BrowserUtil = { + isMac() { + return navigator.platform.toUpperCase().indexOf('MAC') >= 0 + }, + isWindows() { + return navigator.platform.toUpperCase().indexOf('WIN') >= 0 + }, + isLinux() { + return navigator.platform.toUpperCase().indexOf('LINUX') >= 0 + } +} + +export const ShellUtil = { + quotaPath(p: string) { + return `"${p}"` + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1770d10 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,37 @@ +import {createApp} from 'vue' +import App from './App.vue' +import router from './router' +import store from "./store"; + +import ArcoVue, {Message} from '@arco-design/web-vue' +import ArcoVueIcon from '@arco-design/web-vue/es/icon' +import '@arco-design/web-vue/dist/arco.css' + +import {i18n, t} from "./lang"; + +import './style.less' +import {Dialog} from "./lib/dialog"; + +import {CommonComponents} from "./components/common"; +import {TaskManager} from "./task"; +import {useSettingStore} from "./store/modules/setting"; + +const settingStore = useSettingStore() + +const app = createApp(App) +app.use(ArcoVue) +app.use(ArcoVueIcon) +app.use(CommonComponents) +app.use(i18n) +app.use(store) +app.use(router) +Message._context = app._context +app.config.globalProperties.$mapi = window.$mapi +app.config.globalProperties.$dialog = Dialog +app.config.globalProperties.$t = t as any +TaskManager.init() + +app.mount('#app') + .$nextTick(() => { + postMessage({payload: 'removeLoading'}, '*') + }) diff --git a/src/pages/DetachWindow/operate.ts b/src/pages/DetachWindow/operate.ts new file mode 100644 index 0000000..5f13839 --- /dev/null +++ b/src/pages/DetachWindow/operate.ts @@ -0,0 +1,53 @@ +import {Menu} from "@electron/remote"; + +export const useDetachWindowOperate = ({plugin}) => { + const doShowZoomMenu = () => { + const menuTemplate: any[] = [] + const zoomPercent = [ + 50, 67, 75, 80, 90, 100, + 110, 125, 150, 175, 200, 250, 300, + ] + for (let z of zoomPercent) { + menuTemplate.push({ + label: `${z}%`, + click: async () => { + await window.$mapi.manager.setDetachPluginZoom(z) + plugin.value.runtime.config.zoom = z + } + }) + } + Menu.buildFromTemplate(menuTemplate).popup(); + }; + + const doShowMoreMenu = () => { + const autoDetach = !!plugin.value.runtime.config.autoDetach + const menuTemplate: any[] = [ + { + label: '打开调试窗口', + click: async () => { + await window.$mapi.manager.openDetachPluginDevTools() + } + }, + { + label: '自动分离为独立窗口显示', + type: 'checkbox', + checked: autoDetach, + click: async () => { + await window.$mapi.manager.setPluginAutoDetach(!autoDetach) + plugin.value.runtime.config = await window.$mapi.manager.getPluginConfig(plugin.value.name) + } + } + ] + Menu.buildFromTemplate(menuTemplate).popup(); + } + + const doClose = async () => { + await window.$mapi.manager.closeDetachPlugin() + } + + return { + doShowZoomMenu, + doShowMoreMenu, + doClose + } +} diff --git a/src/pages/FastPanel/FastPanelResult.vue b/src/pages/FastPanel/FastPanelResult.vue new file mode 100644 index 0000000..105f9d9 --- /dev/null +++ b/src/pages/FastPanel/FastPanelResult.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/src/pages/FastPanel/FastPanelSearch.vue b/src/pages/FastPanel/FastPanelSearch.vue new file mode 100644 index 0000000..8c4a3d7 --- /dev/null +++ b/src/pages/FastPanel/FastPanelSearch.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/pages/FastPanel/Lib/resultOperate.ts b/src/pages/FastPanel/Lib/resultOperate.ts new file mode 100644 index 0000000..df63817 --- /dev/null +++ b/src/pages/FastPanel/Lib/resultOperate.ts @@ -0,0 +1,16 @@ +import {useManagerStore} from "../../../store/modules/manager"; +import {ActionRecord} from "../../../types/Manager"; + +const manager = useManagerStore() + +export const useResultOperate = () => { + + const doOpenAction = async (action: ActionRecord) => { + // await manager.showMainWindow() + await manager.openAction(action) + } + + return { + doOpenAction, + } +} diff --git a/src/pages/Home.vue b/src/pages/Home.vue new file mode 100644 index 0000000..7836eae --- /dev/null +++ b/src/pages/Home.vue @@ -0,0 +1,28 @@ + + + + diff --git a/src/pages/Main/Components/ResultItem.vue b/src/pages/Main/Components/ResultItem.vue new file mode 100644 index 0000000..e8b2e18 --- /dev/null +++ b/src/pages/Main/Components/ResultItem.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/pages/Main/Lib/entryListener.ts b/src/pages/Main/Lib/entryListener.ts new file mode 100644 index 0000000..cf7068f --- /dev/null +++ b/src/pages/Main/Lib/entryListener.ts @@ -0,0 +1,114 @@ +import {useManagerStore} from "../../../store/modules/manager"; + +const manager = useManagerStore() + +let clipboardState = { + textInit: false, + textLast: '' as string, + imageInit: false, + imageLast: '', + filesInit: false, + filesLastJson: '' as string, +} + +export const EntryListener = { + prepareSearch: async (option: { + // 主动粘贴 + isPaste?: boolean, + // 快速面板 + isFastPanel?: boolean, + }) => { + + // console.log('EntryListener.prepareSearch', option) + + let searchValue = manager.searchValue + + let files, image, text + + // 选中 + const selectedContent = await window.$mapi.manager.getSelectedContent() + + // 文件 + manager.setCurrentFiles([]) + if (selectedContent && selectedContent.type === 'file' && selectedContent.files?.length > 0) { + manager.setCurrentFiles(selectedContent.files as ClipboardFileItem[]) + } else { + files = await window.$mapi.manager.getClipboardFiles() + const filesJson = JSON.stringify(files); + if (!clipboardState.filesInit) { + clipboardState.filesLastJson = filesJson + clipboardState.filesInit = true + } else if (clipboardState.filesLastJson !== filesJson) { + clipboardState.filesLastJson = filesJson + manager.setCurrentFiles(files as ClipboardFileItem[]) + } else if (option.isPaste) { + clipboardState.filesLastJson = filesJson + manager.setCurrentFiles(files as ClipboardFileItem[]) + } + } + + // 图片 + manager.setCurrentImage('') + if (!manager.currentFiles.length) { + image = await window.$mapi.app.getClipboardImage() + if (!clipboardState.imageInit) { + clipboardState.imageLast = image + clipboardState.imageInit = true + } else if (clipboardState.imageLast !== image) { + clipboardState.imageLast = image + manager.setCurrentImage(image) + } else if (option.isPaste) { + clipboardState.imageLast = image + manager.setCurrentImage(image) + } + } + // 文本 + manager.setCurrentText('') + if (!manager.currentFiles.length && !manager.currentImage) { + if (selectedContent && selectedContent.type === 'text' && selectedContent.text) { + manager.setCurrentText(selectedContent.text) + } else { + text = await window.$mapi.app.getClipboardText() + // console.log('text', text, clipboardState.textInit, clipboardState.textLast, option.isPaste) + if (!clipboardState.textInit) { + clipboardState.textLast = text + clipboardState.textInit = true + } else if (clipboardState.textLast !== text) { + clipboardState.textLast = text + manager.setCurrentText(text) + } else if (option.isPaste) { + clipboardState.textLast = text + manager.setCurrentText(text) + } + } + } + if (!option.isFastPanel && manager.currentText) { + // 单行复制的文本,直接粘贴到搜索框 + if ( + manager.currentText.split('\n').length === 1 + && + manager.currentText.length < 100 + ) { + searchValue = manager.currentText + manager.setCurrentText('') + } + } + + if (option.isFastPanel) { + await manager.searchFastPanel(searchValue) + } else { + await manager.search(searchValue) + } + + // console.log('prepareSearch', {selectedContent, searchValue, files, image, text}) + // nextTick(() => { + // console.log('state', JSON.stringify({ + // searchValue, + // image: manager.currentImage, + // files: manager.currentFiles, + // text: manager.currentText + // }, null, 2)) + // }) + } +} + diff --git a/src/pages/Main/Lib/resultOperate.ts b/src/pages/Main/Lib/resultOperate.ts new file mode 100644 index 0000000..d3442a2 --- /dev/null +++ b/src/pages/Main/Lib/resultOperate.ts @@ -0,0 +1,238 @@ +import {computed, ref, watch} from "vue"; +import {chunk} from "lodash-es"; +import {ActionRecord, PluginRecord} from "../../../types/Manager"; +import {useManagerStore} from "../../../store/modules/manager"; +import {ComputedRef} from "@vue/reactivity"; +import {EntryListener} from "./entryListener"; + +const LINE_ACTION_COUNT = 8; +type ActionGroupType = 'search' | 'match' | 'history' | 'pin' | never + +const manager = useManagerStore() + +export const useResultOperate = () => { + const hasActions = computed(() => { + return manager.searchActions.length > 0 + || manager.matchActions.length > 0 + || manager.historyActions.length > 0 + || manager.pinActions.length > 0; + }); + + const searchActionIsExtend = ref(false) + const matchActionIsExtend = ref(false) + const historyActionIsExtend = ref(false) + const pinActionIsExtend = ref(false) + + watch(() => manager.searchActions, () => { + searchActionIsExtend.value = manager.searchActions.length <= LINE_ACTION_COUNT + resetActive() + }); + watch(() => manager.matchActions, () => { + matchActionIsExtend.value = manager.matchActions.length <= LINE_ACTION_COUNT + resetActive() + }); + watch(() => manager.historyActions, () => { + historyActionIsExtend.value = manager.historyActions.length <= LINE_ACTION_COUNT + resetActive() + }); + watch(() => manager.pinActions, () => { + pinActionIsExtend.value = manager.pinActions.length <= LINE_ACTION_COUNT + resetActive() + }); + + const doSearchActionExtend = () => { + if (searchActionIsExtend.value) { + return + } + searchActionIsExtend.value = true + } + const doMatchActionExtend = () => { + if (matchActionIsExtend.value) { + return + } + matchActionIsExtend.value = true + } + const doHistoryActionExtend = () => { + if (historyActionIsExtend.value) { + return + } + historyActionIsExtend.value = true + } + const doPinActionExtend = () => { + if (pinActionIsExtend.value) { + return + } + pinActionIsExtend.value = true + } + + const showSearchActions: ComputedRef = computed(() => { + return searchActionIsExtend.value ? manager.searchActions : manager.searchActions.slice(0, LINE_ACTION_COUNT) + }) + const showMatchActions: ComputedRef = computed(() => { + return matchActionIsExtend.value ? manager.matchActions : manager.matchActions.slice(0, LINE_ACTION_COUNT) + }) + const showHistoryActions: ComputedRef = computed(() => { + return historyActionIsExtend.value ? manager.historyActions : manager.historyActions.slice(0, LINE_ACTION_COUNT) + }) + const showPinActions: ComputedRef = computed(() => { + return pinActionIsExtend.value ? manager.pinActions : manager.pinActions.slice(0, LINE_ACTION_COUNT) + }) + + + const activeActionGroup = ref('search'); + const actionActionIndex = ref(0); + const resetActive = () => { + if (manager.searchActions.length > 0) { + activeActionGroup.value = 'search'; + } else if (manager.matchActions.length > 0) { + activeActionGroup.value = 'match'; + } else if (manager.historyActions.length > 0) { + activeActionGroup.value = 'history'; + } else if (manager.pinActions.length > 0) { + activeActionGroup.value = 'pin'; + } + actionActionIndex.value = 0; + } + + const doActionNavigate = (direction: string) => { + const grids: any[][] = []; + [ + [showSearchActions.value, 'search'], + [showMatchActions.value, 'match'], + [showHistoryActions.value, 'history'], + [showPinActions.value, 'pin'], + ].forEach((actions) => { + let items = [] as any[] + (actions[0] as ActionRecord[]).forEach((_, itemIndex) => { + items.push({ + group: actions[1], + index: itemIndex, + }) + }) + chunk(items, LINE_ACTION_COUNT).forEach((chunk) => { + grids.push(chunk) + }) + }); + let activeGridRowIndex = grids.findIndex((gridLine) => gridLine.find((grid) => grid.group === activeActionGroup.value && grid.index === actionActionIndex.value)) + let activeGridColIndex = grids[activeGridRowIndex].findIndex((grid) => grid.group === activeActionGroup.value && grid.index === actionActionIndex.value) + switch (direction) { + case 'up': + if (activeGridRowIndex > 0) { + activeGridRowIndex-- + activeGridColIndex = Math.min(activeGridColIndex, grids[activeGridRowIndex].length - 1) + } + break + case 'down': + if (activeGridRowIndex < grids.length - 1) { + activeGridRowIndex++ + activeGridColIndex = Math.min(activeGridColIndex, grids[activeGridRowIndex].length - 1) + } + break + case 'left': + activeGridColIndex-- + if (activeGridColIndex < 0) { + if (activeGridRowIndex > 0) { + activeGridRowIndex-- + activeGridColIndex = grids[activeGridRowIndex].length - 1 + } else { + activeGridColIndex = 0 + } + } + break + case 'right': + activeGridColIndex++ + if (activeGridColIndex >= grids[activeGridRowIndex].length) { + if (activeGridRowIndex < grids.length - 1) { + activeGridRowIndex++ + activeGridColIndex = 0 + } else { + activeGridColIndex = grids[activeGridRowIndex].length - 1 + } + } + break + } + activeActionGroup.value = grids[activeGridRowIndex][activeGridColIndex].group + actionActionIndex.value = grids[activeGridRowIndex][activeGridColIndex].index + manager.setSelectedAction(getActiveAction() as ActionRecord) + } + + const getActiveAction = () => { + let activeAction: any = null + switch (activeActionGroup.value) { + case 'search': + activeAction = showSearchActions.value[actionActionIndex.value] + break + case 'match': + activeAction = showMatchActions.value[actionActionIndex.value] + break + case 'history': + activeAction = showHistoryActions.value[actionActionIndex.value] + break + case 'pin': + activeAction = showPinActions.value[actionActionIndex.value] + break + } + return activeAction as ActionRecord | null + } + + const onInputKey = (key: string) => { + if (['up', 'down', 'left', 'right'].includes(key)) { + doActionNavigate(key) + } else if ('enter' === key) { + const action = getActiveAction() + if (action) { + doOpenAction(action).then() + } + } else if ('delete' === key) { + if ('' === manager.searchValue) { + onClose() + } + } else if ('paste' === key) { + if (!manager.activePlugin) { + EntryListener.prepareSearch({isPaste: true}).then() + } + } + } + + const onClose = () => { + if (manager.activePlugin) { + doClosePlugin().then() + } else { + manager.setCurrentFiles([]) + manager.setCurrentImage('') + manager.setCurrentText('') + manager.search('').then() + } + } + + const doClosePlugin = async (plugin?: PluginRecord) => { + await manager.closeMainPlugin(plugin) + } + + const doOpenAction = async (action: ActionRecord) => { + await manager.openAction(action) + } + + return { + hasActions, + searchActionIsExtend, + matchActionIsExtend, + historyActionIsExtend, + pinActionIsExtend, + doSearchActionExtend, + doMatchActionExtend, + doHistoryActionExtend, + doPinActionExtend, + showSearchActions, + showMatchActions, + showHistoryActions, + showPinActions, + activeActionGroup, + actionActionIndex, + doActionNavigate, + getActiveAction, + onInputKey, + onClose, + doOpenAction, + } +} diff --git a/src/pages/Main/Lib/resultResize.ts b/src/pages/Main/Lib/resultResize.ts new file mode 100644 index 0000000..2afafdc --- /dev/null +++ b/src/pages/Main/Lib/resultResize.ts @@ -0,0 +1,25 @@ +import {onBeforeUnmount, onMounted} from "vue"; +import {UI} from "../../../lib/ui"; +import {useManagerStore} from "../../../store/modules/manager"; +import {WindowConfig} from "../../../../electron/config/window"; + +const manager = useManagerStore() + +export const useResultResize = (groupContainer: any) => { + onMounted(() => { + UI.onResize(groupContainer.value, (width: number, height: number) => { + // console.log('resize', width, height, manager.activePlugin) + if (!manager.activePlugin) { + manager.resize(width, height + WindowConfig.mainHeight).then() + } + }); + }); + onBeforeUnmount(() => { + UI.offResize(groupContainer.value); + }); +} + +export const fireResultResize = (groupContainer: any) => { + UI.fireResize(groupContainer.value) +} + diff --git a/src/pages/Main/Lib/searchOperate.ts b/src/pages/Main/Lib/searchOperate.ts new file mode 100644 index 0000000..c53f8be --- /dev/null +++ b/src/pages/Main/Lib/searchOperate.ts @@ -0,0 +1,143 @@ +import {useManagerStore} from "../../../store/modules/manager"; +import {computed} from "vue"; + +const {Menu} = require('@electron/remote'); + +const manager = useManagerStore() + +export const useSearchOperate = (emit) => { + + const hasActions = computed(() => { + return manager.searchActions.length > 0 || manager.historyActions.length > 0 || manager.pinActions.length > 0; + }); + + const doShowMenu = () => { + const menuTemplate: any[] = [] + if (manager.activePlugin) { + menuTemplate.push({ + label: '独立窗口显示', + click: () => { + doDetachPlugin().then() + } + }) + menuTemplate.push({ + label: '插件调试', + click: () => { + manager.openMainPluginDevTools().then() + } + }); + } + if (!menuTemplate.length) { + return + } + Menu.buildFromTemplate(menuTemplate).popup(); + }; + + const doDetachPlugin = async () => { + await manager.detachPlugin() + } + + const onSearchKeydown = (e, key: string) => { + const {ctrlKey, shiftKey, altKey, metaKey} = e; + const modifiers: Array = []; + ctrlKey && modifiers.push('control'); + shiftKey && modifiers.push('shift'); + altKey && modifiers.push('alt'); + metaKey && modifiers.push('meta'); + let fireKey = ''; + switch (key) { + case 'up': + case 'down': + case 'left': + case 'right': + case 'enter': + if (hasActions.value) { + fireKey = key; + } + break; + case 'esc': + if (manager.searchValue === '') { + manager.hideMainWindow().then() + } + break + default: + switch (e.keyCode) { + case 8: + if (manager.searchValue === '') { + fireKey = 'delete'; + } + break; + case 86: + if (manager.searchValue === '') { + if (ctrlKey || metaKey) { + fireKey = 'paste'; + } + } + break; + } + break; + } + if (fireKey) { + e.preventDefault(); + emit('onInputKey', fireKey); + } + }; + + const clipboardFilesInfo = computed<{ + name: string, + extName: string + }>(() => { + const result = { + name: '多个文件', + extName: 'ext.unknown' + } + if (manager.currentFiles.length <= 0) { + return result + } + // 只有一个文件的情况 + if (manager.currentFiles.length === 1) { + const file = manager.currentFiles[0]; + result.name = file.name; + result.extName = file.name; + if (file.isDirectory) { + result.extName = 'ext.folder'; + } + return result; + } + // 如果全部是目录 + const directoryCount = manager.currentFiles.filter(f => f.isDirectory).length; + if (directoryCount === manager.currentFiles.length) { + result.name = '多个文件夹'; + result.extName = 'ext.folder'; + return result + } + // 如果全部是文件 + const fileCount = manager.currentFiles.filter(f => f.isFile).length; + if (fileCount === manager.currentFiles.length) { + // 如果全部是图片 + const imageCount = manager.currentFiles.filter(f => { + const ext = f.name.split('.').pop()?.toLowerCase(); + return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext || ''); + }).length; + if (imageCount === manager.currentFiles.length) { + result.name = '多个图片'; + result.extName = 'ext.png'; + return result + } + } + return result + }); + + const onSearchDoubleClick = () => { + if (manager.activePlugin) { + doDetachPlugin().then() + } + } + + return { + onSearchKeydown, + onSearchDoubleClick, + doShowMenu, + clipboardFilesInfo + } +} diff --git a/src/pages/Main/MainResult.vue b/src/pages/Main/MainResult.vue new file mode 100644 index 0000000..0cbb904 --- /dev/null +++ b/src/pages/Main/MainResult.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/src/pages/Main/MainSearch.vue b/src/pages/Main/MainSearch.vue new file mode 100644 index 0000000..600254c --- /dev/null +++ b/src/pages/Main/MainSearch.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/src/pages/PageAbout.vue b/src/pages/PageAbout.vue new file mode 100644 index 0000000..093e498 --- /dev/null +++ b/src/pages/PageAbout.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/pages/PageDetachWindow.vue b/src/pages/PageDetachWindow.vue new file mode 100644 index 0000000..c35d374 --- /dev/null +++ b/src/pages/PageDetachWindow.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/src/pages/PageFastPanel.vue b/src/pages/PageFastPanel.vue new file mode 100644 index 0000000..56022f6 --- /dev/null +++ b/src/pages/PageFastPanel.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/src/pages/PageGuide.vue b/src/pages/PageGuide.vue new file mode 100644 index 0000000..7e4093b --- /dev/null +++ b/src/pages/PageGuide.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/pages/PageSetup.vue b/src/pages/PageSetup.vue new file mode 100644 index 0000000..4828eb8 --- /dev/null +++ b/src/pages/PageSetup.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/pages/PageStore.vue b/src/pages/PageStore.vue new file mode 100644 index 0000000..3d2f36a --- /dev/null +++ b/src/pages/PageStore.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/pages/PageSystem.vue b/src/pages/PageSystem.vue new file mode 100644 index 0000000..68cb875 --- /dev/null +++ b/src/pages/PageSystem.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/pages/PageUser.vue b/src/pages/PageUser.vue new file mode 100644 index 0000000..c3665f8 --- /dev/null +++ b/src/pages/PageUser.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/pages/PageWorkflow.vue b/src/pages/PageWorkflow.vue new file mode 100644 index 0000000..f87db6c --- /dev/null +++ b/src/pages/PageWorkflow.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/pages/Setting.vue b/src/pages/Setting.vue new file mode 100644 index 0000000..00d6860 --- /dev/null +++ b/src/pages/Setting.vue @@ -0,0 +1,3 @@ + diff --git a/src/pages/System/SystemAbout.vue b/src/pages/System/SystemAbout.vue new file mode 100644 index 0000000..8ec37dc --- /dev/null +++ b/src/pages/System/SystemAbout.vue @@ -0,0 +1,81 @@ + + + + diff --git a/src/pages/System/SystemAction.vue b/src/pages/System/SystemAction.vue new file mode 100644 index 0000000..8bea381 --- /dev/null +++ b/src/pages/System/SystemAction.vue @@ -0,0 +1,275 @@ + + + diff --git a/src/pages/System/SystemData.vue b/src/pages/System/SystemData.vue new file mode 100644 index 0000000..8c6be4c --- /dev/null +++ b/src/pages/System/SystemData.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/pages/System/SystemFile.vue b/src/pages/System/SystemFile.vue new file mode 100644 index 0000000..b4bd6fe --- /dev/null +++ b/src/pages/System/SystemFile.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/pages/System/SystemLaunch.vue b/src/pages/System/SystemLaunch.vue new file mode 100644 index 0000000..562e192 --- /dev/null +++ b/src/pages/System/SystemLaunch.vue @@ -0,0 +1,79 @@ + + + + diff --git a/src/pages/System/SystemPlugin.vue b/src/pages/System/SystemPlugin.vue new file mode 100644 index 0000000..8500144 --- /dev/null +++ b/src/pages/System/SystemPlugin.vue @@ -0,0 +1,409 @@ + + + + diff --git a/src/pages/System/SystemSetting.vue b/src/pages/System/SystemSetting.vue new file mode 100644 index 0000000..f9acb11 --- /dev/null +++ b/src/pages/System/SystemSetting.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/pages/System/SystemUser.vue b/src/pages/System/SystemUser.vue new file mode 100644 index 0000000..caf976f --- /dev/null +++ b/src/pages/System/SystemUser.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/pages/System/components/HotkeyInput.vue b/src/pages/System/components/HotkeyInput.vue new file mode 100644 index 0000000..cb8275b --- /dev/null +++ b/src/pages/System/components/HotkeyInput.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/pages/System/components/SystemActionMatchDetailDialog.vue b/src/pages/System/components/SystemActionMatchDetailDialog.vue new file mode 100644 index 0000000..e0bd0ba --- /dev/null +++ b/src/pages/System/components/SystemActionMatchDetailDialog.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/pages/System/components/SystemDataBackup/WebDavManage.vue b/src/pages/System/components/SystemDataBackup/WebDavManage.vue new file mode 100644 index 0000000..ce6c918 --- /dev/null +++ b/src/pages/System/components/SystemDataBackup/WebDavManage.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/pages/System/components/SystemDataBackup/WebDavManageSettingDialog.vue b/src/pages/System/components/SystemDataBackup/WebDavManageSettingDialog.vue new file mode 100644 index 0000000..d638287 --- /dev/null +++ b/src/pages/System/components/SystemDataBackup/WebDavManageSettingDialog.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/pages/System/components/SystemDataBackupDialog.vue b/src/pages/System/components/SystemDataBackupDialog.vue new file mode 100644 index 0000000..a66ac71 --- /dev/null +++ b/src/pages/System/components/SystemDataBackupDialog.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/pages/System/components/SystemDataViewDetailDialog.vue b/src/pages/System/components/SystemDataViewDetailDialog.vue new file mode 100644 index 0000000..a559d42 --- /dev/null +++ b/src/pages/System/components/SystemDataViewDetailDialog.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/pages/System/components/SystemDataViewDialog.vue b/src/pages/System/components/SystemDataViewDialog.vue new file mode 100644 index 0000000..1f3dfa8 --- /dev/null +++ b/src/pages/System/components/SystemDataViewDialog.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/pages/System/components/type.ts b/src/pages/System/components/type.ts new file mode 100644 index 0000000..107860b --- /dev/null +++ b/src/pages/System/components/type.ts @@ -0,0 +1,7 @@ +import {PluginRecord} from "../../../types/Manager"; + + +export type SystemDataRecord = { + plugin: PluginRecord, + count: 0, +} diff --git a/src/pages/User/hook.ts b/src/pages/User/hook.ts new file mode 100644 index 0000000..1704603 --- /dev/null +++ b/src/pages/User/hook.ts @@ -0,0 +1,82 @@ +import {ref} from "vue"; +import {useUserStore} from "../../store/modules/user"; +import {useSettingStore} from "../../store/modules/setting"; + +const setting = useSettingStore() + +export const useUserPage = ({web, status}) => { + + const webPreload = ref('') + const webUrl = ref('') + const webUserAgent = window.$mapi.app.getUserAgent() + + const user = useUserStore() + const canGoBack = ref(false) + + const webUrlList = [ + '/app_manager/user', + '/member_vip', + '/login', + '/register', + '/logout' + ] + + const getUrl = () => { + const url = web.value.getURL() + return new URL(url).pathname; + } + + const getCanGoBack = () => { + if (webUrlList[0] === getUrl()) { + return false + } + return true + } + const doBack = async () => { + web.value.loadURL(await user.webUrl()) + } + + const onMount = async () => { + web.value.addEventListener('did-fail-load', (event: any) => { + status.value?.setStatus('fail') + }); + web.value.addEventListener('did-finish-load', (event: any) => { + if (setting.shouldDarkMode()) { + web.value.executeJavaScript(`document.body.setAttribute('data-theme', 'dark');`) + } + }) + web.value.addEventListener('dom-ready', (e) => { + // web.value.openDevTools() + window.$mapi.user.refresh() + canGoBack.value = getCanGoBack() + web.value.insertCSS(`.pb-page-member-vip .top{ padding-left: 5rem; }`) + web.value.executeJavaScript(` +document.addEventListener('click', (event) => { + const target = event.target; + if (target.tagName !== 'A') return; + const url = target.href + if(url.startsWith('javascript:')) return; + const urlPath = new URL(url).pathname; + const whiteList = ${JSON.stringify(webUrlList)}; + if (whiteList.includes(urlPath)) return; + event.preventDefault(); + window.$mapi.user.openWebUrl(url) +}); +`) + status.value?.setStatus('success') + }); + status.value?.setStatus('loading') + webPreload.value = await window.$mapi.app.getPreload() + webUrl.value = await user.webUrl() + } + + return { + webPreload, + webUrl, + webUserAgent, + user, + canGoBack, + doBack, + onMount, + } +} diff --git a/src/pages/Workflow/WorkflowEdit.vue b/src/pages/Workflow/WorkflowEdit.vue new file mode 100644 index 0000000..d830c56 --- /dev/null +++ b/src/pages/Workflow/WorkflowEdit.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/pages/Workflow/WorkflowList.vue b/src/pages/Workflow/WorkflowList.vue new file mode 100644 index 0000000..3f2cebe --- /dev/null +++ b/src/pages/Workflow/WorkflowList.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/pages/Workflow/components/SpecialEdge.vue b/src/pages/Workflow/components/SpecialEdge.vue new file mode 100644 index 0000000..bf49413 --- /dev/null +++ b/src/pages/Workflow/components/SpecialEdge.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/pages/Workflow/components/SpecialNode.vue b/src/pages/Workflow/components/SpecialNode.vue new file mode 100644 index 0000000..ef6d9d6 --- /dev/null +++ b/src/pages/Workflow/components/SpecialNode.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..2a6e682 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,34 @@ +import {createRouter, createWebHashHistory} from 'vue-router' + + +const routes = [ + { + path: '/', + component: () => import('./layouts/Main.vue'), + children: [ + {path: '', component: () => import('./pages/Home.vue')}, + {path: 'setting', component: () => import('./pages/Setting.vue')}, + ] + }, + { + path: '/', + component: () => import('./layouts/Raw.vue'), + children: [ + ] + }, +] + +const router = createRouter({ + history: createWebHashHistory(), + routes +}) + +// watch router change +router.beforeEach((to, from, next) => { + window.$mapi?.statistics?.tick('visit', { + path: to.path + }) + next() +}) + +export default router diff --git a/src/service/SoundCloneService.ts b/src/service/SoundCloneService.ts new file mode 100644 index 0000000..4671e2a --- /dev/null +++ b/src/service/SoundCloneService.ts @@ -0,0 +1,130 @@ +import {TimeUtil} from "../lib/util"; +import {useTaskStore} from "../store/modules/task"; + +const taskStore = useTaskStore() + +export type SoundCloneRecord = { + id?: number; + + serverName: string; + serverTitle: string; + serverVersion: string; + promptName: string; + promptWav: string; + promptText: string; + text: string; + speed: number; + seed: number; + param?: any; + + status?: 'queue' | 'running' | 'success' | 'fail'; + statusMsg?: string; + jobId?: string; + jobResult?: any; + startTime?: number, + endTime?: number | undefined, + resultWav?: string; + + runtime?: SoundCloneRuntime, +} + +export type SoundCloneRuntime = {} + +export const SoundCloneService = { + tableName() { + return 'data_sound_clone' + }, + async resultWavPath(record: SoundCloneRecord) { + return await window.$mapi.file.fullPath(`soundClone/${record.id}.wav`) + }, + decodeRecord(record: SoundCloneRecord): SoundCloneRecord | null { + if (!record) { + return null + } + return { + ...record, + param: JSON.parse(record.param ? record.param : '{}'), + jobResult: JSON.parse(record.jobResult ? record.jobResult : '{}') + } as SoundCloneRecord + }, + encodeRecord(record: SoundCloneRecord): SoundCloneRecord { + if ('param' in record) { + record.param = JSON.stringify(record.param || {}) + } + if ('jobResult' in record) { + record.jobResult = JSON.stringify(record.jobResult || {}) + } + return record + }, + async get(id: number): Promise { + const record: any = await window.$mapi.db.first(`SELECT * + FROM ${this.tableName()} + WHERE id = ?`, [id]) + return this.decodeRecord(record) + }, + async list(): Promise { + const records: SoundCloneRecord[] = await window.$mapi.db.select(`SELECT * + FROM ${this.tableName()} + ORDER BY id DESC`) + return records.map(this.decodeRecord) as SoundCloneRecord[] + }, + async restoreForTask() { + const records: SoundCloneRecord[] = await window.$mapi.db.select(`SELECT * + FROM ${this.tableName()} + WHERE status = 'running' + OR status = 'queue' + ORDER BY id DESC`) + // console.log('SoundCloneService.restoreForTask', records.length) + for (let record of records) { + let status = record.status === 'running' ? 'querying' : 'queue' + await taskStore.dispatch('SoundClone', record.id as any, {}, { + status: status, + runStart: record.startTime, + }) + } + }, + async submit(record: SoundCloneRecord) { + record.status = 'queue' + record.startTime = TimeUtil.timestampMS() + const fields = [ + 'serverName', 'serverTitle', 'serverVersion', + 'promptName', 'promptWav', 'promptText', + 'text', 'speed', 'seed', 'param', + 'status', 'statusMsg', 'startTime', 'endTime', + ] + record = this.encodeRecord(record) + const values = fields.map(f => record[f]) + const valuesPlaceholder = fields.map(f => '?') + const id = await window.$mapi.db.insert(`INSERT INTO ${this.tableName()} (${fields.join(',')}) + VALUES (${valuesPlaceholder.join(',')})`, values) + await taskStore.dispatch('SoundClone', id) + }, + async update(id: number, record: Partial) { + record = this.encodeRecord(record as SoundCloneRecord) + const fields = Object.keys(record) + const values = fields.map(f => record[f]) + const set = fields.map(f => `${f} = ?`).join(',') + return await window.$mapi.db.execute(`UPDATE ${this.tableName()} + SET ${set} + WHERE id = ?`, [...values, id]) + }, + async saveResultWav(record: SoundCloneRecord, resultWav: string) { + const resultWavAbs = window.$mapi.file.absolutePath(resultWav) + const resultWavNew = await SoundCloneService.resultWavPath(record) + const resultWavNewAbs = window.$mapi.file.absolutePath(resultWavNew) + // console.log('CloneService.saveResultWav', {resultWav, resultWavAbs, resultWavNew, resultWavNewAbs}) + await window.$mapi.file.rename(resultWavAbs, resultWavNewAbs, { + overwrite: true + }) + return resultWavNew + }, + async delete(record: SoundCloneRecord) { + if (record.resultWav) { + const resultWavAbs = window.$mapi.file.absolutePath(record.resultWav) + await window.$mapi.file.deletes(resultWavAbs) + } + await window.$mapi.db.delete(`DELETE + FROM ${this.tableName()} + WHERE id = ?`, [record.id]) + } +} diff --git a/src/service/SoundTtsService.ts b/src/service/SoundTtsService.ts new file mode 100644 index 0000000..2c9718f --- /dev/null +++ b/src/service/SoundTtsService.ts @@ -0,0 +1,124 @@ +import {TimeUtil} from "../lib/util"; +import {useTaskStore} from "../store/modules/task"; + +const taskStore = useTaskStore() + +export type SoundTtsRecord = { + id?: number; + + serverName: string; + serverTitle: string; + serverVersion: string; + text: string; + param: any; + + status?: 'queue' | 'running' | 'success' | 'fail'; + statusMsg?: string; + jobId?: string; + jobResult?: any; + startTime?: number, + endTime?: number | undefined, + resultWav?: string; + + runtime?: SoundTtsRuntime, +} + +export type SoundTtsRuntime = {} + +export const SoundTtsService = { + tableName() { + return 'data_sound_tts' + }, + async resultWavPath(record: SoundTtsRecord) { + return await window.$mapi.file.fullPath(`soundTts/${record.id}.wav`) + }, + decodeRecord(record: SoundTtsRecord): SoundTtsRecord | null { + if (!record) { + return null + } + return { + ...record, + param: JSON.parse(record.param ? record.param : '{}'), + jobResult: JSON.parse(record.jobResult ? record.jobResult : '{}') + } as SoundTtsRecord + }, + encodeRecord(record: SoundTtsRecord): SoundTtsRecord { + if ('param' in record) { + record.param = JSON.stringify(record.param || {}) + } + if ('jobResult' in record) { + record.jobResult = JSON.stringify(record.jobResult || {}) + } + return record + }, + async get(id: number): Promise { + const record: any = await window.$mapi.db.first(`SELECT * + FROM ${this.tableName()} + WHERE id = ?`, [id]) + return this.decodeRecord(record) + }, + async list(): Promise { + const records: SoundTtsRecord[] = await window.$mapi.db.select(`SELECT * + FROM ${this.tableName()} + ORDER BY id DESC`) + return records.map(this.decodeRecord) as SoundTtsRecord[] + }, + async restoreForTask() { + const records: SoundTtsRecord[] = await window.$mapi.db.select(`SELECT * + FROM ${this.tableName()} + WHERE status = 'running' + OR status = 'queue' + ORDER BY id DESC`) + // console.log('SoundTtsService.restoreForTask', records.length) + for (let record of records) { + let status = record.status === 'running' ? 'querying' : 'queue' + await taskStore.dispatch('SoundTts', record.id as any, {}, { + status: status, + runStart: record.startTime, + }) + } + }, + async submit(record: SoundTtsRecord) { + record.status = 'queue' + record.startTime = TimeUtil.timestampMS() + const fields = [ + 'serverName', 'serverTitle', 'serverVersion', + 'text', 'param', + 'status', 'statusMsg', 'startTime', 'endTime', + ] + record = this.encodeRecord(record) + const values = fields.map(f => record[f]) + const valuesPlaceholder = fields.map(f => '?') + const id = await window.$mapi.db.insert(`INSERT INTO ${this.tableName()} (${fields.join(',')}) + VALUES (${valuesPlaceholder.join(',')})`, values) + await taskStore.dispatch('SoundTts', id) + }, + async update(id: number, record: Partial) { + record = this.encodeRecord(record as SoundTtsRecord) + const fields = Object.keys(record) + const values = fields.map(f => record[f]) + const set = fields.map(f => `${f} = ?`).join(',') + return await window.$mapi.db.update(`UPDATE ${this.tableName()} + SET ${set} + WHERE id = ?`, [...values, id]) + }, + async saveResultWav(record: SoundTtsRecord, resultWav: string) { + const resultWavAbs = window.$mapi.file.absolutePath(resultWav) + const resultWavNew = await SoundTtsService.resultWavPath(record) + const resultWavNewAbs = window.$mapi.file.absolutePath(resultWavNew) + // console.log('TtsService.saveResultWav', {resultWav, resultWavAbs, resultWavNew, resultWavNewAbs}) + await window.$mapi.file.rename(resultWavAbs, resultWavNewAbs, { + overwrite: true + }) + return resultWavNew + }, + async delete(record: SoundTtsRecord) { + if (record.resultWav) { + const resultWavAbs = window.$mapi.file.absolutePath(record.resultWav) + await window.$mapi.file.deletes(resultWavAbs) + } + await window.$mapi.db.delete(`DELETE + FROM ${this.tableName()} + WHERE id = ?`, [record.id]) + } +} diff --git a/src/service/VideoGenService.ts b/src/service/VideoGenService.ts new file mode 100644 index 0000000..16710d0 --- /dev/null +++ b/src/service/VideoGenService.ts @@ -0,0 +1,134 @@ +import {TimeUtil} from "../lib/util"; +import {useTaskStore} from "../store/modules/task"; + +const taskStore = useTaskStore() + +export type VideoGenRecord = { + id?: number; + + serverName: string; + serverTitle: string; + serverVersion: string; + + videoTemplateId: number; + videoTemplateName: string; + soundType: string; + soundTtsId: number; + soundTtsText: string; + soundCloneId: number; + soundCloneText: string; + + param?: any; + + status?: 'queue' | 'running' | 'success' | 'fail'; + statusMsg?: string; + jobId?: string; + jobResult?: any; + startTime?: number, + endTime?: number | undefined, + resultMp4?: string; + + runtime?: VideoGenRuntime, +} + +export type VideoGenRuntime = {} + +export const VideoGenService = { + tableName() { + return 'data_video_gen' + }, + async resultMp4Path(record: VideoGenRecord) { + return await window.$mapi.file.fullPath(`videoGen/${record.id}.mp4`) + }, + decodeRecord(record: VideoGenRecord): VideoGenRecord | null { + if (!record) { + return null + } + return { + ...record, + param: JSON.parse(record.param ? record.param : '{}'), + jobResult: JSON.parse(record.jobResult ? record.jobResult : '{}') + } as VideoGenRecord + }, + encodeRecord(record: VideoGenRecord): VideoGenRecord { + if ('param' in record) { + record.param = JSON.stringify(record.param || {}) + } + if ('jobResult' in record) { + record.jobResult = JSON.stringify(record.jobResult || {}) + } + return record + }, + async get(id: number): Promise { + const record: any = await window.$mapi.db.first(`SELECT * + FROM ${this.tableName()} + WHERE id = ?`, [id]) + return this.decodeRecord(record) + }, + async list(): Promise { + const records: VideoGenRecord[] = await window.$mapi.db.select(`SELECT * + FROM ${this.tableName()} + ORDER BY id DESC`) + return records.map(this.decodeRecord) as VideoGenRecord[] + }, + async restoreForTask() { + const records: VideoGenRecord[] = await window.$mapi.db.select(`SELECT * + FROM ${this.tableName()} + WHERE status = 'running' + OR status = 'queue' + ORDER BY id DESC`) + // console.log('VideoGenService.restoreForTask', records.length) + for (let record of records) { + let status = record.status === 'running' ? 'querying' : 'queue' + await taskStore.dispatch('VideoGen', record.id as any, {}, { + status: status, + runStart: record.startTime, + }) + } + }, + async submit(record: VideoGenRecord) { + record.status = 'queue' + record.startTime = TimeUtil.timestampMS() + const fields = [ + 'serverName', 'serverTitle', 'serverVersion', + 'videoTemplateId', 'videoTemplateName', + 'soundType', 'soundTtsId', 'soundTtsText', 'soundCloneId', 'soundCloneText', + 'param', + 'status', 'statusMsg', 'startTime', 'endTime', + ] + record = this.encodeRecord(record) + const values = fields.map(f => record[f]) + const valuesPlaceholder = fields.map(f => '?') + const id = await window.$mapi.db.insert(`INSERT INTO ${this.tableName()} (${fields.join(',')}) + VALUES (${valuesPlaceholder.join(',')})`, values) + await taskStore.dispatch('VideoGen', id) + }, + async update(id: number, record: Partial) { + record = this.encodeRecord(record as VideoGenRecord) + const fields = Object.keys(record) + const values = fields.map(f => record[f]) + const set = fields.map(f => `${f} = ?`).join(',') + return await window.$mapi.db.execute(`UPDATE ${this.tableName()} + SET ${set} + WHERE id = ?`, [...values, id]) + }, + async saveResultMp4(record: VideoGenRecord, resultMp4: string) { + const resultMp4Abs = window.$mapi.file.absolutePath(resultMp4) + const resultMp4New = await VideoGenService.resultMp4Path(record) + const resultMp4NewAbs = window.$mapi.file.absolutePath(resultMp4New) + // console.log('CloneService.saveResultWav', {resultMp4, resultMp4Abs, resultMp4New, resultMp4NewAbs}) + await window.$mapi.file.rename(resultMp4Abs, resultMp4NewAbs, { + overwrite: true + }) + return resultMp4New + }, + async delete(record: VideoGenRecord) { + if (record.resultMp4) { + const resultMp4Abs = window.$mapi.file.absolutePath(record.resultMp4) + await window.$mapi.file.deletes(resultMp4Abs) + } + await window.$mapi.db.delete(`DELETE + FROM ${this.tableName()} + WHERE id = ?`, [record.id]) + } +} diff --git a/src/service/VideoTemplateService.ts b/src/service/VideoTemplateService.ts new file mode 100644 index 0000000..d6286e1 --- /dev/null +++ b/src/service/VideoTemplateService.ts @@ -0,0 +1,62 @@ +import {VideoGenRecord} from "./VideoGenService"; + +export type VideoTemplateRecord = { + id?: number; + name: string; + video: string; +} + +export const VideoTemplateService = { + tableName() { + return 'data_video_template' + }, + decodeRecord(record: VideoTemplateRecord): VideoTemplateRecord | null { + if (!record) { + return null + } + return { + ...record, + } as VideoTemplateRecord + }, + encodeRecord(record: VideoTemplateRecord): VideoTemplateRecord { + return record + }, + async get(id: number): Promise { + const record: any = await window.$mapi.db.first(`SELECT * + FROM ${this.tableName()} + WHERE id = ?`, [id]) + return this.decodeRecord(record) + }, + async getByName(name: string): Promise { + const record: any = await window.$mapi.db.first(`SELECT * + FROM ${this.tableName()} + WHERE name = ?`, [name]) + return this.decodeRecord(record) + }, + async list(): Promise { + const records: VideoTemplateRecord[] = await window.$mapi.db.select(`SELECT * + FROM ${this.tableName()} + ORDER BY id DESC`) + return records.map(this.decodeRecord) as VideoTemplateRecord[] + }, + async insert(record: VideoTemplateRecord) { + return await window.$mapi.db.insert(`INSERT INTO ${this.tableName()} (name, video) + VALUES (?, ?)`, [record.name, record.video]) + }, + async delete(record: VideoTemplateRecord) { + if (record.video) { + await window.$mapi.file.deletes(record.video, { + isFullPath: true + }) + } + await window.$mapi.db.delete(`DELETE + FROM ${this.tableName()} + WHERE id = ?`, [record.id]) + }, + async update(record: VideoTemplateRecord) { + await window.$mapi.db.update(`UPDATE ${this.tableName()} + SET name = ?, + video = ? + WHERE id = ?`, [record.name, record.video, record.id]) + } +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..92171c4 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,4 @@ +import { createPinia } from "pinia" + +const store = createPinia() +export default store diff --git a/src/store/modules/app.ts b/src/store/modules/app.ts new file mode 100644 index 0000000..5e4d2b1 --- /dev/null +++ b/src/store/modules/app.ts @@ -0,0 +1,21 @@ +import {defineStore} from "pinia" +import store from "../index"; + +export const appStore = defineStore("app", { + state() { + return {} + }, + actions: { + async init() { + + }, + } +}) + +export const app = appStore(store) +app.init().then(() => { +}) + +export const useAppStore = () => { + return app +} diff --git a/src/store/modules/manager.ts b/src/store/modules/manager.ts new file mode 100644 index 0000000..c58d3e2 --- /dev/null +++ b/src/store/modules/manager.ts @@ -0,0 +1,220 @@ +import {defineStore} from "pinia" +import store from "../index"; +import { + ActionRecord, + ActionTypeEnum, + ConfigRecord, + PluginRecord +} from "../../types/Manager"; +import debounce from "lodash/debounce"; +import {WindowConfig} from "../../../electron/config/window"; +import {computed, toRaw} from "vue"; + +const searchFastPanelActionDebounce = debounce((query, cb) => { + window.$mapi.manager.searchFastPanelAction(query) + .then(result => { + cb(result) + }); +}) + +const searchDebounce = debounce((query, cb) => { + window.$mapi.manager.searchAction(query) + .then(result => { + cb(result) + }); +}, 300); + +const subInputChangeDebounce = debounce((keywords) => { + window.$mapi.manager.subInputChange(keywords) +}, 300) + +export const managerStore = defineStore("manager", { + state: () => ({ + config: {} as ConfigRecord, + searchLoading: false, + searchValue: '', + searchPlaceholder: 'FocusAny,让您的工作专注高效', + searchSubPlaceholder: '', + searchActions: [] as ActionRecord[], + matchActions: [] as ActionRecord[], + historyActions: [] as ActionRecord[], + pinActions: [] as ActionRecord[], + selectedAction: null as ActionRecord | null, + activePlugin: null as PluginRecord | null, + + currentFiles: [] as ClipboardFileItem[], + currentImage: '', + currentText: '', + + fastPanelActionLoading: false, + fastPanelActions: [] as ActionRecord[], + + notice: null as { + text: string, + type: 'info' | 'error' | 'success', + duration: number, + } | null, + noticeCleanTimer: null as any, + }), + actions: { + async init() { + this.config = await window.$mapi.manager.getConfig() + }, + + async setConfig(key: string, value: any) { + // console.log('setConfig', key, value, toRaw(this.config)) + this.config[key] = value + await window.$mapi.manager.setConfig(toRaw(this.config)) + }, + async onConfigChange(key: string, value: any) { + return await this.setConfig(key, toRaw(value)) + }, + configGet(key: string, defaultValue: any = null) { + return computed(() => { + if (key in this.config) { + return this.config[key] + } + return defaultValue + }) + }, + + setActivePlugin(plugin: PluginRecord | null) { + this.activePlugin = plugin + }, + setSearchValue(value: string) { + if (this.activePlugin) { + return + } + this.searchValue = value + }, + setSelectedAction(action: ActionRecord) { + this.selectedAction = action + }, + setCurrentFiles(files: ClipboardFileItem[]) { + this.currentFiles = files + }, + setCurrentImage(image: string) { + this.currentImage = image + }, + setCurrentText(text: string) { + this.currentText = text + }, + + async searchFastPanel(keywords: string) { + this.fastPanelActions = [] + this.fastPanelActionLoading = true + searchFastPanelActionDebounce({ + keywords: keywords, + currentFiles: toRaw(this.currentFiles), + currentImage: this.currentImage, + currentText: this.currentText, + }, (result: { + fastPanelActions: ActionRecord[], + }) => { + // console.log('searchFastPanel', result) + this.fastPanelActions = result.fastPanelActions + this.fastPanelActionLoading = false + }) + }, + + async search(keywords: string) { + if (this.activePlugin) { + subInputChangeDebounce(keywords) + this.searchValue = keywords + return + } + this.searchLoading = true + this.searchValue = keywords + searchDebounce({ + keywords, + currentFiles: toRaw(this.currentFiles), + currentImage: this.currentImage, + currentText: this.currentText, + }, (result: { + searchActions: ActionRecord[], + matchActions: ActionRecord[], + historyActions: ActionRecord[], + pinActions: ActionRecord[], + }) => { + this.searchActions = result.searchActions + this.matchActions = result.matchActions + this.historyActions = result.historyActions + this.pinActions = result.pinActions + this.searchLoading = false + }) + }, + async resize(width: number, height: number) { + height = Math.min(height, WindowConfig.mainMaxHeight) + await window.$mapi.app.windowSetSize(null, WindowConfig.mainWidth, height, { + center: false, + }); + }, + async showMainWindow() { + await window.$mapi.manager.show() + }, + async hideMainWindow() { + await window.$mapi.manager.hide() + }, + async openAction(action: ActionRecord) { + this.searchActions = [] + this.matchActions = [] + this.historyActions = [] + this.pinActions = [] + await window.$mapi.manager.openAction(toRaw(action)) + if (action.type === ActionTypeEnum.COMMAND + || action.type === ActionTypeEnum.CODE + || action.type === ActionTypeEnum.BACKEND) { + this.searchValue = '' + await window.$mapi.manager.hide() + } + }, + async closeMainPlugin(plugin?: PluginRecord) { + await window.$mapi.manager.closeMainPlugin(plugin ? toRaw(plugin) : null); + }, + async openMainPluginDevTools(plugin?: PluginRecord) { + await window.$mapi.manager.openMainPluginDevTools(plugin ? toRaw(plugin) : null) + }, + async detachPlugin() { + await window.$mapi.manager.detachPlugin() + }, + setSubInput(payload: { placeholder: string, isFocus?: boolean }) { + if (!this.activePlugin) { + return + } + this.searchSubPlaceholder = payload.placeholder + }, + removeSubInput() { + if (!this.activePlugin) { + return + } + this.searchSubPlaceholder = '' + this.searchValue = '' + }, + setSubInputValue(value: string) { + if (!this.activePlugin) { + return + } + this.searchValue = value + }, + onNotice(data: any) { + this.notice = data + if (this.notice?.duration && this.notice?.duration > 0) { + if (this.noticeCleanTimer) { + clearTimeout(this.noticeCleanTimer) + } + this.noticeCleanTimer = setTimeout(() => { + this.notice = null + }, this.notice.duration) + } + } + } +}) + +const manager = managerStore(store) +manager.init().then() + +window.__page.onBroadcast('Notice', manager.onNotice) + +export const useManagerStore = () => { + return manager +} diff --git a/src/store/modules/server.ts b/src/store/modules/server.ts new file mode 100644 index 0000000..4834690 --- /dev/null +++ b/src/store/modules/server.ts @@ -0,0 +1,252 @@ +import {defineStore} from "pinia" +import store from "../index"; +import {EnumServerStatus, EnumServerType, ServerRecord, ServerRuntime} from "../../types/Server"; +import {computed, ref, toRaw} from "vue"; +import {cloneDeep} from "lodash-es"; +import {ComputedRef} from "@vue/reactivity"; +import {TimeUtil} from "../../lib/util"; + + +const serverRuntime = ref>(new Map()) +const createServerStatus = (record: ServerRecord): ComputedRef => { + return computed(() => { + return serverRuntime.value?.get(record.key)?.status || EnumServerStatus.STOPPED + }) +} +const createServerRuntime = (record: ServerRecord): ComputedRef => { + return computed(() => { + return serverRuntime.value?.get(record.key) || { + status: EnumServerStatus.STOPPED, + } as ServerRuntime + }) +} +const getServerRuntime = (record: ServerRecord): ServerRuntime => { + const value = serverRuntime.value?.get(record.key) + if (value) { + return value + } + serverRuntime.value?.set(record.key, { + status: EnumServerStatus.STOPPED, + } as ServerRuntime) + return serverRuntime.value?.get(record.key) as ServerRuntime +} +const deleteServerRuntime = (record: ServerRecord) => { + serverRuntime.value?.delete(record.key) +} + +export const serverStore = defineStore("server", { + state: () => ({ + records: [] as ServerRecord[], + }), + actions: { + async init() { + await window.$mapi.storage.get("server", "records", []) + .then((records) => { + records.forEach((record: ServerRecord) => { + record.status = createServerStatus(record) + record.runtime = createServerRuntime(record) + }) + this.records = records + }) + await this.refresh() + }, + async refresh() { + const dirs = await window.$mapi.file.list('model') + const localRecords: ServerRecord[] = [] + for (let dir of dirs) { + const config = await window.$mapi.file.read(`model/${dir.name}/config.json`) + let json + try { + json = JSON.parse(config) + } catch (e) { + continue + } + if (!json) { + continue + } + localRecords.push({ + key: this.generateServerKey({ + name: json.name, + version: json.version, + } as any), + name: json.name || dir.name, + title: json.title || dir.name, + version: json.version || '1.0.0', + type: EnumServerType.LOCAL, + functions: json.functions || [], + localPath: `model/${dir.name}`, + settings: json.settings || [], + setting: json.setting || {}, + } as ServerRecord) + } + let changed = false + for (let lr of localRecords) { + const record = this.records.find((record) => record.key === lr.key) + if (!record) { + lr.status = createServerStatus(lr) + lr.runtime = createServerRuntime(lr) + this.records.unshift(lr as any) + changed = true + } else { + if (!record.settings && lr.settings) { + record.settings = lr.settings + changed = true + } + } + } + if (changed) { + await this.sync() + } + }, + findRecord(server: ServerRecord) { + return this.records.find((record) => record.key === server.key) + }, + async start(server: ServerRecord) { + const record = this.findRecord(server) + if (record?.status === EnumServerStatus.STOPPED || record?.status === EnumServerStatus.ERROR) { + } else { + throw new Error('StatusError') + } + const serverRuntime = getServerRuntime(server) + serverRuntime.status = EnumServerStatus.STARTING + serverRuntime.startTimestampMS = TimeUtil.timestampMS() + serverRuntime.logFile = `logs/${server.name}_${server.version}_${TimeUtil.dateString()}_${serverRuntime.startTimestampMS}.log` + const eventChannel = await window.$mapi.event.channelCreate(function (channelData) { + const {type, data} = channelData + switch (type) { + case 'success': + clearTimeout(serverRuntime.pingCheckTimer) + serverRuntime.status = EnumServerStatus.STOPPED + window.$mapi.event.channelDestroy(eventChannel).then() + break + case 'error': + clearTimeout(serverRuntime.pingCheckTimer) + serverRuntime.status = EnumServerStatus.ERROR + window.$mapi.event.channelDestroy(eventChannel).then() + break + case 'starting': + break + default: + console.log('eventChannel', type, data) + break + } + }) + const serverInfo = await this.serverInfo(server) + serverInfo.logFile = serverRuntime.logFile + serverInfo.eventChannelName = eventChannel + await window.$mapi.server.start(serverInfo) + let pingTimeout = 60 * 5 * 1000 + let pingStart = TimeUtil.timestampMS() + const pingCheck = () => { + const now = TimeUtil.timestampMS() + if (now - pingStart > pingTimeout) { + // console.log('ping.timeout') + serverRuntime.status = EnumServerStatus.ERROR + window.$mapi.server.stop(serverInfo) + return + } + window.$mapi.server.ping(serverInfo) + .then(success => { + if (success) { + serverRuntime.status = EnumServerStatus.RUNNING + } else { + serverRuntime.pingCheckTimer = setTimeout(pingCheck, 5000) + } + }) + .catch(err => { + serverRuntime.pingCheckTimer = setTimeout(pingCheck, 5000) + }) + } + serverRuntime.pingCheckTimer = setTimeout(pingCheck, 10 * 1000) + }, + async stop(server: ServerRecord) { + const record = this.findRecord(server) + if (record?.status === EnumServerStatus.RUNNING) { + } else { + throw new Error('StatusError') + } + const serverRuntime = getServerRuntime(server) + serverRuntime.status = EnumServerStatus.STOPPING + const serverInfo = await this.serverInfo(server) + serverInfo.logFile = serverRuntime.logFile + await window.$mapi.server.stop(serverInfo) + }, + async updateSetting(key: string, setting: any) { + const record = this.records.find((record) => record.key === key) + if (!record) { + return + } + record.setting = Object.assign(record.setting || {}, setting) + await this.sync() + }, + async delete(server: ServerRecord) { + const index = this.records.findIndex((record) => record.key === server.key) + if (index === -1) { + return + } + const record = this.records[index] + if (record.status === EnumServerStatus.STOPPED + || record.status === EnumServerStatus.ERROR) { + } else { + throw new Error('StatusError') + } + if (record.type === EnumServerType.LOCAL) { + await window.$mapi.file.deletes(record.localPath as string) + } + this.records.splice(index, 1) + deleteServerRuntime(server) + await this.sync() + }, + async add(server: ServerRecord) { + let record = this.records.find((record) => record.key === server.key) + if (record) { + return + } + server.status = createServerStatus(server) + server.runtime = createServerRuntime(server) + this.records.unshift(server) + await this.sync() + }, + async sync() { + const savedRecords = toRaw(cloneDeep(this.records)) + savedRecords.forEach((record) => { + record.status = undefined + record.runtime = undefined + }) + await window.$mapi.storage.set("server", "records", savedRecords) + }, + async getByKey(key: string): Promise { + return this.records.find((record) => record.key === key) + }, + async getByNameVersion(name: string, version: string): Promise { + return this.records.find((record) => record.name === name && record.version === version) + }, + generateServerKey(server: ServerRecord) { + return `${server.name}|${server.version}` + }, + async serverInfo(server: ServerRecord) { + const result = { + localPath: '', + name: server.name, + version: server.version, + setting: toRaw(server.setting), + logFile: '', + eventChannelName: '', + } + if (server.type === EnumServerType.LOCAL) { + result.localPath = await window.$mapi.file.fullPath(server.localPath as string) + } else if (server.type === EnumServerType.LOCAL_DIR) { + result.localPath = server.localPath as string + } + return result + } + } +}) + +const server = serverStore(store) +server.init().then(() => { +}) + +export const useServerStore = () => { + return server +} diff --git a/src/store/modules/setting.ts b/src/store/modules/setting.ts new file mode 100644 index 0000000..a4ea581 --- /dev/null +++ b/src/store/modules/setting.ts @@ -0,0 +1,106 @@ +import {defineStore} from "pinia" +import store from "../index"; +import {AppConfig} from "../../config"; +import {computed} from "vue"; +import {cloneDeep} from "lodash-es"; + +export const settingStore = defineStore("setting", { + state() { + return { + version: AppConfig.version, + basic: cloneDeep(AppConfig.basic), + isDarkMode: false, + config: { + guideWatched: false as boolean, + darkMode: '' as 'light' | 'dark' | 'auto', + }, + } + }, + actions: { + async init() { + this.isDarkMode = await window.$mapi.app.isDarkMode() + this.config = await window.$mapi.config.all() + this.setupDarkMode() + this.showGuideWhenReady().then() + }, + async showGuideWhenReady() { + if (!await window.$mapi.app.setupIsOk()) { + setTimeout(() => { + this.showGuideWhenReady() + }, 1000) + return + } + setTimeout(() => { + if (!this.config.guideWatched) { + window.$mapi.app.windowOpen('guide').then() + this.setConfig('guideWatched', true).then() + } + }, 2000) + }, + onConfigChangeBroadcast(data: any) { + (async () => { + this.config = await window.$mapi.config.all() + this.setupDarkMode() + })() + }, + onDarkModeChangeBroadcast(data: any) { + this.isDarkMode = data.isDarkMode + this.setupDarkMode() + }, + shouldDarkMode() { + const darkMode = this.config['darkMode'] || 'auto' + if ('dark' === darkMode) { + return true + } else if ('light' === darkMode) { + return false + } else if ('auto' === darkMode) { + return this.isDarkMode + } + return false + }, + setupDarkMode() { + // console.log('setupDarkMode') + if (this.shouldDarkMode()) { + document.body.setAttribute('arco-theme', 'dark') + document.body.setAttribute('data-theme', 'dark') + document.documentElement.setAttribute('data-theme', 'dark') + } else { + document.body.removeAttribute('arco-theme'); + document.body.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-theme'); + } + }, + async initBasic(basic: object) { + this.basic = Object.assign(this.basic, basic) + }, + async setConfig(key: string, value: any) { + // console.log('setConfig', key, value) + this.config[key] = value + await window.$mapi.config.set(key, value) + if ('darkMode' === key) { + setTimeout(() => this.setupDarkMode(), 100) + } + }, + async onConfigChange(key: string, value: any) { + return await this.setConfig(key, value) + }, + configGet(key: string, defaultValue: any = null) { + return computed(() => { + if (key in this.config) { + return this.config[key] + } + return defaultValue + }) + }, + } +}) + +const setting = settingStore(store) +setting.init().then() + +window.__page.onBroadcast('ConfigChange', setting.onConfigChangeBroadcast) +window.__page.onBroadcast('DarkModeChange', setting.onDarkModeChangeBroadcast) + +export const useSettingStore = () => { + return setting +} diff --git a/src/store/modules/task.ts b/src/store/modules/task.ts new file mode 100644 index 0000000..a0957e2 --- /dev/null +++ b/src/store/modules/task.ts @@ -0,0 +1,313 @@ +import {defineStore} from "pinia" +import store from "../index"; +import {toRaw} from "vue"; +import {cloneDeep} from "lodash-es"; +import {StringUtil} from "../../lib/util"; +import {mapError} from "../../lib/error"; + +export type TaskRecordStatus = 'queue' | 'running' | 'querying' | 'success' | 'fail' | 'delete' + +export type TaskRecordRunStatus = 'retry' | 'success' | 'querying' + +export type TaskRecordQueryStatus = 'running' | 'success' | 'fail' + +export type TaskChangeType = 'running' | 'success' | 'fail' + +export type TaskRecord = { + id: string; + status: TaskRecordStatus, + msg: string; + biz: string; + bizId: string; + bizParam: any; + // 开始运行时间 + runStart: number; + // 是否正在调用 runFunc + runCalling: boolean; + // 超过 runAfter 才会执行,0表示是一个新任务,>0表示是一个重试任务 + runAfter: number; + // 是否正在调用 queryFunc + queryCalling: boolean; + // 超过 queryAfter 才会查询 + queryAfter: number; + // 查询间隔 + queryInterval: number; + // 是否正在调用 successFunc + successCalling: boolean; + // 超时时间 + timeout: number; +} + +export type TaskBiz = { + runFunc: (bizId: string, bizParam: any) => Promise, + queryFunc?: (bizId: string, bizParam: any) => Promise, + successFunc: (bizId: string, bizParam: any) => Promise, + // 请确保 failFunc 不会抛出异常 + failFunc: (bizId: string, msg: string, bizParam: any) => Promise, + restore?: () => Promise, +} + +const taskChangeListeners = [] as { + biz: string, + callback: (bizId: string, status: TaskChangeType) => void +}[] +let runNextTimer = null as any + +export const taskStore = defineStore("task", { + state() { + return { + isInit: false, + bizMap: {} as Record, + records: [] as TaskRecord[], + } + }, + actions: { + async init() { + await window.$mapi.storage.get("task", "records", []) + .then((records) => { + this.records = records + this.isInit = true + this._run(true) + }) + }, + async waitInit() { + while (!this.isInit) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + }, + _runExecute() { + let changed = false + // console.log('task._runExecute.start', JSON.stringify(this.records)) + // error record + this.records.forEach((record) => { + if (!this.bizMap[record.biz]) { + record.status = 'fail' + record.msg = 'biz not found' + changed = true + } + }) + // console.log('task.records', JSON.stringify(this.records, null, 2)) + // queue + this.records + .filter(r => r.status === 'queue') + .filter(r => r.runAfter <= Date.now() && !r.runCalling) + .forEach((record) => { + changed = true + record.status = 'running' + record.runStart = Date.now() + record.runCalling = true + let runCallFinish = false + setTimeout(() => { + if (runCallFinish) { + return + } + this.fireChange(record, 'running') + }, 1000) + this.bizMap[record.biz] + .runFunc(record.bizId, record.bizParam) + .then((status: TaskRecordRunStatus) => { + runCallFinish = true + switch (status) { + case 'success': + record.status = 'success' + break + case 'querying': + record.queryAfter = Date.now() + record.queryInterval + record.status = 'querying' + break + case 'retry': + record.status = 'queue' + record.runStart = 0 + record.runAfter = Date.now() + 1000 + break + } + }) + .catch((e) => { + runCallFinish = true + console.error('task.runFunc.error', e) + record.status = 'fail' + record.msg = mapError(e) + }) + .finally(() => { + record.runCalling = false + this.fireChange(record, 'running') + }) + }) + // querying + this.records + .filter(r => r.status === 'querying') + .filter(r => r.queryAfter <= Date.now() && !r.queryCalling) + .forEach((record) => { + record.queryCalling = true + const taskBiz = this.bizMap[record.biz] + taskBiz.queryFunc?.(record.bizId, record.bizParam) + .then((status: TaskRecordQueryStatus) => { + switch (status) { + case 'running': + record.queryAfter = Date.now() + record.queryInterval + break + case 'success': + record.status = 'success' + changed = true + break + case 'fail': + record.status = 'fail' + changed = true + break + } + }) + .catch((e) => { + console.error('task.queryFunc.error', e) + record.status = 'fail' + record.msg = mapError(e) + changed = true + }) + .finally(() => { + record.queryCalling = false + }) + }) + // expire + this.records + .filter(r => r.status === 'running' || r.status === 'querying') + .filter(r => Date.now() - r.runStart > r.timeout) + .forEach((record) => { + record.status = 'fail' + record.msg = mapError('ProcessTimeout') + changed = true + }) + // success + this.records + .filter(r => r.status === 'success') + .filter(r => !r.successCalling) + .forEach((record) => { + record.successCalling = true + changed = true + this.bizMap[record.biz] + .successFunc(record.bizId, record.bizParam) + .then(() => { + record.status = 'delete' + }) + .catch((e) => { + console.error('task.successFunc.error', e) + record.status = 'fail' + record.msg = mapError(e) + }) + .finally(() => { + if (record.status === 'delete') { + this.fireChange(record, 'success') + } + record.successCalling = false + }) + }) + // fail + this.records + .filter(r => r.status === 'fail') + .forEach((record) => { + changed = true + record.status = 'delete' + if (!this.bizMap[record.biz]) { + return + } + this.bizMap[record.biz] + .failFunc(record.bizId, record.msg, record.bizParam) + .then(() => { + }) + .catch((e) => { + console.error('task.failFunc.error', e) + window.$mapi.log.error(`task.failFunc:${e}`) + }) + .finally(() => { + this.fireChange(record, 'fail') + }) + }) + // console.log('task._runExecute.end', JSON.stringify(this.records)) + // delete + this.records = this.records.filter(r => r.status !== 'delete') + // sync + if (changed) { + this.sync().then() + } + // next run + // console.log('run', changed, JSON.stringify(this.records)) + if (this.records.length > 0) { + this._run(changed) + } + }, + _run(immediate: boolean) { + if (runNextTimer) { + clearTimeout(runNextTimer) + runNextTimer = null + } + setTimeout(() => { + this._runExecute() + }, immediate ? 0 : 1000) + }, + register(biz: string, taskBiz: TaskBiz) { + this.bizMap[biz] = taskBiz + }, + unregister(biz: string) { + delete this.bizMap[biz] + }, + onChange(biz: string, callback: (bizId: string, type: TaskChangeType) => void) { + taskChangeListeners.push({biz, callback}) + }, + offChange(biz: string, callback: (bizId: string, type: TaskChangeType) => void) { + const index = taskChangeListeners.findIndex((v) => v.biz === biz && v.callback === callback) + taskChangeListeners.splice(index, 1) + }, + fireChange(record: TaskRecord, type: TaskChangeType) { + taskChangeListeners.forEach((v) => { + if (v.biz === record.biz) { + v.callback(record.bizId, type) + } + }) + }, + async dispatch(biz: string, bizId: string, bizParam?: any, param?: object) { + await this.waitInit() + if (!this.bizMap[biz]) { + throw new Error('TaskBizNotFound') + } + param = Object.assign({ + timeout: 24 * 60 * 60 * 1000, + queryInterval: 1 * 1000, + status: 'queue', + runStart: 0, + }, param) + const taskRecord = { + id: `${biz}-${Date.now()}-${StringUtil.random(8)}`, + status: param['status'], + msg: '', + biz, + bizId, + bizParam, + runStart: param['runStart'], + runAfter: 0, + runCalling: false, + queryAfter: 0, + queryInterval: param['queryInterval'], + queryCalling: false, + successCalling: false, + timeout: param['timeout'], + } as TaskRecord + this.records.push(taskRecord) + this._run(true) + }, + async sync() { + await this.waitInit() + const savedRecords = toRaw(cloneDeep(this.records)) + savedRecords.forEach((record) => { + // record.status = undefined + // record.runtime = undefined + }) + await window.$mapi.storage.set("task", "records", savedRecords) + }, + } +}) + +export const task = taskStore(store) +task.init().then(() => { +}) + +export const useTaskStore = () => { + return task +} diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts new file mode 100644 index 0000000..628a6c8 --- /dev/null +++ b/src/store/modules/user.ts @@ -0,0 +1,79 @@ +import {defineStore} from "pinia" +import store from "../index"; +import {userInfoApi} from "../../api/user"; +import {toRaw} from "vue"; +import {AppConfig} from "../../config"; +import {useSettingStore} from "./setting"; + +const setting = useSettingStore() + +export const userStore = defineStore("user", { + state() { + return { + isInit: false, + lastSavedJson: '', + apiToken: null as string | null, + user: { + id: null as string | null, + name: null as string | null, + avatar: null as string | null, + }, + data: { + vip: {}, + functions: {} + }, + basic: {}, + } + }, + actions: { + async init() { + await this.load() + }, + async load() { + const {apiToken, user, data, basic} = await window.$mapi.user.get() + this.apiToken = apiToken + this.user = Object.assign(this.user, user) + this.data = data as any + this.basic = basic + await setting.initBasic(this.basic) + this.isInit = true + }, + onChangeBroadcast() { + this.load().then() + }, + async waitInit() { + if (this.isInit) { + return + } + await new Promise((resolve) => { + const timer = setInterval(() => { + if (this.isInit) { + clearInterval(timer) + resolve(undefined) + } + }, 100) + }) + }, + async webUrl() { + await this.waitInit() + let param: string[] = [] + if (this.apiToken) { + param.push(`api_token=${this.apiToken}`) + } + if (setting.shouldDarkMode()) { + param.push('is_dark=1') + } + return `${AppConfig.apiBaseUrl}/app_manager/user_web?${param.join('&')}` + } + } +}) + +export const user = userStore(store) + +user.init().then() + +window.__page.onBroadcast('UserChange', user.onChangeBroadcast) + +export const useUserStore = () => { + return user +} diff --git a/src/style.less b/src/style.less new file mode 100644 index 0000000..68a5259 --- /dev/null +++ b/src/style.less @@ -0,0 +1,223 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root, body { + --primary-1: 255, 255, 255; + --primary-2: 207, 207, 207; + --primary-3: 160, 160, 160; + --primary-4: 112, 112, 112; + --primary-5: 65, 65, 65; + --primary-6: 17, 17, 17; + --primary-7: 17, 13, 13; + --primary-8: 17, 9, 9; + --primary-9: 17, 4, 6; + --primary-10: 17, 0, 2; + + --color-primary-light-1: rgb(var(--primary-1)); + --color-primary-light-2: rgb(var(--primary-2)); + --color-primary-light-3: rgb(var(--primary-3)); + --color-primary-light-4: rgb(var(--primary-4)); + + --window-header-height: 2.5rem; + --page-nav-width: 4rem; + + //--color-primary: #5154E0; // 41 85 254 + --color-primary: #265BD7; + // --color-primary-lighter: #6A6DFF; + --color-primary-lighter: lighten(#265BD7, 10%); + //--color-bg-page-nav: #2A3A5D; + --color-bg-page-nav: #FFFFFF; + //--color-bg-page-nav-active: #5154E0; + --color-bg-page-nav-active: #FFFFFF; + //--color-text-page-nav: #FFFFFF; + --color-text-page-nav: #000000; + //--color-text-page-nav-active: #EEEEEE; + --color-text-page-nav-active: #265BD7; + //--color-border-page-nav: #2A3A5D; + --color-border-page-nav: #EEEEEE; + + --color-background: #FFFFFF; + --color-background-content: #ededed; + --color-text: #111111; + --color-border: #E5E6EB; +} + +body[data-theme="dark"] { + --primary-1: 17, 0, 2; + --primary-2: 17, 4, 6; + --primary-3: 17, 9, 9; + --primary-4: 17, 13, 13; + --primary-5: 17, 17, 17; + --primary-6: 65, 65, 65; + --primary-7: 112, 112, 112; + --primary-8: 160, 160, 160; + --primary-9: 207, 207, 207; + --primary-10: 255, 255, 255; + + --color-background: #17171A; + --color-background-content: #333333; + --color-text: #CCCCCC; + --color-border: #484849; + --color-text-page-nav: #CCCCCC; + --color-bg-page-nav: #17171A; + --color-bg-page-nav-active: #2d3443; +} + +body { + overflow: hidden; +} + +html { + background-color: transparent; +} + +body { + background-color: var(--color-background); + font-size: 14px; + color: var(--color-text); +} + +/////////// layout start /////////// + +.page-container { + height: calc(100vh - var(--window-header-height)); + width: 100vw; +} + +.page-narrow-container { + max-width: 100rem; + margin: 0 auto; +} + +.page-nav-item { + color: var(--color-text-page-nav); + + &.active { + background: var(--color-bg-page-nav-active); + color: var(--color-text-page-nav-active); + } +} + +.window-header { + -webkit-user-select: none; + + .window-header-title { + -webkit-app-region: drag; + } +} + + +* { + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: #FFFFFF; + } + + &::-webkit-scrollbar-thumb { + background: #999999; + border-radius: 10px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #888888; + } +} + +body[data-theme="dark"] { + * { + &::-webkit-scrollbar-track { + background: #222222; + } + + &::-webkit-scrollbar-thumb { + background: #333333; + } + + &::-webkit-scrollbar-thumb:hover { + background: #888888; + } + } +} + +/////////// layout end /////////// + +/////////// global start /////////// + +.text-link { + color: #007bff; + cursor: pointer; +} + +.hover\:text-primary { + &:hover { + color: var(--color-primary); + } +} + +.bg-default { + background-color: var(--color-background); +} + +.bg-primary { + background-color: var(--color-primary); +} + +.text-default { + color: var(--color-text); +} + +.text-primary { + color: var(--color-primary); +} + +.border-default { + border-color: var(--color-border); +} + +.plugin-logo-filter { + filter: drop-shadow(0 0 1px #FFF); +} + +.debug { + border: 1px solid red; +} + +/////////// global end /////////// + + +/////////// arco start /////////// +.arco-btn, .arco-input-wrapper { + border-radius: 0.5rem; +} + +.arco-tabs-tab-title { + &:before { + border-radius: 0.5rem !important; + } +} + +.arco-modal-confirm { + .arco-modal-header { + border-bottom: none; + } + + .arco-modal-footer { + border-top: none; + } +} + +[data-theme="dark"] { + .arco-radio-group-button { + .arco-radio-button { + color: #CCC; + } + } +} + +/////////// arco end /////////// + diff --git a/src/task/TestAsync.ts b/src/task/TestAsync.ts new file mode 100644 index 0000000..67418ff --- /dev/null +++ b/src/task/TestAsync.ts @@ -0,0 +1,22 @@ +import {TaskBiz} from "../store/modules/task"; + +export const TestAsync: TaskBiz = { + runFunc: async (bizId, bizParam) => { + console.log('TestAsync.runFunc', {bizId, bizParam}) + return 'success' + }, + queryFunc(bizId, bizParam) { + return new Promise((resolve) => { + console.log('TestAsync.queryFunc', {bizId, bizParam}) + setTimeout(() => { + resolve(Math.random() > 0.7 ? 'success' : 'running') + }, 1000) + }) + }, + successFunc: async (bizId, bizParam) => { + console.log('TestAsync.successFunc', {bizId, bizParam}) + }, + failFunc: async (bizId, msg, bizParam) => { + console.log('TestAsync.failFunc', {bizId, bizParam, msg}) + } +} diff --git a/src/task/TestSync.ts b/src/task/TestSync.ts new file mode 100644 index 0000000..d0c893d --- /dev/null +++ b/src/task/TestSync.ts @@ -0,0 +1,14 @@ +import {TaskBiz} from "../store/modules/task"; + +export const TestSync: TaskBiz = { + runFunc: async (bizId, bizParam) => { + console.log('TestSync.runFunc', {bizId, bizParam}) + return 'success' + }, + successFunc: async (bizId, bizParam) => { + console.log('TestSync.successFunc', {bizId, bizParam}) + }, + failFunc: async (bizId, msg, bizParam) => { + console.log('TestSync.failFunc', {bizId, bizParam, msg}) + } +} diff --git a/src/task/index.ts b/src/task/index.ts new file mode 100644 index 0000000..a96616f --- /dev/null +++ b/src/task/index.ts @@ -0,0 +1,30 @@ +import {useTaskStore} from "../store/modules/task"; +import {nextTick} from "vue"; + +const taskStore = useTaskStore() + +export const TaskManager = { + init() { + // taskStore.register('SoundTts', SoundTts) + // taskStore.register('SoundClone', SoundClone) + // taskStore.register('VideoGen', VideoGen) + nextTick(async () => { + // await SoundTts.restore?.() + // await SoundClone.restore?.() + // await VideoGen.restore?.() + }).then() + // taskStore.register('TestSync', TestSync) + // taskStore.register('TestAsync', TestAsync) + // setInterval(async () => { + // // await taskStore.dispatch('TestSync', StringUtil.random()) + // await taskStore.dispatch('TestAsync', StringUtil.random(), { + // 'a': 1, + // }, { + // timeout: 3 * 1000, + // }) + // }, 10 * 1000) + }, + count() { + return taskStore.records.length + } +} diff --git a/src/types/Common.ts b/src/types/Common.ts new file mode 100644 index 0000000..6bd71a7 --- /dev/null +++ b/src/types/Common.ts @@ -0,0 +1,5 @@ +export type ResultType = { + code: number + msg: string + data: any +} diff --git a/src/types/File.ts b/src/types/File.ts new file mode 100644 index 0000000..3edcd2d --- /dev/null +++ b/src/types/File.ts @@ -0,0 +1,11 @@ +export type FileItem = { + name: string + isDirectory: boolean + size: number + lastModified: number + path: string + fullPath: string + content: string + contentBase64: string + mode: number +} diff --git a/src/types/Log.ts b/src/types/Log.ts new file mode 100644 index 0000000..b0f63fd --- /dev/null +++ b/src/types/Log.ts @@ -0,0 +1,13 @@ +export enum EnumLogType { + INFO = 'info', + ERROR = 'error', + WARN = 'warn', +} + +export type LogRecord = { + projectId: string | null; + level: EnumLogType; + time: number; + msg: string; + data: any | null; +} diff --git a/src/types/Manager.ts b/src/types/Manager.ts new file mode 100644 index 0000000..f242963 --- /dev/null +++ b/src/types/Manager.ts @@ -0,0 +1,207 @@ +import {HotkeyKeyItem, HotkeyKeySimpleItem,} from "../../electron/mapi/keys/type"; + +export type ConfigRecord = { + mainTrigger: HotkeyKeyItem, + fastPanelTrigger: HotkeyKeySimpleItem, +} + +export type PluginConfig = { + autoDetach: boolean, + zoom: number, +} + +export enum PluginType { + SYSTEM = 'system', + STORE = 'store', + ZIP = 'zip', + DIR = 'dir', +} + +export enum PluginEnv { + DEV = 'dev', + PROD = 'prod', +} + +export type PluginRecord = { + // 以下配置信息和原始的 config.json 一致,未经过处理 + name: string, + title: string, + version: string, + logo: string, + main: string, + mainFastPanel?: string, + actions: ActionRecord[], + description?: string, + preload?: string, + platform?: PlatformType[], + versionRequire?: string, + author?: string, + homepage?: string, + setting?: { + autoDetach?: boolean, + width?: string, + height?: string, + keepCodeDevTools?: boolean, + singleton?: boolean, + zoom?: number, + preloadBase?: string, + nodeIntegration?: boolean, + }, + development?: { + env?: 'dev' | 'prod', + main?: string, + mainFastPanel?: string, + }, + + type?: PluginType, + env?: PluginEnv, + runtime?: { + // 插件运行的根目录 + root?: string | null, + // 配置信息 + config?: PluginConfig, + }, +} + +export type PluginState = { + value: string + placeholder: string +} + +export type ActionMatch = ( + ActionMatchText + | ActionMatchKey + | ActionMatchRegex + | ActionMatchFile + | ActionMatchImage + | ActionMatchWindow + ) + +export enum ActionMatchTypeEnum { + TEXT = 'text', + KEY = 'key', + REGEX = 'regex', + IMAGE = 'image', + FILE = 'file', + WINDOW = 'window', +} + +export type ActionMatchBase = { + type: ActionMatchTypeEnum, + name?: string, +} + +export type ActionMatchText = ActionMatchBase & { + text: string, + minLength: number, + maxLength: number, +} + +export type ActionMatchKey = ActionMatchBase & { + key: string, +} + +export type ActionMatchRegex = ActionMatchBase & { + regex: string, + title: string, + minLength: number, + maxLength: number, +} + +export type ActionMatchFile = ActionMatchBase & { + title: string, + minCount: number, + maxCount: number, + filterFileType: 'file' | 'directory', + filterExtensions: string[], +} + +export type ActionMatchImage = ActionMatchBase & { + title: string, +} + +export type ActionMatchWindow = ActionMatchBase & { + nameRegex: string, + titleRegex: string, + attrRegex: Record, +} + +export type SelectedContent = { + type: 'file' | 'image' | 'text', + files?: ClipboardFileItem[], + image?: string, + text?: string +} + +export type ActiveWindow = { + name: string, + title: string, + attr: Record +} + +export type ClipboardDataType = { + type: 'file' | 'image' | 'text', + files?: ClipboardFileItem[], + image?: string, + text?: string +} + +export type ClipboardHistoryRecord = { + type: 'file' | 'image' | 'text', + timestamp: number, + files?: ClipboardFileItem[], + image?: string, + text?: string, +} + +export type ActionRecord = { + fullName?: string, + pluginName?: string, + name: string, + title: string, + matches: ActionMatch[], + pluginType?: PluginType, + platform?: PlatformType[], + icon?: string, + data?: { + command?: string + }, + + type?: ActionTypeEnum, + runtime?: { + searchScore?: number, + searchTitleMatched?: string, + match?: ActionMatch | null, + requestId?: string | null, + view?: any + }, +} + +export type PluginActionRecord = { + pluginName: string, + actionName: string, +} + +export type ActionTypeCodeData = { + actionName: string, +} + +export enum ActionTypeEnum { + COMMAND = 'command', + WEB = 'web', + CODE = 'code', + BACKEND = 'backend', + VIEW = 'view' +} + + +export type FilePluginRecord = { + icon: string, + title: string, + path: string, +} + +export type LaunchRecord = { + hotkey: HotkeyKeyItem + keyword: string +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..1a2be41 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,16 @@ +/// +import {Dialog} from "./lib/dialog"; + +declare module '*.vue' { + import type {DefineComponent} from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $mapi: typeof window.$mapi, + $dialog: typeof Dialog, + $t: typeof import('vue-i18n').GlobalTranslate + } +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..f5ac57d --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx,vue}", + ], + darkMode: ['selector', '[data-theme="dark"]'], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b754d54 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": [ + "ESNext", + "DOM" + ], + "skipLibCheck": true, + "noEmit": true, + "noImplicitAny": false, + "allowJs": true + }, + "include": [ + "src", + "sdk/focusany.d.ts" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..30b0133 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts", + "package.json", + "electron", + "sdk", + "src/config.ts", + "src/types" + ] +} diff --git a/vite.config.flat.txt b/vite.config.flat.txt new file mode 100644 index 0000000..1ab23d5 --- /dev/null +++ b/vite.config.flat.txt @@ -0,0 +1,76 @@ +import { rmSync } from 'node:fs' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import electron from 'vite-plugin-electron' +import renderer from 'vite-plugin-electron-renderer' +import pkg from './package.json' + +// https://vitejs.dev/config/ +export default defineConfig(({ command }) => { + rmSync('dist-electron', { recursive: true, force: true }) + + const isServe = command === 'serve' + const isBuild = command === 'build' + const sourcemap = isServe || !!process.env.VSCODE_DEBUG + + return { + plugins: [ + vue(), + electron([ + { + // Main process entry file of the Electron App. + entry: 'electron/main/index.ts', + onstart({ startup }) { + if (process.env.VSCODE_DEBUG) { + console.log(/* For `.vscode/.debug.script.mjs` */'[startup] Electron App') + } else { + startup() + } + }, + vite: { + build: { + sourcemap, + minify: isBuild, + outDir: 'dist-electron/main', + rollupOptions: { + // Some third-party Node.js libraries may not be built correctly by Vite, especially `C/C++` addons, + // we can use `external` to exclude them to ensure they work correctly. + // Others need to put them in `dependencies` to ensure they are collected into `app.asar` after the app is built. + // Of course, this is not absolute, just this way is relatively simple. :) + external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + }, + }, + }, + }, + { + entry: 'electron/preload/index.ts', + onstart({ reload }) { + // Notify the Renderer process to reload the page when the Preload scripts build is complete, + // instead of restarting the entire Electron App. + reload() + }, + vite: { + build: { + sourcemap: sourcemap ? 'inline' : undefined, // #332 + minify: isBuild, + outDir: 'dist-electron/preload', + rollupOptions: { + external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + }, + }, + }, + } + ]), + // Use Node.js API in the Renderer process + renderer(), + ], + server: process.env.VSCODE_DEBUG && (() => { + const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL) + return { + host: url.hostname, + port: +url.port, + } + })(), + clearScreen: false, + } +}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..0ac0e01 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,167 @@ +import fs from 'node:fs' +import {defineConfig} from 'vite' +import vue from '@vitejs/plugin-vue' +import electron from 'vite-plugin-electron' +import renderer from 'vite-plugin-electron-renderer' +import pkg from './package.json' +import path from "node:path"; +import {AppConfig} from "./src/config"; + +// https://vitejs.dev/config/ +export default defineConfig(({command}) => { + + fs.rmSync('dist-electron', {recursive: true, force: true}) + + const isServe = command === 'serve' + const isBuild = command === 'build' + const sourcemap = isServe || !!process.env.VSCODE_DEBUG + const minify = isBuild && !process.env.VSCODE_DEBUG + + const externalPackages = [ + ...Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + ...Object.keys('devDependencies' in pkg ? pkg.devDependencies : {}), + ...Object.keys('optionalDependencies' in pkg ? pkg.optionalDependencies : {}), + ] + + return { + plugins: [ + vue({ + template: { + compilerOptions: { + isCustomElement: (tag) => { + if (['webview'].includes(tag)) { + return true + } + return false + }, + }, + }, + }), + { + name: 'process-variables', + closeBundle() { + const files = [ + 'index.html' + ]; + files.forEach(f => { + const p = path.resolve(__dirname, 'dist', f); + let html = fs.readFileSync(p, 'utf-8'); + for (const key in AppConfig) { + html = html.replace(new RegExp(`%${key}%`, 'g'), AppConfig[key]); + } + fs.writeFileSync(p, html, 'utf-8'); + }) + + }, + }, + electron([ + { + // Shortcut of `build.lib.entry` + entry: 'electron/main/index.ts', + onstart({startup}) { + if (process.env.VSCODE_DEBUG) { + console.log(/* For `.vscode/.debug.script.mjs` */'[startup] Electron App') + } else { + startup() + } + }, + vite: { + build: { + sourcemap, + minify: minify, + outDir: 'dist-electron/main', + rollupOptions: { + // Some third-party Node.js libraries may not be built correctly by Vite, especially `C/C++` addons, + // we can use `external` to exclude them to ensure they work correctly. + // Others need to put them in `dependencies` to ensure they are collected into `app.asar` after the app is built. + // Of course, this is not absolute, just this way is relatively simple. :) + external: externalPackages, + }, + }, + }, + }, + { + // Shortcut of `build.rollupOptions.input`. + // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. + entry: 'electron/preload/index.ts', + onstart({reload}) { + // Notify the Renderer process to reload the page when the Preload scripts build is complete, + // instead of restarting the entire Electron App. + reload() + }, + vite: { + build: { + target: 'es2015', + sourcemap: undefined, // #332 + minify: minify, + outDir: 'dist-electron/preload', + lib: { + formats: ['cjs'], + }, + rollupOptions: { + external: externalPackages, + output: { + format: 'cjs', + entryFileNames: '[name].cjs', + compact: false, + }, + }, + }, + }, + }, + { + // Shortcut of `build.rollupOptions.input`. + // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`. + entry: 'electron/preload/plugin.ts', + onstart({reload}) { + // Notify the Renderer process to reload the page when the Preload scripts build is complete, + // instead of restarting the entire Electron App. + reload() + }, + vite: { + build: { + target: 'es2015', + sourcemap: undefined, // #332 + minify: minify, + outDir: 'dist-electron/preload-plugin', + lib: { + formats: ['cjs'], + }, + rollupOptions: { + external: externalPackages, + output: { + format: 'cjs', + entryFileNames: '[name].cjs', + compact: false, + }, + }, + }, + }, + }, + ]), + renderer(), + ], + build: { + sourcemap: sourcemap, + rollupOptions: { + input: { + main: path.resolve(__dirname, 'index.html'), + detachWindow: path.resolve(__dirname, 'page/detachWindow.html'), + fastPanel: path.resolve(__dirname, 'page/fastPanel.html'), + // 内置插件 + system: path.resolve(__dirname, 'page/system.html'), + store: path.resolve(__dirname, 'page/store.html'), + workflow: path.resolve(__dirname, 'page/workflow.html'), + // 其他页面 + about: path.resolve(__dirname, 'page/about.html'), + user: path.resolve(__dirname, 'page/user.html'), + guide: path.resolve(__dirname, 'page/guide.html'), + setup: path.resolve(__dirname, 'page/setup.html'), + } + } + }, + server: { + port: 20000 + }, + } +})