diff --git a/Cargo.lock b/Cargo.lock index 53bb24c..1ba17bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -85,6 +105,25 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb50c5a2ef4b9b1e7ae73e3a73b52ea24b20312d629f9c4df28260b7ad2c3c4" +dependencies = [ + "bincode_derive", + "serde", +] + +[[package]] +name = "bincode_derive" +version = "2.0.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a45a23389446d2dd25dc8e73a7a3b3c43522b630cac068927f0649d43d719d2" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -113,6 +152,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -183,6 +224,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crawdad" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fbd1ecd2ed790e11c8fbe034f9b3e7687404818d1bdfd8218d26ec645ec7c5" + [[package]] name = "crc32fast" version = "1.5.2" @@ -208,6 +255,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -291,7 +347,7 @@ dependencies = [ [[package]] name = "framely" -version = "0.4.3-preview.2" +version = "0.4.3-preview.3" dependencies = [ "anyhow", "base64", @@ -299,6 +355,7 @@ dependencies = [ "hex", "libc", "pbkdf2", + "pinyin", "rand", "semver", "serde", @@ -306,9 +363,12 @@ dependencies = [ "sha2", "tempfile", "tiny_http", + "unicode-normalization", "ureq", "url", + "vibrato", "zip", + "zstd", ] [[package]] @@ -343,6 +403,15 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -487,7 +556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -502,6 +571,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "libc" version = "0.2.189" @@ -570,6 +649,18 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pinyin" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "potential_utf" version = "0.1.6" @@ -642,6 +733,35 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -888,6 +1008,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "typenum" version = "1.20.1" @@ -900,6 +1026,15 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -954,6 +1089,26 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vibrato" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb75519548ea63e35dd02e9bc3cc672a25423a9b8757bd035f5387771eb4cd" +dependencies = [ + "bincode", + "bincode_derive", + "crawdad", + "csv-core", + "hashbrown 0.12.3", + "regex", +] + +[[package]] +name = "virtue" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b60dcd6a64dd45abf9bd426970c9843726da7fc08f44cd6fcebf68c21220a63" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1215,3 +1370,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 75d54bc..2180030 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "framely" -version = "0.4.3-preview.2" +version = "0.4.3-preview.3" edition = "2021" license = "AGPL-3.0-only" @@ -20,6 +20,10 @@ url = "2" ureq = { version = "2.12", features = ["json"] } zip = { version = "2.4", default-features = false, features = ["deflate"] } pbkdf2 = "0.12.2" +pinyin = "0.11" +vibrato = { version = "=0.5.0", default-features = false } +zstd = "0.13" +unicode-normalization = "0.1" [dev-dependencies] tempfile = "3" diff --git a/assets/search/COPYING b/assets/search/COPYING new file mode 100644 index 0000000..85a0c63 --- /dev/null +++ b/assets/search/COPYING @@ -0,0 +1,73 @@ +Copyright 2000, 2001, 2002, 2003 Nara Institute of Science +and Technology. +Copyright 2023, LegalOn Technologies, Inc. +All Rights Reserved. + +Use, reproduction, and distribution of this software is permitted. +Any copy of this software, whether in its original form or modified, +must include both the above copyright notice and the following +paragraphs. + +Nara Institute of Science and Technology (NAIST), +the copyright holders, disclaims all warranties with regard to this +software, including all implied warranties of merchantability and +fitness, in no event shall NAIST be liable for +any special, indirect or consequential damages or any damages +whatsoever resulting from loss of use, data or profits, whether in an +action of contract, negligence or other tortuous action, arising out +of or in connection with the use or performance of this software. + +A large portion of the dictionary entries +originate from ICOT Free Software. The following conditions for ICOT +Free Software applies to the current dictionary as well. + +Each User may also freely distribute the Program, whether in its +original form or modified, to any third party or parties, PROVIDED +that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear +on, or be attached to, the Program, which is distributed substantially +in the same form as set out herein and that such intended +distribution, if actually made, will neither violate or otherwise +contravene any of the laws and regulations of the countries having +jurisdiction over the User or the intended distribution itself. + +NO WARRANTY + +The program was produced on an experimental basis in the course of the +research and development conducted during the project and is provided +to users as so produced on an experimental basis. Accordingly, the +program is provided without any warranty whatsoever, whether express, +implied, statutory or otherwise. The term "warranty" used herein +includes, but is not limited to, any warranty of the quality, +performance, merchantability and fitness for a particular purpose of +the program and the nonexistence of any infringement or violation of +any right of any third party. + +Each user of the program will agree and understand, and be deemed to +have agreed and understood, that there is no warranty whatsoever for +the program and, accordingly, the entire risk arising from or +otherwise connected with the program is assumed by the user. + +Therefore, neither ICOT, the copyright holder, or any other +organization that participated in or was otherwise related to the +development of the program and their respective officials, directors, +officers and other employees shall be held liable for any and all +damages, including, without limitation, general, special, incidental +and consequential damages, arising out of or otherwise in connection +with the use or inability to use the program or any product, material +or result produced or otherwise obtained by using the program, +regardless of whether they have been advised of, or otherwise had +knowledge of, the possibility of such damages at any time during the +project or thereafter. Each user will be deemed to have agreed to the +foregoing by his or her commencement of use of the program. The term +"use" as used herein includes, but is not limited to, the use, +modification, copying and distribution of the program and the +production of secondary products from the program. + +In the case where the program, whether in its original form or +modified, was distributed or delivered to or received by a user from +any person, organization or entity other than ICOT, unless it makes or +grants independently of ICOT any specific warranty to the user in +writing, such person, organization or entity, will also be exempted +from and not be held liable to the user for any such damages as noted +above as far as the program is concerned. +÷÷ diff --git a/assets/search/NOTICE b/assets/search/NOTICE new file mode 100644 index 0000000..9bf64bd --- /dev/null +++ b/assets/search/NOTICE @@ -0,0 +1,7 @@ +This software includes a binary version of data from + + http://jaist.dl.sourceforge.net/project/mecab/mecab-ipadic/2.7.0-20070801/mecab-ipadic-2.7.0-20070801.tar.gz, + +where the connection ids are remapped using CORE data in BCCWJ (except the PN category) + + https://clrd.ninjal.ac.jp/bccwj/. diff --git a/assets/search/README.md b/assets/search/README.md new file mode 100644 index 0000000..3d38657 --- /dev/null +++ b/assets/search/README.md @@ -0,0 +1,21 @@ +# Offline launcher search data + +`ipadic.dic.zst` is the IPADic 2.7.0 dictionary compiled for Vibrato 0.5.0. +Vibrato is pinned to the matching reader format; upgrade the reader and dictionary together. + +Source: https://github.com/daac-tools/vibrato/releases/download/v0.5.0/ipadic-mecab-2_7_0.tar.xz + +Source archive SHA-256: `4764f983b7c3a9e1cb6a5ee945e00558efd812980e0dad61224f63ee3b0475d9` + +Dictionary SHA-256: `82a6da70bb4a17be70f20ff44f650f9ad1d2b0b4fcb2f39c17fc797f92d0ab75` + +Chinese pinyin uses rust-pinyin 0.11.0 and pinyin-data 0.15.0 (MIT); see `pinyin-LICENSE`. +Vibrato license notices are included as `vibrato-LICENSE-*`. + +Keep `COPYING` and `NOTICE` with the dictionary. Release packaging copies this directory to +`share/search`, includes it in core updates and offline packages, and records its checksum. +The dictionary is loaded lazily by the session's search worker. No remote service is used. + +中文:此目录包含 Vibrato 0.5.0 格式的 IPADic 2.7.0 离线日语读音词典。读入器与词典必须一起升级。 +发布包复制到 `share/search`,包含于本体更新包和完整离线包,并纳入校验。 +请保留 `COPYING` 和 `NOTICE`。词典由会话搜索线程按需加载,不使用远程服务。 diff --git a/assets/search/ipadic.dic.zst b/assets/search/ipadic.dic.zst new file mode 100644 index 0000000..230307b Binary files /dev/null and b/assets/search/ipadic.dic.zst differ diff --git a/assets/search/pinyin-LICENSE b/assets/search/pinyin-LICENSE new file mode 100644 index 0000000..80d4f8c --- /dev/null +++ b/assets/search/pinyin-LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 mozillazg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/assets/search/vibrato-LICENSE-APACHE b/assets/search/vibrato-LICENSE-APACHE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/assets/search/vibrato-LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/search/vibrato-LICENSE-MIT b/assets/search/vibrato-LICENSE-MIT new file mode 100644 index 0000000..b127dd2 --- /dev/null +++ b/assets/search/vibrato-LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 legalforce-research + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/launcher.md b/docs/launcher.md index a02f3b0..a664775 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -2,16 +2,32 @@ [简体中文](zh-CN/launcher.md) -Point at the Framely Dock icon: use the upper trigger for the launcher and the primary trigger for the quick panel. **Settings → Launcher → Open launcher with the primary trigger** swaps these shortcuts immediately. Other controllers keep the ordinary click entry. +Point at the Framely Dock icon: use the upper trigger for the launcher and the primary trigger for the quick panel. **Manager → Launcher → Open launcher with the primary trigger** swaps these shortcuts immediately. Other controllers keep the ordinary click entry. -The launcher is a native Dock dashboard panel, 3 meters wide. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing follows the headset refresh rate within a 60–144 FPS budget, with a 90 FPS fallback when the rate is unavailable. This is a drawing limit, not a guaranteed measured frame rate. The renderer retains the high-resolution texture and uploads only changed regions. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. Use the side category selector for All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps. +The launcher is a native Dock dashboard panel, 3 meters wide. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The renderer retains the high-resolution texture and uploads only changed regions. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. Use the side category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps keep a visible filled blue star; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps. -Press and release may drift by up to 36 UI pixels without changing the initial hit. Icon and category hit areas are larger than the artwork. Deliberate movement cancels stabilization. Hold a plugin icon with the primary trigger for 400 ms to open its action menu; ordinary jitter keeps the hold active. It contains developer actions plus Quick panel, Manage plugin and Uninstall plugin. Press **B** or click an unused area to close the launcher. During icon arrangement, the first press or click exits arrangement and keeps the launcher open. Hold the primary trigger and swipe horizontally: the current page follows your movement while the adjacent page slides into view. Release to snap to that page, or return to the current page if the drag is too short or reaches the boundary. By default the action menu closes ten seconds after the pointer leaves; returning cancels the timer. Configure the switch and delay (1–300 seconds) in **Settings → Launcher**. Uninstall requires confirmation and keeps plugin data. +Press and release may drift by up to 36 UI pixels without changing the initial hit. Icon and category hit areas are larger than the artwork. Deliberate movement cancels stabilization. Hold a plugin icon with the primary trigger for 400 ms to open its action menu; ordinary jitter keeps the hold active. It contains developer actions plus Quick panel, Manage plugin and Uninstall plugin. Press **B** or click an unused area to close the launcher. During icon arrangement, the first press or click exits arrangement and keeps the launcher open. Hold the primary trigger and swipe horizontally: the current page follows your movement while the adjacent page slides into view. Release to snap to that page, or return to the current page if the drag is too short or reaches the boundary. By default the action menu closes ten seconds after the pointer leaves; returning cancels the timer. Configure the switch and delay (1–300 seconds) in **Manager → Launcher**. Uninstall requires confirmation and keeps plugin data. -Keep holding for **800 ms** to enter icon arrangement. Drag any icon (including Framely itself, which opens the management panel) onto another position; icons shift and automatically fill the 4 / 5 / 4 slots without gaps. The left drag edge is to the right of the category selector; the selector itself does not trigger paging. Hold a dragged icon at either edge of the icon area for 650 ms to turn a page; continue holding to move across further pages. Release to save the order, or choose **Done** to finish arranging. Closing or hiding the Dock cancels an unfinished drag. A manually arranged order takes precedence over favorite sorting; new apps are appended. +Keep holding for **800 ms** to enter icon arrangement. Drag any icon (including Framely itself, which opens the management panel) onto another position; icons shift and automatically fill the 4 / 5 / 4 slots without gaps. The left drag edge is to the right of the category selector; the selector itself does not trigger paging. Hold a dragged icon at either edge of the icon area for 650 ms to turn a page; continue holding to move across further pages. Release to save the order, or choose **Done** to finish arranging. Closing or hiding the Dock cancels an unfinished drag. Manual icon order is preserved across favorites, search, refreshes and reopening. Favoriting never moves icons to the front; the Favorites category filters the same order. Apps without a saved position are appended in name order. Either controller's stick switches categories vertically and pages horizontally. A gesture uses only its dominant axis; return below the neutral threshold before the next navigation step. This prevents a diagonal or simultaneous stick input from performing both actions. Desktop applications come from the session user's XDG application directories, including Flatpak exports; hidden entries and duplicate Steam game shortcuts are omitted. Steam-installed APK applications appear in the Lepton category and launch through Steam. Sideloaded Lepton apps are discovered from installed APKs in each context's package database; empty contexts and Android system packages are excluded. These launch in their existing user-owned context. Missing Android labels/artwork fall back to the package name and initials. Downloaded but uninstalled APK files are not included. Enumeration does not start containers. Steam launch feedback confirms handoff to Steam rather than game readiness. Plugin-library entries can specify `engines.framely`; update Framely if your version does not satisfy the shown range. + +## Categories and favorites + +The manager has a dedicated **Launcher** page for trigger assignment, menu timeout and the categories included in **All**. Plugins (including Framely), Steam, Lepton and Desktop apps are included by default. Turning a category off only removes it from All; its own category and Favorites remain accessible. Turning every category off leaves All empty. + +Every app type can be favorited using the star shown on hover. Existing plugin and Steam favorites are preserved. Disabled plugins stay hidden; favorites for temporarily unavailable apps are retained. Each opening clears the search and resets pagination. Once local enumeration completes, Favorites opens if any favorite is currently available; otherwise All opens. Removing the final favorite while viewing Favorites shows an empty state without switching categories until the next opening. + +## Search + +Click the capsule at the top to open the VR keyboard. Search stays within the selected category and preserves its ordering. Clear the field to restore its apps. While the keyboard is open, launcher stick navigation and background dismissal are paused; B closes the keyboard first. The icon canvas keeps its fixed size and position while searching. + +Search accepts original names, English word initials, Chinese pinyin and Japanese romaji / syllable initials. Matching ignores case, width, tone marks, spaces and punctuation; it supports prefixes and continuous fragments. Examples: `toushi`, `tiaose` and `tsts` find **透视调色**; `sakura` and `skr` find **さくら**; `toukyou` finds **東京**; `hla` finds **Half-Life: Alyx**. Chinese alternate character readings are supported within a bounded index; uncommon names or compound readings may differ from the intended pronunciation. + +Indexes are built asynchronously and cached on the device. Chinese uses offline pinyin data; Japanese uses the bundled Vibrato / IPADic reading dictionary. Han names receive both Chinese and Japanese candidates regardless of interface language. Unknown dictionary readings retain their original text. Original-name search remains available before phonetic indexing completes. No app names are sent to an external search service. + +Session API: `launcher.search.index` accepts `{names: string[]}` and returns a name-keyed map of `{terms: string[]}`. `launcher.favorite` accepts `{key, favorite}` with the same stable keys as launcher ordering. Plugin and Steam keys update their existing favorites; `framely`, `desktop:` and local `lepton:` keys use `launcherFavorites`. `launcher.settings.save` now includes `allCategories` (`plugin`, `steam`, `lepton`, `desktop`); omitted fields migrate to all four categories, while an explicit empty array means none. Search dictionaries and notices are included in both core updates and offline packages under `share/search`. diff --git a/docs/zh-CN/launcher.md b/docs/zh-CN/launcher.md index 581cd89..439847d 100644 --- a/docs/zh-CN/launcher.md +++ b/docs/zh-CN/launcher.md @@ -2,16 +2,34 @@ [English](../launcher.md) -激光指向 Framely Dock 图标:上扳机打开启动台,主扳机打开快捷面板。在 **设置 → 启动台 → 主扳机打开启动台** 开启开关,两个入口立即互换。其他手柄保留普通点击入口。 +激光指向 Framely Dock 图标:上扳机打开启动台,主扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开启开关,两个入口立即互换。其他手柄保留普通点击入口。 -启动台是 Dock 内宽 3 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限跟随头显刷新率,在 60–144 FPS 范围内调整;读取不到刷新率时回退到 90 FPS。这是绘制上限,并不保证实测达到该帧率。渲染保留高分辨率纹理,只上传发生变化的区域。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,平时只显示应用图标、名称和分类器。左侧切换全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。 +启动台是 Dock 内宽 3 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。渲染保留高分辨率纹理,只上传发生变化的区域。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。 -按下后的激光落点允许在 36 个界面像素内抖动,点击区域大于图标本身;明显拖动会取消落点稳定处理。主扳机按住插件图标 400 毫秒展开操作菜单,普通抖动不会取消长按,包含开发者按钮和“快捷面板、管理插件、卸载插件”。按 **B** 或点击空白区域关闭启动台;处于图标整理状态时,第一次操作先退出整理,启动台继续显示。按住主扳机横向拖动时,当前页随手移动,相邻页同步滑入;松手后吸附到相邻页,拖动距离不足或到达边界则回到当前页。操作菜单默认在指针离开 10 秒后关闭,重新移入取消计时。在 **设置 → 启动台** 调整自动关闭开关与时长(1–300 秒)。卸载需要确认,并保留插件数据。 +按下后的激光落点允许在 36 个界面像素内抖动,点击区域大于图标本身;明显拖动会取消落点稳定处理。主扳机按住插件图标 400 毫秒展开操作菜单,普通抖动不会取消长按,包含开发者按钮和“快捷面板、管理插件、卸载插件”。按 **B** 或点击空白区域关闭启动台;处于图标整理状态时,第一次操作先退出整理,启动台继续显示。按住主扳机横向拖动时,当前页随手移动,相邻页同步滑入;松手后吸附到相邻页,拖动距离不足或到达边界则回到当前页。操作菜单默认在指针离开 10 秒后关闭,重新移入取消计时。在 **管理面板 → 启动台** 调整自动关闭开关与时长(1–300 秒)。卸载需要确认,并保留插件数据。 -继续按住到 **800 毫秒**进入图标整理。拖动任意图标到其他位置,其他图标按顺序移动并自动补齐 4/5/4 槽位,不留空洞。Framely 本身也在启动台中,点击打开管理面板,并可一起排列。向左拖动的翻页边缘位于分类器右侧,分类器本身不会触发翻页。拖到图标区域左右边缘停留 650 毫秒自动翻页,继续停留可跨更多页面。放手保存排列,点击“完成”退出整理。关闭启动台或隐藏 Dock 会取消尚未完成的拖动。手动排列优先于收藏排序,新应用追加在后面。 +继续按住到 **800 毫秒**进入图标整理。拖动任意图标到其他位置,其他图标按顺序移动并自动补齐 4/5/4 槽位,不留空洞。Framely 本身也在启动台中,点击打开管理面板,并可一起排列。向左拖动的翻页边缘位于分类器右侧,分类器本身不会触发翻页。拖到图标区域左右边缘停留 650 毫秒自动翻页,继续停留可跨更多页面。放手保存排列,点击“完成”退出整理。关闭启动台或隐藏 Dock 会取消尚未完成的拖动。手动排列在收藏、搜索、刷新和重新打开后保持不变。收藏不会将图标移到前面,收藏分类按相同顺序筛选;尚无保存位置的应用按名称追加到末尾。 任一手柄的摇杆上下切换分类、左右翻页。每次拨动只取幅度较大的一个方向,回到中位范围后才允许再次导航,避免斜向或两个摇杆同时触发分类与翻页。 桌面程序从会话用户的 XDG 应用目录和 Flatpak 导出入口读取,隐藏入口及重复的 Steam 游戏快捷方式不会显示。Steam 安装的 APK 应用出现在 Lepton 分类,通过 Steam 启动;侧载应用从 Lepton 各容器的已安装 APK 与包数据库读取,排除空容器和 Android 系统组件,并在原有用户容器中启动。缺少 Android 名称、图片时使用包名和首字母。仅下载但未安装的 APK 不会加入启动台,读取应用列表也不会启动容器。 Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成。插件库版本可声明 `engines.framely`;当前本体不满足显示的范围时,请先升级本体。 + +## 分类与收藏 + +管理面板新增独立的 **启动台** 页面,集中设置扳机分配、菜单自动关闭及“全部包含的分类”。默认包含插件(含 Framely)、Steam、Lepton、桌面程序。关闭某个分类只影响“全部”,其单独分类和收藏仍可访问;全部关闭时,“全部”显示空状态。 + +所有类型的应用均可用悬停显示的星标收藏,已有插件与 Steam 收藏保持不变。停用插件不显示,暂时不可用的应用仍保留收藏记录。每次打开会清空搜索并重置分页,本地应用枚举完成后,有可显示的收藏则默认进入收藏,否则进入全部。在收藏页取消最后一个收藏时保持当前分类,显示空状态;下次打开才回退至全部。 + +## 搜索 + +点击顶部胶囊搜索框打开 VR 键盘。仅搜索当前分类,并保留原有排序;清空后恢复分类内容。键盘打开期间暂停摇杆导航及背景关闭,B先关闭键盘。搜索不会改变图标区域的固定大小或位置。 + +支持原文、英文单词首字母、中文拼音、日语罗马音及音节首字母。不区分大小写、全半角,忽略声调、空格与分隔符,支持前缀及连续片段。例如 `toushi`、`tiaose`、`tsts` 匹配“透视调色”;`sakura`、`skr` 匹配“さくら”;`toukyou` 匹配“東京”;`hla` 匹配“Half-Life: Alyx”。中文索引在有限大小内包含单字的其他读音,生僻名称或词组读音可能与实际读法不同。 + +索引在设备后台异步生成并缓存。中文使用离线拼音数据,日语使用随包提供的 Vibrato/IPADic 读音词典。汉字名称同时生成中文和日语候选,不依赖界面语言;词典无法识别的部分保留原文。读音索引尚未完成时仍可使用原文搜索,不会将应用名称发送至外部搜索服务。 + +会话接口:`launcher.search.index` 接收 `{names: string[]}`,返回名称对应的 `{terms: string[]}`。`launcher.favorite` 接收 `{key, favorite}`,使用与图标排列相同的稳定标识。插件和 Steam 仍更新原有收藏,`framely`、`desktop:` 和本地 `lepton:` 标识保存于 `launcherFavorites`。`launcher.settings.save` 新增 `allCategories`(`plugin`、`steam`、`lepton`、`desktop`);旧配置省略字段时默认全选,显式空数组表示全部不选。词典和许可说明位于发布包 `share/search`,同时包含于本体更新包和完整离线包。 + +已收藏应用的右上角显示常驻的蓝色实心星标,再次点击即可取消收藏;收藏状态变化不会改变图标排序。 diff --git a/native/host.cpp b/native/host.cpp index 6ba30cf..54ca394 100644 --- a/native/host.cpp +++ b/native/host.cpp @@ -50,10 +50,12 @@ static GLXPbuffer glsurface=0; static std::atomic notification_badge_count{0}; static bool launcher_open=false; static LauncherDismissGuard launcher_dismiss; +static LauncherDockGuard launcher_dock; static EntryTrigger launcher_close_button; static LauncherStick launcher_stick; static std::chrono::steady_clock::time_point launcher_outside_at{}; static bool launcher_selected=false; +static std::chrono::steady_clock::time_point launcher_keyboard_back_until{}; static std::atomic launcher_primary{false}; static bool entry_actions_ready=false; static vr::VRActionSetHandle_t entry_set=0; @@ -164,7 +166,8 @@ static void show(View& v,bool shown){ if(v.key=="menu"||v.key=="notifications")update_menu_modal(); } } -static void close_keyboard(bool hide=true){if(keyboard_open&&hide)overlays->HideKeyboard();keyboard_open=false;keyboard_close_pending=false;keyboard_toolbar=false;keyboard_chars=false;keyboard_done=false;keyboard_target=0;keyboard_menu.end(std::chrono::steady_clock::now());if(keyboard_view=="menu")update_menu_modal();if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();} +static void keyboard_state(bool open){if(keyboard_frame)execute(keyboard_frame,std::string("window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:")+(open?"true":"false")+"}))");} +static void close_keyboard(bool hide=true){keyboard_state(false);if(keyboard_open&&hide)overlays->HideKeyboard();keyboard_open=false;keyboard_close_pending=false;keyboard_toolbar=false;keyboard_chars=false;keyboard_done=false;keyboard_target=0;keyboard_menu.end(std::chrono::steady_clock::now());if(keyboard_view=="menu")update_menu_modal();if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();} static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_overlay=0){ // The runtime toolbar opens the keyboard without a host.keyboard request. if(e.eventType==vr::VREvent_KeyboardOpened_Global){ @@ -175,7 +178,7 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o close_keyboard(false);owner->host->set_focus(owner->host,1); keyboard_frame=owner->browser->get_focused_frame(owner->browser);keyboard_view=owner->key; keyboard_target=target;keyboard_token=e.data.keyboard.uUserValue;keyboard_open=true;keyboard_toolbar=true; - keyboard_menu.begin(owner->key=="menu");return true; + keyboard_menu.begin(owner->key=="menu");keyboard_state(true);return true; } if(e.eventType!=vr::VREvent_KeyboardCharInput&&e.eventType!=vr::VREvent_KeyboardDone&&e.eventType!=vr::VREvent_KeyboardClosed&&e.eventType!=vr::VREvent_KeyboardClosed_Global)return false; if(!keyboard_open)return true; @@ -218,7 +221,7 @@ static View& create_view(const std::string& key,const std::string& title,const s } } if(dock){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);} - cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate(vr_system->GetFloatTrackedDeviceProperty(vr::k_unTrackedDeviceIndex_Hmd,vr::Prop_DisplayFrequency_Float));bs.background_color=0;std::cout<<"Browser frame rate "<DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr; } static void plugin_thumbnail(View& view,const std::string& encoded){ @@ -236,12 +239,28 @@ static void plugin_thumbnail(View& view,const std::string& encoded){ overlays->SetOverlayRaw(view.thumbnail,pixels.data(),128,128,4); } static void navigate(View& view,const std::string& hash){auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"location.hash="+json(hash).dump()+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);} +static bool launcher_dock_interaction(){ + // These are Steam's Dock bar and native window controls on Frame, not app + // content. The runtime keeps ownership of their positioning and interaction. + bool hovered=false; + for(const char* key:{"valve.steam.gamepadui.bar","valve.steam.gamepadui.floatingfooter","system.systemui"}){ + vr::VROverlayHandle_t overlay=0; + if(!overlays->FindOverlay(key,&overlay)&&overlays->IsOverlayVisible(overlay)&&overlays->IsHoverTargetOverlay(overlay))hovered=true; + } + bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held; + auto now=std::chrono::steady_clock::now();launcher_dock.observe(hovered,held,now); + if(launcher_dock.holds(now)){launcher_outside_at={};return true;} + return false; +} static void hide_launcher(){ - launcher_open=false;launcher_selected=false; + if(keyboard_view=="launcher")close_keyboard(); + launcher_open=false;launcher_selected=false;launcher_dock.clear(); if(views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)) overlays->ShowDashboard("valve.steam.gamepadui.main"); } static void launcher_back(){ + auto now=std::chrono::steady_clock::now();if(nowget_main_frame(view.browser); execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base); @@ -256,7 +275,7 @@ static void toggle_entry(bool launcher){ if(launcher_open){hide_launcher();return;} auto& view=create_view("launcher","Framely launcher",origin+"/launcher",true,1200,800); vr::VREvent_t stale{};while(overlays->PollNextOverlayEvent(view.overlay,&stale,sizeof(stale))){} - launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={}; + launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={}; overlays->ShowDashboard("launcher"); auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base); }else{hide_launcher();menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());} @@ -315,7 +334,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){ if(kind==0&&!active)entry_analog[hand].held=false; bool was_held=entry_buttons[hand][kind].held; if(entry_buttons[hand][kind].update(active,down,hit,now))toggle_entry(kind==0?launcher_primary.load():!launcher_primary.load()); - else if(kind==0&&active&&down&&!was_held&&launcher_open&&!hit&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60); + else if(kind==0&&active&&down&&!was_held&&launcher_open&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60); } if(!launcher_open)continue; if(hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState; @@ -327,13 +346,13 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){ // Pick a single controller and a single axis; never emit two directions. if(active&&(!stick_active||std::max(std::abs(x),std::abs(y))>std::max(std::abs(stick_x),std::abs(stick_y)))){stick_active=true;stick_x=x;stick_y=y;} } - if(launcher_open){int direction=launcher_stick.update(stick_x,stick_y,stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}} + if(launcher_open){int direction=launcher_stick.update(keyboard_open?0:stick_x,keyboard_open?0:stick_y,!keyboard_open&&stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}} else{launcher_stick={};launcher_close_button={};} - if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};launcher_back();} + if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();} } -static void commands(){std::deque commands;{std::lock_guard lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="launcher.close"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely 插件管理",origin+"/manager"+((page=="catalog"||page=="updates"||page=="notification-settings"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="updates"||page=="notification-settings"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get()+"."+c.at("window").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "< commands;{std::lock_guard lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="launcher.close"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely 插件管理",origin+"/manager"+((page=="catalog"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="updates"||page=="notification-settings"||page=="launcher-settings"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get()+"."+c.at("window").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:mouse.x=v.width/2;mouse.y=v.height/2;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}} +static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!launcher_dock_interaction())launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:mouse.x=v.width/2;mouse.y=v.height/2;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}} int main(int argc,char** argv){ std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose; if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);} - if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible; + if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible; // The runtime owns dashboard placement, curvature, visibility and switching. // Never move the launcher into standing space or hide another app's overlay. + if(launcher_open)launcher_dock_interaction(); if(views.count("launcher")){ bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay); if(launcher_open&&selected)launcher_selected=true; - if(launcher_open&&!selected&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false; + if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false; } // Toggle once for a matched left click; never activate on a stale release. vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){ diff --git a/native/launcher_input.h b/native/launcher_input.h index 270e750..7400c5d 100644 --- a/native/launcher_input.h +++ b/native/launcher_input.h @@ -82,3 +82,16 @@ inline bool launcher_stick_axes(const vr::RenderModel_ComponentState_t& rest,con float det=aa*bb-ab*ab;if(det<.00001f)return false; x=std::clamp((av*bb-bv*ab)/det,-1.f,1.f);y=std::clamp((bv*aa-av*ab)/det,-1.f,1.f);return std::isfinite(x)&&std::isfinite(y); } + +// Runtime dashboard controls are outside the app surface. Preserve their drag +// gesture and delayed modal-cancel events without swallowing later empty clicks. +struct LauncherDockGuard { + std::chrono::steady_clock::time_point until{};bool dragging=false; + void observe(bool hovered,bool held,std::chrono::steady_clock::time_point now){ + if(hovered&&held)dragging=true; + if(hovered||dragging)until=now+std::chrono::milliseconds(250); + if(!held)dragging=false; + } + bool holds(std::chrono::steady_clock::time_point now)const{return dragging||now -#include -inline int browser_frame_rate(float refresh){ - // Preserve smooth UI on an unknown display, with a bounded update budget. - return std::isfinite(refresh)&&refresh>0?std::clamp(int(std::round(std::clamp(refresh,60.f,144.f))),60,144):90; -} +// Cap CEF painting independently of headset refresh to limit pixel readback. +inline constexpr int browser_frame_rate(float = 0){return 60;} diff --git a/src/launcher_search.rs b/src/launcher_search.rs new file mode 100644 index 0000000..d0392f6 --- /dev/null +++ b/src/launcher_search.rs @@ -0,0 +1,387 @@ +//! Offline, cached phonetic indexes. This runs in the session HTTP worker, never CEF. +use anyhow::{Context, Result}; +use pinyin::{ToPinyin, ToPinyinMulti}; +use serde::Serialize; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::File, + path::Path, +}; +use unicode_normalization::UnicodeNormalization; +use vibrato::{dictionary::Dictionary, Tokenizer}; + +#[derive(Clone, Serialize)] +pub struct SearchIndex { + terms: Vec, +} +#[derive(Default)] +pub struct SearchCache { + tokenizer: Option, + entries: BTreeMap, +} +fn normalize(s: &str) -> String { + let decomposed = s + .nfkc() + .collect::() + .nfd() + .filter(|c| { + !unicode_normalization::char::is_combining_mark(*c) + || ['\u{3099}', '\u{309a}'].contains(c) + }) + .collect::(); + decomposed + .nfc() + .flat_map(char::to_lowercase) + .filter(|c| c.is_alphanumeric()) + .collect() +} + +impl SearchCache { + pub fn batch(&mut self, path: &Path, names: &[String]) -> Result { + if self.tokenizer.is_none() { + let reader = zstd::Decoder::new( + File::open(path).context("Japanese search dictionary missing")?, + )?; + self.tokenizer = Some(Tokenizer::new(Dictionary::read(reader)?).ignore_space(true)?); + } + // A bounded cache also avoids keeping every renamed app forever. + if self.entries.len() + names.len() > 8192 { + self.entries.clear(); + } + for name in names { + if !self.entries.contains_key(name) { + self.entries + .insert(name.clone(), index(name, self.tokenizer.as_ref().unwrap())); + } + } + let result = serde_json::to_value( + names + .iter() + .map(|name| (name, &self.entries[name])) + .collect::>(), + )?; + let bytes: usize = self + .entries + .iter() + .map(|(name, index)| name.len() + index.terms.iter().map(String::len).sum::()) + .sum(); + if bytes > 8 * 1024 * 1024 { + self.entries.clear(); + } + Ok(result) + } +} +fn index(name: &str, tokenizer: &Tokenizer) -> SearchIndex { + let name = name.nfkc().collect::(); + let mut terms = BTreeSet::new(); + terms.insert(normalize(&name)); + terms.insert(normalize( + &name + .split(|c: char| !c.is_alphanumeric()) + .filter_map(|w| w.chars().next()) + .collect::(), + )); + let parts: Vec = name + .chars() + .map(|c| { + c.to_pinyin() + .map(|p| p.plain().into()) + .unwrap_or_else(|| c.to_string()) + }) + .collect(); + terms.insert(normalize(&parts.concat())); + terms.insert(normalize( + &parts + .iter() + .filter_map(|p| p.chars().next()) + .collect::(), + )); + // Add individual alternate readings without a combinatorial expansion. + for (i, c) in name.chars().enumerate() { + if terms.len() >= 64 { + break; + } + if let Some(readings) = c.to_pinyin_multi() { + for reading in readings.into_iter().take(8) { + if terms.len() >= 64 { + break; + } + let mut variant = parts.clone(); + variant[i] = reading.plain().into(); + terms.insert(normalize(&variant.concat())); + terms.insert(normalize( + &variant + .iter() + .filter_map(|p| p.chars().next()) + .collect::(), + )); + } + } + } + let mut worker = tokenizer.new_worker(); + worker.reset_sentence(&name); + worker.tokenize(); + let mut roman = String::new(); + let mut initials = String::new(); + for i in 0..worker.num_tokens() { + let token = worker.token(i); + let reading = token + .feature() + .split(',') + .nth(7) + .filter(|r| *r != "*") + .unwrap_or(token.surface()); + let (full, short) = romanize(reading); + roman.push_str(&full); + initials.push_str(&short); + } + terms.insert(normalize(&roman)); + terms.insert(normalize(&initials)); + let (full, short) = romanize(&name); + terms.insert(normalize(&full)); + terms.insert(normalize(&short)); + terms.remove(""); + SearchIndex { + terms: terms.into_iter().collect(), + } +} +// Hepburn-style syllables, retaining explicit vowels (トウキョウ -> toukyou). +fn romanize(value: &str) -> (String, String) { + let chars: Vec = value + .nfkc() + .map(|c| { + if ('ぁ'..='ゖ').contains(&c) { + char::from_u32(c as u32 + 0x60).unwrap() + } else { + c + } + }) + .collect(); + let mut full = String::new(); + let mut short = String::new(); + let mut i = 0; + let mut geminate = false; + let mut in_latin = false; + while i < chars.len() { + let c = chars[i]; + i += 1; + if c == 'ッ' { + geminate = true; + continue; + } + if c == 'ー' { + if let Some(v) = full.chars().rev().find(|c| "aeiou".contains(*c)) { + full.push(v); + } + continue; + } + let syllable = match c { + 'ア' | 'ァ' => "a", + 'イ' | 'ィ' => "i", + 'ウ' | 'ゥ' => "u", + 'エ' | 'ェ' => "e", + 'オ' | 'ォ' => "o", + 'カ' => "ka", + 'キ' => "ki", + 'ク' => "ku", + 'ケ' => "ke", + 'コ' => "ko", + 'サ' => "sa", + 'シ' => "shi", + 'ス' => "su", + 'セ' => "se", + 'ソ' => "so", + 'タ' => "ta", + 'チ' => "chi", + 'ツ' => "tsu", + 'テ' => "te", + 'ト' => "to", + 'ナ' => "na", + 'ニ' => "ni", + 'ヌ' => "nu", + 'ネ' => "ne", + 'ノ' => "no", + 'ハ' => "ha", + 'ヒ' => "hi", + 'フ' => "fu", + 'ヘ' => "he", + 'ホ' => "ho", + 'マ' => "ma", + 'ミ' => "mi", + 'ム' => "mu", + 'メ' => "me", + 'モ' => "mo", + 'ヤ' | 'ャ' => "ya", + 'ユ' | 'ュ' => "yu", + 'ヨ' | 'ョ' => "yo", + 'ラ' => "ra", + 'リ' => "ri", + 'ル' => "ru", + 'レ' => "re", + 'ロ' => "ro", + 'ワ' | 'ヮ' => "wa", + 'ヰ' => "wi", + 'ヱ' => "we", + 'ヲ' => "wo", + 'ン' => "n", + 'ガ' => "ga", + 'ギ' => "gi", + 'グ' => "gu", + 'ゲ' => "ge", + 'ゴ' => "go", + 'ザ' => "za", + 'ジ' => "ji", + 'ズ' => "zu", + 'ゼ' => "ze", + 'ゾ' => "zo", + 'ダ' => "da", + 'ヂ' => "ji", + 'ヅ' => "zu", + 'デ' => "de", + 'ド' => "do", + 'バ' => "ba", + 'ビ' => "bi", + 'ブ' => "bu", + 'ベ' => "be", + 'ボ' => "bo", + 'パ' => "pa", + 'ピ' => "pi", + 'プ' => "pu", + 'ペ' => "pe", + 'ポ' => "po", + 'ヴ' => "vu", + _ => "", + }; + if syllable.is_empty() { + full.push(c); + if c.is_alphanumeric() { + if !in_latin { + short.push(c); + } + in_latin = true; + } else { + in_latin = false; + } + geminate = false; + continue; + } + in_latin = false; + let mut syllable = syllable.to_owned(); + if i < chars.len() { + let small = chars[i]; + if "ャュョ".contains(small) && syllable.ends_with('i') { + let base = match syllable.as_str() { + "shi" => "sh", + "chi" => "ch", + "ji" => "j", + _ => &syllable[..syllable.len() - 1], + }; + syllable = format!( + "{}{}{}", + base, + if ["sh", "ch", "j"].contains(&base) { + "" + } else { + "y" + }, + match small { + 'ャ' => "a", + 'ュ' => "u", + _ => "o", + } + ); + i += 1; + } else if "ァィゥェォ".contains(small) + && !["a", "i", "u", "e", "o"].contains(&syllable.as_str()) + { + let base = if syllable == "fu" { + "f" + } else if syllable == "vu" { + "v" + } else { + &syllable[..syllable.len() - 1] + }; + syllable = format!( + "{}{}", + base, + match small { + 'ァ' => "a", + 'ィ' => "i", + 'ゥ' => "u", + 'ェ' => "e", + _ => "o", + } + ); + i += 1; + } + } + if geminate { + if let Some(first) = syllable.chars().next().filter(|c| !"aeioun".contains(*c)) { + full.push(first); + } + geminate = false; + } + short.push(syllable.chars().next().unwrap()); + full.push_str(&syllable); + } + (full, short) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn offline_multilingual_index_and_cache() { + let mut cache = SearchCache::default(); + let names = vec![ + "透视调色", + "さくら", + "東京", + "Half-Life: Alyx", + "Termix", + "ゲーム", + "キャット", + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + let started = std::time::Instant::now(); + let v = cache + .batch(Path::new("assets/search/ipadic.dic.zst"), &names) + .unwrap(); + println!("Launcher index cold: {:?}", started.elapsed()); + for (name, needle) in [ + ("透视调色", "toushi"), + ("透视调色", "tiaose"), + ("透视调色", "tsts"), + ("さくら", "sakura"), + ("さくら", "skr"), + ("東京", "toukyou"), + ("Half-Life: Alyx", "hla"), + ("Termix", "termix"), + ("ゲーム", "geemu"), + ("キャット", "kyatto"), + ] { + assert!( + v[name]["terms"] + .as_array() + .unwrap() + .iter() + .any(|t| t.as_str().unwrap().contains(needle)), + "{name}: {needle}, {}", + v[name] + ); + } + let cached = std::time::Instant::now(); + assert_eq!(v, cache.batch(Path::new("does-not-exist"), &names).unwrap()); + println!("Launcher index cached: {:?}", cached.elapsed()); + } + #[test] + fn kana_and_normalization() { + assert_eq!(romanize("さくら"), ("sakura".into(), "skr".into())); + assert_eq!(romanize("トウキョウ").0, "toukyou"); + assert_eq!(normalize("TOU shí-"), "toushi"); + assert_ne!(normalize("ガ"), normalize("カ")); + assert_eq!(romanize("My アプリ").0, "My apuri"); + } +} diff --git a/src/main.rs b/src/main.rs index ece42c4..c8e066a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod http; mod ipc; mod jobs; mod localization; +mod launcher_search; mod model; mod package; mod planner; diff --git a/src/model.rs b/src/model.rs index 9cad0d4..d90afa6 100644 --- a/src/model.rs +++ b/src/model.rs @@ -185,6 +185,14 @@ pub struct LauncherSettings { pub menu_auto_close: bool, #[serde(default = "menu_timeout")] pub menu_timeout_seconds: u32, + #[serde(default = "launcher_categories")] + pub all_categories: Vec, +} +fn launcher_categories() -> Vec { + ["plugin", "steam", "lepton", "desktop"] + .into_iter() + .map(str::to_owned) + .collect() } fn menu_timeout() -> u32 { 10 @@ -195,11 +203,20 @@ impl Default for LauncherSettings { primary_trigger: false, menu_auto_close: true, menu_timeout_seconds: 10, + all_categories: launcher_categories(), } } } impl LauncherSettings { pub fn validate(&self) -> Result<()> { + let unique: std::collections::BTreeSet<_> = self.all_categories.iter().collect(); + ensure!( + unique.len() == self.all_categories.len() + && unique + .iter() + .all(|c| ["plugin", "steam", "lepton", "desktop"].contains(&c.as_str())), + "Invalid launcher categories" + ); ensure!( (1..=300).contains(&self.menu_timeout_seconds), "Menu timeout must be between 1 and 300 seconds" @@ -773,6 +790,8 @@ pub struct Database { pub steam_favorites: std::collections::BTreeSet, #[serde(default)] pub launcher_order: Vec, + #[serde(default)] + pub launcher_favorites: std::collections::BTreeSet, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/service.rs b/src/service.rs index c07cd9d..6bf2572 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1016,6 +1016,48 @@ impl Service { self.runtime.remove(&id); Ok(json!(true)) } + "launcher.favorite" => { + let key = p["key"].as_str().context("Missing launcher entry")?; + let favorite = p["favorite"].as_bool().context("Missing favorite")?; + if let Some(id) = key.strip_prefix("plugin:") { + self.db + .plugins + .get_mut(id) + .context("Unknown plugin")? + .favorite = favorite; + } else if let Some(id) = key.strip_prefix("steam:") { + let app: u32 = id.parse().context("Invalid Steam ID")?; + ensure!(app > 0, "Invalid Steam ID"); + if favorite { + self.db.steam_favorites.insert(app); + } else { + self.db.steam_favorites.remove(&app); + } + } else { + ensure!( + key.len() <= 512 + && !key.chars().any(char::is_control) + && (key == "framely" + || ["desktop:", "lepton:"] + .iter() + .any(|prefix| key.starts_with(prefix) + && key.len() > prefix.len())), + "Invalid launcher entry" + ); + if favorite { + ensure!( + self.db.launcher_favorites.len() < 4096 + || self.db.launcher_favorites.contains(key), + "Too many favorites" + ); + self.db.launcher_favorites.insert(key.into()); + } else { + self.db.launcher_favorites.remove(key); + } + } + self.save()?; + Ok(json!(true)) + } "launcher.settings.save" => { let settings: LauncherSettings = serde_json::from_value(p)?; settings.validate()?; @@ -2232,7 +2274,8 @@ impl Service { let roots: Vec<_> = impacted .iter() .filter(|id| { - self.db.plugins.get(*id).is_some_and(|p| p.enabled) + !self.db.safe_mode + && self.db.plugins.get(*id).is_some_and(|p| p.enabled) && (running.contains(*id) || plugin_autostart(&self.db, id)) }) .cloned() diff --git a/src/session.rs b/src/session.rs index 5ac1bd1..64eb9ca 100644 --- a/src/session.rs +++ b/src/session.rs @@ -31,6 +31,7 @@ struct Agent { commands: Mutex>, events: Mutex, jobs: crate::jobs::Jobs, + launcher_search: Mutex, catalog_cache: Arc>>, } #[derive(Default)] @@ -617,6 +618,33 @@ impl Agent { ); } match method { + "launcher.search.index" => { + let names: Vec = serde_json::from_value(p["names"].clone())?; + ensure!( + names.len() <= 4096 + && names.iter().map(String::len).sum::() <= 262144 + && names + .iter() + .all(|n| n.len() <= 1024 && !n.chars().any(char::is_control)), + "Invalid launcher search names" + ); + let parent = self + .assets + .parent() + .context("Missing search asset directory")?; + let packaged = parent.join("search/ipadic.dic.zst"); + let path = if packaged.is_file() { + packaged + } else { + // Source checkout: --assets ui/dist, with data in assets/search. + parent + .parent() + .unwrap_or(Path::new(".")) + .join("assets/search/ipadic.dic.zst") + }; + // HTTP requests run on workers, so dictionary loading never blocks native input. + self.launcher_search.lock().unwrap().batch(&path, &names) + } "desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))), "desktop.launch" => { let id = p["app"].as_str().context("Missing desktop app")?; @@ -770,7 +798,7 @@ impl Agent { if method == "host.manager.open" && matches!( p["page"].as_str(), - Some("catalog" | "updates" | "notification-settings") + Some("catalog" | "updates" | "notification-settings" | "launcher-settings") ) { command["page"] = p["page"].clone(); @@ -903,6 +931,7 @@ impl Agent { | "plugin.launcher.action" | "launcher.settings.save" | "launcher.order.save" + | "launcher.favorite" | "steam.favorite" | "window.open" | "window.close" @@ -1212,6 +1241,7 @@ pub fn serve( commands: Mutex::default(), events: Mutex::default(), jobs: crate::jobs::Jobs::default(), + launcher_search: Mutex::default(), catalog_cache: Arc::default(), }); let network_agent = agent.clone(); @@ -1596,6 +1626,7 @@ mod tests { commands: Mutex::default(), events: Mutex::default(), jobs: crate::jobs::Jobs::default(), + launcher_search: Mutex::default(), catalog_cache: Arc::default(), }) } @@ -1613,6 +1644,89 @@ mod tests { }) } #[test] + fn launcher_favorites_pass_through_real_http_session_and_persist() { + let root = tempfile::tempdir().unwrap(); + let server = Server::http("127.0.0.1:0").unwrap(); + let agent = agent(&server, root.path()); + let manager = unsafe { libc::geteuid() }; + let mut core = crate::tests::accepted_service(&agent.state, manager).unwrap(); + let package = crate::tests::fixture(root.path(), "favorite.demo", "1.0.0", 1, None); + core.handle( + "install", + json!({"package":STANDARD.encode(package),"approve":true}), + ) + .unwrap(); + let socket = UnixListener::bind(&agent.socket).unwrap(); + socket.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let core_stop = stop.clone(); + let core_worker = std::thread::spawn(move || { + while !core_stop.load(Ordering::Relaxed) { + if let Ok((mut stream, _)) = socket.accept() { + let request = ipc::read(&mut stream).unwrap(); + let response = ipc::response(core.handle( + request["method"].as_str().unwrap(), + request["params"].clone(), + )); + ipc::write(&mut stream, &response).unwrap(); + } else { + std::thread::sleep(Duration::from_millis(5)); + } + } + }); + let http = http_server(server, agent.clone(), stop.clone()); + let request = |method: &str, params: Value| -> Value { + ureq::post(&format!("{}/api", agent.origin)) + .set("Cookie", &format!("framely={}", agent.web_key)) + .set("Origin", &agent.origin) + .send_json(json!({"method":method,"params":params})) + .unwrap() + .into_json() + .unwrap() + }; + let responses: Vec<_> = [ + "plugin:favorite.demo", + "steam:42", + "framely", + "desktop:terminal.desktop", + "lepton:default/app", + ] + .into_iter() + .map(|key| request("launcher.favorite", json!({"key":key,"favorite":true}))) + .collect(); + let status = request("status", json!({})); + let removed = request( + "launcher.favorite", + json!({"key":"framely","favorite":false}), + ); + let denied = request("launcher.favorite.invalid", json!({})); + stop.store(true, Ordering::Relaxed); + http.join().unwrap(); + core_worker.join().unwrap(); + for response in responses { + assert_eq!(response["result"], true, "{response}"); + } + assert_eq!( + status["result"]["database"]["plugins"]["favorite.demo"]["favorite"], + true + ); + assert_eq!(status["result"]["database"]["steamFavorites"], json!([42])); + assert_eq!(removed["result"], true); + assert!(denied.get("error").is_some()); + let loaded = crate::service::Service::load(&agent.state, manager).unwrap(); + assert!(loaded.db.plugins["favorite.demo"].favorite); + assert!(loaded.db.steam_favorites.contains(&42)); + assert_eq!( + loaded.db.launcher_favorites, + [ + "desktop:terminal.desktop".to_owned(), + "lepton:default/app".to_owned() + ] + .into_iter() + .collect() + ); + } + #[test] fn manager_entry_preserves_catalog_and_update_targets_and_rejects_arbitrary_pages() { let root = tempfile::tempdir().unwrap(); let server = Server::http("127.0.0.1:0").unwrap(); @@ -1621,6 +1735,7 @@ mod tests { json!({"page":"catalog"}), json!({"page":"updates"}), json!({"page":"notification-settings"}), + json!({"page":"launcher-settings"}), json!({}), json!({"page":"https://example.org"}), ] { @@ -1635,8 +1750,12 @@ mod tests { commands[2], json!({"kind":"manager.open","page":"notification-settings"}) ); - assert_eq!(commands[3], json!({"kind":"manager.open"})); + assert_eq!( + commands[3], + json!({"kind":"manager.open","page":"launcher-settings"}) + ); assert_eq!(commands[4], json!({"kind":"manager.open"})); + assert_eq!(commands[5], json!({"kind":"manager.open"})); } #[test] fn declining_agreement_only_closes_current_view() { diff --git a/src/tests.rs b/src/tests.rs index cc4b445..04f20d6 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -814,6 +814,36 @@ fn apply_prepared(core: &mut Service, prepared: crate::planner::Prepared) -> any core.handle("install.batch",json!({"approve":true,"approveRunAs":true,"plan":prepared.plan,"packages":prepared.packages.iter().map(|(bytes,source)|{let mut request=bytes.request();request["source"]=json!(source);request}).collect::>()})) } #[test] +fn batch_install_in_safe_mode_preserves_packages_without_starting_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let _tools = fake_backend_tools(tmp.path()); + let mut core = accepted_service(&tmp.path().join("state"), 1000).unwrap(); + core.handle("safeMode", json!({"enabled":true})).unwrap(); + for version in ["1.0.0", "1.1.0"] { + let bytes = relation_package( + tmp.path(), + "test.safe", + version, + json!({"backend":{"entry":"fail.sh","autostart":true}}), + ); + let prepared = crate::planner::prepare(&core.db, bytes, None, Default::default()).unwrap(); + apply_prepared(&mut core, prepared).unwrap(); + assert_eq!(core.db.plugins["test.safe"].manifest.version, version); + assert!(core.db.plugins["test.safe"].enabled); + assert!(core.db.safe_mode); + assert!(!core.root.join("install-transaction.json").exists()); + let status = core.handle("status", json!({})).unwrap(); + assert_eq!(status["runtime"]["test.safe"]["phase"], "installed"); + let reloaded = Service::load(&core.root, core.manager).unwrap(); + assert!(reloaded.db.safe_mode); + assert_eq!(reloaded.db.plugins["test.safe"].manifest.version, version); + } + core.handle("safeMode", json!({"enabled":false})).unwrap(); + let lifecycle = fs::read_to_string(core.root.join("logs/test.safe.lifecycle.log")).unwrap(); + assert!(lifecycle.contains("\"phase\":\"starting\"")); + core.shutdown(); +} +#[test] fn dependency_install_conflicts_reverse_constraints_and_removal() { let tmp = tempfile::tempdir().unwrap(); let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap(); @@ -1971,3 +2001,61 @@ fn launcher_dispatch_preserves_old_open_semantics_and_window_context() { let loaded = Service::load(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap(); assert_eq!(loaded.db.launcher_order, order); } + +#[test] +fn launcher_categories_migrate_validate_and_favorites_survive_reload() { + let root = tempfile::tempdir().unwrap(); + let state = root.path().join("state"); + let mut core = accepted_service(&state, unsafe { libc::geteuid() }).unwrap(); + assert_eq!( + core.db.launcher.all_categories, + vec!["plugin", "steam", "lepton", "desktop"] + ); + core.handle("launcher.settings.save",json!({"primaryTrigger":true,"menuAutoClose":true,"menuTimeoutSeconds":10,"allCategories":[]})).unwrap(); + for key in [ + "framely", + "desktop:terminal.desktop", + "lepton:default/app", + "steam:42", + ] { + core.handle("launcher.favorite", json!({"key":key,"favorite":true})) + .unwrap(); + } + for invalid in [ + json!(["all"]), + json!(["plugin", "plugin"]), + json!(["favorites"]), + ] { + assert!(core.handle("launcher.settings.save",json!({"primaryTrigger":true,"menuAutoClose":true,"menuTimeoutSeconds":10,"allCategories":invalid})).is_err()); + } + for invalid in [ + "", + "plugin:missing", + "steam:0", + "steam:-1", + "steam:4294967296", + "desktop:", + "invalid:app", + "lepton:\napp", + ] { + assert!(core + .handle("launcher.favorite", json!({"key":invalid,"favorite":true})) + .is_err()); + } + let mut loaded = Service::load(&state, unsafe { libc::geteuid() }).unwrap(); + assert!(loaded.db.launcher.all_categories.is_empty()); + assert!(loaded.db.launcher.primary_trigger); + assert_eq!(loaded.db.launcher_favorites.len(), 3); + assert!(loaded.db.steam_favorites.contains(&42)); + loaded + .handle( + "launcher.favorite", + json!({"key":"framely","favorite":false}), + ) + .unwrap(); + assert!(!loaded.db.launcher_favorites.contains("framely")); + loaded + .handle("launcher.settings.save", json!({"menuTimeoutSeconds":25})) + .unwrap(); + assert_eq!(loaded.db.launcher.all_categories.len(), 4); +} diff --git a/tests/browser_probe.cpp b/tests/browser_probe.cpp index 81bc018..5f69d66 100644 --- a/tests/browser_probe.cpp +++ b/tests/browser_probe.cpp @@ -40,7 +40,7 @@ int main(int argc,char** argv){ display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){int ink=0;for(int py=std::max(0,y*4);py128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;}; client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();}; - cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=90;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser); + cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=60;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser); auto end=std::chrono::steady_clock::now()+std::chrono::seconds(45);bool injected=false; while(std::chrono::steady_clock::now()send_mouse_move_event(host,&mouse,0);if(now-test_pointer.pressed>=std::chrono::milliseconds(press_duration)){mouse.modifiers=0;host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;}}if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input:not([type=checkbox]),textarea"))throw Error("release configuration leaked into user settings");if(!updates.querySelector(".automatic-update-settings input[type=checkbox]")?.disabled)throw Error("automatic check controls enabled without source");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].find(b=>b.textContent==="取消").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于"));[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于").click();await until(()=>document.querySelector(".system-updates"));[...document.querySelectorAll("nav button")].find(b=>b.textContent.includes("插件库")).click();const card=await until(()=>document.querySelector(".store-card"));if(!card.textContent.includes("可更新"))throw Error("update not detected");if(card.querySelector(".plugin-avatar").getBoundingClientRect().width<64)throw Error("store icon not enlarged");if(document.querySelectorAll(".store-card").length!==1)throw Error("versions duplicated plugin cards");document.querySelector(".framely-select button").click();const tag=await until(()=>[...document.querySelectorAll("[role=option]")].find(b=>b.textContent==="工具"));if(!tag)throw Error("tag missing");tag.click();await wait(100);if(document.querySelector(".store-card .run-user-badge")?.textContent!=="root")throw Error("store run user missing");document.querySelector(".store-card").click();await wait(100);if(!document.body.textContent.includes("Store integration details"))throw Error("detail missing");if(!document.querySelector(".store-detail .run-user-notice.is-root"))throw Error("store root warning missing");console.log("FRAMELY_PREVIEW_STORE");document.querySelector(".detail-heading button").click();const approve=await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="确认安装"));if(document.querySelector(".modal h2")?.textContent!=="更新 Bridge")throw Error("manual install update title missing");if(document.querySelector(".install-review .install-account")?.textContent!=="steamos")throw Error("install must use package identity");if(document.body.textContent.includes("权限变化"))throw Error("unexpected privilege change");approve.click();await until(()=>!document.querySelector(".modal"));const status=await fetch("/api",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:"status",params:{}})}).then(r=>r.json());if(status.result.database.plugins["test.bridge"].manifest.version!=="2.0.0")throw Error("install not persisted");const chooseVersion=async v=>{document.querySelector('.store-detail .framely-select button').click();const option=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent.split(' · ')[0]===v));option.click();await wait(100);if(document.querySelector('.store-detail .install-account')?.textContent!==(v==='2.0.0'?'root':'steamos'))throw Error('selected version user incorrect');document.querySelector('.detail-heading button').click();const approveVersion=await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='确认安装'));approveVersion.click();await until(()=>!document.querySelector('.modal'));const result=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(result.result.database.plugins['test.bridge'].manifest.version!==v)throw Error('selected version not installed '+v);};await chooseVersion('1');await chooseVersion('2.0.0');console.log('FRAMELY_VERSION_SELECTION_PASS');[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();const removeSub=await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="删除订阅"));removeSub.click();const confirm=await until(()=>[...document.querySelectorAll(".modal button")].find(b=>b.textContent==="确认"));confirm.click();await until(()=>!document.body.textContent.includes("Subscription fixture"));console.log("FRAMELY_BRIDGE_PASS");})().catch(e=>console.error("FRAMELY_BRIDGE_FAIL "+e)))JS":R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);if(innerWidth!==600||innerHeight!==840||devicePixelRatio!==2)throw Error('quick layout or DPI changed');const dpiButton=document.createElement('button');dpiButton.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';dpiButton.textContent='DPI test';let clicked=false;dpiButton.onclick=()=>{clicked=true;console.log('FRAMELY_DPI_CLICK_PASS');};document.body.append(dpiButton);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);dpiButton.remove();if(!clicked)throw Error('logical pointer coordinates failed');const originalFetch=window.fetch;let pulses=0;window.fetch=function(url,init){if(init?.body&&JSON.parse(init.body).method==='host.haptic')pulses++;return originalFetch.call(this,url,init);};const fixture=document.createElement('div');fixture.innerHTML='';document.body.append(fixture);const children=fixture.querySelectorAll('span'),buttons=fixture.querySelectorAll('button');const over=e=>e.dispatchEvent(new PointerEvent('pointerover',{bubbles:true}));const leave=(e,target)=>e.dispatchEvent(new PointerEvent('pointerout',{bubbles:true,relatedTarget:target}));over(children[0]);leave(children[0],children[1]);over(children[1]);leave(children[1],children[2]);over(children[2]);leave(children[2],buttons[2]);over(buttons[2]);await wait(100);fixture.remove();window.fetch=originalFetch;if(pulses!==2)throw Error('hover count='+pulses);console.log('FRAMELY_HOVER_PASS');console.log('FRAMELY_PREVIEW_FAVORITES');[...document.querySelectorAll('nav button')].find(b=>b.textContent==='设置').click();await wait(200);console.log('FRAMELY_PREVIEW_SETTINGS');if(!document.querySelector('.quick-settings').firstElementChild.classList.contains('setting-entry'))throw Error('manager entry is below language settings');document.querySelector('.setting-entry').click();await wait(100);[...document.querySelectorAll('nav button')].find(b=>b.textContent==='已安装').click();await wait(200);console.log('FRAMELY_PREVIEW_INSTALLED');for(let i=0;i<100;i++){await wait(50);const b=document.querySelector('.plugin-main');if(b){b.click();return;}}console.error('FRAMELY_BRIDGE_FAIL manager plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS");f->execute_java_script(f,&code,&source,1);cef_string_utf16_clear(&code);f->base.release(&f->base);injected=true;}std::this_thread::sleep_for(std::chrono::milliseconds(5));} if(!passed&&!failed){auto* f=browser->get_main_frame(browser);cef_string_t diagnostic{},source{};str(diagnostic,"console.error('FRAMELY_TIMEOUT '+location.pathname.replace(/\\/boot\\/[^/]+/,'/boot/token')+' '+document.body.innerText.slice(0,400))");f->execute_java_script(f,&diagnostic,&source,1);cef_string_utf16_clear(&diagnostic);f->base.release(&f->base);for(int i=0;i<40;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}snapshot("FAILURE");} diff --git a/tests/launcher_fixture.tsx b/tests/launcher_fixture.tsx index ae3d54b..d483959 100644 --- a/tests/launcher_fixture.tsx +++ b/tests/launcher_fixture.tsx @@ -18,11 +18,11 @@ window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected f return new Response(JSON.stringify({result}));}; function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?
redraw()}/>
:redraw()}/>;} function button(text:string){return [...document.querySelectorAll('button')].find(b=>b.textContent===text)!;} -function openMenu(){const b=document.querySelector('.launch-icon')!;b.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));} +function openMenu(){const b=document.querySelector('.launch-icon[aria-label=Termix]')!;b.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));} (window as any).runInstallReviewChecks=async()=>{try{ - configureLanguage('en-US',[]);redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10); + configureLanguage('en-US',[]);redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); if(document.body.textContent?.includes('Disabled app'))throw Error('Disabled plugin displayed'); - if(document.querySelector('.launcher-header')||document.querySelector('.launcher-shell input'))throw Error('Launcher contains panel controls'); + if(document.querySelector('.launcher-header')||!document.querySelector('.launcher-search input'))throw Error('Launcher contains panel controls'); if(getComputedStyle(document.querySelector('.launcher-shell')!).backgroundColor!=='rgba(0, 0, 0, 0)')throw Error('Launcher is not transparent'); const grid=document.querySelector('.launcher-grid')!,filters=document.querySelector('.launcher-filters')!;const gridBefore=grid.getBoundingClientRect(),filterBefore=filters.getBoundingClientRect();if(gridBefore.width<1110)throw Error('Icon spacing was not widened');if(document.querySelector('.launcher-filters button')!.getBoundingClientRect().width<56)throw Error('Category hit target is too small'); const shellRect=document.querySelector('.launcher-shell')!.getBoundingClientRect();if(Math.abs(gridBefore.x+gridBefore.width/2-shellRect.x-shellRect.width/2)>1)throw Error('Icon area is not independently centered');const categoryRight=filterBefore.right; @@ -40,11 +40,11 @@ function openMenu(){const b=document.querySelector('.launch-i redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);sameLayout();assertRows([1]); for(let n=1;n<13;n++){plugins[`layout.${n}`]={...plugins['demo.beta'],enabled:true,manifest:{...plugins['demo.beta'].manifest,id:`layout.${n}`,name:`Layout ${String(n).padStart(2,'0')}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===n+1);sameLayout();assertRows(arrangements[n]);} for(let n=1;n<=13;n++)delete plugins[`layout.${n}`];plugins['demo.alpha'].enabled=true;plugins['demo.beta'].enabled=true;redraw();await until(()=>document.querySelectorAll('.launch-icon').length===3); - document.querySelector('.launcher-filters button')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); - const category=document.querySelector('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([4,3]);document.querySelector('.launcher-filters button')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); + document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); + const category=document.querySelector('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([4,3]);document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10); const assertControlsPainted=()=>{for(const node of document.querySelectorAll('.launcher-filters button svg,.launcher-pages button span')){const r=node.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_REGION_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}}; await wait(500);assertControlsPainted();for(const label of document.querySelectorAll('.launch-name')){const r=label.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_INK_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EN'); - const icon=document.querySelector('.launch-icon')!; + const icon=document.querySelector('.launch-icon[aria-label=Termix]')!; const rect=icon.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_HOVER_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}`);await wait(350);if(icon.getBoundingClientRect().width!==rect.width)throw Error('Hover moved the input target');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_HOVER_EN');console.log('FRAMELY_LAUNCHER_HOVER_0_0');await wait(250); const beforeClick=calls.filter(c=>c.method==='plugin.launch').length;console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+10)}_${Math.round(rect.y+rect.height/2)}_-28_15_150`);await until(()=>calls.filter(c=>c.method==='plugin.launch').length===beforeClick+1);if(calls.filter(c=>c.method==='plugin.launch').at(-1)?.params.plugin!=='demo.alpha')throw Error('Jitter click launched a different app'); const beforeDrag=calls.filter(c=>c.method==='plugin.launch').length;console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}_80_0_300`);await wait(550);if(calls.filter(c=>c.method==='plugin.launch').length!==beforeDrag||document.querySelector('[role=menu]'))throw Error('Deliberate movement activated an app'); @@ -74,7 +74,7 @@ function openMenu(){const b=document.querySelector('.launch-i const selfIcon=document.querySelector('.launch-icon[aria-label=Framely]')!;selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));const closeCount=calls.filter(c=>c.method==='host.launcher.close').length;window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('B closed the launcher instead of exiting arrangement');selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Blank click closed launcher during arrangement'); // Add enough icons for edge paging and swipe navigation. for(let n=0;n<9;n++)plugins[`edge.${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`edge.${n}`,name:`Edge ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2); - console.log('FRAMELY_LAUNCHER_PRESS_700_40_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));const activePage=document.querySelector('.launcher-page')!,adjacent=document.querySelector('.launcher-adjacent-page')!;if(!document.querySelector('.launcher-grid')!.classList.contains('launcher-panning')||!getComputedStyle(activePage).translate.startsWith('-180px'))throw Error('Canvas did not follow the held trigger');if(getComputedStyle(adjacent).translate==='none')throw Error('Neighbor did not slide alongside canvas');if(document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')!=='page')throw Error('Swipe committed before release');await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page'); + console.log('FRAMELY_LAUNCHER_PRESS_700_90_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));const activePage=document.querySelector('.launcher-page')!,adjacent=document.querySelector('.launcher-adjacent-page')!;if(!document.querySelector('.launcher-grid')!.classList.contains('launcher-panning')||!getComputedStyle(activePage).translate.startsWith('-180px'))throw Error('Canvas did not follow the held trigger');if(getComputedStyle(adjacent).translate==='none')throw Error('Neighbor did not slide alongside canvas');if(document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')!=='page')throw Error('Swipe committed before release');await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page'); window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page'); await wait(340);const edgeFirst=document.querySelector('.launch-icon')!.getBoundingClientRect();const saves=calls.filter(c=>c.method==='launcher.order.save').length; console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(edgeFirst.x+edgeFirst.width/2)}_${Math.round(edgeFirst.y+edgeFirst.height/2)}_0_0_2600`);await until(()=>document.querySelector('.launcher-editing'));const edgeBox=document.querySelector('.launcher-grid')!.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(edgeBox.right-25)}_400`);await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');console.log('FRAMELY_EDGE_PAGE_OK');await wait(340);const leftEdge=Math.max(edgeBox.left,categoryRight+24)+25;if(leftEdge<=categoryRight)throw Error('Left drag edge overlaps category rail');console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(leftEdge)}_400`);await until(()=>!!document.querySelector('.launcher-drag-edge.left'));const indicator=document.querySelector('.launcher-drag-edge.left')!.getBoundingClientRect();if(indicator.left<=categoryRight)throw Error('Left edge feedback is behind the category rail');await until(()=>calls.filter(c=>c.method==='launcher.order.save').length>saves);console.log('FRAMELY_EDGE_SAVE_OK');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing')); @@ -82,10 +82,10 @@ function openMenu(){const b=document.querySelector('.launch-i console.log('FRAMELY_EDGE_DONE_OK'); // Framely and new app kinds use their own safe launch APIs. document.querySelector('.launch-icon[aria-label=Framely]')!.click();await until(()=>calls.some(c=>c.method==='host.manager.open')); - console.log('FRAMELY_SELF_LAUNCH_OK');localApps=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'test/com.example.browser',name:'Android browser',kind:'lepton'}];window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>document.querySelector('.launch-icon[aria-label="Desktop terminal"]')); + console.log('FRAMELY_SELF_LAUNCH_OK');localApps=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'test/com.example.browser',name:'Android browser',kind:'lepton'}];await until(()=>!document.querySelector('.launch-icon:disabled'));window.dispatchEvent(new Event('framely.launcher.open'));await wait(120);await until(()=>!document.querySelector('.launcher-initializing'));document.querySelector('.launcher-filters button[aria-label=All]')!.click();await until(()=>document.querySelector('.launch-icon[aria-label="Desktop terminal"]')); document.querySelector('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='desktop:terminal.desktop')); await until(()=>!document.querySelector('.launch-icon')!.disabled);window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));await until(()=>document.querySelector('.launcher-filters button[aria-label=Lepton]')!.getAttribute('aria-pressed')==='true');document.querySelector('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='lepton:test/com.example.browser')); const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+2); - settings=true;redraw();await wait();const switches=document.querySelectorAll('input[type=checkbox]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS'); + settings=true;redraw();await wait();const switches=document.querySelectorAll('[role=switch]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS'); }catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}}; createRoot(document.getElementById('root')!).render();wait(150).then(()=>console.log('FRAMELY_VIEW_READY')); diff --git a/tests/launcher_input.cpp b/tests/launcher_input.cpp index 5fff21f..015df6d 100644 --- a/tests/launcher_input.cpp +++ b/tests/launcher_input.cpp @@ -33,4 +33,15 @@ vr::RenderModel_ComponentState_t joystick_rest{},joystick_x{},joystick_y{},joyst // Releasing the opening trigger rearms input once, even without mouse events. guard.opened(now);guard.observe(true);guard.observe(false); assert(guard.allows(now+std::chrono::milliseconds(700),true)); + // Dock dragging and delayed cancel keep the launcher; later background + // clicks become dismissible again, and reopening clears the prior gesture. + LauncherDockGuard dock;assert(!dock.holds(now));dock.observe(true,true,now); + assert(dock.holds(now+std::chrono::milliseconds(60))); + dock.observe(false,true,now+std::chrono::seconds(2)); + assert(dock.holds(now+std::chrono::seconds(2))); + dock.observe(false,false,now+std::chrono::seconds(3)); + assert(dock.holds(now+std::chrono::milliseconds(3200))); + assert(!dock.holds(now+std::chrono::milliseconds(3250))); + dock.clear();assert(!dock.holds(now+std::chrono::seconds(3))); + } diff --git a/tests/launcher_manager_fixture.tsx b/tests/launcher_manager_fixture.tsx new file mode 100644 index 0000000..2bfe9bb --- /dev/null +++ b/tests/launcher_manager_fixture.tsx @@ -0,0 +1,18 @@ +// Check routing and migrated settings in the actual manager, not a reproduced page. +import {configureLanguage} from '../ui/src/i18n'; +const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,350));}; +history.replaceState(null,'','/manager#launcher-settings');let language='en-US';let launcher:any={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']}; +window.fetch=async(_url,init)=>{const {method,params}=JSON.parse(String(init?.body));let result:any=true; + if(method==='status')result={version:'0.4.3-preview.2',build:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{plugins:{},sources:[],safeMode:false,language,launcher,networkPanel:{enabled:true,port:15915},proxy:{http:'',github:''}},running:[],notifications:[],inbox:[]}; + else if(method==='language.list')result={packs:[],invalidFiles:[]};else if(method==='ui.events')result={cursor:0,events:[]};else if(method==='network.status')result={passwordConfigured:true,localOrigin:'http://localhost:15915',ips:[],listener:{}};else if(method==='launcher.settings.save')launcher=params;else if(method==='language.save')language=params.language; + return new Response(JSON.stringify({result}));}; +const nav=(label:string)=>[...document.querySelectorAll('.workspace>nav button')].find(b=>b.textContent===label)!; +(window as any).runInstallReviewChecks=async()=>{try{ + await until(()=>!!document.querySelector('.launcher-settings'));if(nav('Launcher').getAttribute('aria-current')!=='page')throw Error('Hash did not select Launcher');if(document.querySelectorAll('.launcher-settings [role=switch]').length!==6)throw Error('Missing launcher controls'); + nav('Settings').click();await until(()=>!document.querySelector('.launcher-settings'));nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings')); + const switcher=document.querySelector('.launcher-settings [role=switch]')!;switcher.click();await until(()=>launcher.primaryTrigger);await until(()=>!switcher.disabled); + const categories=document.querySelectorAll('.launcher-settings [role=switch]');categories[3].click();await until(()=>!launcher.allCategories.includes('steam'));await until(()=>!categories[3].disabled); + await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_EN');const content=document.querySelector('.content')!;content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_EN');content.scrollTop=0;language='zh-CN';configureLanguage(language,[]);location.hash='#launcher-settings';window.dispatchEvent(new Event('hashchange'));await until(()=>document.documentElement.lang==='zh-CN'&&!!nav('启动台'));await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_ZH');content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_ZH'); + const first=document.querySelector('.launcher-settings .switch-row')!.getBoundingClientRect();const second=document.querySelectorAll('.launcher-settings')[1].querySelector('.switch-row')!.getBoundingClientRect();if(Math.abs(first.width-second.width)>1)throw Error('Settings widths differ');console.log('FRAMELY_BRIDGE_PASS'); +}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}}; +void import('../ui/src/main'); diff --git a/tests/launcher_search_fixture.tsx b/tests/launcher_search_fixture.tsx new file mode 100644 index 0000000..af225fa --- /dev/null +++ b/tests/launcher_search_fixture.tsx @@ -0,0 +1,69 @@ +import React,{useState} from 'react';import{createRoot}from'react-dom/client'; +import{Launcher,LauncherSettings,type LauncherConfig}from'../ui/src/launcher';import{configureLanguage}from'../ui/src/i18n';import type{Plugin}from'../ui/src/main';import{matchesSearch}from'../ui/src/launcher-search';import'../ui/src/style.css'; +const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<160;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,300));}; +let config:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10};let saved:string[]=[];let order:string[]=[];let steamFavorites:number[]=[42];let settings=false;let redraw:()=>void;let delay=80;const calls:{method:string;params:any}[]=[]; +const plugins:Record={ + 'demo.alpha':{manifest:{id:'demo.alpha',icon:'icon.png',name:'Termix',description:'Terminal',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}, + 'demo.beta':{manifest:{id:'demo.beta',icon:'icon.png',name:'透视调色',description:'Color',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:1}, + 'demo.off':{manifest:{id:'demo.off',name:'Disabled app',description:'',author:'Framely',version:'1.0.0',ui:{windows:{}}},enabled:false,favorite:true,order:2}, +}; +const local=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'default/sakura',name:'さくら',kind:'lepton'},{id:'default/tokyo',name:'東京',kind:'lepton'}]; +window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true; + if(method==='steam.list'){await wait(delay);result=[{id:42,name:'Half-Life: Alyx',icon:'/preview-icons/546560.jpg',iconFit:'cover'}];} + else if(method==='desktop.list'){await wait(delay);result=local;} + else if(method==='launcher.search.index'){await wait(150);result=Object.fromEntries(params.names.map((name:string)=>[name,{terms:name==='透视调色'?['toushitiaose','tsts']:name==='さくら'?['sakura','skr']:name==='東京'?['toukyou','tuku']:name==='Half-Life: Alyx'?['halflifealyx','hla']:[name.toLowerCase().replace(/\s/g,'')]}]));} + else if(method==='launcher.favorite'){const{key,favorite}=params;if(key.startsWith('plugin:'))plugins[key.slice(7)].favorite=favorite;else if(key.startsWith('steam:'))steamFavorites=favorite?[...steamFavorites,+key.slice(6)]:steamFavorites.filter(id=>id!==+key.slice(6));else saved=favorite?[...saved,key]:saved.filter(k=>k!==key);} + else if(method==='launcher.settings.save')config=params; + return new Response(JSON.stringify({result}));}; +function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?

{document.documentElement.lang==='zh-CN'?'启动台':'Launcher'}

{document.documentElement.lang==='zh-CN'?'启动方式、菜单与分类。':'Launch controls, menus and categories.'}

redraw()}/>
:redraw()}/>;} +const category=(name:string)=>document.querySelector(`.launcher-filters button[aria-label="${name}"]`)!; +const labels=()=>[...document.querySelectorAll('.launch-name')].map(n=>n.textContent); +const input=()=>document.querySelector('.launcher-search input')!; +function query(value:string){Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input(),value);input().dispatchEvent(new Event('input',{bubbles:true}));} +const favorite=(name:string)=>document.querySelector(`.launch-icon[aria-label="${name}"]`)!.parentElement!.querySelector('.launch-favorite')!; +(window as any).runInstallReviewChecks=async()=>{try{ + configureLanguage('en-US',[]);redraw();await until(()=>!document.querySelector('.launcher-initializing')); + if(category('Favorites').getAttribute('aria-pressed')!=='true'||labels().sort().join('|')!=='Half-Life: Alyx|Termix')throw Error('Default favorites / disabled filtering incorrect '+labels().join('|')); + const keys=[...document.querySelectorAll('.launcher-filters button')].map(n=>n.getAttribute('aria-label'));if(keys.join('|')!=='Favorites|All|Plugin|Steam|Lepton|Desktop apps')throw Error('Category order '+keys); + category('All').click();await until(()=>labels().length===7);const initialGrid=document.querySelector('.launcher-grid')!.getBoundingClientRect();const initialFilters=document.querySelector('.launcher-filters')!.getBoundingClientRect(); + const stable=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=document.querySelector('.launcher-filters')!.getBoundingClientRect();if(g.x!==initialGrid.x||g.y!==initialGrid.y||g.height!==570||f.y!==initialFilters.y)throw Error('Search / category changed fixed layout');}; + await wait(400);const searchBottom=document.querySelector('.launcher-search')!.getBoundingClientRect().bottom;const artworkTop=document.querySelector('.launch-disc')!.getBoundingClientRect().top;if(artworkTop-searchBottom<72)throw Error('Search is too close to the icon artwork'); + // Every app kind keeps its position when favorited or unfavorited. + const initialOrder=labels().join('|'); + for(const name of ['Termix','Half-Life: Alyx','透视调色','Framely','Desktop terminal','さくら','東京']){ + const was=favorite(name).getAttribute('aria-pressed')==='true'; + for(const checked of [!was,was]){ + favorite(name).click();await until(()=>favorite(name).getAttribute('aria-pressed')===String(checked));await until(()=>!favorite(name).disabled); + if(labels().join('|')!==initialOrder)throw Error('Favorite reordered '+name); + if(checked){await until(()=>getComputedStyle(favorite(name)).opacity==='1');if(!favorite(name).classList.contains('selected')||getComputedStyle(favorite(name)).opacity!=='1'||favorite(name).querySelector('svg')!.getAttribute('fill')!=='currentColor')throw Error('Favorite state is not visible '+name);} + } + } + // Explicit manual ordering survives favorite changes, refresh and reopening. + order=[...document.querySelectorAll('[data-launch-key]')].map(n=>n.dataset.launchKey!).reverse();redraw();await until(()=>labels().join('|')!==initialOrder);const manualOrder=labels().join('|'); + favorite('Framely').click();await until(()=>saved.includes('framely'));await until(()=>!favorite('Framely').disabled);if(labels().join('|')!==manualOrder)throw Error('Manual order changed after favorite'); + window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('Favorites').getAttribute('aria-pressed')==='true');category('All').click();await until(()=>labels().length===7);if(labels().join('|')!==manualOrder)throw Error('Reopening lost manual order'); + favorite('Framely').click();await until(()=>!saved.includes('framely'));await until(()=>!favorite('Framely').disabled);if(labels().join('|')!==manualOrder)throw Error('Manual order changed after removing favorite');order=[];redraw();await until(()=>labels().join('|')===initialOrder); + favorite('Framely').click();await until(()=>saved.includes('framely'));await until(()=>!document.querySelector('.launch-favorite:disabled'));favorite('Desktop terminal').click();await until(()=>saved.includes('desktop:terminal.desktop'));await until(()=>!document.querySelector('.launch-favorite:disabled'));favorite('さくら').click();await until(()=>saved.includes('lepton:default/sakura'));await until(()=>!document.querySelector('.launch-favorite:disabled')); + category('Favorites').click();await until(()=>labels().length===5);if(!labels().includes('Framely')||!labels().includes('Desktop terminal')||!labels().includes('さくら'))throw Error('New types absent from Favorites'); + await wait(500);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_FAVORITES_EN'); + configureLanguage('zh-CN',[]);redraw();await wait(500);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_FAVORITES_ZH');configureLanguage('en-US',[]);redraw();await wait(); + // Search remains in the selected category, and key strokes reuse an index. + category('Plugin').click();await until(()=>labels().length===3);await until(()=>calls.some(c=>c.method==='launcher.search.index'));await wait(250);const indexCount=calls.filter(c=>c.method==='launcher.search.index').length; + for(const q of ['toushi','tiaose','tsts','TSTS']){query(q);await until(()=>labels().length===1);if(labels()[0]!=='透视调色')throw Error('Pinyin search '+q);stable();} + query('sakura');await until(()=>labels().length===0);if(!document.body.innerText.includes('No matching apps'))throw Error('Missing no results');query('');await until(()=>labels().length===3); + category('Lepton').click();for(const q of ['sakura','skr','SKR']){query(q);await until(()=>labels().length===1);if(labels()[0]!=='さくら')throw Error('Kana search '+q);}query('toukyou');await until(()=>labels().length===1);if(labels()[0]!=='東京')throw Error('Japanese kanji search');query(''); + category('Steam').click();query('HLA');await until(()=>labels().length===1);if(labels()[0]!=='Half-Life: Alyx')throw Error('English initials');query(''); + if(calls.filter(c=>c.method==='launcher.search.index').length!==indexCount)throw Error('Typing rebuilt indexes'); + input().focus();if(getComputedStyle(input()).outlineStyle!=='none'&&getComputedStyle(input()).outlineWidth!=='0px')throw Error('Search focus still has an outline');await until(()=>calls.some(c=>c.method==='host.keyboard'));window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:true}));(window as any).__framelyCommitKeyboard('HLA');await until(()=>input().value==='HLA');const closeCount=calls.filter(c=>c.method==='host.launcher.close').length;window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));window.dispatchEvent(new Event('framely.back'));document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await wait();if(category('Steam').getAttribute('aria-pressed')!=='true'||calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Keyboard allowed launcher dismissal/navigation');window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:false}));input().blur();query(''); + category('Plugin').click();query('tsts');await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SEARCH_EN');configureLanguage('zh-CN',[]);redraw();await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SEARCH_ZH');configureLanguage('en-US',[]);redraw();query(''); + config={...config,allCategories:[]};redraw();category('All').click();await until(()=>labels().length===0);category('Favorites').click();await until(()=>labels().length===5);category('Plugin').click();await until(()=>labels().length===3); + config={...config,allCategories:['desktop']};redraw();category('All').click();await until(()=>labels().join('|')==='Desktop terminal');query('termix');await until(()=>labels().length===0);query('');stable(); + window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('Favorites').getAttribute('aria-pressed')==='true');if(input().value!=='')throw Error('Opening did not clear query'); + // Late refresh must not override an explicit category selection. + delay=350;window.dispatchEvent(new Event('framely.launcher.open'));await wait(30);category('Steam').click();await wait(450);if(category('Steam').getAttribute('aria-pressed')!=='true')throw Error('Refresh overwrote category selection');delay=80; + saved=[];steamFavorites=[];plugins['demo.alpha'].favorite=false;redraw();window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('All').getAttribute('aria-pressed')==='true');await until(()=>!document.querySelector('.launcher-initializing'));if(labels().join('|')!=='Desktop terminal')throw Error('No favorites fallback ignored All membership'); + favorite('Desktop terminal').click();await until(()=>saved.length===1);window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('Favorites').getAttribute('aria-pressed')==='true');favorite('Desktop terminal').click();await until(()=>labels().length===0);if(category('Favorites').getAttribute('aria-pressed')!=='true'||!document.body.innerText.includes('No favorites yet'))throw Error('Removing final favorite navigated away');window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('All').getAttribute('aria-pressed')==='true'); + if(!matchesSearch('Termix','termix')||!matchesSearch('École','ecole')||matchesSearch('Alpha','beta')||matchesSearch('ガ','カ'))throw Error('Search normalization'); + settings=true;redraw();await until(()=>document.querySelectorAll('[role=switch]').length===6);const switches=document.querySelectorAll('[role=switch]');switches[0].click();await until(()=>config.primaryTrigger);await until(()=>!document.querySelector('[role=switch]:disabled'));switches[2].click();await until(()=>(config.allCategories??[]).includes('plugin'));await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');configureLanguage('zh-CN',[]);redraw();await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');console.log('FRAMELY_BRIDGE_PASS'); +}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}}; +createRoot(document.getElementById('root')!).render();wait(150).then(()=>console.log('FRAMELY_VIEW_READY')); diff --git a/tests/manager_layout_fixture.tsx b/tests/manager_layout_fixture.tsx new file mode 100644 index 0000000..b035977 --- /dev/null +++ b/tests/manager_layout_fixture.tsx @@ -0,0 +1,35 @@ +import {configureLanguage} from '../ui/src/i18n'; +const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms)); +const until=async(f:()=>any)=>{for(let i=0;i<160;i++){if(f())return;await wait(25);}throw Error('Timeout '+document.body.innerText.slice(0,300));}; +history.replaceState(null,'','/manager'); +const plugin={manifest:{id:'demo',name:'Demo plugin',version:'1.0.0',description:'A demo plugin',author:'Community',ui:{windows:{}}},enabled:true,favorite:false,order:0}; +const source={id:'community',name:'Community',url:'https://example.org/catalog.json',enabled:true,allowHttp:false}; +const sub={id:'daily',name:'Daily sources',url:'https://example.org/subscription.json',enabled:true,autoRefresh:true,entries:[],pending:[],nextRefresh:0}; +const database:any={plugins:{demo:plugin},sources:[source],subscriptions:[sub],safeMode:false,language:'en-US',launcher:{primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']},networkPanel:{enabled:true,port:15915,passwordEnabled:true},proxy:{http:'',github:''},updateSource:{url:'https://example.org/update.json'},updateCheck:{enabled:true,intervalHours:6}}; +const calls:any[]=[]; +window.fetch=async(_url,init)=>{const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true; + if(method==='status')result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database,running:[],notifications:[],inbox:[]}; + else if(method==='language.list')result={packs:[],invalidFiles:[]};else if(method==='ui.events')result={cursor:0,events:[]};else if(method==='network.status')result={passwordConfigured:true,localOrigin:'http://localhost:15915',ips:[],listener:{}}; + else if(method==='catalog.start')result={job:'catalog'};else if(method==='job.status')result={id:'catalog',phase:'done',result:[{source,catalog:{name:'Community',plugins:[{...plugin.manifest,apiVersion:1,url:'https://example.org/demo.framely',sha256:'a'.repeat(64)}]}}]}; + else if(method==='network.save')Object.assign(database.networkPanel,params);else if(method==='safeMode')database.safeMode=params.enabled;else if(method==='sources.save')database.sources=params.sources;else if(method==='subscriptions.change')Object.assign(sub,params);else if(method==='system.check.settings.save')database.updateCheck=params.settings; + return new Response(JSON.stringify({result}));}; +const nav=(label:string)=>[...document.querySelectorAll('.workspace>nav button')].find(b=>b.textContent===label)!; +const width=(selector:string)=>{const node=document.querySelector(selector)!;if(!node)throw Error('Missing '+selector);const parent=node.parentElement!;const style=getComputedStyle(parent);const available=parent.clientWidth-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight);if(Math.abs(node.getBoundingClientRect().width-available)>2)throw Error('Unused width '+selector+' '+node.getBoundingClientRect().width+'/'+available);}; +const switcher=(label:string)=>document.querySelector(`[role=switch][aria-label="${label}"]`)!; +(window as any).runInstallReviewChecks=async()=>{try{ + await until(()=>!!document.querySelector('.plugin-row'));if(!switcher('Enable Demo plugin').classList.contains('ui-switch'))throw Error('Plugin switch is not shared');width('.plugin-row'); + nav('Settings').click();await until(()=>!!document.querySelector('.network-password'));for(const selector of ['.language-picker','.network-password','.network-port label','.proxy-fields','.switch-row'])width(selector); + if(document.querySelector('.content input[type=checkbox]'))throw Error('Checkbox left in settings'); + switcher('Safe mode').click();await until(()=>database.safeMode);await until(()=>!switcher('Safe mode').disabled); + switcher('Use an access password').click();await until(()=>!document.querySelector('.network-password'));switcher('Use an access password').click();await until(()=>!!document.querySelector('.network-password')); + await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_MANAGER_SETTINGS_EN'); + nav('Sources').click();await until(()=>document.querySelectorAll('.source-row').length===2);width('.source-row');switcher('Refresh daily').click();await until(()=>!sub.autoRefresh);await until(()=>!switcher('Refresh daily').disabled); + const add=[...document.querySelectorAll('.content button')].find(b=>b.textContent==='Add plugin source')!;add.click();await until(()=>!!document.querySelector('.source-form'));width('.source-form');if(document.querySelector('.content input[type=checkbox]'))throw Error('Source still has a checkbox'); + nav('Plugin library').click();await until(()=>!!document.querySelector('.store-card'));width('.store-card');document.querySelector('.store-title')!.click();await until(()=>!!document.querySelector('.store-detail'));width('.store-detail');width('.detail-summary'); + nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings'));width('.launcher-settings .switch-row');width('.launcher-settings label'); + nav('Notifications').click();await until(()=>!!document.querySelector('.notification-global-options'));width('.notification-global-options');width('.notification-plugin-list'); + nav('About').click();await until(()=>!!document.querySelector('.automatic-update-settings'));width('.automatic-update-settings .framely-select');if(!switcher('Check for updates automatically').classList.contains('ui-switch'))throw Error('Update switch is not shared'); + database.language='zh-CN';configureLanguage('zh-CN',[]);nav('Settings').click();await until(()=>document.documentElement.lang==='zh-CN');nav('设置').click();await until(()=>!!document.querySelector('.network-password'));width('.network-password');await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_MANAGER_SETTINGS_ZH'); + console.log('FRAMELY_BRIDGE_PASS'); +}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}}; +void import('../ui/src/main'); diff --git a/tests/quick_panel_navigation_fixture.tsx b/tests/quick_panel_navigation_fixture.tsx new file mode 100644 index 0000000..0be3554 --- /dev/null +++ b/tests/quick_panel_navigation_fixture.tsx @@ -0,0 +1,32 @@ +// Exercise the real quick panel across status polling and repeated host launches. +const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms)); +const until=async(f:()=>any)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,350));}; +history.replaceState(null,'','/?fixture=1#plugin:test.quick'); +let polls=0,opens=0; +window.fetch=async(_url,init)=>{ + const {method}=JSON.parse(String(init?.body));let result:any=true; + if(method==='status'){polls++;result={version:'0.4.3-preview.2',agreement:{version:1,accepted:true},database:{language:'en-US',plugins:{'test.quick':{manifest:{id:'test.quick',name:'Quick fixture',version:'1',author:'Test',description:'',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:true,order:0}},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};} + else if(method==='language.list')result={packs:[],invalidFiles:[]}; + else if(method==='ui.events')result={cursor:0,events:[]}; + else if(method==='plugin.open')opens++; + return new Response(JSON.stringify({result})); +}; +const back=()=>document.querySelector('.page-title button')!; +(window as any).runInstallReviewChecks=async()=>{try{ + await until(()=>!!back()&&!!document.querySelector('iframe')); + if(location.hash||location.search!=='?fixture=1')throw Error('Plugin hash was not consumed correctly'); + back().click();await until(()=>!!document.querySelector('.workspace')); + const before=polls;await until(()=>polls>=before+2); + if(back()||document.querySelector('iframe')||opens!==1)throw Error('Status polling reopened plugin'); + location.hash='#plugin:test.quick';await until(()=>!!back()&&opens===2); + window.dispatchEvent(new Event('framely.back'));await until(()=>!!document.querySelector('.workspace')); + const second=polls;await until(()=>polls>=second+2); + if(back()||location.hash||opens!==2)throw Error('Back event reopened plugin'); + location.hash='#plugin:test.quick';await until(()=>!!back()&&opens===3); + window.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape'}));await until(()=>!!document.querySelector('.workspace')); + const third=polls;await until(()=>polls>=third+2); + if(back()||location.hash||opens!==3)throw Error('Escape reopened plugin'); + console.log('FRAMELY_BRIDGE_PASS'); + }catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);} +}; +void import('../ui/src/main'); diff --git a/tests/render_timing.cpp b/tests/render_timing.cpp index ca3cf9c..d7e69c2 100644 --- a/tests/render_timing.cpp +++ b/tests/render_timing.cpp @@ -2,7 +2,7 @@ #include #include int main(){ - assert(browser_frame_rate(72)==72);assert(browser_frame_rate(90)==90);assert(browser_frame_rate(120)==120); - assert(browser_frame_rate(0)==90);assert(browser_frame_rate(-1)==90);assert(browser_frame_rate(std::numeric_limits::quiet_NaN())==90); - assert(browser_frame_rate(30)==60);assert(browser_frame_rate(1000)==144); + assert(browser_frame_rate(72)==60);assert(browser_frame_rate(90)==60);assert(browser_frame_rate(120)==60); + assert(browser_frame_rate(0)==60);assert(browser_frame_rate(-1)==60);assert(browser_frame_rate(std::numeric_limits::quiet_NaN())==60); + assert(browser_frame_rate(30)==60);assert(browser_frame_rate(1000)==60); } diff --git a/tests/system_updates_fixture.tsx b/tests/system_updates_fixture.tsx index 74f60b5..ced8881 100644 --- a/tests/system_updates_fixture.tsx +++ b/tests/system_updates_fixture.tsx @@ -43,8 +43,8 @@ async function choose(label:string){(document.querySelector('.framely-select but try{ configureLanguage('zh-CN',[]);updateStatus({...status});await wait(); if(!document.querySelector('.framely-select button')?.textContent?.includes('正式版'))throw Error('legacy source did not default to stable'); - const automaticSwitch=()=>document.querySelector('.automatic-update-settings input[type=checkbox]') as HTMLInputElement; - if(!automaticSwitch().checked)throw Error('automatic checks did not default to enabled'); + const automaticSwitch=()=>document.querySelector('.automatic-update-settings [role=switch]') as HTMLButtonElement; + if(automaticSwitch().getAttribute('aria-checked')!=='true')throw Error('automatic checks did not default to enabled'); automaticSwitch().click();await until(()=>automaticSaves===1);await wait(); if(status.database.updateCheck.enabled||(document.querySelector('.automatic-update-settings .framely-select button') as HTMLButtonElement).disabled===false)throw Error('disabled automatic check settings not reflected'); automaticSwitch().click();await until(()=>automaticSaves===2);await wait(); @@ -52,7 +52,12 @@ async function choose(label:string){(document.querySelector('.framely-select but ([...document.querySelectorAll('[role=option]')].find(o=>o.textContent==='每 12 小时') as HTMLButtonElement).click();await until(()=>automaticSaves===3);await wait(); if(status.database.updateCheck.intervalHours!==12)throw Error('automatic interval not saved'); button('检查更新').click();await until(()=>!!document.querySelector('.release-card')); + const releaseDialog=document.querySelector('.update-release-dialog') as HTMLDialogElement; + if(!releaseDialog?.open||!releaseDialog.matches(':modal')||document.activeElement!==button('稍后'))throw Error('Available release was not announced in a modal'); + if(!releaseDialog.contains(document.querySelector('.release-card')))throw Error('Release details remained inline'); if(!document.querySelector('.release-card')?.textContent?.includes('较旧版本'))throw Error('channel downgrade not identified'); + window.dispatchEvent(new Event('framely.back'));await wait();if(document.querySelector('.release-card')||downloads!==0||attempts!==0)throw Error('Later left inline release content or started update'); + button('检查更新').click();await until(()=>!!document.querySelector('.update-release-dialog')); button('下载并安装').focus();button('下载并安装').click();await wait(); const dialog=document.querySelector('.update-confirmation') as HTMLDialogElement|null; if(downloads!==0||attempts!==0||!dialog?.open||!dialog.matches(':modal'))throw Error('update bypassed modal confirmation'); @@ -75,11 +80,13 @@ async function choose(label:string){(document.querySelector('.framely-select but console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_STABLE'); button('安装已校验版本').click();await wait();button('确认安装').click();await until(()=>attempts===2);await wait(); if(downloads!==2)throw Error('installation retry downloaded again'); + button('稍后').click();await wait(); await choose('测试版'); if(status.database.updateChannel!=='testing'||document.querySelector('.release-card'))throw Error('channel not saved or stale release retained'); button('检查更新').click();await until(()=>!!document.querySelector('.release-card')); if(document.querySelector('.release-card')?.textContent?.includes('较旧版本'))throw Error('new preview reported as downgrade'); if([...document.querySelectorAll('button')].some(b=>b.textContent==='安装已校验版本'))throw Error('download reused across channels'); + await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_AVAILABLE_ZH');await wait(100); hold=true;failApply=false;button('下载并安装').click();await wait();button('确认下载并安装').click();await until(()=>downloads===3);await wait(); if(!(document.querySelector('.framely-select button') as HTMLButtonElement).disabled||!automaticSwitch().disabled||!button('处理中…').disabled)throw Error('settings mutable during download'); if(attempts!==2)throw Error('installation started before verification'); @@ -100,13 +107,14 @@ async function choose(label:string){(document.querySelector('.framely-select but empty=false;configureLanguage('en-US',[]);updateStatus({...status});await wait(); if(!document.querySelector('.framely-select button')?.textContent?.includes('Stable'))throw Error('English channel untranslated'); await choose('Testing');button('Check for updates').click();await until(()=>!!document.querySelector('.release-card')); + await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_AVAILABLE_EN');await wait(100); button('Download and install').click();await wait(); if(!document.querySelector('.update-confirmation')?.textContent?.includes('installed automatically'))throw Error('English update flow untranslated'); hold=true;button('Confirm download and installation').click(); await until(()=>!!document.querySelector('progress[aria-label="Verification progress"]')); if(!document.body.textContent?.includes('Download complete. Verifying')||!document.body.textContent?.includes('Verified 0.5 / 1.0 MiB'))throw Error('English verification progress untranslated'); hold=false;await until(()=>installs===2); - if(checks!==5||downloads!==4||attempts!==4)throw Error(`unexpected workflow counts ${checks}/${downloads}/${attempts}`); + if(checks!==6||downloads!==4||attempts!==4)throw Error(`unexpected workflow counts ${checks}/${downloads}/${attempts}`); console.log('FRAMELY_PREVIEW_INSTALL_UPDATE_ENGLISH'); remount();await wait();button('Roll back to previous release').click();await wait(); if(rollbacks!==0)throw Error('rollback bypassed confirmation'); diff --git a/tests/test_cef_runtime.py b/tests/test_cef_runtime.py index f850bec..585e1e9 100644 --- a/tests/test_cef_runtime.py +++ b/tests/test_cef_runtime.py @@ -31,6 +31,7 @@ class CEFRuntimeTests(unittest.TestCase): 'lib/cef/libcef.so': 'browser', 'lib/cef/icudtl.dat': 'icu', 'lib/cef/locales/en-US.pak': 'locale', 'share/licenses/cef.txt': 'license', 'share/licenses/cef-credits.html': 'credits', 'share/licenses/openvr.txt': 'openvr', + 'share/search/ipadic.dic.zst': 'dictionary', 'share/search/COPYING': 'dictionary license', 'install.sh': 'install', 'uninstall.sh': 'uninstall'} for name, data in files.items(): p = stage / name @@ -62,6 +63,12 @@ class CEFRuntimeTests(unittest.TestCase): extracted = bootstrap.extract_archive(archive, folder / archive.name.replace('.tar.gz', ''), runtime=archive.name.startswith('framely-cef-')) subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=extracted, check=True) + if not archive.name.startswith('framely-cef-'): + self.assertEqual((extracted / 'share/search/ipadic.dic.zst').read_text(), 'dictionary') + self.assertEqual((extracted / 'share/search/COPYING').read_text(), 'dictionary license') + self.assertIn('share/search/ipadic.dic.zst', (extracted / 'SHA256SUMS').read_text()) + else: + self.assertFalse((extracted / 'share/search').exists()) if archive.name == stage.name + '-linux-arm64.tar.gz': self.assertFalse((extracted / 'lib/cef/libcef.so').exists()) self.assertFalse((extracted / 'share/licenses/cef-credits.html').exists()) diff --git a/tools/package-release.sh b/tools/package-release.sh index 87f7565..6277733 100755 --- a/tools/package-release.sh +++ b/tools/package-release.sh @@ -11,7 +11,7 @@ npm run build bash tools/build-native.sh "$cef" binary=${CARGO_TARGET_DIR:-target}/release/framely version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) -build_id="$version-$(find "$binary" target/native ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding README.md README.zh-CN.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)" +build_id="$version-$(find "$binary" target/native ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)" stage="$base/release/framely-$build_id" [[ ! -e $stage ]] || { echo 'Release already exists.' >&2; exit 1; } mkdir -p "$stage"/{bin,lib/cef,lib/openvr,share/ui,share/licenses,tools} @@ -24,6 +24,9 @@ rm -f "$stage/lib/cef/framely-probe" "$stage/lib/cef/framely-browser-probe" strip --strip-debug "$stage/lib/cef/libcef.so" cp native/vendor/openvr/lib/linuxarm64/libopenvr_api.so "$stage/lib/openvr/" cp -a ui/dist/. "$stage/share/ui/" +cp -a assets/search "$stage/share/" +cp assets/search/COPYING "$stage/share/licenses/ipadic.txt" +cp assets/search/NOTICE "$stage/share/licenses/ipadic-notice.txt" cp -a native/input "$stage/share/" cp "$cef/LICENSE.txt" "$stage/share/licenses/cef.txt" cp native/vendor/openvr/LICENSE "$stage/share/licenses/openvr.txt" diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index 1974795..70f3b2b 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -469,6 +469,17 @@ "日志已保存到:{0}": "Logs saved to: {0}", "日志包已生成,请查看浏览器下载。": "Log archive created. Check your browser downloads.", "日志包缺少数据": "Log archive data is missing", - "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin and renderer logs with service status. Plugin settings and access password files are excluded." + "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin and renderer logs with service status. Plugin settings and access password files are excluded.", + "发现可用发行版本": "Release available", + "稍后": "Later", + "全部包含的分类": "Categories included in All", + "只影响全部分类,其他分类和收藏仍可访问。": "Only affects All. Other categories and Favorites remain available.", + "包含 Framely": "Includes Framely", + "启动方式、菜单与分类。": "Launch controls, menus and categories.", + "没有匹配的应用": "No matching apps", + "还没有收藏的应用": "No favorites yet", + "收藏分类": "Favorites", + "读音搜索暂不可用,仍可使用原文搜索。": "Phonetic search is unavailable. You can still search original names.", + "启动方式与菜单": "Launch controls and menus" } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index fd67473..4cfdffb 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -469,6 +469,17 @@ "日志已保存到:{0}": "日志已保存到:{0}", "日志包已生成,请查看浏览器下载。": "日志包已生成,请查看浏览器下载。", "日志包缺少数据": "日志包缺少数据", - "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。" + "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。", + "发现可用发行版本": "发现可用发行版本", + "稍后": "稍后", + "全部包含的分类": "全部包含的分类", + "只影响全部分类,其他分类和收藏仍可访问。": "只影响全部分类,其他分类和收藏仍可访问。", + "包含 Framely": "包含 Framely", + "启动方式、菜单与分类。": "启动方式、菜单与分类。", + "没有匹配的应用": "没有匹配的应用", + "还没有收藏的应用": "还没有收藏的应用", + "收藏分类": "收藏", + "读音搜索暂不可用,仍可使用原文搜索。": "读音搜索暂不可用,仍可使用原文搜索。", + "启动方式与菜单": "启动方式与菜单" } } diff --git a/ui/src/launcher-search.ts b/ui/src/launcher-search.ts new file mode 100644 index 0000000..e64afd3 --- /dev/null +++ b/ui/src/launcher-search.ts @@ -0,0 +1,3 @@ +export type SearchIndex={terms:string[]}; +export function normalizeSearch(value:string){return value.normalize('NFKC').normalize('NFD').replace(/\p{M}/gu,mark=>mark==='\u3099'||mark==='\u309a'?mark:'').normalize('NFC').toLowerCase().replace(/[\s\p{P}\p{S}]/gu,'');} +export function matchesSearch(name:string,query:string,index?:SearchIndex){const needle=normalizeSearch(query);return !needle||[normalizeSearch(name),...(index?.terms??[])].some(term=>term.includes(needle));} diff --git a/ui/src/launcher.css b/ui/src/launcher.css index 4d884f7..d681b11 100644 --- a/ui/src/launcher.css +++ b/ui/src/launcher.css @@ -2,14 +2,14 @@ Layer order: content 0, categories 2, context menu 3, feedback 4, dialog 5. */ .launcher-view,.launcher-view body,.launcher-view #root{background:transparent!important} .launcher-shell{--launch-ink:#eff4f8;--launch-blue:#b4dbf4;--launch-ease:cubic-bezier(.2,.75,.2,1);height:100dvh;min-height:600px;padding:90px 60px 104px;color:var(--launch-ink);background:transparent;display:flex;flex-direction:column;position:relative;overflow:hidden;font-size:20px} -.launcher-tools{position:absolute;left:0;top:calc(50% - 7px);transform:translateY(-50%);display:flex;flex-direction:column;gap:5px;padding:7px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2} +.launcher-tools{position:absolute;left:0;top:calc(50% + 17px);transform:translateY(-50%);display:flex;flex-direction:column;gap:5px;padding:7px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2} .launcher-filters button{position:relative;width:56px;height:56px;min-height:56px;padding:15px;border:0;border-radius:50%;background:transparent;color:#d9e3ec;transition:background .2s var(--launch-ease),color .2s var(--launch-ease)} .launcher-category-active{position:absolute;left:7px;top:7px;width:56px;height:56px;border-radius:50%;background:#d3e4ef35;box-shadow:inset 0 1px 0 #eef9ff40,inset 0 0 0 1px #e0f2ff19;pointer-events:none;transition:transform 360ms cubic-bezier(.22,.65,.24,1)} .launcher-filters button.active{background:transparent;box-shadow:none;color:var(--launch-blue)} .launcher-filters button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc} .launcher-category-label{position:absolute;left:calc(100% + 20px);white-space:nowrap;font-size:15px;font-weight:500;line-height:1.4;padding:7px 13px;border-radius:12px;background:#1d2934eb;color:#eaf4fb;box-shadow:inset 0 0 0 1px #e2f0fa1f;opacity:0;pointer-events:none;transition:opacity .18s} .launcher-filters button:hover .launcher-category-label,.launcher-filters button:focus-visible .launcher-category-label{opacity:1} -.launcher-grid{position:absolute;left:50%;top:calc(50% - 7px);height:570px;width:calc(100% - 90px);transform:translate(-50%,-50%);isolation:isolate} +.launcher-grid{position:absolute;left:50%;top:calc(50% + 17px);height:570px;width:calc(100% - 90px);transform:translate(-50%,-50%);isolation:isolate} .launcher-page,.launcher-adjacent-page{position:absolute;inset:0;display:grid;grid-template-columns:repeat(24,minmax(0,1fr));grid-template-rows:repeat(6,minmax(0,1fr));gap:12px} .launch-item{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;min-width:0;padding:4px} .launch-icon{position:relative;width:138px;height:122px;min-height:122px;border:0;background:transparent;padding:13px 21px;border-radius:50%;cursor:pointer;touch-action:none;isolation:isolate} @@ -24,7 +24,7 @@ .launch-icon:active:not(:disabled) .launch-disc{transform:scale(.97);box-shadow:inset 0 1px 2px #edf7ff38,0 5px 12px #07172b28} .launch-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:center;font-size:18px;font-weight:450;line-height:1.28;letter-spacing:.005em;width:100%;max-width:164px;height:47px;color:#eff4f8;text-shadow:0 1px 3px #0c172cd9,0 2px 10px #0c172c70;pointer-events:none} .launch-favorite{position:absolute;top:10px;right:5px;padding:7px;background:#263440ce;border:1px solid #e2effa2c;border-radius:50%;color:#dbe6f0;opacity:0;min-height:32px;width:32px;height:32px;transition:opacity .16s;z-index:1} -.launch-item:hover .launch-favorite,.launch-favorite:focus-visible{opacity:1}.launch-favorite.selected{color:var(--launch-blue)} +.launch-item:hover .launch-favorite,.launch-favorite:focus-visible{opacity:1}.launch-favorite.selected{opacity:1;color:var(--launch-blue);background:#142b3aee;border-color:#a6d4f280;box-shadow:0 2px 8px #07172b40}.launch-favorite.selected:hover:not(:disabled){background:#25465bee;border-color:#bfe4ff} .launch-item-expanded{z-index:3} .launch-menu{position:absolute;top:calc(100% - 6px);left:50%;transform:translateX(-50%);width:260px;max-height:404px;overflow:auto;padding:7px;background:#24323eef;border:1px solid #e6f3ff30;border-radius:22px;box-shadow:inset 0 1px 0 #ecf6ff25,0 14px 36px #05101f4a;z-index:3;animation:launcher-menu-in .18s var(--launch-ease)} .launch-menu.align-right{left:auto;right:0;transform:none}.launch-menu.above{top:auto;bottom:100%} @@ -60,3 +60,16 @@ @media(max-width:900px){.launcher-drag-edge.right{right:88px}} .launcher-panning{overflow:hidden}.launcher-adjacent-page{pointer-events:none}.launcher-adjacent-page .launch-icon{display:block}.launcher-panning .launcher-page{will-change:translate} +/* Search shares the category capsule material without moving the icon canvas. */ +.launcher-search{position:absolute;top:20px;left:50%;transform:translateX(-50%);width:420px;height:56px;display:flex;align-items:center;gap:12px;padding:0 18px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2;color:#d9e3ec} +.launcher-search:focus-within{background:#a7bac73b;box-shadow:inset 0 1px 1px #f0f7ff40,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b} +.launcher-search input,.launcher-search input:focus,.launcher-search input:focus-visible{flex:1;min-width:0;width:0;height:100%;padding:0;border:0!important;outline:0;background:transparent!important;color:var(--launch-ink);font-size:18px;box-shadow:none!important} +.launcher-search input::placeholder{color:#d9e3ec9c} +.launcher-search input::-webkit-search-cancel-button{display:none} +.launcher-search button{display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-height:32px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit} +.launcher-search button:hover{background:#e3f1ff26} +.launcher-editing .launcher-search{visibility:hidden} + +.launcher-initializing .launcher-grid,.launcher-initializing .launcher-category-active{visibility:hidden} + +.launcher-settings>label{max-width:none} diff --git a/ui/src/launcher.tsx b/ui/src/launcher.tsx index 2f0fac0..e05be1e 100644 --- a/ui/src/launcher.tsx +++ b/ui/src/launcher.tsx @@ -1,13 +1,15 @@ +import {SwitchRow} from './switch'; import React,{useEffect,useLayoutEffect,useRef,useState} from 'react'; import {api} from './api'; import {t} from './i18n'; -import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck} from '@tabler/icons-react'; +import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX} from '@tabler/icons-react'; import {installedIcon} from './store'; import {PluginFrame} from './plugin-frame'; import type {Plugin} from './main'; import './launcher.css'; -export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number}; -const defaults:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10}; +import {matchesSearch,type SearchIndex} from './launcher-search'; +export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]}; +const defaults:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']}; type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string}; function LauncherArtwork({src,name,kind,fit}:{src?:string|null;name:string;kind:string;fit?:string}){ const [failed,setFailed]=useState(false); @@ -21,30 +23,34 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds); useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]); async function save(p:Partial){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}} - return

{t('启动台')}

{error&&

{error}

}
; + return <>

{t('启动方式与菜单')}

void save({primaryTrigger})}/>void save({menuAutoClose})}/>{error&&

{error}

}

{t('全部包含的分类')}

{t('只影响全部分类,其他分类和收藏仍可访问。')}

{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}
; } type LocalApp={id:string;name:string;kind:'desktop'|'lepton';icon?:string}; type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean}; const emptyOrder:string[]=[]; -const categories=[{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}]; -export function Launcher({plugins,safeMode,config=defaults,favorites,order=emptyOrder,refresh}:{plugins:Record;safeMode:boolean;config?:LauncherConfig;favorites:number[];order?:string[];refresh:()=>Promise}){ +const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}]; +export function Launcher({plugins,safeMode,config=defaults,favorites,localFavorites=emptyOrder,order=emptyOrder,refresh}:{plugins:Record;safeMode:boolean;config?:LauncherConfig;favorites:number[];localFavorites?:string[];order?:string[];refresh:()=>Promise}){ + const[query,setQuery]=useState(''),[indexes,setIndexes]=useState>({}),keyboard=useRef(false),opening=useRef(true),props=useRef({plugins,favorites,localFavorites});props.current={plugins,favorites,localFavorites}; const[steam,setSteam]=useState([]),[local,setLocal]=useState([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState(null),[busy,setBusy]=useState(false),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontend,setFrontend]=useState<{plugin:string;action:string;context:any}|null>(null),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null); const panTimer=useRef|null>(null),skipPageAnimation=useRef(false),grid=useRef(null),pageContent=useRef(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef(null),hold=useRef|null>(null),editHold=useRef|null>(null),edgeTimer=useRef|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef(null),mounted=useRef(true),busyRef=useRef(false),frontTimer=useRef|null>(null),menuRef=useRef(null),drag=useRef<{key:string;before:string[];order:string[];moved:boolean;over:string|null;edge:number;lastX:number;lastY:number;startedInEdit:boolean}|null>(null),state=useRef({editing:false,current:0,pages:1,filter:'all',panning:false,items:[] as LaunchItem[],all:[] as LaunchItem[]}); useEffect(()=>{if(!drag.current)setManual(order);},[order]); function cancelHold(){if(hold.current)clearTimeout(hold.current);if(editHold.current)clearTimeout(editHold.current);hold.current=null;editHold.current=null;holdTarget.current=null;} function stopEdge(){if(edgeTimer.current)clearTimeout(edgeTimer.current);edgeTimer.current=null;setEdge(0);if(drag.current)drag.current.edge=0;} function cancelDrag(){if(drag.current)setManual(drag.current.before);drag.current=null;setDragging(null);setGhost(null);stopEdge();} - useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([api('steam.list'),api('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(frontTimer.current)clearTimeout(frontTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]); + useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;opening.current=true;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([api('steam.list'),api('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));setFilter(hasFavorite?'favorites':'all');opening.current=false;}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(frontTimer.current)clearTimeout(frontTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]); useEffect(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]); useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||busy)return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busy,config.menuAutoClose,config.menuTimeoutSeconds]); function cancelPan(){if(panTimer.current)clearTimeout(panTimer.current);panTimer.current=null;swipe.current=null;setPanView(null);} - function close(){cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void api('host.launcher.close');} + function close(){if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void api('host.launcher.close');} function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1110,dx=gesture.dx??0,direction=dx<0?1:-1,next=s.current+direction,valid=next>=0&&next=70;setPanView({offset:advance?-direction*width:0,direction,width,settling:true});long.current=true;panTimer.current=setTimeout(()=>{panTimer.current=null;if(advance){skipPageAnimation.current=true;setPage(next);}setPanView(null);},240);} - useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(drag.current||busyRef.current||state.current.panning)return;const direction=(e as CustomEvent).detail;const s=state.current;setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);setFilter(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]); - const all:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:false},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:false}))]; - const rank=new Map(manual.map((key,index)=>[key,index]));all.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||Number(b.favorite)-Number(a.favorite)||a.name.localeCompare(b.name)); - const items=all.filter(a=>filter==='all'||a.kind===filter||(filter==='plugin'&&a.kind==='framely')); + useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(keyboard.current||drag.current||busyRef.current||state.current.panning)return;const direction=(e as CustomEvent).detail;const s=state.current;setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);setFilter(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]); + const all:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`)}))]; + const rank=new Map(manual.map((key,index)=>[key,index]));all.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key)); + const indexKey=JSON.stringify(all.map(a=>a.name).sort()); + useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void api>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]); + useEffect(()=>{const changed=(e:Event)=>{keyboard.current=!!(e as CustomEvent).detail;if(keyboard.current){cancelPan();cancelHold();setMenu(null);}};window.addEventListener('framely.keyboard',changed);return()=>window.removeEventListener('framely.keyboard',changed);},[]); + const items=all.filter(a=>(filter==='favorites'?a.favorite:filter==='all'?(config.allCategories??defaults.allCategories!).includes(a.kind==='framely'?'plugin':a.kind):a.kind===filter||(filter==='plugin'&&a.kind==='framely'))&&matchesSearch(a.name,query,indexes[a.name])); const pageSize=13,columns=6,pages=Math.max(1,Math.ceil(items.length/pageSize)),current=Math.min(page,pages-1),selected=menu?all.find(a=>a.key===menu):null,selectedPlugin=selected?.kind==='plugin'?plugins[selected.id]:null,visible=items.slice(current*pageSize,current*pageSize+pageSize); const counts=[Math.min(visible.length,4),Math.min(Math.max(visible.length-4,0),5),Math.max(visible.length-9,0)].filter(count=>count>0),rows=Math.max(1,counts.length); const cells=counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(columns-(rows===1?count:row===1?5:4))*2+1+col*4}))); @@ -68,17 +74,18 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,order=empty async function custom(id:string,action:string){await run(async()=>{const v=await api('plugin.launcher.action',{plugin:id,action});if(v.frontend){busyRef.current=true;setBusy(true);await new Promise((resolve,reject)=>{pending.current={resolve,reject};setFrontend({plugin:id,action,context:v.context});frontTimer.current=setTimeout(()=>done('Launcher action timed out'),90000);});}if(v.opened)setMenu(null);});} const pending=useRef<{resolve:()=>void;reject:(e:Error)=>void}|null>(null); function done(error?:string){if(frontTimer.current)clearTimeout(frontTimer.current);const p=pending.current;pending.current=null;setFrontend(null);if(error)p?.reject(new Error(error));else p?.resolve();} - return
{if(e.button===0&&!(e.target as HTMLElement).closest('button,.launch-menu,.modal,.launcher-error')){if(!editing&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,.launch-menu,.modal,.launcher-error'))close();}}> - + return
{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!editing&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''} ${loading&&opening.current?'launcher-initializing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error'))close();}}> +
{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&}
+ {editing&&
{t('拖动图标排列,移到两侧翻页')}
} {safeMode&&

{t('安全模式:第三方插件暂停运行。')}

}{error&&
{error}
}
{visible.map((a,index)=>
{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}> {a.name} - {(a.kind==='plugin'||a.key.startsWith('steam:'))&&!editing&&} + {!editing&&} {menu===a.key&&
=(columns-2)*4?'align-right':''} ${rows>1&&cells[index].row===rows-1?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}> {selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&
}:<>} {busy&&{t('正在执行…')}}
}
)}
{panView&¤t+panView.direction>=0&¤t+panView.direction
}
{ghost&&
}{!!edge&&; } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index d0ab56d..5ea6b09 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,8 +1,9 @@ +import {Switch,SwitchRow} from './switch'; import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from './plugin-frame'; import {Launcher,LauncherSettings,type LauncherConfig} from './launcher'; import {NotificationSettings,PopupPermission,useReadInbox,defaultNotificationConfig,type NotificationConfig} from './notification-settings'; import {NotificationCenter,type NotificationEntry} from './notifications'; -import {IconBell} from '@tabler/icons-react'; +import {IconBell,IconLayoutGrid} from '@tabler/icons-react'; import {t} from './i18n'; import {uploadFile} from './upload'; import {LanguageSettings} from './language'; @@ -20,12 +21,12 @@ import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'rea export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record;optionalDependencies?:Record;conflicts?:Record;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record;launch?:Record;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}}; export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string}; type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean}; -type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{launcher?:LauncherConfig;steamFavorites?:number[];launcherOrder?:string[];notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record;running:string[]}; +type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{launcher?:LauncherConfig;launcherFavorites?:string[];steamFavorites?:number[];launcherOrder?:string[];notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record;running:string[]}; const managerMode=location.pathname==='/manager'; const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]}; function App(){ - const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':'installed'):'favorites'),[page,setPage]=useState(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState(null),[install,setInstall]=useState(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState(null),[downloadRequest,setDownloadRequest]=useState(null),[relationAction,setRelationAction]=useState(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false); + const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'installed'):'favorites'),[page,setPage]=useState(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState(null),[install,setInstall]=useState(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState(null),[downloadRequest,setDownloadRequest]=useState(null),[relationAction,setRelationAction]=useState(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false); useEffect(()=>{const ticket=install?.ticket;return()=>{if(ticket)void api('job.cancel',{job:ticket}).catch(()=>{});};},[install?.ticket]); const localePacks=useRef(null);const localePreference=useRef(null); const readError=useReadInbox(!managerMode&&tab==='notifications'&&!page&&!!status.agreement?.accepted,status.inbox??[],refresh); @@ -60,8 +61,8 @@ function App(){ else{const plan=await api('plugin.disable.preview',{plugin:id});if(plan.dependents.length){setRelationAction({title:kind==='uninstall'?t("确认卸载及依赖停用"):t("确认停用计划"),enable:[],disable:plan.dependents,method:kind==='uninstall'?'plugin.uninstall':'plugin.enable',params:{plugin:id,enabled:false,purge:false,...extra,approveDependents:true,fingerprint:plan.fingerprint}});return;}await api(kind==='uninstall'?'plugin.uninstall':'plugin.enable',{plugin:id,enabled:false,purge:false,...extra});} setManage(null); } - useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#updates','#notification-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]); - useEffect(()=>{const navigate=()=>{if(location.hash.startsWith('#plugin:')){const id=decodeURIComponent(location.hash.slice(8));if(managerMode){setPage(null);setTab('installed');const plugin=status.database.plugins[id];if(plugin){setManage(plugin);history.replaceState(null,'',location.pathname);}}else setPage(id);}};navigate();window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[status.database.plugins]); + useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#updates','#notification-settings','#launcher-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]); + useEffect(()=>{const navigate=()=>{if(location.hash.startsWith('#plugin:')){const id=decodeURIComponent(location.hash.slice(8));if(managerMode){setPage(null);setTab('installed');const plugin=status.database.plugins[id];if(plugin){setManage(plugin);history.replaceState(null,'',location.pathname+location.search);}}else{history.replaceState(null,'',location.pathname+location.search);setPage(id);}}};navigate();window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[status.database.plugins]); async function launchPlugin(p:Plugin){const remote=managerMode&&!!document.querySelector('meta[name=framely-remote]');const result=await api('plugin.launch',{plugin:p.manifest.id,source:managerMode?'manager':'quickPanel',navigate:!remote});if((remote||!managerMode)&&result.target.type==='quickPage')setPage(p.manifest.id);else if(remote&&result.target.type==='window')location.assign(`/window/${p.manifest.id}/${result.target.window}`);} async function loadCatalog(){setCatalogTarget(null);setTab('catalog');} const plugins=Object.values(status.database.plugins).sort((a,b)=>a.order-b.order); @@ -77,29 +78,30 @@ function App(){ if(parts[1]==='notifications'&&!status.agreement.accepted)return null; if(!status.agreement.accepted)return ; if(reviewAgreement)return setReviewAgreement(false)} onRevoked={closed=>{setAgreementDeclined(!closed);setReviewAgreement(false);setPage(null);setManage(null);}}/>; - if(parts[1]==='launcher')return ; + if(parts[1]==='launcher')return ; if(parts[1]==='notifications')return ; if(windowPlugin){const p=status.database.plugins[windowPlugin];return
{p?.enabled&&!status.database.safeMode?:

{t("插件已停用或正在启动。")}

}{error&&

{error}

}
;} return
{managerMode?<>:<>}
{error&&

{error}

}{status.database.safeMode&&

{t("安全模式:第三方插件暂停运行。")}

} - {selected?<>
{selected.manifest.name}{managerMode&&}
{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?:

{t("插件已停用、处于安全模式或没有快捷页面。")}

}:
+ {selected?<>
{selected.manifest.name}{managerMode&&}
{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?:

{t("插件已停用、处于安全模式或没有快捷页面。")}

}:
{(tab==='favorites'||tab==='installed')&&<> {managerMode&&

{t("已安装")}

{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}

setQuery(e.target.value)}/>
} {managerMode&&
{t("插件")}{t("状态")}{t("版本")}{t("操作")}
}
{listedPlugins.map(p=>
- {managerMode&&<>{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}{p.manifest.version}} + {managerMode&&<>{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}{p.manifest.version}void action(()=>changePlugin(p,'enable'))}/>} {managerMode&&}
)}
{listedPlugins.length===0&&

{query?t("未找到插件"):tab==='favorites'?t("没有收藏的插件"):onlyDisabledInstalled?t("没有已启用的插件"):t("还没有安装插件")}

{query?t("换一个关键词再试。"):tab==='favorites'?t("点击插件旁的星标,将它加入常用。"):onlyDisabledInstalled?t("打开插件管理以启用插件。"):t("打开插件管理以安装插件。")}

} } {tab==='notifications'&&!managerMode&&<>}/>{readError&&

{readError}

}} + {tab==='launcher-settings'&&managerMode&&<>

{t('启动台')}

{t('启动方式、菜单与分类。')}

} {tab==='notification-settings'&&managerMode&&} {tab==='sources'&&managerMode&&<>

{t("插件源")}

{t("管理插件目录及订阅。")}

{await api('sources.save',{sources:s});await refresh();}}/>
} {tab==='about'&&managerMode&&<>

{t("关于 Framely")}

{t("用户协议与隐私声明")}

} - {tab==='settings'&&(!managerMode?

{t("运行模式")}

Framely{t("版本")}{status.build??status.version}
:<>

{t("设置")}

{t("运行模式与诊断。")}

{t("恢复与诊断")}

{t("导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。")}

{logExport&&

{logExport}

}

{t("运行用户")}

{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}

)} + {tab==='settings'&&(!managerMode?

{t("运行模式")}

void action(()=>api('safeMode',{enabled}))}/>
Framely{t("版本")}{status.build??status.version}
:<>

{t("设置")}

{t("运行模式与诊断。")}

{t("恢复与诊断")}

void action(()=>api('safeMode',{enabled}))}/>

{t("导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。")}

{logExport&&

{logExport}

}

{t("运行用户")}

{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}

)} {tab==='catalog'&&setTab('sources')} busy={busy}/>}
} {importing&&
{await inspect(p);setImporting(false);}} busy={busy}/>
} - {manage&&

{manage.manifest.name}

· {manage.manifest.version} · {manage.manifest.backend?.runAs??manage.manifest.lifecycle?.runAs??'steamos'}

{status.database.plugins[manage.manifest.id]?.error&&

{status.database.plugins[manage.manifest.id].error}

}
{t("插件依赖")}
{status.database.plugins[manage.manifest.id]?.error?.includes("卸载清理失败")&&}
} + {manage&&

{manage.manifest.name}

· {manage.manifest.version} · {manage.manifest.backend?.runAs??manage.manifest.lifecycle?.runAs??'steamos'}

{status.database.plugins[manage.manifest.id]?.error&&

{status.database.plugins[manage.manifest.id].error}

}
{t("插件依赖")}
void action(async()=>{await changePlugin(manage,'enable');})}/>
{status.database.plugins[manage.manifest.id]?.error?.includes("卸载清理失败")&&}
} {install&&void resolveChoice(id,url)} onCancel={()=>setInstall(null)} onInstall={()=>void action(async()=>{await runJob('install.start',{ticket:install.ticket,approve:true,approveRunAs:confirmRunAs},setTransfer);setInstall(null);setTransfer(null);})}/>} {transfer&&void inspect(downloadRequest):undefined} cancel={()=>void api('job.cancel',{job:transfer.id}).then(()=>setTransfer(v=>v?{...v,phase:'cancelled'}:null))} close={()=>setTransfer(null)}/>} @@ -107,6 +109,6 @@ function App(){ {logs!==null&&

{t("诊断与日志")}

{logs||t("暂无日志")}
}
; } -function InstallInput({inspect,busy}:{inspect:(p:any)=>Promise;busy:boolean}){const[url,setUrl]=useState(''),[http,setHttp]=useState(false),[fileError,setFileError]=useState(''),[uploadProgress,setUploadProgress]=useState(null);const abort=useRef(null);async function upload(file:File){const controller=new AbortController();abort.current=controller;let ticket='';setFileError('');setUploadProgress(0);try{ticket=await uploadFile(file,controller.signal,setUploadProgress,{start:size=>api('upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});setUploadProgress(null);await inspect({upload:ticket});}catch(e){if(!controller.signal.aborted)setFileError(String(e));}finally{if(ticket)await api('upload.abort',{upload:ticket}).catch(()=>{});abort.current=null;setUploadProgress(null);}}useEffect(()=>()=>abort.current?.abort(),[]);return

{t("导入插件包")}

{fileError&&

{fileError}

}{const f=e.target.files?.[0];e.target.value='';if(f)void upload(f);}}/>{uploadProgress!==null&&

{t("正在上传…")} {uploadProgress}%

}setUrl(e.target.value)}/>{t("在头显里不方便选择文件时,可使用 framely install 命令导入。")}
} +function InstallInput({inspect,busy}:{inspect:(p:any)=>Promise;busy:boolean}){const[url,setUrl]=useState(''),[http,setHttp]=useState(false),[fileError,setFileError]=useState(''),[uploadProgress,setUploadProgress]=useState(null);const abort=useRef(null);async function upload(file:File){const controller=new AbortController();abort.current=controller;let ticket='';setFileError('');setUploadProgress(0);try{ticket=await uploadFile(file,controller.signal,setUploadProgress,{start:size=>api('upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});setUploadProgress(null);await inspect({upload:ticket});}catch(e){if(!controller.signal.aborted)setFileError(String(e));}finally{if(ticket)await api('upload.abort',{upload:ticket}).catch(()=>{});abort.current=null;setUploadProgress(null);}}useEffect(()=>()=>abort.current?.abort(),[]);return

{t("导入插件包")}

{fileError&&

{fileError}

}{const f=e.target.files?.[0];e.target.value='';if(f)void upload(f);}}/>{uploadProgress!==null&&

{t("正在上传…")} {uploadProgress}%

}setUrl(e.target.value)}/>{t("在头显里不方便选择文件时,可使用 framely install 命令导入。")}
} class Boundary extends React.Component{state={error:null as string|null};static getDerivedStateFromError(e:Error){return {error:e.message}}render(){return this.state.error?

{t("界面发生错误")}

{this.state.error}

:this.props.children;}} createRoot(document.getElementById('root')!).render();console.log('FRAMELY_VIEW_READY'); diff --git a/ui/src/network.tsx b/ui/src/network.tsx index f0e6474..5334310 100644 --- a/ui/src/network.tsx +++ b/ui/src/network.tsx @@ -1,3 +1,4 @@ +import {SwitchRow} from './switch'; import {t} from './i18n'; import React,{useEffect,useState} from 'react'; import {api} from './api'; @@ -8,5 +9,5 @@ export function NetworkSettings({config,refresh}:{config:NetworkPanel;refresh:() useEffect(()=>{let live=true;const load=()=>void api('network.status').then(value=>{if(live)setInfo(value);}).catch(e=>{if(live)setError(e.message);});load();const timer=setInterval(load,2000);return()=>{live=false;clearInterval(timer);};},[]); const number=Number(port),valid=/^\d+$/.test(port)&&number>=1024&&number<=65535; async function save(){setBusy(true);setError('');setNotice('');try{const result=await api('network.save',{enabled,port:number,passwordEnabled,...(password?{password}:{})});setPassword('');if(result.reauthenticate&&info?.localOrigin&&location.origin!==info.localOrigin&&enabled&&number===config.port){location.replace('/manager');return;}if(info?.localOrigin&&location.origin!==info.localOrigin&&(!enabled||number!==config.port||passwordEnabled!==(config.passwordEnabled??false)||!!password)){setNotice(enabled?t("已保存,请访问 http://{0}:{1} 重新连接。", {"0": location.hostname, "1": number}):t("已关闭网络管理面板,此页面连接会断开。"));}else{await refresh();setNotice(t("已保存,设置立即生效。"));}}catch(e){setError(String((e as Error).message));}finally{setBusy(false);}} - return

{t("网络管理面板")}

{passwordEnabled&&}
{info?.listener?.enabled&&<>

{t("让手机或电脑连接同一网络,在浏览器中打开下面的地址。")}

{info.ips?.length?
{info.ips.map((ip:string)=>
{t("设备 IP:")}{ip}http://{ip}:{info.listener.port}
)}
:

{t("未检测到设备 IP,请连接 Wi-Fi 或网线。")}

}

{config.passwordEnabled?t("已开启密码验证,访问时输入你设置的密码。"):info.passwordConfigured?t("密码验证未开启,打开上述地址即可进入管理面板。"):t("首次访问网络管理面板时,必须先设置访问密码。")}

}{info?.listener?.error&&

{info.listener.error}

}{info&&!info.listener?.enabled&&!info.listener?.error&&

{t("网络管理面板已关闭或正在启动。")}

}{notice&&

{notice}

}{error&&

{error}

}
; + return

{t("网络管理面板")}

{passwordEnabled&&}
{info?.listener?.enabled&&<>

{t("让手机或电脑连接同一网络,在浏览器中打开下面的地址。")}

{info.ips?.length?
{info.ips.map((ip:string)=>
{t("设备 IP:")}{ip}http://{ip}:{info.listener.port}
)}
:

{t("未检测到设备 IP,请连接 Wi-Fi 或网线。")}

}

{config.passwordEnabled?t("已开启密码验证,访问时输入你设置的密码。"):info.passwordConfigured?t("密码验证未开启,打开上述地址即可进入管理面板。"):t("首次访问网络管理面板时,必须先设置访问密码。")}

}{info?.listener?.error&&

{info.listener.error}

}{info&&!info.listener?.enabled&&!info.listener?.error&&

{t("网络管理面板已关闭或正在启动。")}

}{notice&&

{notice}

}{error&&

{error}

}
; } diff --git a/ui/src/notification-settings.tsx b/ui/src/notification-settings.tsx index 1346653..f82ad54 100644 --- a/ui/src/notification-settings.tsx +++ b/ui/src/notification-settings.tsx @@ -1,3 +1,4 @@ +import {Switch as BaseSwitch} from './switch'; import React,{useState,useEffect} from 'react'; import {IconBell,IconSettings,IconSearch} from '@tabler/icons-react'; import {api,viewKey} from './api'; @@ -8,10 +9,8 @@ import type {NotificationEntry} from './notifications'; export type NotificationPreference={popups:boolean;badge:boolean}; export type NotificationConfig=NotificationPreference&{plugins:Record}; export const defaultNotificationConfig:NotificationConfig={popups:true,badge:true,plugins:{}}; +const Switch=(props:React.ComponentProps)=>; type Installed={manifest:{id:string;name:string;description?:string;icon?:string|null;version:string};enabled:boolean;order:number}; -function Switch({checked,label,disabled,onChange}:{checked:boolean;label:string;disabled:boolean;onChange:()=>void}){ - return ; -} function useSave(refresh:()=>Promise){ const[busy,setBusy]=useState(false),[error,setError]=useState(''); async function run(task:()=>Promise){if(busy)return;setBusy(true);setError('');try{await task();await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}} diff --git a/ui/src/plugin-frame.tsx b/ui/src/plugin-frame.tsx index 021ac77..c350fa6 100644 --- a/ui/src/plugin-frame.tsx +++ b/ui/src/plugin-frame.tsx @@ -49,7 +49,7 @@ export function PluginFrame({plugin,entry='quick',actionContext,onActionDone}:{p } if(live){setSandbox(flags);setSource(url);} })().catch(e=>{if(live)setError(String(e));}); - return()=>{live=false;window.removeEventListener('message',message);window.removeEventListener('framely.event',event);window.removeEventListener('framely.language',language);}; + return()=>{live=false;window.removeEventListener('message',message);window.removeEventListener('framely.event',event);window.removeEventListener('framely.language',language);window.removeEventListener('framely.launch',launched);}; },[plugin,entry,attempt]); return error?

{error}

:source?