Compare commits

..

12 Commits

Author SHA1 Message Date
momen 4f6d223da4 fix: 搜索聚焦和初始化时平滑滚动到顶部
在搜索框获得焦点和应用初始化时,除了切换分类外,添加平滑滚动到顶部的功能,提升用户体验
2026-04-11 12:43:40 +08:00
momen 180b88b5c0 fix(update-center): cascade local and remote icon fallbacks
Keep update list icons from dropping straight to placeholders by retrying the remote store icon after local load failures. Align the update-center IPC and renderer types with the split local/remote icon contract.
2026-04-11 11:41:01 +08:00
momen c16ba5536f feat(update-center): add update list icons 2026-04-10 21:15:43 +08:00
momen 1d51f38e64 feat(滚动): 添加分类切换时重置虚拟滚动位置功能
添加 scrollKey 属性到 AppGrid 组件,当分类变化时自动重置滚动位置
添加相关单元测试验证滚动重置功能
2026-04-10 16:17:38 +08:00
momen 4a2cbe1f2a fix(update-center): 将apm命令从ssaudit改为ssinstall并优化打印URI命令
更新apm安装命令,使用ssinstall替代ssaudit以正确执行安装操作。同时优化获取包URI的命令,使用更可靠的bash调用方式。
2026-04-10 15:34:33 +08:00
momen 0b17ada45a feat(update-center): 实现集中式软件更新中心功能
新增更新中心模块,支持管理 APM 和传统 deb 软件更新任务
- 添加更新任务队列管理、状态跟踪和日志记录功能
- 实现更新项忽略配置持久化存储
- 新增更新确认对话框和迁移提示
- 优化主窗口关闭时的任务保护机制
- 添加单元测试覆盖核心逻辑
2026-04-09 08:19:51 +08:00
shenmo7192 97bb8e5f59 LICENSE to GPL3 2026-04-05 22:56:11 +08:00
shenmo7192 a1e0d7f301 fix: 将aptss升级失败的错误提示从弹窗改为日志输出 2026-04-05 22:50:45 +08:00
shenmo7192 593cb8ea75 feat(应用管理): 添加 APM 可用性检查并调整相关逻辑
当 APM 不可用时,自动切换到 Spark 应用管理
禁用 APM 软件标签页的切换按钮
移除侧边栏中 APM 可用性检查的冗余条件
2026-04-05 22:48:19 +08:00
shenmo7192 f7424ba4a7 修复 shell-caller 无法安装 apm 的问题 2026-04-05 22:34:39 +08:00
shenmo7192 04004c2b85 修复fedora安装指令 2026-04-05 21:07:17 +08:00
shenmo7192 445fbba391 更新: 优化readme格式 2026-04-05 16:39:06 +08:00
50 changed files with 9199 additions and 420 deletions
+579 -70
View File
@@ -1,127 +1,636 @@
# 木兰宽松许可证, 第2版 # GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
您对"软件"的复制、使用、修改及分发受木兰宽松许可证,第2版("本许可证")的如下条款的约束: Copyright (C) 2007 [Free Software Foundation, Inc.](http://fsf.org/)
## 0. 定义 Everyone is permitted to copy and distribute verbatim copies of this license
"软件" 是指由"贡献"构成的许可在"本许可证"下的程序和相关文档的集合。 document, but changing it is not allowed.
"贡献" 是指由任一"贡献者"许可在"本许可证"下的受版权法保护的作品。 ## Preamble
"贡献者" 是指将受版权法保护的作品许可在"本许可证"下的自然人或"法人实体"。 The GNU General Public License is a free, copyleft license for software and
other kinds of works.
"法人实体" 是指提交贡献的机构及其"关联实体"。 The licenses for most software and other practical works are designed to take
away your freedom to share and change the works. By contrast, the GNU General
Public License is intended to guarantee your freedom to share and change all
versions of a program--to make sure it remains free software for all its users.
We, the Free Software Foundation, use the GNU General Public License for most
of our software; it applies also to any other work released this way by its
authors. You can apply it to your programs, too.
"关联实体" 是指,对"本许可证"下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 When we speak of free software, we are referring to freedom, not price. Our
General Public Licenses are designed to make sure that you have the freedom to
distribute copies of free software (and charge for them if you wish), that you
receive source code or can get it if you want it, that you can change the
software or use pieces of it in new free programs, and that you know you can do
these things.
## 1. 授予版权许可 To protect your rights, we need to prevent others from denying you these rights
每个"贡献者"根据"本许可证"授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其"贡献",不论修改与否。 or asking you to surrender the rights. Therefore, you have certain
responsibilities if you distribute copies of the software, or if you modify it:
responsibilities to respect the freedom of others.
## 2. 授予专利许可 For example, if you distribute copies of such a program, whether gratis or for
每个"贡献者"根据"本许可证"授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其"贡献"或以其他方式转移其"贡献"。前述专利许可仅限于"贡献者"现在或将来拥有或控制的其"贡献"本身或其"贡献"与许可"贡献"时的"软件"结合而将必然会侵犯的专利权利要求,不包括对"贡献"的修改或包含"贡献"的其他结合。如果您或您的"关联实体"直接或间接地,就"软件"或其中的"贡献"对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则"本许可证"授予您对"软件"的专利许可自您提起诉讼或发起维权行动之日终止。 a fee, you must pass on to the recipients the same freedoms that you received.
You must make sure that they, too, receive or can get the source code. And you
must show them these terms so they know their rights.
## 3. 无商标许可 Developers that use the GNU GPL protect your rights with two steps:
"本许可证"不提供对"贡献者"的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定的声明义务而必须使用除外。
## 4. 分发限制 1. assert copyright on the software, and
您可以在任何媒介中将"软件"以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供"本许可证"的副本,并保留"软件"中的版权、商标、专利及免责声明。 2. offer you this License giving you legal permission to copy, distribute
and/or modify it.
## 5. 免责声明与责任限制 For the developers' and authors' protection, the GPL clearly explains that
"软件"及其中的"贡献"在提供时不带任何明示或默示的担保。在任何情况下,"贡献者"或版权所有者不对任何人因使用"软件"或其中的"贡献"而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。 there is no warranty for this free software. For both users' and authors' sake,
the GPL requires that modified versions be marked as changed, so that their
problems will not be attributed erroneously to authors of previous versions.
## 6. 语言 Some devices are designed to deny users access to install or run modified
"本许可证"以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。 versions of the software inside them, although the manufacturer can do so. This
is fundamentally incompatible with the aim of protecting users' freedom to
change the software. The systematic pattern of such abuse occurs in the area of
products for individuals to use, which is precisely where it is most
unacceptable. Therefore, we have designed this version of the GPL to prohibit
the practice for those products. If such problems arise substantially in other
domains, we stand ready to extend this provision to those domains in future
versions of the GPL, as needed to protect the freedom of users.
条款结束 Finally, every program is threatened constantly by software patents. States
should not allow patents to restrict development and use of software on
general-purpose computers, but in those that do, we wish to avoid the special
danger that patents applied to a free program could make it effectively
proprietary. To prevent this, the GPL assures that patents cannot be used to
render the program non-free.
如何将木兰宽松许可证,第2版,应用到您的软件 The precise terms and conditions for copying, distribution and modification
follow.
如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步: ## TERMS AND CONDITIONS
1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字; ### 0. Definitions.
2, 请您在软件包的一级目录下创建以"LICENSE"为名的文件,将整个许可证文本放入该文件中; *This License* refers to version 3 of the GNU General Public License.
3, 请将如下声明文本放入每个源文件的头部注释中。 *Copyright* also means copyright-like laws that apply to other kinds of works,
such as semiconductor masks.
Copyright (c) 2026-present The Spark Project Contributors *The Program* refers to any copyrightable work licensed under this License.
Each licensee is addressed as *you*. *Licensees* and *recipients* may be
individuals or organizations.
apm-store is licensed under Mulan PSL v2. To *modify* a work means to copy from or adapt all or part of the work in a
fashion requiring copyright permission, other than the making of an exact copy.
The resulting work is called a *modified version* of the earlier work or a work
*based on* the earlier work.
You can use this software according to the terms and conditions of the Mulan PSL v2. A *covered work* means either the unmodified Program or a work based on the
Program.
You may obtain a copy of Mulan PSL v2 at: To *propagate* a work means to do anything with it that, without permission,
would make you directly or secondarily liable for infringement under applicable
copyright law, except executing it on a computer or modifying a private copy.
Propagation includes copying, distribution (with or without modification),
making available to the public, and in some countries other activities as well.
http://license.coscl.org.cn/MulanPSL2 To *convey* a work means any kind of propagation that enables other parties to
make or receive copies. Mere interaction with a user through a computer
network, with no transfer of a copy, is not conveying.
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, An interactive user interface displays *Appropriate Legal Notices* to the
extent that it includes a convenient and prominently visible feature that
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, 1. displays an appropriate copyright notice, and
2. tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the work
under this License, and how to view a copy of this License.
MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. If the interface presents a list of user commands or options, such as a menu, a
prominent item in the list meets this criterion.
See the Mulan PSL v2 for more details. ### 1. Source Code.
The *source code* for a work means the preferred form of the work for making
modifications to it. *Object code* means any non-source form of a work.
A *Standard Interface* means an interface that either is an official standard
defined by a recognized standards body, or, in the case of interfaces specified
for a particular programming language, one that is widely used among developers
working in that language.
# Mulan Permissive Software LicenseVersion 2 The *System Libraries* of an executable work include anything, other than the
work as a whole, that (a) is included in the normal form of packaging a Major
Component, but which is not part of that Major Component, and (b) serves only
to enable use of the work with that Major Component, or to implement a Standard
Interface for which an implementation is available to the public in source code
form. A *Major Component*, in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system (if any) on
which the executable work runs, or a compiler used to produce the work, or an
object code interpreter used to run it.
Your reproduction, use, modification and distribution of the Software shall be subject to Mulan PSL v2 (this License) with the following terms and conditions: The *Corresponding Source* for a work in object code form means all the source
code needed to generate, install, and (for an executable work) run the object
code and to modify the work, including scripts to control those activities.
However, it does not include the work's System Libraries, or general-purpose
tools or generally available free programs which are used unmodified in
performing those activities but which are not part of the work. For example,
Corresponding Source includes interface definition files associated with source
files for the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require, such as
by intimate data communication or control flow between those subprograms and
other parts of the work.
## 0. Definition The Corresponding Source need not include anything that users can regenerate
Software means the program and related documents which are licensed under this License and comprise all Contribution(s). automatically from other parts of the Corresponding Source.
Contribution means the copyrightable work licensed by a particular Contributor under this License. The Corresponding Source for a work in source code form is that same work.
Contributor means the Individual or Legal Entity who licenses its copyrightable work under this License. ### 2. Basic Permissions.
Legal Entity means the entity making a Contribution and all its Affiliates. All rights granted under this License are granted for the term of copyright on
the Program, and are irrevocable provided the stated conditions are met. This
License explicitly affirms your unlimited permission to run the unmodified
Program. The output from running a covered work is covered by this License only
if the output, given its content, constitutes a covered work. This License
acknowledges your rights of fair use or other equivalent, as provided by
copyright law.
Affiliates means entities that control, are controlled by, or are under common control with the acting entity under this License, 'control' means direct or indirect ownership of at least fifty percent (50%) of the voting power, capital or other securities of controlled or commonly controlled entity. You may make, run and propagate covered works that you do not convey, without
conditions so long as your license otherwise remains in force. You may convey
covered works to others for the sole purpose of having them make modifications
exclusively for you, or provide you with facilities for running those works,
provided that you comply with the terms of this License in conveying all
material for which you do not control copyright. Those thus making or running
the covered works for you must do so exclusively on your behalf, under your
direction and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
## 1. Grant of Copyright License Conveying under any other circumstances is permitted solely under the
Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable copyright license to reproduce, use, modify, or distribute its Contribution, with modification or not. conditions stated below. Sublicensing is not allowed; section 10 makes it
unnecessary.
## 2. Grant of Patent License ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (except for revocation under this Section) patent license to make, have made, use, offer for sale, sell, import or otherwise transfer its Contribution, where such patent license is only limited to the patent claims owned or controlled by such Contributor now or in future which will be necessarily infringed by its Contribution alone, or by combination of the Contribution with the Software to which the Contribution was contributed. The patent license shall not apply to any modification of the Contribution, and any other combination which includes the Contribution. If you or your Affiliates directly or indirectly institute patent litigation (including a cross claim or counterclaim in a litigation) or other patent enforcement activities against any individual or entity by alleging that the Software or any Contribution in it infringes patents, then any patent license granted to you under this License for the Software shall terminate as of the date such litigation or activity is filed or taken.
## 3. No Trademark License No covered work shall be deemed part of an effective technological measure
No trademark license is granted to use the trade names, trademarks, service marks, or product names of Contributor, except as required to fulfill notice requirements in section 4. under any applicable law fulfilling obligations under article 11 of the WIPO
copyright treaty adopted on 20 December 1996, or similar laws prohibiting or
restricting circumvention of such measures.
## 4. Distribution Restriction When you convey a covered work, you waive any legal power to forbid
You may distribute the Software in any medium with or without modification, whether in source or executable forms, provided that you provide recipients with a copy of this License and retain copyright, patent, trademark and disclaimer statements in the Software. circumvention of technological measures to the extent such circumvention is
effected by exercising rights under this License with respect to the covered
work, and you disclaim any intention to limit operation or modification of the
work as a means of enforcing, against the work's users, your or third parties'
legal rights to forbid circumvention of technological measures.
## 5. Disclaimer of Warranty and Limitation of Liability ### 4. Conveying Verbatim Copies.
THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO MATTER HOW IT'S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
## 6. Language You may convey verbatim copies of the Program's source code as you receive it,
THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION SHALL PREVAIL. in any medium, provided that you conspicuously and appropriately publish on
each copy an appropriate copyright notice; keep intact all notices stating that
this License and any non-permissive terms added in accord with section 7 apply
to the code; keep intact all notices of the absence of any warranty; and give
all recipients a copy of this License along with the Program.
END OF THE TERMS AND CONDITIONS You may charge any price or no price for each copy that you convey, and you may
offer support or warranty protection for a fee.
How to Apply the Mulan Permissive Software LicenseVersion 2 (Mulan PSL v2) to Your Software ### 5. Conveying Modified Source Versions.
To apply the Mulan PSL v2 to your work, for easy identification by recipients, you are suggested to complete following three steps: You may convey a work based on the Program, or the modifications to produce it
from the Program, in the form of source code under the terms of section 4,
provided that you also meet all of these conditions:
i. Fill in the blanks in following statement, including insert your software name, the year of the first publication of your software, and your name identified as the copyright owner; - a) The work must carry prominent notices stating that you modified it, and
giving a relevant date.
- b) The work must carry prominent notices stating that it is released under
this License and any conditions added under section 7. This requirement
modifies the requirement in section 4 to *keep intact all notices*.
- c) You must license the entire work, as a whole, under this License to
anyone who comes into possession of a copy. This License will therefore
apply, along with any applicable section 7 additional terms, to the whole
of the work, and all its parts, regardless of how they are packaged. This
License gives no permission to license the work in any other way, but it
does not invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your work need
not make them do so.
ii. Create a file named "LICENSE" which contains the whole context of this License in the first directory of your software package; A compilation of a covered work with other separate and independent works,
which are not by their nature extensions of the covered work, and which are not
combined with it such as to form a larger program, in or on a volume of a
storage or distribution medium, is called an *aggregate* if the compilation and
its resulting copyright are not used to limit the access or legal rights of the
compilation's users beyond what the individual works permit. Inclusion of a
covered work in an aggregate does not cause this License to apply to the other
parts of the aggregate.
iii. Attach the statement to the appropriate annotated syntax at the beginning of each source file. ### 6. Conveying Non-Source Forms.
Copyright (c) 2026-present The Spark Project Contributors You may convey a covered work in object code form under the terms of sections 4
and 5, provided that you also convey the machine-readable Corresponding Source
under the terms of this License, in one of these ways:
apm-store is licensed under Mulan PSL v2. - a) Convey the object code in, or embodied in, a physical product (including
a physical distribution medium), accompanied by the Corresponding Source
fixed on a durable physical medium customarily used for software
interchange.
- b) Convey the object code in, or embodied in, a physical product (including
a physical distribution medium), accompanied by a written offer, valid for
at least three years and valid for as long as you offer spare parts or
customer support for that product model, to give anyone who possesses the
object code either
1. a copy of the Corresponding Source for all the software in the product
that is covered by this License, on a durable physical medium
customarily used for software interchange, for a price no more than your
reasonable cost of physically performing this conveying of source, or
2. access to copy the Corresponding Source from a network server at no
charge.
- c) Convey individual copies of the object code with a copy of the written
offer to provide the Corresponding Source. This alternative is allowed only
occasionally and noncommercially, and only if you received the object code
with such an offer, in accord with subsection 6b.
- d) Convey the object code by offering access from a designated place
(gratis or for a charge), and offer equivalent access to the Corresponding
Source in the same way through the same place at no further charge. You
need not require recipients to copy the Corresponding Source along with the
object code. If the place to copy the object code is a network server, the
Corresponding Source may be on a different server operated by you or a
third party) that supports equivalent copying facilities, provided you
maintain clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the Corresponding
Source, you remain obligated to ensure that it is available for as long as
needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission, provided you
inform other peers where the object code and Corresponding Source of the
work are being offered to the general public at no charge under subsection
6d.
You can use this software according to the terms and conditions of the Mulan PSL v2. A separable portion of the object code, whose source code is excluded from the
Corresponding Source as a System Library, need not be included in conveying the
object code work.
You may obtain a copy of Mulan PSL v2 at: A *User Product* is either
http://license.coscl.org.cn/MulanPSL2 1. a *consumer product*, which means any tangible personal property which is
normally used for personal, family, or household purposes, or
2. anything designed or sold for incorporation into a dwelling.
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, In determining whether a product is a consumer product, doubtful cases shall be
resolved in favor of coverage. For a particular product received by a
particular user, *normally used* refers to a typical or common use of that
class of product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected to use,
the product. A product is a consumer product regardless of whether the product
has substantial commercial, industrial or non-consumer uses, unless such uses
represent the only significant mode of use of the product.
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, *Installation Information* for a User Product means any methods, procedures,
authorization keys, or other information required to install and execute
modified versions of a covered work in that User Product from a modified
version of its Corresponding Source. The information must suffice to ensure
that the continued functioning of the modified object code is in no case
prevented or interfered with solely because modification has been made.
MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as part of a
transaction in which the right of possession and use of the User Product is
transferred to the recipient in perpetuity or for a fixed term (regardless of
how the transaction is characterized), the Corresponding Source conveyed under
this section must be accompanied by the Installation Information. But this
requirement does not apply if neither you nor any third party retains the
ability to install modified object code on the User Product (for example, the
work has been installed in ROM).
See the Mulan PSL v2 for more details. The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates for a
work that has been modified or installed by the recipient, or for the User
Product in which it has been modified or installed. Access to a network may be
denied when the modification itself materially and adversely affects the
operation of the network or violates the rules and protocols for communication
across the network.
Corresponding Source conveyed, and Installation Information provided, in accord
with this section must be in a format that is publicly documented (and with an
implementation available to the public in source code form), and must require
no special password or key for unpacking, reading or copying.
### 7. Additional Terms.
*Additional permissions* are terms that supplement the terms of this License by
making exceptions from one or more of its conditions. Additional permissions
that are applicable to the entire Program shall be treated as though they were
included in this License, to the extent that they are valid under applicable
law. If additional permissions apply only to part of the Program, that part may
be used separately under those permissions, but the entire Program remains
governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any
additional permissions from that copy, or from any part of it. (Additional
permissions may be written to require their own removal in certain cases when
you modify the work.) You may place additional permissions on material, added
by you to a covered work, for which you have or can give appropriate copyright
permission.
Notwithstanding any other provision of this License, for material you add to a
covered work, you may (if authorized by the copyright holders of that material)
supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the terms of
sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or author
attributions in that material or in the Appropriate Legal Notices displayed
by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in reasonable
ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors or authors
of the material; or
- e) Declining to grant rights under trademark law for use of some trade
names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that material by
anyone who conveys the material (or modified versions of it) with
contractual assumptions of liability to the recipient, for any liability
that these contractual assumptions directly impose on those licensors and
authors.
All other non-permissive additional terms are considered *further restrictions*
within the meaning of section 10. If the Program as you received it, or any
part of it, contains a notice stating that it is governed by this License along
with a term that is a further restriction, you may remove that term. If a
license document contains a further restriction but permits relicensing or
conveying under this License, you may add to a covered work material governed
by the terms of that license document, provided that the further restriction
does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place,
in the relevant source files, a statement of the additional terms that apply to
those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a
separately written license, or stated as exceptions; the above requirements
apply either way.
### 8. Termination.
You may not propagate or modify a covered work except as expressly provided
under this License. Any attempt otherwise to propagate or modify it is void,
and will automatically terminate your rights under this License (including any
patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a
particular copyright holder is reinstated
- a) provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and
- b) permanently, if the copyright holder fails to notify you of the
violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated
permanently if the copyright holder notifies you of the violation by some
reasonable means, this is the first time you have received notice of violation
of this License (for any work) from that copyright holder, and you cure the
violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses
of parties who have received copies or rights from you under this License. If
your rights have been terminated and not permanently reinstated, you do not
qualify to receive new licenses for the same material under section 10.
### 9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy
of the Program. Ancillary propagation of a covered work occurring solely as a
consequence of using peer-to-peer transmission to receive a copy likewise does
not require acceptance. However, nothing other than this License grants you
permission to propagate or modify any covered work. These actions infringe
copyright if you do not accept this License. Therefore, by modifying or
propagating a covered work, you indicate your acceptance of this License to do
so.
### 10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a
license from the original licensors, to run, modify and propagate that work,
subject to this License. You are not responsible for enforcing compliance by
third parties with this License.
An *entity transaction* is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered work
results from an entity transaction, each party to that transaction who receives
a copy of the work also receives whatever licenses to the work the party's
predecessor in interest had or could give under the previous paragraph, plus a
right to possession of the Corresponding Source of the work from the
predecessor in interest, if the predecessor has it or can get it with
reasonable efforts.
You may not impose any further restrictions on the exercise of the rights
granted or affirmed under this License. For example, you may not impose a
license fee, royalty, or other charge for exercise of rights granted under this
License, and you may not initiate litigation (including a cross-claim or
counterclaim in a lawsuit) alleging that any patent claim is infringed by
making, using, selling, offering for sale, or importing the Program or any
portion of it.
### 11. Patents.
A *contributor* is a copyright holder who authorizes use under this License of
the Program or a work on which the Program is based. The work thus licensed is
called the contributor's *contributor version*.
A contributor's *essential patent claims* are all patent claims owned or
controlled by the contributor, whether already acquired or hereafter acquired,
that would be infringed by some manner, permitted by this License, of making,
using, or selling its contributor version, but do not include claims that would
be infringed only as a consequence of further modification of the contributor
version. For purposes of this definition, *control* includes the right to grant
patent sublicenses in a manner consistent with the requirements of this
License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent
license under the contributor's essential patent claims, to make, use, sell,
offer for sale, import and otherwise run, modify and propagate the contents of
its contributor version.
In the following three paragraphs, a *patent license* is any express agreement
or commitment, however denominated, not to enforce a patent (such as an express
permission to practice a patent or covenant not to sue for patent
infringement). To *grant* such a patent license to a party means to make such
an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the
Corresponding Source of the work is not available for anyone to copy, free of
charge and under the terms of this License, through a publicly available
network server or other readily accessible means, then you must either
1. cause the Corresponding Source to be so available, or
2. arrange to deprive yourself of the benefit of the patent license for this
particular work, or
3. arrange, in a manner consistent with the requirements of this License, to
extend the patent license to downstream recipients.
*Knowingly relying* means you have actual knowledge that, but for the patent
license, your conveying the covered work in a country, or your recipient's use
of the covered work in a country, would infringe one or more identifiable
patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you
convey, or propagate by procuring conveyance of, a covered work, and grant a
patent license to some of the parties receiving the covered work authorizing
them to use, propagate, modify or convey a specific copy of the covered work,
then the patent license you grant is automatically extended to all recipients
of the covered work and works based on it.
A patent license is *discriminatory* if it does not include within the scope of
its coverage, prohibits the exercise of, or is conditioned on the non-exercise
of one or more of the rights that are specifically granted under this License.
You may not convey a covered work if you are a party to an arrangement with a
third party that is in the business of distributing software, under which you
make payment to the third party based on the extent of your activity of
conveying the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory patent
license
- a) in connection with copies of the covered work conveyed by you (or copies
made from those copies), or
- b) primarily for and in connection with specific products or compilations
that contain the covered work, unless you entered into that arrangement, or
that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied
license or other defenses to infringement that may otherwise be available to
you under applicable patent law.
### 12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not excuse
you from the conditions of this License. If you cannot convey a covered work so
as to satisfy simultaneously your obligations under this License and any other
pertinent obligations, then as a consequence you may not convey it at all. For
example, if you agree to terms that obligate you to collect a royalty for
further conveying from those to whom you convey the Program, the only way you
could satisfy both those terms and this License would be to refrain entirely
from conveying the Program.
### 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to
link or combine any covered work with a work licensed under version 3 of the
GNU Affero General Public License into a single combined work, and to convey
the resulting work. The terms of this License will continue to apply to the
part which is the covered work, but the special requirements of the GNU Affero
General Public License, section 13, concerning interaction through a network
will apply to the combination as such.
### 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU
General Public License from time to time. Such new versions will be similar in
spirit to the present version, but may differ in detail to address new problems
or concerns.
Each version is given a distinguishing version number. If the Program specifies
that a certain numbered version of the GNU General Public License *or any later
version* applies to it, you have the option of following the terms and
conditions either of that numbered version or of any later version published by
the Free Software Foundation. If the Program does not specify a version number
of the GNU General Public License, you may choose any version ever published by
the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the
GNU General Public License can be used, that proxy's public statement of
acceptance of a version permanently authorizes you to choose that version for
the Program.
Later license versions may give you additional or different permissions.
However, no additional obligations are imposed on any author or copyright
holder as a result of your choosing to follow a later version.
### 15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE
LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER
PARTIES PROVIDE THE PROGRAM *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER
EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE
QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
CORRECTION.
### 16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY
COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS
PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE
THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED
INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE
PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY
HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
### 17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot
be given local legal effect according to their terms, reviewing courts shall
apply local law that most closely approximates an absolute waiver of all civil
liability in connection with the Program, unless a warranty or assumption of
liability accompanies a copy of the Program in return for a fee.
## END OF TERMS AND CONDITIONS ###
### How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible
use to the public, the best way to achieve this is to make it free software
which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach
them to the start of each source file to most effectively state the exclusion
of warranty; and each file should have at least the *copyright* line and a
pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like
this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w` and `show c` should show the appropriate
parts of the General Public License. Of course, your program's commands might
be different; for a GUI interface, you would use an *about box*.
You should also get your employer (if you work as a programmer) or school, if
any, to sign a *copyright disclaimer* for the program, if necessary. For more
information on this, and how to apply and follow the GNU GPL, see
[http://www.gnu.org/licenses/](http://www.gnu.org/licenses/).
The GNU General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may consider
it more useful to permit linking proprietary applications with the library. If
this is what you want to do, use the GNU Lesser General Public License instead
of this License. But first, please read
[http://www.gnu.org/philosophy/why-not-lgpl.html](http://www.gnu.org/philosophy/why-not-lgpl.html).
+2 -2
View File
@@ -43,12 +43,12 @@ Linux 应用的数量相对有限,Wine 软件的可获取性也颇为困难。
* Fedora * Fedora
1. `sudo dnf enable xmp360/spark-store` 1. `sudo dnf copr enable xmp360/spark-store`
2. `sudo dnf install spark-store` 2. `sudo dnf install spark-store`
* Arch Linux * Arch Linux
1. paru -S spark-store 1. `paru -S spark-store`
--- ---
<div align="center"> <div align="center">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,674 @@
# Update Center Icons Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add icons to the Electron update-center list using local icon resolution first, remote URL fallback second, and a frontend placeholder last.
**Architecture:** Add a focused `icons.ts` helper in the update-center backend to resolve icon paths/URLs while loading update items, then pass the single `icon` field through the service snapshot into the renderer. Keep the Vue side minimal by rendering a fixed icon slot in `UpdateCenterItem.vue` and falling back to a placeholder icon on `img` load failure.
**Tech Stack:** Electron main process, Node.js `fs`/`path`, Vue 3 `<script setup>`, Tailwind CSS 4, Vitest, Testing Library Vue, TypeScript strict mode.
---
## File Map
- Create: `electron/main/backend/update-center/icons.ts` — resolves update-item icons from local desktop/APM metadata and remote fallback URLs.
- Modify: `electron/main/backend/update-center/types.ts` — add backend `icon?: string` field.
- Modify: `electron/main/backend/update-center/index.ts` — enrich loaded update items with resolved icons.
- Modify: `electron/main/backend/update-center/service.ts` — expose `icon` in renderer-facing snapshots.
- Modify: `src/global/typedefinition.ts` — add renderer-facing `icon?: string` field.
- Modify: `src/components/update-center/UpdateCenterItem.vue` — render icon slot and placeholder fallback.
- Test: `src/__tests__/unit/update-center/icons.test.ts` — backend icon-resolution tests.
- Modify: `src/__tests__/unit/update-center/load-items.test.ts` — verify loaded update items include icon data when available.
- Create: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts` — component-level icon rendering and fallback tests.
### Task 1: Add Backend Icon Resolution Helpers
**Files:**
- Create: `electron/main/backend/update-center/icons.ts`
- Modify: `electron/main/backend/update-center/types.ts`
- Test: `src/__tests__/unit/update-center/icons.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildRemoteFallbackIconUrl,
resolveApmIcon,
resolveDesktopIcon,
} from "../../../../electron/main/backend/update-center/icons";
describe("update-center icons", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("prefers local desktop icon paths for aptss items", () => {
const existsSync = vi.spyOn(require("node:fs"), "existsSync");
const readdirSync = vi.spyOn(require("node:fs"), "readdirSync");
const readFileSync = vi.spyOn(require("node:fs"), "readFileSync");
existsSync.mockImplementation((target) =>
String(target).includes("/usr/share/applications"),
);
readdirSync.mockReturnValue(["spark-weather.desktop"]);
readFileSync.mockReturnValue(
"Name=Spark Weather\nIcon=/usr/share/icons/hicolor/128x128/apps/spark-weather.png\n",
);
expect(resolveDesktopIcon("spark-weather")).toBe(
"/usr/share/icons/hicolor/128x128/apps/spark-weather.png",
);
});
it("resolves APM icon names from entries/icons when desktop icon is not absolute", () => {
const existsSync = vi.spyOn(require("node:fs"), "existsSync");
const readdirSync = vi.spyOn(require("node:fs"), "readdirSync");
const readFileSync = vi.spyOn(require("node:fs"), "readFileSync");
existsSync.mockImplementation(
(target) =>
String(target).includes(
"/var/lib/apm/apm/files/ace-env/var/lib/apm/com.qihoo.360zip/entries/icons/hicolor/48x48/apps/360zip.png",
) ||
String(target).includes(
"/var/lib/apm/apm/files/ace-env/var/lib/apm/com.qihoo.360zip/entries/applications",
),
);
readdirSync.mockReturnValue(["360zip.desktop"]);
readFileSync.mockReturnValue("Name=360压缩\nIcon=360zip\n");
expect(resolveApmIcon("com.qihoo.360zip")).toBe(
"/var/lib/apm/apm/files/ace-env/var/lib/apm/com.qihoo.360zip/entries/icons/hicolor/48x48/apps/360zip.png",
);
});
it("builds a remote fallback URL when category and arch are available", () => {
expect(
buildRemoteFallbackIconUrl({
pkgname: "spark-weather",
source: "aptss",
arch: "amd64",
category: "network",
}),
).toBe(
"https://erotica.spark-app.store/amd64-store/network/spark-weather/icon.png",
);
});
it("returns empty string when neither local nor remote icon can be determined", () => {
expect(
buildRemoteFallbackIconUrl({
pkgname: "spark-weather",
source: "aptss",
arch: "amd64",
}),
).toBe("");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
Expected: FAIL with `Cannot find module '../../../../electron/main/backend/update-center/icons'`.
- [ ] **Step 3: Write minimal implementation**
```ts
// electron/main/backend/update-center/types.ts
export interface UpdateCenterItem {
pkgname: string;
source: UpdateSource;
currentVersion: string;
nextVersion: string;
icon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
```
```ts
// electron/main/backend/update-center/icons.ts
import fs from "node:fs";
import path from "node:path";
const APM_STORE_BASE_URL = "https://erotica.spark-app.store";
const APM_BASE_PATH = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
export const resolveDesktopIcon = (pkgname: string): string => {
const desktopRoots = [
"/usr/share/applications",
`/opt/apps/${pkgname}/entries/applications`,
];
for (const root of desktopRoots) {
if (!fs.existsSync(root)) continue;
for (const file of fs.readdirSync(root)) {
if (!file.endsWith(".desktop")) continue;
const content = fs.readFileSync(path.join(root, file), "utf8");
const match = content.match(/^Icon=(.+)$/m);
if (!match) continue;
const iconValue = match[1].trim();
if (iconValue.startsWith("/")) return iconValue;
}
}
return "";
};
export const resolveApmIcon = (pkgname: string): string => {
const entriesPath = path.join(
APM_BASE_PATH,
pkgname,
"entries",
"applications",
);
if (!fs.existsSync(entriesPath)) return "";
for (const file of fs.readdirSync(entriesPath)) {
if (!file.endsWith(".desktop")) continue;
const content = fs.readFileSync(path.join(entriesPath, file), "utf8");
const match = content.match(/^Icon=(.+)$/m);
if (!match) continue;
const iconValue = match[1].trim();
if (iconValue.startsWith("/")) return iconValue;
const iconPath = path.join(
APM_BASE_PATH,
pkgname,
"entries",
"icons",
"hicolor",
"48x48",
"apps",
`${iconValue}.png`,
);
if (fs.existsSync(iconPath)) return iconPath;
}
return "";
};
export const buildRemoteFallbackIconUrl = (input: {
pkgname: string;
source: "aptss" | "apm";
arch: string;
category?: string;
}): string => {
if (!input.category) return "";
const finalArch =
input.source === "aptss" ? `${input.arch}-store` : `${input.arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${input.category}/${input.pkgname}/icon.png`;
};
export const resolveUpdateItemIcon = (item: {
pkgname: string;
source: "aptss" | "apm";
arch?: string;
category?: string;
}): string => {
const localIcon =
item.source === "apm"
? resolveApmIcon(item.pkgname)
: resolveDesktopIcon(item.pkgname);
if (localIcon) {
return localIcon;
}
if (!item.arch) {
return "";
}
return buildRemoteFallbackIconUrl({
pkgname: item.pkgname,
source: item.source,
arch: item.arch,
category: item.category,
});
};
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/update-center/icons.test.ts`
Expected: PASS with 4 tests passed.
- [ ] **Step 5: Commit**
```bash
git add electron/main/backend/update-center/types.ts electron/main/backend/update-center/icons.ts src/__tests__/unit/update-center/icons.test.ts
git commit -m "feat(update-center): add icon resolution helpers"
```
### Task 2: Enrich Loaded Update Items with Icons
**Files:**
- Modify: `electron/main/backend/update-center/index.ts`
- Modify: `electron/main/backend/update-center/service.ts`
- Modify: `src/global/typedefinition.ts`
- Modify: `src/__tests__/unit/update-center/load-items.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../../electron/main/backend/update-center/icons", () => ({
resolveUpdateItemIcon: vi.fn((item) =>
item.pkgname === "spark-weather"
? "/usr/share/icons/hicolor/128x128/apps/spark-weather.png"
: "",
),
}));
import { loadUpdateCenterItems } from "../../../../electron/main/backend/update-center";
describe("update-center load items", () => {
it("adds icon data to loaded update items", async () => {
const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`;
if (key.includes("list --upgradable")) {
return {
code: 0,
stdout: "spark-weather/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
}
if (key.includes("dpkg-query")) {
return {
code: 0,
stdout: "spark-weather\tinstall ok installed\n",
stderr: "",
};
}
return { code: 0, stdout: "", stderr: "" };
});
expect(result.items).toContainEqual(
expect.objectContaining({
pkgname: "spark-weather",
icon: "/usr/share/icons/hicolor/128x128/apps/spark-weather.png",
}),
);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts`
Expected: FAIL because loaded items do not yet include `icon`.
- [ ] **Step 3: Write minimal implementation**
```ts
// electron/main/backend/update-center/index.ts
import { resolveUpdateItemIcon } from "./icons";
const withResolvedIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
return items.map((item) => ({
...item,
icon: resolveUpdateItemIcon(item),
}));
};
export const loadUpdateCenterItems = async (
runCommand: UpdateCenterCommandRunner = runCommandCapture,
): Promise<UpdateCenterLoadItemsResult> => {
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
await Promise.all([
runCommand(
APTSS_LIST_UPGRADABLE_COMMAND.command,
APTSS_LIST_UPGRADABLE_COMMAND.args,
),
runCommand("apm", ["list", "--upgradable"]),
runCommand(
DPKG_QUERY_INSTALLED_COMMAND.command,
DPKG_QUERY_INSTALLED_COMMAND.args,
),
runCommand("apm", ["list", "--installed"]),
]);
const warnings = [
getCommandError("aptss upgradable query", aptssResult),
getCommandError("apm upgradable query", apmResult),
getCommandError("dpkg installed query", aptssInstalledResult),
getCommandError("apm installed query", apmInstalledResult),
].filter((message): message is string => message !== null);
const aptssItems =
aptssResult.code === 0
? parseAptssUpgradableOutput(aptssResult.stdout)
: [];
const apmItems =
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : [];
if (aptssResult.code !== 0 && apmResult.code !== 0) {
throw new Error(warnings.join("; "));
}
const installedSources = buildInstalledSourceMap(
aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "",
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
);
const enrichedApmItems = await enrichApmItems(apmItems, runCommand);
return {
items: withResolvedIcons(
mergeUpdateSources(aptssItems, enrichedApmItems.items, installedSources),
),
warnings: [...warnings, ...enrichedApmItems.warnings],
};
};
```
```ts
// electron/main/backend/update-center/service.ts
const toState = (
snapshot: UpdateCenterQueueSnapshot,
): UpdateCenterServiceState => ({
items: snapshot.items.map((item) => ({
taskKey: getTaskKey(item),
packageName: item.pkgname,
displayName: item.pkgname,
currentVersion: item.currentVersion,
newVersion: item.nextVersion,
source: item.source,
icon: item.icon,
ignored: item.ignored,
downloadUrl: item.downloadUrl,
fileName: item.fileName,
size: item.size,
sha512: item.sha512,
isMigration: item.isMigration,
migrationSource: item.migrationSource,
migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion,
})),
tasks: snapshot.tasks.map((task) => ({
taskKey: getTaskKey(task.item),
packageName: task.pkgname,
source: task.item.source,
status: task.status,
progress: task.progress,
logs: task.logs.map((log) => ({ ...log })),
errorMessage: task.error ?? "",
})),
warnings: [...snapshot.warnings],
hasRunningTasks: snapshot.hasRunningTasks,
});
```
```ts
// src/global/typedefinition.ts
export interface UpdateCenterItem {
taskKey: string;
packageName: string;
displayName: string;
currentVersion: string;
newVersion: string;
source: UpdateSource;
icon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/update-center/load-items.test.ts`
Expected: PASS with icon assertions included.
- [ ] **Step 5: Commit**
```bash
git add electron/main/backend/update-center/index.ts electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/__tests__/unit/update-center/load-items.test.ts
git commit -m "feat(update-center): pass resolved icons to renderer"
```
### Task 3: Render Update-List Icons with Placeholder Fallback
**Files:**
- Modify: `src/components/update-center/UpdateCenterItem.vue`
- Create: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import UpdateCenterItem from "@/components/update-center/UpdateCenterItem.vue";
const item = {
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss" as const,
icon: "/usr/share/icons/hicolor/128x128/apps/spark-weather.png",
};
describe("UpdateCenterItem", () => {
it("renders an icon image when item.icon exists", () => {
render(UpdateCenterItem, {
props: { item, selected: false },
});
const image = screen.getByRole("img", { name: "Spark Weather 图标" });
expect(image.getAttribute("src")).toBe(
"file:///usr/share/icons/hicolor/128x128/apps/spark-weather.png",
);
});
it("falls back to a placeholder icon when the image fails", async () => {
render(UpdateCenterItem, {
props: { item, selected: false },
});
const image = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(image);
expect(screen.getByTestId("update-center-icon-fallback")).toBeTruthy();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
Expected: FAIL because `UpdateCenterItem.vue` does not render icon markup yet.
- [ ] **Step 3: Write minimal implementation**
```vue
<!-- src/components/update-center/UpdateCenterItem.vue -->
<template>
<label
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
>
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="selected"
:disabled="item.ignored === true"
@change="$emit('toggle-selection')"
/>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800"
>
<img
v-if="resolvedIcon && !iconFailed"
:src="resolvedIcon"
:alt="`${item.displayName} 图标`"
class="h-8 w-8 object-contain"
@error="iconFailed = true"
/>
<i
v-else
data-testid="update-center-icon-fallback"
class="fas fa-cube text-lg text-slate-400"
></i>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-900 dark:text-white">
{{ item.displayName }}
</p>
<span
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
{{ sourceLabel }}
</span>
<span
v-if="item.isMigration"
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
>
将迁移到 APM
</span>
<span
v-if="item.ignored === true"
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
>
已忽略
</span>
</div>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
{{ item.newVersion }}
</p>
<p
v-if="item.ignored === true"
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400"
>
已忽略的更新不会加入本次任务
</p>
</div>
<div
v-if="task"
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300"
>
<p>{{ statusLabel }}</p>
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
</div>
</div>
<div v-if="showProgress" class="space-y-2">
<div
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<div
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
:style="progressStyle"
></div>
</div>
</div>
</label>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import type {
UpdateCenterItem,
UpdateCenterTaskState,
} from "@/global/typedefinition";
const props = defineProps<{
item: UpdateCenterItem;
task?: UpdateCenterTaskState;
selected: boolean;
}>();
const iconFailed = ref(false);
const resolvedIcon = computed(() => {
if (!props.item.icon) return "";
return props.item.icon.startsWith("/")
? `file://${props.item.icon}`
: props.item.icon;
});
</script>
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --run src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
Expected: PASS with 2 tests passed.
- [ ] **Step 5: Commit**
```bash
git add src/components/update-center/UpdateCenterItem.vue src/__tests__/unit/update-center/UpdateCenterItem.test.ts
git commit -m "feat(update-center): render update item icons"
```
### Task 4: Verify the Icon Feature End-to-End
**Files:**
- Modify: `electron/main/backend/update-center/icons.ts`
- Modify: `electron/main/backend/update-center/index.ts`
- Modify: `electron/main/backend/update-center/service.ts`
- Modify: `src/global/typedefinition.ts`
- Modify: `src/components/update-center/UpdateCenterItem.vue`
- Modify: `src/__tests__/unit/update-center/icons.test.ts`
- Modify: `src/__tests__/unit/update-center/load-items.test.ts`
- Modify: `src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
- [ ] **Step 1: Format the changed files**
Run: `npm run format`
Expected: Prettier rewrites changed `src/` and `electron/` files without errors.
- [ ] **Step 2: Run lint and the targeted update-center suite**
Run: `npm run lint && npm run test -- --run src/__tests__/unit/update-center/icons.test.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/UpdateCenterItem.test.ts`
Expected: ESLint exits 0 and the new icon-related tests pass.
- [ ] **Step 3: Run the complete unit suite and production build**
Run: `npm run test -- --run && npm run build:vite`
Expected: all existing unit tests remain green and `vue-tsc` plus Vite production build complete successfully.
- [ ] **Step 4: Commit the verified icon feature**
```bash
git add electron/main/backend/update-center/types.ts electron/main/backend/update-center/icons.ts electron/main/backend/update-center/index.ts electron/main/backend/update-center/service.ts src/global/typedefinition.ts src/components/update-center/UpdateCenterItem.vue src/__tests__/unit/update-center/icons.test.ts src/__tests__/unit/update-center/load-items.test.ts src/__tests__/unit/update-center/UpdateCenterItem.test.ts
git commit -m "feat(update-center): show icons in update list"
```
@@ -0,0 +1,214 @@
# 更新中心列表图标设计
## 背景
当前 Electron 更新中心已经可以展示更新项、来源、迁移标记、进度和日志,但更新列表仍然只有文字信息,没有应用图标。对于 APM 包、传统 deb 包和迁移项,纯文字列表会降低识别效率,尤其在批量更新和搜索场景下不够直观。
仓库现状里已经存在多套可复用的图标来源逻辑:
1. 主商店卡片通过远程商店 URL 拼接 `icon.png`
2. 已安装应用列表支持本地图标和远程 URL 双来源。
3. 旧 Qt 更新器会为 APM 更新项解析 desktop 与 entries/icons,并在无本地图标时继续使用其他数据源。
目标是在更新中心列表中加入应用图标,同时保持最小改动、兼容当前后端结构,并遵循“本地解析优先,其次远程 URL,最后占位图标”的策略。
## 目标
1. 在更新中心列表中为每个更新项展示应用图标。
2. 图标来源优先级为:本地解析 > 远程 URL > 前端占位图标。
3. 前后端仅增加一个最小公共字段,不引入复杂的图标对象结构。
4. 图标缺失或加载失败时,界面仍然保持稳定、整齐、不闪烁。
## 非目标
1. 不为图标来源新增额外网络探测请求。
2. 不在本次设计中重构应用详情页、已安装列表或主商店卡片的图标逻辑。
3. 不在 UI 中展示“图标来源”说明文字。
## 方案概览
采用“主进程解析来源、渲染层只展示”的方案:
1. 更新中心主进程在加载更新项时解析图标来源,并将结果写入更新项的 `icon` 字段。
2. 渲染层更新列表只消费 `item.icon`,不参与解析来源。
3. 前端负责单次图片加载失败回退到占位图标。
## 数据结构变化
### 主进程
修改:`electron/main/backend/update-center/types.ts`
`UpdateCenterItem` 增加:
```ts
icon?: string;
```
### 渲染层
修改:`src/global/typedefinition.ts`
`UpdateCenterItem` 增加:
```ts
icon?: string;
```
### Service 映射
修改:`electron/main/backend/update-center/service.ts`
在主进程 snapshot -> renderer snapshot 的映射中透传 `icon` 字段。
## 图标来源策略
### 优先级
每个更新项统一按以下顺序取图标:
1. 本地图标路径
2. 远程商店图标 URL
3. 前端占位图标
### 1. 本地图标路径
#### 传统 deb / Spark 更新项
优先复用仓库中已有的 desktop 文件扫描与 `Icon=` 解析思路,来源参考:
- `electron/main/backend/install-manager.ts`
解析策略:
1. 从已安装包对应的 desktop 文件中读取 `Icon=`
2. 如果解析结果为绝对路径,直接返回。
3. 如果解析结果为图标名,则尝试根据系统图标路径补全。
4. 若无法得到有效路径,则继续下一层来源。
#### APM 更新项
优先复用旧 Qt 更新器已存在的 APM 图标解析逻辑,来源参考:
- `spark-update-tool/src/aptssupdater.cpp`
解析策略:
1. 查找 APM 包的 `entries/applications/*.desktop`
2. 从 desktop 的 `Icon=` 字段中解析图标。
3.`Icon=` 为绝对路径,直接返回。
4.`Icon=` 为图标名,则尝试拼接 APM 包内 `entries/icons/...` 路径。
5. 若仍无结果,则继续下一层来源。
### 2. 远程商店图标 URL
如果本地图标解析失败,则为更新项生成远程图标 URL。
实现原则:
1. 不主动探测 URL 是否可用。
2. 仅按现有商店规则拼接 URL,并交给浏览器加载。
3. 浏览器加载失败后由前端回退占位图标。
对 Spark/传统 deb
1. 使用当前商店已有的远程图标拼接规则。
2. 若更新项可以推断出对应 category 和 arch,则拼接:
`${APM_STORE_BASE_URL}/${arch}/${category}/${pkgname}/icon.png`
对 APM
1. 若仓库中已有 APM 对应商店资源约定,则使用同样的 `icon.png` 规则。
2. 若当前数据无法可靠推断 category,则允许直接跳过远程 URL,进入前端占位图标。
### 3. 占位图标
如果主进程未能提供 `icon`,或者前端加载失败,则使用统一占位图标。
占位规则:
1. 图标尺寸与正常图标一致。
2. 使用仓库现有品牌资源或统一默认应用图标。
3. 不因失败状态改变列表布局高度或间距。
## 模块边界
新增:
- `electron/main/backend/update-center/icons.ts`
职责:
1. `resolveUpdateItemIcon()`
2. `resolveApmIcon()`
3. `resolveDesktopIcon()`
4. `buildRemoteFallbackIconUrl()`
该模块只负责“根据更新项得到一个 `icon?: string`”,不参与更新队列、安装、刷新、忽略等逻辑。
## 数据流
### 主进程加载更新项
1. 查询并合并更新项。
2. 对每个更新项执行图标解析。
3. 将解析到的 `icon` 字段写入 `UpdateCenterItem`
4.`service.ts` 将该字段透传到渲染层 snapshot。
### 渲染层展示
1. `UpdateCenterItem.vue` 读取 `item.icon`
2. 如果 `item.icon` 为本地绝对路径,则转成 `file://` URL。
3. 如果 `item.icon` 为远程 URL,则直接作为图片地址使用。
4. 若图片加载失败,则切换为占位图标,并记住失败状态避免重复尝试。
## UI 设计
### 列表项布局
在更新列表中新增一个固定图标位:
1. 位置:复选框后、应用信息前。
2. 尺寸:`40x40`
3. 样式:圆角矩形,视觉与商店应用卡片图标一致。
4. 图标位固定占位,避免有图和无图的项出现布局跳动。
### 失败回退
前端仅做一次失败回退:
1. 优先渲染 `item.icon`
2. 触发 `@error` 后切换为占位图。
3. 记录该项失败状态,避免反复向无效地址重新请求。
## 测试方案
### 主进程测试
新增或扩展测试覆盖:
1. 本地图标优先于远程 URL。
2. APM 更新项可解析包内 desktop/icons。
3. 传统 deb 更新项可解析 desktop `Icon=`
4. 无本地图标时能生成远程 URL 或返回空值。
### 组件测试
扩展 `UpdateCenterItem.vue` 组件测试:
1.`item.icon` 时渲染图片。
2. 图片加载失败时回退到占位图。
3. 图标存在时不影响当前状态标签、迁移标签、进度条显示。
## 风险与约束
1. 更新项当前不一定总能推断出 category,因此远程 URL 兜底对部分项可能不可用;这是可接受的,因为前端还有占位图兜底。
2. 本地图标解析涉及多个来源路径,必须限制在读取路径和拼接路径,不做额外昂贵的同步探测。
3. APM 图标路径依赖当前系统安装结构,若个别包结构不标准,应直接退回远程或占位图,而不是阻断更新列表。
## 决策总结
1. 更新中心增加单字段 `icon?: string`,不引入复杂图标对象。
2. 主进程解析图标来源,渲染层只负责展示和失败回退。
3. 图标来源顺序固定为:本地解析 > 远程 URL > 占位图。
4. UI 仅新增稳定图标位,不改变现有更新列表信息层级。
+4 -3
View File
@@ -31,7 +31,7 @@ export const tasks = new Map<number, InstallTask>();
let idle = true; // Indicates if the installation manager is idle let idle = true; // Indicates if the installation manager is idle
const checkSuperUserCommand = async (): Promise<string> => { export const checkSuperUserCommand = async (): Promise<string> => {
let superUserCmd = ""; let superUserCmd = "";
const execAsync = promisify(exec); const execAsync = promisify(exec);
if (process.getuid && process.getuid() !== 0) { if (process.getuid && process.getuid() !== 0) {
@@ -251,8 +251,9 @@ ipcMain.on("queue-install", async (event, download_json) => {
if (origin === "spark") { if (origin === "spark") {
// Spark Store logic // Spark Store logic
if (upgradeOnly) { if (upgradeOnly) {
execCommand = "pkexec"; execCommand = superUserCmd || SHELL_CALLER_PATH;
execParams.push("spark-update-tool", pkgname); if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
execParams.push("aptss", "install", "-y", pkgname, "--only-upgrade");
} else { } else {
execCommand = superUserCmd || SHELL_CALLER_PATH; execCommand = superUserCmd || SHELL_CALLER_PATH;
if (superUserCmd) execParams.push(SHELL_CALLER_PATH); if (superUserCmd) execParams.push(SHELL_CALLER_PATH);
@@ -0,0 +1,87 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { spawn } from "node:child_process";
import type { UpdateCenterItem } from "./types";
export interface Aria2DownloadResult {
filePath: string;
}
export interface RunAria2DownloadOptions {
item: UpdateCenterItem;
downloadDir: string;
onProgress?: (progress: number) => void;
onLog?: (message: string) => void;
signal?: AbortSignal;
}
const PROGRESS_PATTERN = /(\d{1,3}(?:\.\d+)?)%/;
export const runAria2Download = async ({
item,
downloadDir,
onProgress,
onLog,
signal,
}: RunAria2DownloadOptions): Promise<Aria2DownloadResult> => {
if (!item.downloadUrl || !item.fileName) {
throw new Error(`Missing download metadata for ${item.pkgname}`);
}
await mkdir(downloadDir, { recursive: true });
const filePath = join(downloadDir, item.fileName);
await new Promise<void>((resolve, reject) => {
const child = spawn("aria2c", [
"--dir",
downloadDir,
"--out",
item.fileName,
item.downloadUrl,
]);
const abortDownload = () => {
child.kill();
reject(new Error(`Update task cancelled: ${item.pkgname}`));
};
if (signal?.aborted) {
abortDownload();
return;
}
signal?.addEventListener("abort", abortDownload, { once: true });
const handleOutput = (chunk: Buffer) => {
const message = chunk.toString().trim();
if (!message) {
return;
}
onLog?.(message);
const progressMatch = message.match(PROGRESS_PATTERN);
if (progressMatch) {
onProgress?.(Number(progressMatch[1]));
}
};
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", reject);
child.on("close", (code) => {
signal?.removeEventListener("abort", abortDownload);
if (code === 0) {
resolve();
return;
}
reject(new Error(`aria2c exited with code ${code ?? -1}`));
});
});
onProgress?.(100);
return { filePath };
};
@@ -0,0 +1,211 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { UpdateCenterItem } from "./types";
const APM_BASE_PATH = "/var/lib/apm/apm/files/ace-env/var/lib/apm";
const REMOTE_ICON_BASE_URL = "https://erotica.spark-app.store";
const trimTrailingSlashes = (value: string): string =>
value.replace(/\/+$/, "");
const readDesktopIcon = (desktopPath: string): string => {
if (!fs.existsSync(desktopPath)) {
return "";
}
const content = fs.readFileSync(desktopPath, "utf-8");
const iconMatch = content.match(/^Icon=(.+)$/m);
return iconMatch?.[1]?.trim() ?? "";
};
const listPackageFiles = (pkgname: string): Set<string> => {
const result = spawnSync("dpkg", ["-L", pkgname]);
if (result.error || result.status !== 0) {
return new Set();
}
return new Set(
result.stdout
.toString()
.trim()
.split("\n")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
);
};
const findDesktopIconInDirectories = (
directories: string[],
pkgname: string,
): string => {
const packageFiles = listPackageFiles(pkgname);
for (const directory of directories) {
if (!fs.existsSync(directory)) {
continue;
}
for (const entry of fs.readdirSync(directory)) {
if (!entry.endsWith(".desktop")) {
continue;
}
const desktopPath = path.join(directory, entry);
if (
!desktopPath.startsWith(`/opt/apps/${pkgname}/`) &&
!packageFiles.has(desktopPath)
) {
continue;
}
const desktopIcon = readDesktopIcon(desktopPath);
if (!desktopIcon) {
continue;
}
const resolvedIcon = resolveIconName(desktopIcon, [
`/usr/share/pixmaps/${desktopIcon}.png`,
`/usr/share/icons/hicolor/48x48/apps/${desktopIcon}.png`,
`/usr/share/icons/hicolor/scalable/apps/${desktopIcon}.svg`,
`/opt/apps/${pkgname}/entries/icons/hicolor/48x48/apps/${desktopIcon}.png`,
]);
if (resolvedIcon) {
return resolvedIcon;
}
}
}
return "";
};
const resolveIconName = (iconName: string, candidates: string[]): string => {
if (path.isAbsolute(iconName)) {
return fs.existsSync(iconName) ? iconName : "";
}
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return "";
};
export const resolveDesktopIcon = (pkgname: string): string => {
return findDesktopIconInDirectories(
["/usr/share/applications", `/opt/apps/${pkgname}/entries/applications`],
pkgname,
);
};
export const resolveApmIcon = (pkgname: string): string => {
const apmRoots = [APM_BASE_PATH, "/opt/apps"];
for (const apmRoot of apmRoots) {
const desktopDirectory = path.join(
apmRoot,
pkgname,
"entries",
"applications",
);
if (!fs.existsSync(desktopDirectory)) {
continue;
}
for (const desktopFile of fs.readdirSync(desktopDirectory)) {
if (!desktopFile.endsWith(".desktop")) {
continue;
}
const desktopIcon = readDesktopIcon(
path.join(desktopDirectory, desktopFile),
);
if (!desktopIcon) {
continue;
}
const resolvedIcon = resolveIconName(desktopIcon, [
path.join(
apmRoot,
pkgname,
"entries",
"icons",
"hicolor",
"48x48",
"apps",
`${desktopIcon}.png`,
),
path.join(
apmRoot,
pkgname,
"entries",
"icons",
"hicolor",
"scalable",
"apps",
`${desktopIcon}.svg`,
),
`/usr/share/pixmaps/${desktopIcon}.png`,
`/usr/share/icons/hicolor/48x48/apps/${desktopIcon}.png`,
`/usr/share/icons/hicolor/scalable/apps/${desktopIcon}.svg`,
]);
if (resolvedIcon) {
return resolvedIcon;
}
}
}
return "";
};
export const buildRemoteFallbackIconUrl = ({
pkgname,
source,
arch,
category,
}: Pick<
UpdateCenterItem,
"pkgname" | "source" | "arch" | "category"
>): string => {
const baseUrl = trimTrailingSlashes(REMOTE_ICON_BASE_URL);
if (!baseUrl || !arch || !category) {
return "";
}
const storeArch = arch.includes("-")
? arch
: `${arch}-${source === "aptss" ? "store" : "apm"}`;
return `${baseUrl}/${storeArch}/${category}/${pkgname}/icon.png`;
};
export const resolveUpdateItemIcons = (
item: UpdateCenterItem,
): Pick<UpdateCenterItem, "localIcon" | "remoteIcon"> => {
const localIcon =
item.source === "aptss"
? resolveDesktopIcon(item.pkgname)
: resolveApmIcon(item.pkgname);
const remoteIcon = buildRemoteFallbackIconUrl({
pkgname: item.pkgname,
source: item.source,
arch: item.arch,
category: item.category,
});
if (localIcon && remoteIcon) {
return { localIcon, remoteIcon };
}
if (localIcon) {
return { localIcon };
}
if (remoteIcon) {
return { remoteIcon };
}
return {};
};
@@ -0,0 +1,79 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { UpdateCenterItem } from "./types";
export const LEGACY_IGNORE_CONFIG_PATH = "/etc/spark-store/ignored_apps.conf";
const LEGACY_IGNORE_SEPARATOR = "|";
export const createIgnoreKey = (pkgname: string, version: string): string =>
`${pkgname}${LEGACY_IGNORE_SEPARATOR}${version}`;
export const parseIgnoredEntries = (content: string): Set<string> => {
const ignoredEntries = new Set<string>();
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parts = trimmed.split(LEGACY_IGNORE_SEPARATOR);
if (parts.length !== 2) {
continue;
}
const [pkgname, version] = parts;
if (!pkgname || !version) {
continue;
}
ignoredEntries.add(createIgnoreKey(pkgname, version));
}
return ignoredEntries;
};
export const loadIgnoredEntries = async (
filePath: string,
): Promise<Set<string>> => {
try {
const content = await readFile(filePath, "utf8");
return parseIgnoredEntries(content);
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "ENOENT"
) {
return new Set<string>();
}
throw error;
}
};
export const saveIgnoredEntries = async (
filePath: string,
ignoredEntries: ReadonlySet<string>,
): Promise<void> => {
const sortedEntries = Array.from(ignoredEntries).sort();
const content =
sortedEntries.length > 0 ? `${sortedEntries.join("\n")}\n` : "";
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8");
};
export const applyIgnoredEntries = (
items: UpdateCenterItem[],
ignoredEntries: ReadonlySet<string>,
): UpdateCenterItem[] =>
items.map((item) => ({
...item,
ignored: ignoredEntries.has(
createIgnoreKey(item.pkgname, item.nextVersion),
),
}));
@@ -0,0 +1,377 @@
import { spawn } from "node:child_process";
import { BrowserWindow, ipcMain } from "electron";
import {
buildInstalledSourceMap,
mergeUpdateSources,
parseApmUpgradableOutput,
parseAptssUpgradableOutput,
parsePrintUrisOutput,
} from "./query";
import { resolveUpdateItemIcons } from "./icons";
import {
createUpdateCenterService,
type UpdateCenterIgnorePayload,
type UpdateCenterService,
} from "./service";
import type { UpdateCenterItem } from "./types";
export interface UpdateCenterCommandResult {
code: number;
stdout: string;
stderr: string;
}
export type UpdateCenterCommandRunner = (
command: string,
args: string[],
) => Promise<UpdateCenterCommandResult>;
export interface UpdateCenterLoadItemsResult {
items: UpdateCenterItem[];
warnings: string[];
}
type StoreCategoryMap = Map<string, string>;
interface RemoteCategoryAppEntry {
Pkgname?: string;
}
const REMOTE_STORE_BASE_URL = "https://erotica.spark-app.store";
const categoryCache = new Map<string, Promise<StoreCategoryMap>>();
const APTSS_LIST_UPGRADABLE_COMMAND = {
command: "bash",
args: [
"-lc",
"env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0",
],
};
const DPKG_QUERY_INSTALLED_COMMAND = {
command: "dpkg-query",
args: [
"-W",
"-f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n",
],
};
const getApmPrintUrisCommand = (pkgname: string) => ({
command: "bash",
args: [
"-lc",
`amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download ${pkgname} --print-uris`,
],
});
const runCommandCapture: UpdateCenterCommandRunner = async (
command,
args,
): Promise<UpdateCenterCommandResult> =>
await new Promise((resolve) => {
const child = spawn(command, args, {
shell: false,
env: process.env,
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("error", (error) => {
resolve({ code: -1, stdout, stderr: error.message });
});
child.on("close", (code) => {
resolve({ code: code ?? -1, stdout, stderr });
});
});
const getCommandError = (
label: string,
result: UpdateCenterCommandResult,
): string | null => {
if (result.code === 0) {
return null;
}
return `${label} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`;
};
const loadApmItemMetadata = async (
item: UpdateCenterItem,
runCommand: UpdateCenterCommandRunner,
): Promise<
| { item: UpdateCenterItem; warning?: undefined }
| { item: null; warning: string }
> => {
const printUrisCommand = getApmPrintUrisCommand(item.pkgname);
const metadataResult = await runCommand(
printUrisCommand.command,
printUrisCommand.args,
);
const commandError = getCommandError(
`apm metadata query for ${item.pkgname}`,
metadataResult,
);
if (commandError) {
return { item: null, warning: commandError };
}
const metadata = parsePrintUrisOutput(metadataResult.stdout);
if (!metadata) {
return {
item: null,
warning: `apm metadata query for ${item.pkgname} returned no package metadata`,
};
}
return {
item: {
...item,
...metadata,
},
};
};
const enrichApmItems = async (
items: UpdateCenterItem[],
runCommand: UpdateCenterCommandRunner,
): Promise<UpdateCenterLoadItemsResult> => {
const results = await Promise.all(
items.map((item) => loadApmItemMetadata(item, runCommand)),
);
return {
items: results.flatMap((result) => (result.item ? [result.item] : [])),
warnings: results.flatMap((result) =>
result.warning ? [result.warning] : [],
),
};
};
const getStoreArch = (
item: Pick<UpdateCenterItem, "source" | "arch">,
): string => {
const arch = item.arch;
if (!arch) {
return "";
}
if (arch.includes("-")) {
return arch;
}
return `${arch}-${item.source === "aptss" ? "store" : "apm"}`;
};
const loadJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed for ${url}`);
}
return (await response.json()) as T;
};
const loadStoreCategoryMap = async (
storeArch: string,
): Promise<StoreCategoryMap> => {
const categories = await loadJson<Record<string, unknown>>(
`${REMOTE_STORE_BASE_URL}/${storeArch}/categories.json`,
);
const categoryEntries = await Promise.allSettled(
Object.keys(categories).map(async (category) => {
const apps = await loadJson<RemoteCategoryAppEntry[]>(
`${REMOTE_STORE_BASE_URL}/${storeArch}/${category}/applist.json`,
);
return { apps, category };
}),
);
const categoryMap: StoreCategoryMap = new Map();
for (const entry of categoryEntries) {
if (entry.status !== "fulfilled") {
continue;
}
for (const app of entry.value.apps) {
if (app.Pkgname && !categoryMap.has(app.Pkgname)) {
categoryMap.set(app.Pkgname, entry.value.category);
}
}
}
return categoryMap;
};
const getStoreCategoryMap = (storeArch: string): Promise<StoreCategoryMap> => {
const cached = categoryCache.get(storeArch);
if (cached) {
return cached;
}
const pending = loadStoreCategoryMap(storeArch).catch(() => {
categoryCache.delete(storeArch);
return new Map();
});
categoryCache.set(storeArch, pending);
return pending;
};
const enrichItemCategories = async (
items: UpdateCenterItem[],
): Promise<UpdateCenterItem[]> => {
return await Promise.all(
items.map(async (item) => {
if (item.category) {
return item;
}
const storeArch = getStoreArch(item);
if (!storeArch) {
return item;
}
const categoryMap = await getStoreCategoryMap(storeArch);
const category = categoryMap.get(item.pkgname);
return category ? { ...item, category } : item;
}),
);
};
const enrichItemIcons = (items: UpdateCenterItem[]): UpdateCenterItem[] => {
return items.map((item) => {
const icons = resolveUpdateItemIcons(item);
return Object.keys(icons).length > 0 ? { ...item, ...icons } : item;
});
};
export const loadUpdateCenterItems = async (
runCommand: UpdateCenterCommandRunner = runCommandCapture,
): Promise<UpdateCenterLoadItemsResult> => {
const [aptssResult, apmResult, aptssInstalledResult, apmInstalledResult] =
await Promise.all([
runCommand(
APTSS_LIST_UPGRADABLE_COMMAND.command,
APTSS_LIST_UPGRADABLE_COMMAND.args,
),
runCommand("apm", ["list", "--upgradable"]),
runCommand(
DPKG_QUERY_INSTALLED_COMMAND.command,
DPKG_QUERY_INSTALLED_COMMAND.args,
),
runCommand("apm", ["list", "--installed"]),
]);
const warnings = [
getCommandError("aptss upgradable query", aptssResult),
getCommandError("apm upgradable query", apmResult),
getCommandError("dpkg installed query", aptssInstalledResult),
getCommandError("apm installed query", apmInstalledResult),
].filter((message): message is string => message !== null);
const aptssItems =
aptssResult.code === 0
? parseAptssUpgradableOutput(aptssResult.stdout)
: [];
const apmItems =
apmResult.code === 0 ? parseApmUpgradableOutput(apmResult.stdout) : [];
if (aptssResult.code !== 0 && apmResult.code !== 0) {
throw new Error(warnings.join("; "));
}
const installedSources = buildInstalledSourceMap(
aptssInstalledResult.code === 0 ? aptssInstalledResult.stdout : "",
apmInstalledResult.code === 0 ? apmInstalledResult.stdout : "",
);
const [categorizedAptssItems, categorizedApmItems] = await Promise.all([
enrichItemCategories(aptssItems),
enrichItemCategories(apmItems),
]);
const enrichedApmItems = await enrichApmItems(
categorizedApmItems,
runCommand,
);
return {
items: mergeUpdateSources(
enrichItemIcons(categorizedAptssItems),
enrichItemIcons(enrichedApmItems.items),
installedSources,
),
warnings: [...warnings, ...enrichedApmItems.warnings],
};
};
export const registerUpdateCenterIpc = (
ipc: Pick<typeof ipcMain, "handle">,
service: Pick<
UpdateCenterService,
| "open"
| "refresh"
| "ignore"
| "unignore"
| "start"
| "cancel"
| "getState"
| "subscribe"
>,
): void => {
ipc.handle("update-center-open", () => service.open());
ipc.handle("update-center-refresh", () => service.refresh());
ipc.handle(
"update-center-ignore",
(_event, payload: UpdateCenterIgnorePayload) => service.ignore(payload),
);
ipc.handle(
"update-center-unignore",
(_event, payload: UpdateCenterIgnorePayload) => service.unignore(payload),
);
ipc.handle("update-center-start", (_event, taskKeys: string[]) =>
service.start(taskKeys),
);
ipc.handle("update-center-cancel", (_event, taskKey: string) =>
service.cancel(taskKey),
);
ipc.handle("update-center-get-state", () => service.getState());
service.subscribe((snapshot) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send("update-center-state", snapshot);
}
});
};
let updateCenterService: UpdateCenterService | null = null;
export const initializeUpdateCenter = (): UpdateCenterService => {
if (updateCenterService) {
return updateCenterService;
}
const superUserCmdProvider = async (): Promise<string> => {
const installManager = await import("../install-manager.js");
return installManager.checkSuperUserCommand();
};
updateCenterService = createUpdateCenterService({
loadItems: loadUpdateCenterItems,
superUserCmdProvider,
});
registerUpdateCenterIpc(ipcMain, updateCenterService);
return updateCenterService;
};
export { createUpdateCenterService } from "./service";
@@ -0,0 +1,318 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
import { runAria2Download, type Aria2DownloadResult } from "./download";
import type { UpdateCenterQueue, UpdateCenterTask } from "./queue";
import type { UpdateCenterItem } from "./types";
const SHELL_CALLER_PATH = "/opt/spark-store/extras/shell-caller.sh";
const SSINSTALL_PATH = "/usr/bin/ssinstall";
const DEFAULT_DOWNLOAD_ROOT = "/tmp/spark-store/update-center";
export interface UpdateCommand {
execCommand: string;
execParams: string[];
}
export interface InstallUpdateItemOptions {
item: UpdateCenterItem;
filePath?: string;
superUserCmd?: string;
onLog?: (message: string) => void;
signal?: AbortSignal;
}
export interface TaskRunnerDownloadContext {
item: UpdateCenterItem;
task: UpdateCenterTask;
onProgress: (progress: number) => void;
onLog: (message: string) => void;
signal: AbortSignal;
}
export interface TaskRunnerInstallContext {
item: UpdateCenterItem;
task: UpdateCenterTask;
filePath?: string;
superUserCmd?: string;
onLog: (message: string) => void;
signal: AbortSignal;
}
export interface TaskRunnerDependencies {
runDownload?: (
context: TaskRunnerDownloadContext,
) => Promise<Aria2DownloadResult>;
installItem?: (context: TaskRunnerInstallContext) => Promise<void>;
}
export interface UpdateCenterTaskRunner {
runNextTask: () => Promise<UpdateCenterTask | null>;
cancelActiveTask: () => void;
}
export interface CreateTaskRunnerOptions extends TaskRunnerDependencies {
superUserCmd?: string;
}
const runCommand = async (
execCommand: string,
execParams: string[],
onLog?: (message: string) => void,
signal?: AbortSignal,
): Promise<void> => {
await new Promise<void>((resolve, reject) => {
const child = spawn(execCommand, execParams, {
shell: false,
env: process.env,
});
const handleOutput = (chunk: Buffer) => {
const message = chunk.toString().trim();
if (message) {
onLog?.(message);
}
};
const abortCommand = () => {
child.kill();
reject(new Error(`Update task cancelled: ${execParams.join(" ")}`));
};
if (signal?.aborted) {
abortCommand();
return;
}
signal?.addEventListener("abort", abortCommand, { once: true });
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", reject);
child.on("close", (code) => {
signal?.removeEventListener("abort", abortCommand);
if (code === 0) {
resolve();
return;
}
reject(new Error(`${execCommand} exited with code ${code ?? -1}`));
});
});
};
const buildPrivilegedCommand = (
command: string,
args: string[],
superUserCmd?: string,
): UpdateCommand => {
if (superUserCmd) {
return {
execCommand: superUserCmd,
execParams: [command, ...args],
};
}
return {
execCommand: command,
execParams: args,
};
};
export const buildLegacySparkUpgradeCommand = (
pkgname: string,
superUserCmd = "",
): UpdateCommand => {
if (superUserCmd) {
return {
execCommand: superUserCmd,
execParams: [
SHELL_CALLER_PATH,
"aptss",
"install",
"-y",
pkgname,
"--only-upgrade",
],
};
}
return {
execCommand: SHELL_CALLER_PATH,
execParams: ["aptss", "install", "-y", pkgname, "--only-upgrade"],
};
};
export const installUpdateItem = async ({
item,
filePath,
superUserCmd,
onLog,
signal,
}: InstallUpdateItemOptions): Promise<void> => {
if (item.source === "apm" && !filePath) {
throw new Error("APM update task requires downloaded package metadata");
}
if (item.source === "apm" && filePath) {
const installCommand = buildPrivilegedCommand(
SHELL_CALLER_PATH,
["apm", "ssinstall", filePath],
superUserCmd,
);
await runCommand(
installCommand.execCommand,
installCommand.execParams,
onLog,
signal,
);
return;
}
if (filePath) {
const installCommand = buildPrivilegedCommand(
SSINSTALL_PATH,
[filePath, "--delete-after-install"],
superUserCmd,
);
await runCommand(
installCommand.execCommand,
installCommand.execParams,
onLog,
signal,
);
return;
}
const command = buildLegacySparkUpgradeCommand(
item.pkgname,
superUserCmd ?? "",
);
await runCommand(command.execCommand, command.execParams, onLog, signal);
};
export const createTaskRunner = (
queue: UpdateCenterQueue,
options: CreateTaskRunnerOptions = {},
): UpdateCenterTaskRunner => {
const runDownload =
options.runDownload ??
((context: TaskRunnerDownloadContext) =>
runAria2Download({
item: context.item,
downloadDir: join(DEFAULT_DOWNLOAD_ROOT, context.item.pkgname),
onProgress: context.onProgress,
onLog: context.onLog,
signal: context.signal,
}));
const installItem =
options.installItem ??
((context: TaskRunnerInstallContext) =>
installUpdateItem({
item: context.item,
filePath: context.filePath,
superUserCmd: context.superUserCmd,
onLog: context.onLog,
signal: context.signal,
}));
let inFlightTask: Promise<UpdateCenterTask | null> | null = null;
let activeAbortController: AbortController | null = null;
let activeTaskId: number | null = null;
return {
cancelActiveTask: () => {
if (!activeAbortController || activeAbortController.signal.aborted) {
return;
}
activeAbortController.abort();
},
runNextTask: async () => {
if (inFlightTask) {
return null;
}
inFlightTask = (async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
activeTaskId = task.id;
activeAbortController = new AbortController();
const onLog = (message: string) => {
queue.appendTaskLog(task.id, message);
};
try {
let filePath: string | undefined;
if (
task.item.source === "apm" &&
(!task.item.downloadUrl || !task.item.fileName)
) {
throw new Error(
"APM update task requires downloaded package metadata",
);
}
if (task.item.downloadUrl && task.item.fileName) {
queue.markActiveTask(task.id, "downloading");
const result = await runDownload({
item: task.item,
task,
onLog,
signal: activeAbortController.signal,
onProgress: (progress) => {
queue.updateTaskProgress(task.id, progress);
},
});
filePath = result.filePath;
}
queue.markActiveTask(task.id, "installing");
await installItem({
item: task.item,
task,
filePath,
superUserCmd: options.superUserCmd,
onLog,
signal: activeAbortController.signal,
});
const currentTask = queue
.getSnapshot()
.tasks.find((entry) => entry.id === task.id);
if (currentTask?.status !== "cancelled") {
queue.finishTask(task.id, "completed");
}
return task;
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
const currentTask = queue
.getSnapshot()
.tasks.find((entry) => entry.id === task.id);
if (currentTask?.status !== "cancelled") {
queue.appendTaskLog(task.id, message);
queue.finishTask(task.id, "failed", message);
}
return task;
} finally {
activeAbortController = null;
activeTaskId = null;
}
})();
try {
return await inFlightTask;
} finally {
inFlightTask = null;
if (activeTaskId === null) {
activeAbortController = null;
}
}
},
};
};
@@ -0,0 +1,372 @@
import * as childProcess from "node:child_process";
import type {
InstalledSourceState,
UpdateCenterItem,
UpdateSource,
} from "./types";
const UPGRADABLE_PATTERN =
/^(\S+)\/\S+\s+([^\s]+)\s+\S+\s+\[(?:upgradable from|from):\s*([^\]]+)\]$/i;
const PRINT_URIS_PATTERN = /'([^']+)'\s+(\S+)\s+(\d+)\s+SHA512:([^\s]+)/;
const APM_INSTALLED_PATTERN = /^(\S+)\/\S+(?:,\S+)?\s+\S+\s+\S+\s+\[[^\]]+\]$/;
const splitVersion = (version: string) => {
const epochMatch = version.match(/^(\d+):(.*)$/);
const epoch = epochMatch ? Number(epochMatch[1]) : 0;
const remainder = epochMatch ? epochMatch[2] : version;
const hyphenIndex = remainder.lastIndexOf("-");
return {
epoch,
upstream: hyphenIndex === -1 ? remainder : remainder.slice(0, hyphenIndex),
revision: hyphenIndex === -1 ? "" : remainder.slice(hyphenIndex + 1),
};
};
const getNonDigitOrder = (char: string | undefined): number => {
if (char === "~") {
return -1;
}
if (!char) {
return 0;
}
if (/[A-Za-z]/.test(char)) {
return char.charCodeAt(0);
}
return char.charCodeAt(0) + 256;
};
const compareNonDigitPart = (left: string, right: string): number => {
let leftIndex = 0;
let rightIndex = 0;
while (true) {
const leftChar = left[leftIndex];
const rightChar = right[rightIndex];
const leftIsDigit = leftChar !== undefined && /\d/.test(leftChar);
const rightIsDigit = rightChar !== undefined && /\d/.test(rightChar);
if (
(leftChar === undefined || leftIsDigit) &&
(rightChar === undefined || rightIsDigit)
) {
return 0;
}
const leftOrder = getNonDigitOrder(leftIsDigit ? undefined : leftChar);
const rightOrder = getNonDigitOrder(rightIsDigit ? undefined : rightChar);
if (leftOrder !== rightOrder) {
return leftOrder < rightOrder ? -1 : 1;
}
if (!leftIsDigit && leftChar !== undefined) {
leftIndex += 1;
}
if (!rightIsDigit && rightChar !== undefined) {
rightIndex += 1;
}
}
};
const compareDigitPart = (left: string, right: string): number => {
const normalizedLeft = left.replace(/^0+/, "");
const normalizedRight = right.replace(/^0+/, "");
if (normalizedLeft.length !== normalizedRight.length) {
return normalizedLeft.length < normalizedRight.length ? -1 : 1;
}
if (normalizedLeft === normalizedRight) {
return 0;
}
return normalizedLeft < normalizedRight ? -1 : 1;
};
const compareVersionPart = (left: string, right: string): number => {
let leftIndex = 0;
let rightIndex = 0;
while (leftIndex < left.length || rightIndex < right.length) {
const nonDigitResult = compareNonDigitPart(
left.slice(leftIndex),
right.slice(rightIndex),
);
if (nonDigitResult !== 0) {
return nonDigitResult;
}
while (leftIndex < left.length && !/\d/.test(left[leftIndex])) {
leftIndex += 1;
}
while (rightIndex < right.length && !/\d/.test(right[rightIndex])) {
rightIndex += 1;
}
let leftDigitsEnd = leftIndex;
let rightDigitsEnd = rightIndex;
while (leftDigitsEnd < left.length && /\d/.test(left[leftDigitsEnd])) {
leftDigitsEnd += 1;
}
while (rightDigitsEnd < right.length && /\d/.test(right[rightDigitsEnd])) {
rightDigitsEnd += 1;
}
const digitResult = compareDigitPart(
left.slice(leftIndex, leftDigitsEnd),
right.slice(rightIndex, rightDigitsEnd),
);
if (digitResult !== 0) {
return digitResult;
}
leftIndex = leftDigitsEnd;
rightIndex = rightDigitsEnd;
}
return 0;
};
const fallbackCompareVersions = (left: string, right: string): number => {
const leftVersion = splitVersion(left);
const rightVersion = splitVersion(right);
if (leftVersion.epoch !== rightVersion.epoch) {
return leftVersion.epoch < rightVersion.epoch ? -1 : 1;
}
const upstreamResult = compareVersionPart(
leftVersion.upstream,
rightVersion.upstream,
);
if (upstreamResult !== 0) {
return upstreamResult;
}
return compareVersionPart(leftVersion.revision, rightVersion.revision);
};
const runDpkgVersionCheck = (
left: string,
operator: "gt" | "lt",
right: string,
): boolean | null => {
const result = childProcess.spawnSync("dpkg", [
"--compare-versions",
left,
operator,
right,
]);
if (result.error || typeof result.status !== "number") {
return null;
}
if (result.status === 0) {
return true;
}
if (result.status === 1) {
return false;
}
return null;
};
const parseUpgradableOutput = (
output: string,
source: UpdateSource,
): UpdateCenterItem[] => {
const items: UpdateCenterItem[] = [];
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("Listing")) {
continue;
}
const match = trimmed.match(UPGRADABLE_PATTERN);
if (!match) {
continue;
}
const [, pkgname, nextVersion, currentVersion] = match;
const arch = trimmed.split(/\s+/)[2];
if (!pkgname || nextVersion === currentVersion) {
continue;
}
items.push({
pkgname,
source,
currentVersion,
nextVersion,
arch,
});
}
return items;
};
const getInstalledState = (
installedSources: Map<string, InstalledSourceState>,
pkgname: string,
): InstalledSourceState => {
const existing = installedSources.get(pkgname);
if (existing) {
return existing;
}
const state: InstalledSourceState = { aptss: false, apm: false };
installedSources.set(pkgname, state);
return state;
};
const compareVersions = (left: string, right: string): number => {
const greaterThan = runDpkgVersionCheck(left, "gt", right);
if (greaterThan === true) {
return 1;
}
const lessThan = runDpkgVersionCheck(left, "lt", right);
if (lessThan === true) {
return -1;
}
if (greaterThan === false && lessThan === false) {
return 0;
}
// Fall back to a numeric-aware string comparison when dpkg is unavailable
// or returns an unusable result, rather than silently treating versions as equal.
return fallbackCompareVersions(left, right);
};
export const parseAptssUpgradableOutput = (
output: string,
): UpdateCenterItem[] => parseUpgradableOutput(output, "aptss");
export const parseApmUpgradableOutput = (output: string): UpdateCenterItem[] =>
parseUpgradableOutput(output, "apm");
export const parsePrintUrisOutput = (
output: string,
): Pick<
UpdateCenterItem,
"downloadUrl" | "fileName" | "size" | "sha512"
> | null => {
const match = output.trim().match(PRINT_URIS_PATTERN);
if (!match) {
return null;
}
const [, downloadUrl, fileName, size, sha512] = match;
return {
downloadUrl,
fileName,
size: Number(size),
sha512,
};
};
export const buildInstalledSourceMap = (
dpkgQueryOutput: string,
apmInstalledOutput: string,
): Map<string, InstalledSourceState> => {
const installedSources = new Map<string, InstalledSourceState>();
for (const line of dpkgQueryOutput.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const [pkgname, status] = trimmed.split("\t");
if (!pkgname || status !== "install ok installed") {
continue;
}
getInstalledState(installedSources, pkgname).aptss = true;
}
for (const line of apmInstalledOutput.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("Listing")) {
continue;
}
if (!APM_INSTALLED_PATTERN.test(trimmed)) {
continue;
}
const pkgname = trimmed.split("/")[0];
if (!pkgname) {
continue;
}
getInstalledState(installedSources, pkgname).apm = true;
}
return installedSources;
};
export const mergeUpdateSources = (
aptssItems: UpdateCenterItem[],
apmItems: UpdateCenterItem[],
installedSources: Map<string, InstalledSourceState>,
): UpdateCenterItem[] => {
const aptssMap = new Map(aptssItems.map((item) => [item.pkgname, item]));
const apmMap = new Map(apmItems.map((item) => [item.pkgname, item]));
const merged: UpdateCenterItem[] = [];
for (const item of aptssItems) {
if (!apmMap.has(item.pkgname)) {
merged.push(item);
}
}
for (const item of apmItems) {
if (!aptssMap.has(item.pkgname)) {
merged.push(item);
}
}
for (const aptssItem of aptssItems) {
const apmItem = apmMap.get(aptssItem.pkgname);
if (!apmItem) {
continue;
}
const installedState = installedSources.get(aptssItem.pkgname);
const isMigration =
installedState?.aptss === true &&
installedState.apm === false &&
compareVersions(apmItem.nextVersion, aptssItem.nextVersion) > 0;
if (isMigration) {
merged.push({
...apmItem,
isMigration: true,
migrationSource: "aptss",
migrationTarget: "apm",
aptssVersion: aptssItem.nextVersion,
});
merged.push(aptssItem);
continue;
}
merged.push(aptssItem, apmItem);
}
return merged;
};
@@ -0,0 +1,158 @@
import type { UpdateCenterItem } from "./types";
export type UpdateCenterTaskStatus =
| "queued"
| "downloading"
| "installing"
| "completed"
| "failed"
| "cancelled";
export interface UpdateCenterTaskLog {
time: number;
message: string;
}
export interface UpdateCenterTask {
id: number;
pkgname: string;
item: UpdateCenterItem;
status: UpdateCenterTaskStatus;
progress: number;
logs: UpdateCenterTaskLog[];
error?: string;
}
export interface UpdateCenterQueueSnapshot {
items: UpdateCenterItem[];
tasks: UpdateCenterTask[];
warnings: string[];
hasRunningTasks: boolean;
}
export interface UpdateCenterQueue {
setItems: (items: UpdateCenterItem[]) => void;
startRefresh: () => void;
finishRefresh: (warnings?: string[]) => void;
enqueueItem: (item: UpdateCenterItem) => UpdateCenterTask;
markActiveTask: (
taskId: number,
status: Extract<UpdateCenterTaskStatus, "downloading" | "installing">,
) => void;
updateTaskProgress: (taskId: number, progress: number) => void;
appendTaskLog: (taskId: number, message: string, time?: number) => void;
finishTask: (
taskId: number,
status: Extract<
UpdateCenterTaskStatus,
"completed" | "failed" | "cancelled"
>,
error?: string,
) => void;
getNextQueuedTask: () => UpdateCenterTask | undefined;
getSnapshot: () => UpdateCenterQueueSnapshot;
}
const clampProgress = (progress: number): number => {
if (!Number.isFinite(progress)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(progress)));
};
const createSnapshot = (
items: UpdateCenterItem[],
tasks: UpdateCenterTask[],
warnings: string[],
refreshing: boolean,
): UpdateCenterQueueSnapshot => ({
items: items.map((item) => ({ ...item })),
tasks: tasks.map((task) => ({
...task,
item: { ...task.item },
logs: task.logs.map((log) => ({ ...log })),
})),
warnings: [...warnings],
hasRunningTasks:
refreshing ||
tasks.some((task) =>
["queued", "downloading", "installing"].includes(task.status),
),
});
export const createUpdateCenterQueue = (): UpdateCenterQueue => {
let items: UpdateCenterItem[] = [];
let tasks: UpdateCenterTask[] = [];
let warnings: string[] = [];
let refreshing = false;
let nextTaskId = 1;
const getTask = (taskId: number): UpdateCenterTask | undefined =>
tasks.find((task) => task.id === taskId);
return {
setItems: (nextItems) => {
items = nextItems.map((item) => ({ ...item }));
},
startRefresh: () => {
refreshing = true;
},
finishRefresh: (nextWarnings = []) => {
refreshing = false;
warnings = [...nextWarnings];
},
enqueueItem: (item) => {
const task: UpdateCenterTask = {
id: nextTaskId,
pkgname: item.pkgname,
item: { ...item },
status: "queued",
progress: 0,
logs: [],
};
nextTaskId += 1;
tasks = [...tasks, task];
return task;
},
markActiveTask: (taskId, status) => {
const task = getTask(taskId);
if (!task) {
return;
}
task.status = status;
},
updateTaskProgress: (taskId, progress) => {
const task = getTask(taskId);
if (!task) {
return;
}
task.progress = clampProgress(progress);
},
appendTaskLog: (taskId, message, time = Date.now()) => {
const task = getTask(taskId);
if (!task) {
return;
}
task.logs = [...task.logs, { time, message }];
},
finishTask: (taskId, status, error) => {
const task = getTask(taskId);
if (!task) {
return;
}
task.status = status;
task.error = error;
if (status === "completed") {
task.progress = 100;
}
},
getNextQueuedTask: () => tasks.find((task) => task.status === "queued"),
getSnapshot: () => createSnapshot(items, tasks, warnings, refreshing),
};
};
@@ -0,0 +1,302 @@
import {
LEGACY_IGNORE_CONFIG_PATH,
applyIgnoredEntries,
createIgnoreKey,
loadIgnoredEntries,
saveIgnoredEntries,
} from "./ignore-config";
import { createTaskRunner, type UpdateCenterTaskRunner } from "./install";
import {
createUpdateCenterQueue,
type UpdateCenterQueue,
type UpdateCenterQueueSnapshot,
} from "./queue";
import type { UpdateCenterItem, UpdateSource } from "./types";
export interface UpdateCenterLoadedItems {
items: UpdateCenterItem[];
warnings: string[];
}
export interface UpdateCenterServiceItem {
taskKey: string;
packageName: string;
displayName: string;
currentVersion: string;
newVersion: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
export interface UpdateCenterServiceTask {
taskKey: string;
packageName: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
status: UpdateCenterQueueSnapshot["tasks"][number]["status"];
progress: number;
logs: UpdateCenterQueueSnapshot["tasks"][number]["logs"];
errorMessage: string;
}
export interface UpdateCenterServiceState {
items: UpdateCenterServiceItem[];
tasks: UpdateCenterServiceTask[];
warnings: string[];
hasRunningTasks: boolean;
}
export interface UpdateCenterIgnorePayload {
packageName: string;
newVersion: string;
}
export interface UpdateCenterService {
open: () => Promise<UpdateCenterServiceState>;
refresh: () => Promise<UpdateCenterServiceState>;
ignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
unignore: (payload: UpdateCenterIgnorePayload) => Promise<void>;
start: (taskKeys: string[]) => Promise<void>;
cancel: (taskKey: string) => Promise<void>;
getState: () => UpdateCenterServiceState;
subscribe: (
listener: (snapshot: UpdateCenterServiceState) => void,
) => () => void;
}
export interface CreateUpdateCenterServiceOptions {
loadItems: () => Promise<UpdateCenterItem[] | UpdateCenterLoadedItems>;
loadIgnoredEntries?: () => Promise<Set<string>>;
saveIgnoredEntries?: (entries: ReadonlySet<string>) => Promise<void>;
createTaskRunner?: (
queue: UpdateCenterQueue,
superUserCmd?: string,
) => UpdateCenterTaskRunner;
superUserCmdProvider?: () => Promise<string>;
}
const getTaskKey = (
item: Pick<UpdateCenterItem, "pkgname" | "source">,
): string => `${item.source}:${item.pkgname}`;
const toState = (
snapshot: UpdateCenterQueueSnapshot,
): UpdateCenterServiceState => ({
items: snapshot.items.map((item) => ({
taskKey: getTaskKey(item),
packageName: item.pkgname,
displayName: item.pkgname,
currentVersion: item.currentVersion,
newVersion: item.nextVersion,
source: item.source,
localIcon: item.localIcon,
remoteIcon: item.remoteIcon,
ignored: item.ignored,
downloadUrl: item.downloadUrl,
fileName: item.fileName,
size: item.size,
sha512: item.sha512,
isMigration: item.isMigration,
migrationSource: item.migrationSource,
migrationTarget: item.migrationTarget,
aptssVersion: item.aptssVersion,
})),
tasks: snapshot.tasks.map((task) => ({
taskKey: getTaskKey(task.item),
packageName: task.pkgname,
source: task.item.source,
localIcon: task.item.localIcon,
remoteIcon: task.item.remoteIcon,
status: task.status,
progress: task.progress,
logs: task.logs.map((log) => ({ ...log })),
errorMessage: task.error ?? "",
})),
warnings: [...snapshot.warnings],
hasRunningTasks: snapshot.hasRunningTasks,
});
const normalizeLoadedItems = (
loaded: UpdateCenterItem[] | UpdateCenterLoadedItems,
): UpdateCenterLoadedItems => {
if (Array.isArray(loaded)) {
return { items: loaded, warnings: [] };
}
return {
items: loaded.items,
warnings: loaded.warnings,
};
};
export const createUpdateCenterService = (
options: CreateUpdateCenterServiceOptions,
): UpdateCenterService => {
const queue = createUpdateCenterQueue();
const listeners = new Set<(snapshot: UpdateCenterServiceState) => void>();
const loadIgnored =
options.loadIgnoredEntries ??
(() => loadIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH));
const saveIgnored =
options.saveIgnoredEntries ??
((entries: ReadonlySet<string>) =>
saveIgnoredEntries(LEGACY_IGNORE_CONFIG_PATH, entries));
const createRunner =
options.createTaskRunner ??
((currentQueue: UpdateCenterQueue, superUserCmd?: string) =>
createTaskRunner(currentQueue, { superUserCmd }));
let processingPromise: Promise<void> | null = null;
let activeRunner: UpdateCenterTaskRunner | null = null;
const applyWarning = (message: string): void => {
queue.finishRefresh([message]);
};
const getState = (): UpdateCenterServiceState => toState(queue.getSnapshot());
const emit = (): UpdateCenterServiceState => {
const snapshot = getState();
for (const listener of listeners) {
listener(snapshot);
}
return snapshot;
};
const refresh = async (): Promise<UpdateCenterServiceState> => {
queue.startRefresh();
emit();
try {
const ignoredEntries = await loadIgnored();
const loadedItems = normalizeLoadedItems(await options.loadItems());
const items = applyIgnoredEntries(loadedItems.items, ignoredEntries);
queue.setItems(items);
queue.finishRefresh(loadedItems.warnings);
return emit();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
queue.setItems([]);
applyWarning(message);
return emit();
}
};
const failQueuedTasks = (message: string): void => {
for (const task of queue.getSnapshot().tasks) {
if (task.status === "queued") {
queue.appendTaskLog(task.id, message);
queue.finishTask(task.id, "failed", message);
}
}
};
const ensureProcessing = async (): Promise<void> => {
if (processingPromise) {
return processingPromise;
}
processingPromise = (async () => {
let superUserCmd = "";
try {
superUserCmd = (await options.superUserCmdProvider?.()) ?? "";
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failQueuedTasks(message);
applyWarning(message);
emit();
return;
}
activeRunner = createRunner(queue, superUserCmd);
while (queue.getNextQueuedTask()) {
await activeRunner.runNextTask();
emit();
}
})().finally(() => {
processingPromise = null;
activeRunner = null;
});
return processingPromise;
};
return {
open: refresh,
refresh,
async ignore(payload) {
const entries = await loadIgnored();
entries.add(createIgnoreKey(payload.packageName, payload.newVersion));
await saveIgnored(entries);
await refresh();
},
async unignore(payload) {
const entries = await loadIgnored();
entries.delete(createIgnoreKey(payload.packageName, payload.newVersion));
await saveIgnored(entries);
await refresh();
},
async start(taskKeys) {
const snapshot = queue.getSnapshot();
const existingTaskKeys = new Set(
snapshot.tasks
.filter(
(task) =>
!["completed", "failed", "cancelled"].includes(task.status),
)
.map((task) => getTaskKey(task.item)),
);
const selectedItems = snapshot.items.filter(
(item) =>
taskKeys.includes(getTaskKey(item)) &&
!item.ignored &&
!existingTaskKeys.has(getTaskKey(item)),
);
if (selectedItems.length === 0) {
return;
}
for (const item of selectedItems) {
queue.enqueueItem(item);
}
emit();
await ensureProcessing();
},
async cancel(taskKey) {
const task = queue
.getSnapshot()
.tasks.find((entry) => getTaskKey(entry.item) === taskKey);
if (!task) {
return;
}
queue.finishTask(task.id, "cancelled", "Cancelled");
if (["downloading", "installing"].includes(task.status)) {
activeRunner?.cancelActiveTask();
}
emit();
},
getState,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
};
@@ -0,0 +1,26 @@
export type UpdateSource = "aptss" | "apm";
export interface InstalledSourceState {
aptss: boolean;
apm: boolean;
}
export interface UpdateCenterItem {
pkgname: string;
source: UpdateSource;
currentVersion: string;
nextVersion: string;
arch?: string;
category?: string;
localIcon?: string;
remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
+52 -29
View File
@@ -18,6 +18,11 @@ import { handleCommandLine } from "./deeplink.js";
import { isLoaded } from "../global.js"; import { isLoaded } from "../global.js";
import { tasks } from "./backend/install-manager.js"; import { tasks } from "./backend/install-manager.js";
import { sendTelemetryOnce } from "./backend/telemetry.js"; import { sendTelemetryOnce } from "./backend/telemetry.js";
import { initializeUpdateCenter } from "./backend/update-center/index.js";
import {
getMainWindowCloseAction,
type MainWindowCloseGuardState,
} from "./window-close-guard.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname, "../.."); process.env.APP_ROOT = path.join(__dirname, "../..");
@@ -81,6 +86,7 @@ if (!app.requestSingleInstanceLock()) {
} }
let win: BrowserWindow | null = null; let win: BrowserWindow | null = null;
let allowAppExit = false;
const preload = path.join(__dirname, "../preload/index.mjs"); const preload = path.join(__dirname, "../preload/index.mjs");
const indexHtml = path.join(RENDERER_DIST, "index.html"); const indexHtml = path.join(RENDERER_DIST, "index.html");
@@ -107,6 +113,44 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
ipcMain.handle("get-app-version", (): string => getAppVersion()); ipcMain.handle("get-app-version", (): string => getAppVersion());
const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({
installTaskCount: tasks.size,
hasRunningUpdateCenterTasks:
initializeUpdateCenter().getState().hasRunningTasks,
});
const applyMainWindowCloseAction = (): void => {
if (!win) {
return;
}
const action = getMainWindowCloseAction(getMainWindowCloseGuardState());
if (action === "hide") {
win.hide();
win.setSkipTaskbar(true);
return;
}
win.destroy();
};
const requestApplicationExit = (): void => {
if (!win) {
allowAppExit = true;
app.quit();
return;
}
if (getMainWindowCloseAction(getMainWindowCloseGuardState()) === "hide") {
win.hide();
win.setSkipTaskbar(true);
return;
}
allowAppExit = true;
app.quit();
};
async function createWindow() { async function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
title: "星火应用商店", title: "星火应用商店",
@@ -148,16 +192,13 @@ async function createWindow() {
// win.webContents.on('will-navigate', (event, url) => { }) #344 // win.webContents.on('will-navigate', (event, url) => { }) #344
win.on("close", (event) => { win.on("close", (event) => {
if (allowAppExit) {
return;
}
// 截获 close 默认行为 // 截获 close 默认行为
event.preventDefault(); event.preventDefault();
// 点击关闭时触发close事件,我们按照之前的思路在关闭时,隐藏窗口,隐藏任务栏窗口 applyMainWindowCloseAction();
if (tasks.size > 0) {
win.hide();
win.setSkipTaskbar(true);
} else {
// 如果没有下载任务,才允许关闭窗口
win.destroy();
}
}); });
} }
@@ -173,26 +214,6 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => {
nativeTheme.themeSource = theme; nativeTheme.themeSource = theme;
}); });
// 启动系统更新工具(使用 pkexec 提升权限)
ipcMain.handle("run-update-tool", async () => {
try {
const { spawn } = await import("node:child_process");
const pkexecPath = "/usr/bin/pkexec";
const args = ["spark-update-tool"];
const child = spawn(pkexecPath, args, {
detached: true,
stdio: "ignore",
});
// 让子进程在后台运行且不影响主进程退出
child.unref();
logger.info("Launched pkexec spark-update-tool");
return { success: true };
} catch (err) {
logger.error({ err }, "Failed to launch spark-update-tool");
return { success: false, message: (err as Error)?.message || String(err) };
}
});
// 启动安装设置脚本(可能需要提升权限) // 启动安装设置脚本(可能需要提升权限)
ipcMain.handle("open-install-settings", async () => { ipcMain.handle("open-install-settings", async () => {
try { try {
@@ -220,12 +241,14 @@ app.whenReady().then(() => {
}); });
createWindow(); createWindow();
handleCommandLine(process.argv); handleCommandLine(process.argv);
initializeUpdateCenter();
// 启动后执行一次遥测(仅 Linux,不阻塞) // 启动后执行一次遥测(仅 Linux,不阻塞)
sendTelemetryOnce(getAppVersion()); sendTelemetryOnce(getAppVersion());
}); });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
win = null; win = null;
allowAppExit = false;
if (process.platform !== "darwin") app.quit(); if (process.platform !== "darwin") app.quit();
}); });
@@ -302,7 +325,7 @@ app.whenReady().then(() => {
{ {
label: "退出程序", label: "退出程序",
click: () => { click: () => {
win.destroy(); requestApplicationExit();
}, },
}, },
]); ]);
+15
View File
@@ -0,0 +1,15 @@
export interface MainWindowCloseGuardState {
installTaskCount: number;
hasRunningUpdateCenterTasks: boolean;
}
export type MainWindowCloseAction = "hide" | "destroy";
export const shouldPreventMainWindowClose = (
state: MainWindowCloseGuardState,
): boolean => state.installTaskCount > 0 || state.hasRunningUpdateCenterTasks;
export const getMainWindowCloseAction = (
state: MainWindowCloseGuardState,
): MainWindowCloseAction =>
shouldPreventMainWindowClose(state) ? "hide" : "destroy";
+89 -2
View File
@@ -1,4 +1,51 @@
import { ipcRenderer, contextBridge } from "electron"; import { ipcRenderer, contextBridge, type IpcRendererEvent } from "electron";
type UpdateCenterSnapshot = {
items: Array<{
taskKey: string;
packageName: string;
displayName: string;
currentVersion: string;
newVersion: string;
source: "aptss" | "apm";
localIcon?: string;
remoteIcon?: string;
ignored?: boolean;
}>;
tasks: Array<{
taskKey: string;
packageName: string;
source: "aptss" | "apm";
localIcon?: string;
remoteIcon?: string;
status:
| "queued"
| "downloading"
| "installing"
| "completed"
| "failed"
| "cancelled";
progress: number;
logs: Array<{ time: number; message: string }>;
errorMessage: string;
}>;
warnings: string[];
hasRunningTasks: boolean;
};
type IpcRendererFacade = {
on: typeof ipcRenderer.on;
off: typeof ipcRenderer.off;
send: typeof ipcRenderer.send;
invoke: typeof ipcRenderer.invoke;
};
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
const updateCenterStateListeners = new Map<
UpdateCenterStateListener,
(_event: IpcRendererEvent, snapshot: UpdateCenterSnapshot) => void
>();
// --------- Expose some API to the Renderer process --------- // --------- Expose some API to the Renderer process ---------
contextBridge.exposeInMainWorld("ipcRenderer", { contextBridge.exposeInMainWorld("ipcRenderer", {
@@ -23,7 +70,7 @@ contextBridge.exposeInMainWorld("ipcRenderer", {
// You can expose other APTs you need here. // You can expose other APTs you need here.
// ... // ...
}); } satisfies IpcRendererFacade);
contextBridge.exposeInMainWorld("apm_store", { contextBridge.exposeInMainWorld("apm_store", {
arch: (() => { arch: (() => {
@@ -38,6 +85,46 @@ contextBridge.exposeInMainWorld("apm_store", {
})(), })(),
}); });
contextBridge.exposeInMainWorld("updateCenter", {
open: (): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-open"),
refresh: (): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-refresh"),
ignore: (payload: {
packageName: string;
newVersion: string;
}): Promise<void> => ipcRenderer.invoke("update-center-ignore", payload),
unignore: (payload: {
packageName: string;
newVersion: string;
}): Promise<void> => ipcRenderer.invoke("update-center-unignore", payload),
start: (taskKeys: string[]): Promise<void> =>
ipcRenderer.invoke("update-center-start", taskKeys),
cancel: (taskKey: string): Promise<void> =>
ipcRenderer.invoke("update-center-cancel", taskKey),
getState: (): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-get-state"),
onState: (listener: UpdateCenterStateListener): void => {
const wrapped = (
_event: IpcRendererEvent,
snapshot: UpdateCenterSnapshot,
) => {
listener(snapshot);
};
updateCenterStateListeners.set(listener, wrapped);
ipcRenderer.on("update-center-state", wrapped);
},
offState: (listener: UpdateCenterStateListener): void => {
const wrapped = updateCenterStateListeners.get(listener);
if (!wrapped) {
return;
}
ipcRenderer.off("update-center-state", wrapped);
updateCenterStateListeners.delete(listener);
},
});
// --------- Preload scripts loading --------- // --------- Preload scripts loading ---------
function domReady( function domReady(
condition: DocumentReadyState[] = ["complete", "interactive"], condition: DocumentReadyState[] = ["complete", "interactive"],
+1 -1
View File
@@ -146,7 +146,7 @@ case "$command_type" in
fi fi
# 2) 执行安装(带进度条) # 2) 执行安装(带进度条)
echo "正在安装软件包..." echo "正在安装软件包..."
if ! run_with_progress "安装软件包" "正在安装: $packages,请稍候..." "/usr/bin/aptss ${@:2} -y"; then if ! run_with_progress "安装软件包" "正在安装: $packages,请稍候..." "/usr/bin/aptss install $packages -y"; then
echo "错误:软件包安装失败。" echo "错误:软件包安装失败。"
exit 1 exit 1
fi fi
+2 -2
View File
@@ -330,7 +330,7 @@ void MainWindow::runAptssUpgrade()
} }
if (!process.waitForStarted(5000)) { if (!process.waitForStarted(5000)) {
QMessageBox::warning(this, "升级失败", "无法启动 aptss ssupdate"); qDebug() << "无法启动 aptss ssupdate";
return; return;
} }
process.write("n\n"); process.write("n\n");
@@ -344,7 +344,7 @@ void MainWindow::runAptssUpgrade()
} }
if (process.exitCode() != 0) { if (process.exitCode() != 0) {
QMessageBox::warning(this, "升级失败", "执行 aptss ssupdate 失败,请检查系统环境或稍后再试。"); qDebug() << "执行 aptss ssupdate 失败,请检查系统环境或稍后再试。";
} }
} else { } else {
qDebug() << "aptss命令不存在,跳过aptss ssupdate"; qDebug() << "aptss命令不存在,跳过aptss ssupdate";
+58 -118
View File
@@ -60,6 +60,7 @@
<AppGrid <AppGrid
:apps="filteredApps" :apps="filteredApps"
:loading="loading" :loading="loading"
:scroll-key="activeCategory"
:store-filter="storeFilter" :store-filter="storeFilter"
@open-detail="openDetail" @open-detail="openDetail"
/> />
@@ -119,23 +120,25 @@
:error="installedError" :error="installedError"
:active-origin="activeInstalledOrigin" :active-origin="activeInstalledOrigin"
:store-filter="storeFilter" :store-filter="storeFilter"
:apm-available="apmAvailable"
@close="closeInstalledModal" @close="closeInstalledModal"
@refresh="refreshInstalledApps" @refresh="refreshInstalledApps"
@uninstall="uninstallInstalledApp" @uninstall="uninstallInstalledApp"
@switch-origin="handleSwitchOrigin" @switch-origin="handleSwitchOrigin"
/> />
<UpdateAppsModal <UpdateCenterModal
:show="showUpdateModal" :show="updateCenterStore.isOpen.value"
:apps="upgradableApps" :store="updateCenterStore"
:loading="updateLoading" @update:search-query="updateCenterStore.searchQuery.value = $event"
:error="updateError" @toggle-selection="updateCenterStore.toggleSelection"
:has-selected="hasSelectedUpgrades" @request-start-selected="handleStartSelectedUpdates"
@close="closeUpdateModal" @confirm-migration-start="confirmMigrationStart"
@refresh="refreshUpgradableApps" @dismiss-migration-confirm="
@toggle-all="toggleAllUpgrades" updateCenterStore.showMigrationConfirm.value = false
@upgrade-selected="upgradeSelectedApps" "
@upgrade-one="upgradeSingleApp" @confirm-close="updateCenterStore.closeNow()"
@dismiss-close-confirm="updateCenterStore.showCloseConfirm.value = false"
/> />
<UninstallConfirmModal <UninstallConfirmModal
@@ -150,7 +153,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from "vue"; import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import axios from "axios"; import axios from "axios";
import pino from "pino"; import pino from "pino";
import AppSidebar from "./components/AppSidebar.vue"; import AppSidebar from "./components/AppSidebar.vue";
@@ -162,7 +165,7 @@ import ScreenPreview from "./components/ScreenPreview.vue";
import DownloadQueue from "./components/DownloadQueue.vue"; import DownloadQueue from "./components/DownloadQueue.vue";
import DownloadDetail from "./components/DownloadDetail.vue"; import DownloadDetail from "./components/DownloadDetail.vue";
import InstalledAppsModal from "./components/InstalledAppsModal.vue"; import InstalledAppsModal from "./components/InstalledAppsModal.vue";
import UpdateAppsModal from "./components/UpdateAppsModal.vue"; import UpdateCenterModal from "./components/UpdateCenterModal.vue";
import UninstallConfirmModal from "./components/UninstallConfirmModal.vue"; import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
import AboutModal from "./components/AboutModal.vue"; import AboutModal from "./components/AboutModal.vue";
import { import {
@@ -179,20 +182,17 @@ import {
removeDownloadItem, removeDownloadItem,
watchDownloadsChange, watchDownloadsChange,
} from "./global/downloadStatus"; } from "./global/downloadStatus";
import { import { handleInstall, handleRetry } from "./modules/processInstall";
handleInstall, import { createUpdateCenterStore } from "./modules/updateCenter";
handleRetry,
handleUpgrade,
} from "./modules/processInstall";
import type { import type {
App, App,
AppJson, AppJson,
DownloadItem, DownloadItem,
UpdateAppItem,
ChannelPayload, ChannelPayload,
CategoryInfo, CategoryInfo,
HomeLink, HomeLink,
HomeList, HomeList,
UpdateCenterItem,
} from "./global/typedefinition"; } from "./global/typedefinition";
import type { Ref } from "vue"; import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron"; import type { IpcRendererEvent } from "electron";
@@ -250,12 +250,7 @@ const activeInstalledOrigin = ref<"apm" | "spark">("apm");
const installedApps = ref<App[]>([]); const installedApps = ref<App[]>([]);
const installedLoading = ref(false); const installedLoading = ref(false);
const installedError = ref(""); const installedError = ref("");
const showUpdateModal = ref(false); const updateCenterStore = createUpdateCenterStore();
const upgradableApps = ref<(App & { selected: boolean; upgrading: boolean })[]>(
[],
);
const updateLoading = ref(false);
const updateError = ref("");
const showUninstallModal = ref(false); const showUninstallModal = ref(false);
const uninstallTargetApp: Ref<App | null> = ref(null); const uninstallTargetApp: Ref<App | null> = ref(null);
const showAboutModal = ref(false); const showAboutModal = ref(false);
@@ -344,10 +339,6 @@ const categoryCounts = computed(() => {
return counts; return counts;
}); });
const hasSelectedUpgrades = computed(() => {
return upgradableApps.value.some((app) => app.selected);
});
// 方法 // 方法
const syncThemePreference = () => { const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value); document.documentElement.classList.toggle("dark", isDarkTheme.value);
@@ -567,7 +558,9 @@ const openDetail = async (app: App | Record<string, unknown>) => {
finalApp.viewingOrigin = "apm"; finalApp.viewingOrigin = "apm";
} else { } else {
// 若都安装或都未安装,根据优先级配置决定默认展示 // 若都安装或都未安装,根据优先级配置决定默认展示
finalApp.viewingOrigin = getHybridDefaultOrigin(finalApp.sparkApp || finalApp); finalApp.viewingOrigin = getHybridDefaultOrigin(
finalApp.sparkApp || finalApp,
);
} }
} }
@@ -768,14 +761,7 @@ const nextScreen = () => {
}; };
const handleUpdate = async () => { const handleUpdate = async () => {
try { await openUpdateModal();
const result = await window.ipcRenderer.invoke("run-update-tool");
if (!result || !result.success) {
logger.warn(`启动更新工具失败: ${result?.message || "未知错误"}`);
}
} catch (error) {
logger.error(`调用更新工具时出错: ${error}`);
}
}; };
const handleOpenInstallSettings = async () => { const handleOpenInstallSettings = async () => {
@@ -793,98 +779,43 @@ const handleList = () => {
openInstalledModal(); openInstalledModal();
}; };
const openUpdateModal = () => { const openUpdateModal = async () => {
showUpdateModal.value = true;
refreshUpgradableApps();
};
const closeUpdateModal = () => {
showUpdateModal.value = false;
};
const refreshUpgradableApps = async () => {
updateLoading.value = true;
updateError.value = "";
try { try {
const result = await window.ipcRenderer.invoke("list-upgradable"); await updateCenterStore.open();
if (!result?.success) { } catch (error) {
upgradableApps.value = []; logger.error(`打开更新中心失败: ${error}`);
updateError.value = result?.message || "检查更新失败";
return;
}
upgradableApps.value = (result.apps || []).map(
(app: Record<string, string>) => ({
...app,
// Map properties if needed or assume main matches App interface except field names might differ
// For now assuming result.apps returns objects compatible with App for core fields,
// but let's normalize just in case if main returns different structure.
name: app.name || app.Name || "",
pkgname: app.pkgname || app.Pkgname || "",
version: app.newVersion || app.version || "",
category: app.category || "unknown",
selected: false,
upgrading: false,
}),
);
} catch (error: unknown) {
upgradableApps.value = [];
updateError.value = (error as Error)?.message || "检查更新失败";
} finally {
updateLoading.value = false;
} }
}; };
const toggleAllUpgrades = () => { const hasMigrationSelection = (items: UpdateCenterItem[]): boolean => {
const shouldSelectAll = return items.some((item) => item.isMigration === true);
!hasSelectedUpgrades.value ||
upgradableApps.value.some((app) => !app.selected);
upgradableApps.value = upgradableApps.value.map((app) => ({
...app,
selected: shouldSelectAll ? true : false,
}));
}; };
const upgradeSingleApp = async (app: UpdateAppItem) => { const handleStartSelectedUpdates = async () => {
if (!app?.pkgname) return; const selectedItems = updateCenterStore.getSelectedItems();
const target = apps.value.find((a) => a.pkgname === app.pkgname); if (selectedItems.length === 0) {
if (target) { return;
await handleUpgrade(target);
} else {
// If we can't find it in the list (e.g. category not loaded?), use the info we have
// But handleUpgrade expects App. Let's try to construct minimal App
let minimalApp: App = {
name: app.pkgname,
pkgname: app.pkgname,
version: app.newVersion || "",
category: "unknown",
tags: "",
more: "",
filename: "",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
img_urls: [],
icons: "",
origin: "apm", // Default to APM if unknown, or try to guess
currentStatus: "installed",
};
await handleUpgrade(minimalApp);
} }
if (hasMigrationSelection(selectedItems)) {
updateCenterStore.showMigrationConfirm.value = true;
return;
}
await updateCenterStore.startSelected();
}; };
const upgradeSelectedApps = async () => { const confirmMigrationStart = async () => {
const selectedApps = upgradableApps.value.filter((app) => app.selected); updateCenterStore.showMigrationConfirm.value = false;
for (const app of selectedApps) { await updateCenterStore.startSelected();
await upgradeSingleApp(app);
}
}; };
const openInstalledModal = () => { const openInstalledModal = () => {
showInstalledModal.value = true; showInstalledModal.value = true;
// 如果没有 APM 可用,默认切换到 Spark 应用管理
if (!apmAvailable.value && activeInstalledOrigin.value === "apm") {
activeInstalledOrigin.value = "spark";
}
refreshInstalledApps(); refreshInstalledApps();
}; };
@@ -1213,12 +1144,16 @@ const handleSearchInput = (value: string) => {
}; };
const handleSearchFocus = () => { const handleSearchFocus = () => {
if (activeCategory.value === "home") activeCategory.value = "all"; if (activeCategory.value === "home") {
activeCategory.value = "all";
window.scrollTo({ top: 0, behavior: "smooth" });
}
}; };
// 生命周期钩子 // 生命周期钩子
onMounted(async () => { onMounted(async () => {
initTheme(); initTheme();
updateCenterStore.bind();
// 从主进程获取启动参数(--no-apm / --no-spark),再加载数据 // 从主进程获取启动参数(--no-apm / --no-spark),再加载数据
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter"); storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
@@ -1320,6 +1255,7 @@ onMounted(async () => {
const tryOpen = () => { const tryOpen = () => {
// 先切换到"全部应用"分类 // 先切换到"全部应用"分类
activeCategory.value = "all"; activeCategory.value = "all";
window.scrollTo({ top: 0, behavior: "smooth" });
// 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息 // 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息
const target = apps.value.find((a) => a.pkgname === data.pkgname); const target = apps.value.find((a) => a.pkgname === data.pkgname);
if (target) { if (target) {
@@ -1360,6 +1296,10 @@ onMounted(async () => {
logger.info("Renderer process is ready!"); logger.info("Renderer process is ready!");
}); });
onUnmounted(() => {
updateCenterStore.unbind();
});
// 观察器 // 观察器
watch(themeMode, (newVal) => { watch(themeMode, (newVal) => {
localStorage.setItem("theme", newVal); localStorage.setItem("theme", newVal);
+101
View File
@@ -0,0 +1,101 @@
import { render } from "@testing-library/vue";
import { defineComponent, h, nextTick } from "vue";
import { describe, expect, it, vi } from "vitest";
import AppGrid from "@/components/AppGrid.vue";
import type { App } from "@/global/typedefinition";
vi.mock("@/components/AppCard.vue", () => ({
default: defineComponent({
name: "AppCard",
props: {
app: {
type: Object,
required: true,
},
},
setup(props) {
return () => h("div", props.app.name);
},
}),
}));
vi.mock("vue-virtual-scroller", () => ({
RecycleScroller: defineComponent({
name: "RecycleScroller",
props: {
items: {
type: Array,
required: true,
},
},
setup(props, { attrs, slots }) {
return () =>
h(
"div",
{
...attrs,
style: "max-height: 320px; overflow-y: auto;",
},
(props.items as Array<{ id: number; apps: App[] }>).map((item) =>
slots.default?.({ item }),
),
);
},
}),
}));
const createApp = (index: number, category: string): App => ({
name: `App ${index}`,
pkgname: `app-${category}-${index}`,
version: "1.0.0",
filename: "app.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "1 MB",
more: "",
tags: "",
img_urls: [],
icons: "",
category,
origin: "spark",
currentStatus: "not-installed",
});
const createApps = (count: number, category: string): App[] =>
Array.from({ length: count }, (_, index) => createApp(index, category));
describe("AppGrid", () => {
it("resets the virtual scroller when the category changes", async () => {
const { container, rerender } = render(AppGrid, {
props: {
apps: createApps(60, "development"),
loading: false,
scrollKey: "development",
} as Record<string, unknown>,
});
const scroller = container.querySelector(".scroller");
expect(scroller).toBeInstanceOf(HTMLElement);
if (!(scroller instanceof HTMLElement)) {
throw new Error("Expected virtual scroller element to exist");
}
scroller.scrollTop = 240;
expect(scroller.scrollTop).toBe(240);
await rerender({
apps: createApps(60, "games"),
loading: false,
scrollKey: "games",
} as Record<string, unknown>);
await nextTick();
expect(scroller.scrollTop).toBe(0);
});
});
@@ -0,0 +1,217 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import { defineComponent, nextTick, reactive, ref } from "vue";
import UpdateCenterItem from "@/components/update-center/UpdateCenterItem.vue";
import type {
UpdateCenterItem as UpdateCenterItemData,
UpdateCenterTaskState,
} from "@/global/typedefinition";
const createItem = (
overrides: Partial<UpdateCenterItemData> = {},
): UpdateCenterItemData => ({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss",
...overrides,
});
const createTask = (
overrides: Partial<UpdateCenterTaskState> = {},
): UpdateCenterTaskState => ({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
source: "aptss",
status: "downloading",
progress: 42,
logs: [],
errorMessage: "",
...overrides,
});
describe("UpdateCenterItem", () => {
it("renders localIcon first when both icon sources exist", () => {
render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
expect(icon).toHaveAttribute(
"src",
"file:///usr/share/pixmaps/spark-weather.png",
);
});
it("falls back to remoteIcon when localIcon fails", async () => {
render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(icon);
expect(icon).toHaveAttribute(
"src",
"https://example.com/spark-weather.png",
);
});
it("falls back to the placeholder after localIcon and remoteIcon both fail", async () => {
render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const icon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(icon);
await fireEvent.error(icon);
expect(icon.getAttribute("src")).toContain("data:image/svg+xml");
expect(icon.getAttribute("src")).not.toContain(
"https://example.com/spark-weather.png",
);
});
it("restarts from localIcon when a new item is rendered", async () => {
const { rerender } = render(UpdateCenterItem, {
props: {
item: createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
task: createTask(),
selected: false,
},
});
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(firstIcon);
expect(firstIcon).toHaveAttribute(
"src",
"https://example.com/spark-weather.png",
);
await rerender({
item: createItem({
displayName: "Spark Clock",
localIcon: "/usr/share/pixmaps/spark-clock.png",
remoteIcon: "https://example.com/spark-clock.png",
}),
task: createTask(),
selected: false,
});
const nextIcon = screen.getByRole("img", { name: "Spark Clock 图标" });
expect(nextIcon).toHaveAttribute(
"src",
"file:///usr/share/pixmaps/spark-clock.png",
);
});
it("restarts from localIcon when icon sources change on the same item object", async () => {
const item = reactive(
createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
);
render(UpdateCenterItem, {
props: {
item,
task: createTask(),
selected: false,
},
});
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(firstIcon);
await fireEvent.error(firstIcon);
expect(firstIcon.getAttribute("src")).toContain("data:image/svg+xml");
item.localIcon = "/usr/share/pixmaps/spark-weather-refreshed.png";
item.remoteIcon = "https://example.com/spark-weather-refreshed.png";
await nextTick();
const retriedIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
expect(retriedIcon).toHaveAttribute(
"src",
"file:///usr/share/pixmaps/spark-weather-refreshed.png",
);
});
it("restarts from localIcon for a fresh item object with the same icon sources", async () => {
const item = ref(
createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
}),
);
const Wrapper = defineComponent({
components: { UpdateCenterItem },
setup() {
return {
item,
task: createTask(),
};
},
template:
'<UpdateCenterItem :item="item" :task="task" :selected="false" />',
});
render(Wrapper);
const firstIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
await fireEvent.error(firstIcon);
await fireEvent.error(firstIcon);
expect(firstIcon.getAttribute("src")).toContain("data:image/svg+xml");
item.value = createItem({
localIcon: "/usr/share/pixmaps/spark-weather.png",
remoteIcon: "https://example.com/spark-weather.png",
});
await nextTick();
const retriedIcon = screen.getByRole("img", { name: "Spark Weather 图标" });
expect(retriedIcon).toHaveAttribute(
"src",
"file:///usr/share/pixmaps/spark-weather.png",
);
});
});
@@ -0,0 +1,182 @@
import { computed, ref } from "vue";
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it, vi } from "vitest";
import UpdateCenterModal from "@/components/UpdateCenterModal.vue";
import type {
UpdateCenterItem,
UpdateCenterSnapshot,
UpdateCenterTaskState,
} from "@/global/typedefinition";
import type { UpdateCenterStore } from "@/modules/updateCenter";
const createItem = (
overrides: Partial<UpdateCenterItem> = {},
): UpdateCenterItem => ({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss",
...overrides,
});
const createTask = (
overrides: Partial<UpdateCenterTaskState> = {},
): UpdateCenterTaskState => ({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
source: "aptss",
status: "downloading",
progress: 42,
logs: [],
errorMessage: "",
...overrides,
});
const createStore = (
overrides: Partial<UpdateCenterSnapshot> = {},
): UpdateCenterStore => {
const snapshot = ref<UpdateCenterSnapshot>({
items: [
createItem({
taskKey: "aptss:spark-weather",
source: "aptss",
}),
createItem({
taskKey: "apm:spark-clock",
packageName: "spark-clock",
displayName: "Spark Clock",
source: "apm",
isMigration: true,
migrationTarget: "apm",
}),
],
tasks: [createTask()],
warnings: ["更新过程中请勿关闭商店"],
hasRunningTasks: true,
...overrides,
});
const selectedTaskKeys = ref(new Set<string>(["aptss:spark-weather"]));
return {
isOpen: ref(true),
showCloseConfirm: ref(true),
showMigrationConfirm: ref(false),
searchQuery: ref(""),
selectedTaskKeys,
snapshot,
filteredItems: computed(() => snapshot.value.items),
bind: vi.fn(),
unbind: vi.fn(),
open: vi.fn(),
refresh: vi.fn(),
toggleSelection: vi.fn(),
getSelectedItems: vi.fn(() =>
snapshot.value.items.filter(
(item) =>
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
),
),
closeNow: vi.fn(),
startSelected: vi.fn(),
requestClose: vi.fn(),
};
};
describe("UpdateCenterModal", () => {
it("renders source tags, running state, warnings, migration marker, and close confirmation", () => {
const store = createStore();
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("软件更新")).toBeTruthy();
expect(screen.getByText("传统deb")).toBeTruthy();
expect(screen.getByText("APM")).toBeTruthy();
expect(screen.getByText("将迁移到 APM")).toBeTruthy();
expect(screen.getByText("更新过程中请勿关闭商店")).toBeTruthy();
expect(screen.getByText("下载中")).toBeTruthy();
expect(screen.getByText("42%")).toBeTruthy();
expect(screen.getByText(/确定关闭/)).toBeTruthy();
});
it("close confirmation exposes a confirm-close path", async () => {
const onConfirmClose = vi.fn();
const store = createStore();
render(UpdateCenterModal, {
props: {
show: true,
store,
onConfirmClose,
},
});
await fireEvent.click(screen.getByRole("button", { name: "确认关闭" }));
expect(onConfirmClose).toHaveBeenCalledTimes(1);
});
it("renders ignored items as disabled instead of normal selectable actions", () => {
const store = createStore({
items: [
createItem({
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
source: "aptss",
ignored: true,
}),
],
tasks: [],
warnings: [],
hasRunningTasks: false,
});
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("已忽略")).toBeTruthy();
expect(screen.getByRole("checkbox")).toBeDisabled();
});
it("renders migration confirmation when requested", () => {
const store = createStore({ hasRunningTasks: false });
store.showMigrationConfirm.value = true;
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
expect(screen.getByText("迁移确认")).toBeTruthy();
});
it("close button triggers request-close flow", async () => {
const store = createStore({ hasRunningTasks: false });
render(UpdateCenterModal, {
props: {
show: true,
store,
},
});
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
expect(store.requestClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,289 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type FsState = {
directories?: Record<string, string[]>;
files?: Record<string, string>;
packageFiles?: Record<string, string[]>;
};
const loadIconsModule = async (state: FsState) => {
vi.resetModules();
vi.doMock("node:fs", () => {
const directories = state.directories ?? {};
const files = state.files ?? {};
const existsSync = (targetPath: string): boolean => {
return targetPath in directories || targetPath in files;
};
const readdirSync = (targetPath: string): string[] => {
return directories[targetPath] ?? [];
};
const readFileSync = (targetPath: string): string => {
const content = files[targetPath];
if (content === undefined) {
throw new Error(`Unexpected read for ${targetPath}`);
}
return content;
};
return {
default: {
existsSync,
readdirSync,
readFileSync,
},
existsSync,
readdirSync,
readFileSync,
};
});
vi.doMock("node:child_process", () => {
const packageFiles = state.packageFiles ?? {};
const spawnSync = (_command: string, args: string[]) => {
const operation = args[0] ?? "";
const pkgname = args[1] ?? "";
const ownedFiles = packageFiles[pkgname];
if (operation !== "-L" || !ownedFiles) {
return {
status: 1,
error: undefined,
output: null,
pid: 0,
signal: null,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
};
}
return {
status: 0,
error: undefined,
output: null,
pid: 0,
signal: null,
stdout: Buffer.from(`${ownedFiles.join("\n")}\n`),
stderr: Buffer.alloc(0),
};
};
return {
default: { spawnSync },
spawnSync,
};
});
return await import("../../../../electron/main/backend/update-center/icons");
};
afterEach(() => {
vi.doUnmock("node:fs");
vi.doUnmock("node:child_process");
});
describe("update-center icons", () => {
it("returns both localIcon and remoteIcon when an aptss desktop icon resolves", async () => {
const pkgname = "spark-weather";
const applicationsDirectory = "/usr/share/applications";
const desktopPath = `${applicationsDirectory}/weather-launcher.desktop`;
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
const { resolveUpdateItemIcons } = await loadIconsModule({
directories: {
[applicationsDirectory]: ["weather-launcher.desktop"],
},
files: {
[desktopPath]: `[Desktop Entry]\nName=Spark Weather\nIcon=${iconPath}\n`,
[iconPath]: "png",
},
packageFiles: {
[pkgname]: [desktopPath],
},
});
expect(
resolveUpdateItemIcons({
pkgname,
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
category: "tools",
arch: "amd64",
}),
).toEqual({
localIcon: iconPath,
remoteIcon:
"https://erotica.spark-app.store/amd64-store/tools/spark-weather/icon.png",
});
});
it("resolves APM icon names from entries/icons when desktop icon is not absolute", async () => {
const pkgname = "spark-music";
const desktopDirectory = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/applications`;
const desktopPath = `${desktopDirectory}/${pkgname}.desktop`;
const iconPath = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/icons/hicolor/48x48/apps/${pkgname}.png`;
const { resolveUpdateItemIcons } = await loadIconsModule({
directories: {
[desktopDirectory]: [`${pkgname}.desktop`],
},
files: {
[desktopPath]: `[Desktop Entry]\nName=Spark Music\nIcon=${pkgname}\n`,
[iconPath]: "png",
},
});
expect(
resolveUpdateItemIcons({
pkgname,
source: "apm",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
}),
).toEqual({ localIcon: iconPath });
});
it("checks later APM desktop entries when the first one has no usable icon", async () => {
const pkgname = "spark-player";
const desktopDirectory = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/applications`;
const invalidDesktopPath = `${desktopDirectory}/invalid.desktop`;
const validDesktopPath = `${desktopDirectory}/valid.desktop`;
const iconPath = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/icons/hicolor/48x48/apps/${pkgname}.png`;
const { resolveApmIcon } = await loadIconsModule({
directories: {
[desktopDirectory]: ["invalid.desktop", "valid.desktop"],
},
files: {
[invalidDesktopPath]:
"[Desktop Entry]\nName=Invalid\nIcon=missing-icon\n",
[validDesktopPath]: `[Desktop Entry]\nName=Spark Player\nIcon=${pkgname}\n`,
[iconPath]: "png",
},
});
expect(resolveApmIcon(pkgname)).toBe(iconPath);
});
it("resolves APM icons from installed /opt/apps entries when package-path assets are absent", async () => {
const pkgname = "spark-video";
const installedDesktopDirectory = `/opt/apps/${pkgname}/entries/applications`;
const installedDesktopPath = `${installedDesktopDirectory}/${pkgname}.desktop`;
const installedIconPath = `/opt/apps/${pkgname}/entries/icons/hicolor/48x48/apps/${pkgname}.png`;
const { resolveApmIcon } = await loadIconsModule({
directories: {
[installedDesktopDirectory]: [`${pkgname}.desktop`],
},
files: {
[installedDesktopPath]: `[Desktop Entry]\nName=Spark Video\nIcon=${pkgname}\n`,
[installedIconPath]: "png",
},
});
expect(resolveApmIcon(pkgname)).toBe(installedIconPath);
});
it("resolves APM named icons from shared theme locations when local entries icons are absent", async () => {
const pkgname = "spark-camera";
const desktopDirectory = `/var/lib/apm/apm/files/ace-env/var/lib/apm/${pkgname}/entries/applications`;
const desktopPath = `${desktopDirectory}/${pkgname}.desktop`;
const sharedIconPath = `/usr/share/icons/hicolor/48x48/apps/${pkgname}.png`;
const { resolveApmIcon } = await loadIconsModule({
directories: {
[desktopDirectory]: [`${pkgname}.desktop`],
},
files: {
[desktopPath]: `[Desktop Entry]\nName=Spark Camera\nIcon=${pkgname}\n`,
[sharedIconPath]: "png",
},
});
expect(resolveApmIcon(pkgname)).toBe(sharedIconPath);
});
it("returns only remoteIcon when no local icon resolves", async () => {
const { resolveUpdateItemIcons } = await loadIconsModule({});
expect(
resolveUpdateItemIcons({
pkgname: "spark-clock",
source: "apm",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
category: "utility",
arch: "amd64",
}),
).toEqual({
remoteIcon:
"https://erotica.spark-app.store/amd64-apm/utility/spark-clock/icon.png",
});
});
it("returns only localIcon when a remote fallback URL cannot be built", async () => {
const pkgname = "spark-reader";
const applicationsDirectory = "/usr/share/applications";
const desktopPath = `${applicationsDirectory}/reader-launcher.desktop`;
const iconPath = `/usr/share/pixmaps/${pkgname}.png`;
const { resolveUpdateItemIcons } = await loadIconsModule({
directories: {
[applicationsDirectory]: ["reader-launcher.desktop"],
},
files: {
[desktopPath]: `[Desktop Entry]\nName=Spark Reader\nIcon=${iconPath}\n`,
[iconPath]: "png",
},
packageFiles: {
[pkgname]: [desktopPath],
},
});
expect(
resolveUpdateItemIcons({
pkgname,
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
}),
).toEqual({ localIcon: iconPath });
});
it("returns an empty object when neither local nor remote icons are available", async () => {
const { resolveUpdateItemIcons } = await loadIconsModule({});
expect(
resolveUpdateItemIcons({
pkgname: "spark-empty",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
}),
).toEqual({});
});
it("ignores unrelated desktop files while still resolving owned non-exact filenames", async () => {
const pkgname = "spark-reader";
const applicationsDirectory = "/usr/share/applications";
const unrelatedDesktopPath = `${applicationsDirectory}/notes.desktop`;
const ownedDesktopPath = `${applicationsDirectory}/reader-launcher.desktop`;
const unrelatedIconPath = "/usr/share/pixmaps/notes.png";
const ownedIconPath = `/usr/share/pixmaps/${pkgname}.png`;
const { resolveDesktopIcon } = await loadIconsModule({
directories: {
[applicationsDirectory]: ["notes.desktop", "reader-launcher.desktop"],
},
files: {
[unrelatedDesktopPath]: `[Desktop Entry]\nName=Notes\nIcon=${unrelatedIconPath}\n`,
[ownedDesktopPath]: `[Desktop Entry]\nName=Spark Reader\nIcon=${ownedIconPath}\n`,
[unrelatedIconPath]: "png",
[ownedIconPath]: "png",
},
packageFiles: {
[pkgname]: [ownedDesktopPath],
},
});
expect(resolveDesktopIcon(pkgname)).toBe(ownedIconPath);
});
});
@@ -0,0 +1,124 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import {
LEGACY_IGNORE_CONFIG_PATH,
applyIgnoredEntries,
createIgnoreKey,
loadIgnoredEntries,
parseIgnoredEntries,
saveIgnoredEntries,
} from "../../../../electron/main/backend/update-center/ignore-config";
describe("update-center ignore config", () => {
it("round-trips the legacy package|version format", async () => {
expect(LEGACY_IGNORE_CONFIG_PATH).toBe(
"/etc/spark-store/ignored_apps.conf",
);
const entries = new Set([
createIgnoreKey("spark-clock", "2.0.0"),
createIgnoreKey("spark-browser", "1.5.0"),
]);
const tempDir = await mkdtemp(join(tmpdir(), "spark-ignore-config-"));
const filePath = join(tempDir, "ignored_apps.conf");
try {
await saveIgnoredEntries(filePath, entries);
expect(await readFile(filePath, "utf8")).toBe(
"spark-browser|1.5.0\nspark-clock|2.0.0\n",
);
expect(
parseIgnoredEntries("spark-browser|1.5.0\nspark-clock|2.0.0\n"),
).toEqual(new Set(["spark-browser|1.5.0", "spark-clock|2.0.0"]));
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(entries);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("ignores malformed lines and accepts CRLF legacy entries", () => {
expect(
parseIgnoredEntries(
[
"spark-browser|1.5.0\r",
"spark-clock|2.0.0|extra",
"missing-version|",
"|missing-package",
"spark-player|3.0.0",
"",
].join("\n"),
),
).toEqual(new Set(["spark-browser|1.5.0", "spark-player|3.0.0"]));
});
it("marks only exact package/version matches as ignored", () => {
const items: UpdateCenterItem[] = [
{
pkgname: "spark-clock",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
},
{
pkgname: "spark-clock",
source: "apm",
currentVersion: "1.1.0",
nextVersion: "2.1.0",
},
{
pkgname: "spark-browser",
source: "apm",
currentVersion: "4.0.0",
nextVersion: "5.0.0",
},
];
expect(
applyIgnoredEntries(
items,
new Set([createIgnoreKey("spark-clock", "2.0.0")]),
),
).toEqual([
{
pkgname: "spark-clock",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
ignored: true,
},
{
pkgname: "spark-clock",
source: "apm",
currentVersion: "1.1.0",
nextVersion: "2.1.0",
ignored: false,
},
{
pkgname: "spark-browser",
source: "apm",
currentVersion: "4.0.0",
nextVersion: "5.0.0",
ignored: false,
},
]);
});
it("missing file returns an empty set", async () => {
const tempDir = await mkdtemp(
join(tmpdir(), "spark-ignore-config-missing-"),
);
const filePath = join(tempDir, "missing.conf");
try {
await expect(loadIgnoredEntries(filePath)).resolves.toEqual(new Set());
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,377 @@
import { afterEach, describe, expect, it, vi } from "vitest";
interface CommandResult {
code: number;
stdout: string;
stderr: string;
}
type RemoteStoreResponse =
| Record<string, unknown>
| Array<Record<string, unknown>>;
const APTSS_LIST_UPGRADABLE_KEY =
"bash -lc env LANGUAGE=en_US /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf list --upgradable -o Dir::Etc::sourcelist=/opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/aptss.list -o Dir::Etc::sourceparts=/dev/null -o APT::Get::List-Cleanup=0";
const DPKG_QUERY_INSTALLED_KEY =
"dpkg-query -W -f=${Package}\t${db:Status-Want} ${db:Status-Status} ${db:Status-Eflag}\n";
const APM_PRINT_URIS_KEY =
"bash -lc amber-pm-debug /usr/bin/apt -c /opt/durapps/spark-store/bin/apt-fast-conf/aptss-apt.conf download spark-weather --print-uris";
const loadUpdateCenterModule = async (
remoteStore: Record<string, RemoteStoreResponse>,
) => {
vi.resetModules();
vi.doMock("node:fs", () => {
const existsSync = () => false;
const readdirSync = () => [] as string[];
const readFileSync = () => {
throw new Error("Unexpected icon file read");
};
return {
default: {
existsSync,
readdirSync,
readFileSync,
},
existsSync,
readdirSync,
readFileSync,
};
});
vi.doMock("node:child_process", async () => {
const actual =
await vi.importActual<typeof import("node:child_process")>(
"node:child_process",
);
return {
...actual,
spawnSync: (command: string, args: string[]) => {
if (command === "dpkg" && args[0] === "-L") {
return {
status: 1,
error: undefined,
output: null,
pid: 0,
signal: null,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
};
}
return actual.spawnSync(command, args);
},
};
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL) => {
const url = String(input);
const body = remoteStore[url];
return {
ok: body !== undefined,
async json() {
if (body === undefined) {
throw new Error(`Unexpected fetch for ${url}`);
}
return body;
},
};
}),
);
return await import("../../../../electron/main/backend/update-center/index");
};
afterEach(() => {
vi.doUnmock("node:fs");
vi.doUnmock("node:child_process");
vi.unstubAllGlobals();
});
describe("update-center load items", () => {
it("enriches apm migration items with download metadata and remote fallback icons", async () => {
const commandResults = new Map<string, CommandResult>([
[
APTSS_LIST_UPGRADABLE_KEY,
{
code: 0,
stdout: "spark-weather/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
},
],
[
"apm list --upgradable",
{
code: 0,
stdout: "spark-weather/main 3.0.0 amd64 [upgradable from: 1.5.0]",
stderr: "",
},
],
[
DPKG_QUERY_INSTALLED_KEY,
{
code: 0,
stdout: "spark-weather\tinstall ok installed\n",
stderr: "",
},
],
[
"apm list --installed",
{
code: 0,
stdout: "",
stderr: "",
},
],
[
APM_PRINT_URIS_KEY,
{
code: 0,
stdout:
"'https://example.invalid/spark-weather_3.0.0_amd64.deb' spark-weather_3.0.0_amd64.deb 123456 SHA512:deadbeef",
stderr: "",
},
],
]);
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-store/categories.json": {
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-store/tools/applist.json": [
{ Pkgname: "spark-weather" },
],
"https://erotica.spark-app.store/amd64-apm/categories.json": {
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-apm/tools/applist.json": [
{ Pkgname: "spark-weather" },
],
});
const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`;
const match = commandResults.get(key);
if (!match) {
throw new Error(`Missing mock for ${key}`);
}
return match;
});
expect(result.warnings).toEqual([]);
expect(result.items).toContainEqual({
pkgname: "spark-weather",
source: "apm",
currentVersion: "1.5.0",
nextVersion: "3.0.0",
arch: "amd64",
category: "tools",
remoteIcon:
"https://erotica.spark-app.store/amd64-apm/tools/spark-weather/icon.png",
downloadUrl: "https://example.invalid/spark-weather_3.0.0_amd64.deb",
fileName: "spark-weather_3.0.0_amd64.deb",
size: 123456,
sha512: "deadbeef",
isMigration: true,
migrationSource: "aptss",
migrationTarget: "apm",
aptssVersion: "2.0.0",
});
});
it("degrades to aptss-only results when apm commands fail", async () => {
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-store/categories.json": {
office: { zh: "Office" },
},
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Pkgname: "spark-notes" },
],
});
const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`;
if (key === APTSS_LIST_UPGRADABLE_KEY) {
return {
code: 0,
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
}
if (key === DPKG_QUERY_INSTALLED_KEY) {
return {
code: 0,
stdout: "spark-notes\tinstall ok installed\n",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") {
return {
code: 127,
stdout: "",
stderr: "apm: command not found",
};
}
throw new Error(`Unexpected command ${key}`);
});
expect(result.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
expect(result.warnings).toEqual([
"apm upgradable query failed: apm: command not found",
"apm installed query failed: apm: command not found",
]);
});
it("retries category lookup after an earlier fetch failure in the same process", async () => {
const remoteStore: Record<string, RemoteStoreResponse> = {};
const { loadUpdateCenterItems } = await loadUpdateCenterModule(remoteStore);
const runCommand = async (command: string, args: string[]) => {
const key = `${command} ${args.join(" ")}`;
if (key === APTSS_LIST_UPGRADABLE_KEY) {
return {
code: 0,
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
}
if (key === DPKG_QUERY_INSTALLED_KEY) {
return {
code: 0,
stdout: "spark-notes\tinstall ok installed\n",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") {
return {
code: 127,
stdout: "",
stderr: "apm: command not found",
};
}
throw new Error(`Unexpected command ${key}`);
};
const firstResult = await loadUpdateCenterItems(runCommand);
expect(firstResult.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
},
]);
remoteStore["https://erotica.spark-app.store/amd64-store/categories.json"] =
{
office: { zh: "Office" },
};
remoteStore[
"https://erotica.spark-app.store/amd64-store/office/applist.json"
] = [{ Pkgname: "spark-notes" }];
const secondResult = await loadUpdateCenterItems(runCommand);
expect(secondResult.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
});
it("keeps successfully loaded categories when another category applist fetch fails", async () => {
const { loadUpdateCenterItems } = await loadUpdateCenterModule({
"https://erotica.spark-app.store/amd64-store/categories.json": {
office: { zh: "Office" },
tools: { zh: "Tools" },
},
"https://erotica.spark-app.store/amd64-store/office/applist.json": [
{ Pkgname: "spark-notes" },
],
});
const result = await loadUpdateCenterItems(async (command, args) => {
const key = `${command} ${args.join(" ")}`;
if (key === APTSS_LIST_UPGRADABLE_KEY) {
return {
code: 0,
stdout: "spark-notes/stable 2.0.0 amd64 [upgradable from: 1.0.0]",
stderr: "",
};
}
if (key === DPKG_QUERY_INSTALLED_KEY) {
return {
code: 0,
stdout: "spark-notes\tinstall ok installed\n",
stderr: "",
};
}
if (key === "apm list --upgradable" || key === "apm list --installed") {
return {
code: 127,
stdout: "",
stderr: "apm: command not found",
};
}
throw new Error(`Unexpected command ${key}`);
});
expect(result.items).toEqual([
{
pkgname: "spark-notes",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
arch: "amd64",
category: "office",
remoteIcon:
"https://erotica.spark-app.store/amd64-store/office/spark-notes/icon.png",
},
]);
expect(result.warnings).toEqual([
"apm upgradable query failed: apm: command not found",
"apm installed query failed: apm: command not found",
]);
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
getMainWindowCloseAction,
shouldPreventMainWindowClose,
} from "../../../../electron/main/window-close-guard";
describe("main window close guard", () => {
it("keeps the app alive while update-center work is running", () => {
expect(
shouldPreventMainWindowClose({
installTaskCount: 0,
hasRunningUpdateCenterTasks: true,
}),
).toBe(true);
});
it("allows close only when both install and update-center work are idle", () => {
expect(
shouldPreventMainWindowClose({
installTaskCount: 0,
hasRunningUpdateCenterTasks: false,
}),
).toBe(false);
});
it("returns a hide action while any guarded work is active", () => {
expect(
getMainWindowCloseAction({
installTaskCount: 1,
hasRunningUpdateCenterTasks: false,
}),
).toBe("hide");
});
it("returns a destroy action only when all guarded work is idle", () => {
expect(
getMainWindowCloseAction({
installTaskCount: 0,
hasRunningUpdateCenterTasks: false,
}),
).toBe("destroy");
});
});
@@ -0,0 +1,298 @@
import { describe, expect, it, vi } from "vitest";
import {
buildInstalledSourceMap,
mergeUpdateSources,
parseApmUpgradableOutput,
parseAptssUpgradableOutput,
parsePrintUrisOutput,
} from "../../../../electron/main/backend/update-center/query";
describe("update-center query", () => {
it("parses aptss upgradable output into normalized aptss items", () => {
const output = [
"Listing...",
"spark-weather/stable 2.0.0 amd64 [upgradable from: 1.9.0]",
"spark-same/stable 1.0.0 amd64 [upgradable from: 1.0.0]",
"",
].join("\n");
expect(parseAptssUpgradableOutput(output)).toEqual([
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
arch: "amd64",
},
]);
});
it("parses the legacy from variant in upgradable output", () => {
const aptssOutput = "spark-clock/stable 1.2.0 amd64 [from: 1.1.0]";
const apmOutput = "spark-player/main 2.0.0 amd64 [from: 1.5.0]";
expect(parseAptssUpgradableOutput(aptssOutput)).toEqual([
{
pkgname: "spark-clock",
source: "aptss",
currentVersion: "1.1.0",
nextVersion: "1.2.0",
arch: "amd64",
},
]);
expect(parseApmUpgradableOutput(apmOutput)).toEqual([
{
pkgname: "spark-player",
source: "apm",
currentVersion: "1.5.0",
nextVersion: "2.0.0",
arch: "amd64",
},
]);
});
it("parses apt print-uris output into download metadata", () => {
const output =
"'https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb' spark-weather_2.0.0_amd64.deb 123456 SHA512:deadbeef";
expect(parsePrintUrisOutput(output)).toEqual({
downloadUrl:
"https://example.invalid/pool/main/s/spark-weather_2.0.0_amd64.deb",
fileName: "spark-weather_2.0.0_amd64.deb",
size: 123456,
sha512: "deadbeef",
});
});
it("marks an apm item as migration when the same package is only installed in aptss", () => {
const merged = mergeUpdateSources(
[
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
},
],
[
{
pkgname: "spark-weather",
source: "apm",
currentVersion: "1.8.0",
nextVersion: "3.0.0",
},
],
new Map([["spark-weather", { aptss: true, apm: false }]]),
);
expect(merged).toEqual([
{
pkgname: "spark-weather",
source: "apm",
currentVersion: "1.8.0",
nextVersion: "3.0.0",
isMigration: true,
migrationSource: "aptss",
migrationTarget: "apm",
aptssVersion: "2.0.0",
},
{
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.9.0",
nextVersion: "2.0.0",
},
]);
});
it("uses Debian-style version ordering for migration decisions", () => {
const merged = mergeUpdateSources(
[
{
pkgname: "spark-browser",
source: "aptss",
currentVersion: "9.0",
nextVersion: "10.0",
},
],
[
{
pkgname: "spark-browser",
source: "apm",
currentVersion: "1:0.9",
nextVersion: "2:1.0",
},
],
new Map([["spark-browser", { aptss: true, apm: false }]]),
);
expect(merged[0]).toMatchObject({
pkgname: "spark-browser",
source: "apm",
isMigration: true,
aptssVersion: "10.0",
});
expect(merged[1]).toMatchObject({
pkgname: "spark-browser",
source: "aptss",
nextVersion: "10.0",
});
});
it("uses Debian epoch ordering in the fallback when dpkg is unavailable", async () => {
vi.resetModules();
vi.doMock("node:child_process", async (importOriginal) => {
const actual =
await importOriginal<typeof import("node:child_process")>();
return {
...actual,
default: actual,
spawnSync: vi.fn(() => ({
status: null,
error: new Error("dpkg unavailable"),
output: null,
pid: 0,
signal: null,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
})),
};
});
const { mergeUpdateSources: mergeWithFallback } =
await import("../../../../electron/main/backend/update-center/query");
const merged = mergeWithFallback(
[
{
pkgname: "spark-reader",
source: "aptss",
currentVersion: "2.5.0",
nextVersion: "2.9.0",
},
],
[
{
pkgname: "spark-reader",
source: "apm",
currentVersion: "2.0.0",
nextVersion: "3.0.0",
},
],
new Map([["spark-reader", { aptss: true, apm: false }]]),
);
expect(merged[0]).toMatchObject({
pkgname: "spark-reader",
source: "apm",
isMigration: true,
aptssVersion: "2.9.0",
});
vi.doUnmock("node:child_process");
vi.resetModules();
});
it("uses Debian tilde ordering in the fallback when dpkg is unavailable", async () => {
vi.resetModules();
vi.doMock("node:child_process", async (importOriginal) => {
const actual =
await importOriginal<typeof import("node:child_process")>();
return {
...actual,
default: actual,
spawnSync: vi.fn(() => ({
status: null,
error: new Error("dpkg unavailable"),
output: null,
pid: 0,
signal: null,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
})),
};
});
const { mergeUpdateSources: mergeWithFallback } =
await import("../../../../electron/main/backend/update-center/query");
const merged = mergeWithFallback(
[
{
pkgname: "spark-tilde",
source: "aptss",
currentVersion: "0.9",
nextVersion: "1.0~rc1",
},
],
[
{
pkgname: "spark-tilde",
source: "apm",
currentVersion: "0.9",
nextVersion: "1.0",
},
],
new Map([["spark-tilde", { aptss: true, apm: false }]]),
);
expect(merged[0]).toMatchObject({
pkgname: "spark-tilde",
source: "apm",
isMigration: true,
aptssVersion: "1.0~rc1",
});
vi.doUnmock("node:child_process");
vi.resetModules();
});
it("parses apm list output into normalized apm items", () => {
const output = [
"Listing...",
"spark-music/main 5.0.0 arm64 [upgradable from: 4.5.0]",
"spark-same/main 1.0.0 arm64 [upgradable from: 1.0.0]",
"",
].join("\n");
expect(parseApmUpgradableOutput(output)).toEqual([
{
pkgname: "spark-music",
source: "apm",
currentVersion: "4.5.0",
nextVersion: "5.0.0",
arch: "arm64",
},
]);
});
it("builds installed-source map from dpkg-query and apm list output", () => {
const dpkgOutput = [
"spark-weather\tinstall ok installed",
"spark-weather-data\tdeinstall ok config-files",
"spark-notes\tinstall ok installed",
"",
].join("\n");
const apmInstalledOutput = [
"Listing...",
"spark-weather/main,stable 3.0.0 amd64 [installed]",
"spark-player/main 1.0.0 amd64 [installed,automatic]",
"",
].join("\n");
expect(
Array.from(
buildInstalledSourceMap(dpkgOutput, apmInstalledOutput).entries(),
),
).toEqual([
["spark-weather", { aptss: true, apm: true }],
["spark-notes", { aptss: true, apm: false }],
["spark-player", { aptss: false, apm: true }],
]);
});
});
@@ -0,0 +1,600 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import type { UpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
import {
createUpdateCenterService,
type UpdateCenterServiceState,
} from "../../../../electron/main/backend/update-center/service";
import { registerUpdateCenterIpc } from "../../../../electron/main/backend/update-center";
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
const electronMock = vi.hoisted(() => ({
getAllWindows: vi.fn(),
}));
vi.mock("electron", () => ({
BrowserWindow: {
getAllWindows: electronMock.getAllWindows,
},
}));
const createItem = (): UpdateCenterItem => ({
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
});
describe("update-center/ipc", () => {
beforeEach(() => {
electronMock.getAllWindows.mockReset();
});
it("registers every update-center handler and forwards service calls", async () => {
const handle = vi.fn();
const send = vi.fn();
const snapshot: UpdateCenterServiceState = {
items: [],
tasks: [],
warnings: [],
hasRunningTasks: false,
};
let listener:
| ((nextSnapshot: UpdateCenterServiceState) => void)
| undefined;
const service = {
open: vi.fn().mockResolvedValue(snapshot),
refresh: vi.fn().mockResolvedValue(snapshot),
ignore: vi.fn().mockResolvedValue(undefined),
unignore: vi.fn().mockResolvedValue(undefined),
start: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockReturnValue(snapshot),
subscribe: vi.fn(
(nextListener: (nextSnapshot: UpdateCenterServiceState) => void) => {
listener = nextListener;
return () => undefined;
},
),
};
electronMock.getAllWindows.mockReturnValue([{ webContents: { send } }]);
registerUpdateCenterIpc({ handle }, service);
expect(handle).toHaveBeenCalledWith(
"update-center-open",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-refresh",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-ignore",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-unignore",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-start",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-cancel",
expect.any(Function),
);
expect(handle).toHaveBeenCalledWith(
"update-center-get-state",
expect.any(Function),
);
expect(service.subscribe).toHaveBeenCalledTimes(1);
const openHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-open",
)?.[1] as
| ((event: unknown) => Promise<UpdateCenterServiceState>)
| undefined;
const refreshHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-refresh",
)?.[1] as
| ((event: unknown) => Promise<UpdateCenterServiceState>)
| undefined;
const ignoreHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-ignore",
)?.[1] as
| ((
event: unknown,
payload: { packageName: string; newVersion: string },
) => Promise<void>)
| undefined;
const unignoreHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-unignore",
)?.[1] as
| ((
event: unknown,
payload: { packageName: string; newVersion: string },
) => Promise<void>)
| undefined;
const startHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-start",
)?.[1] as
| ((event: unknown, taskKeys: string[]) => Promise<void>)
| undefined;
const cancelHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-cancel",
)?.[1] as ((event: unknown, taskKey: string) => Promise<void>) | undefined;
const getStateHandler = handle.mock.calls.find(
([channel]: [string]) => channel === "update-center-get-state",
)?.[1] as (() => UpdateCenterServiceState) | undefined;
await openHandler?.({});
await refreshHandler?.({});
await ignoreHandler?.(
{},
{ packageName: "spark-weather", newVersion: "2.0.0" },
);
await unignoreHandler?.(
{},
{ packageName: "spark-weather", newVersion: "2.0.0" },
);
await startHandler?.({}, ["aptss:spark-weather"]);
await cancelHandler?.({}, "aptss:spark-weather");
expect(getStateHandler?.()).toEqual(snapshot);
expect(service.open).toHaveBeenCalledTimes(1);
expect(service.refresh).toHaveBeenCalledTimes(1);
expect(service.ignore).toHaveBeenCalledWith({
packageName: "spark-weather",
newVersion: "2.0.0",
});
expect(service.unignore).toHaveBeenCalledWith({
packageName: "spark-weather",
newVersion: "2.0.0",
});
expect(service.start).toHaveBeenCalledWith(["aptss:spark-weather"]);
expect(service.cancel).toHaveBeenCalledWith("aptss:spark-weather");
listener?.(snapshot);
expect(send).toHaveBeenCalledWith("update-center-state", snapshot);
});
it("service subscribers receive state updates after refresh start and ignore", async () => {
let ignoredEntries = new Set<string>();
const service = createUpdateCenterService({
loadItems: async () => [createItem()],
loadIgnoredEntries: async () => new Set(ignoredEntries),
saveIgnoredEntries: async (entries: ReadonlySet<string>) => {
ignoredEntries = new Set(entries);
},
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
queue.finishTask(task.id, "completed");
return task;
},
}),
});
const snapshots: UpdateCenterServiceState[] = [];
service.subscribe((snapshot: UpdateCenterServiceState) => {
snapshots.push(snapshot);
});
await service.refresh();
await service.start(["aptss:spark-weather"]);
await service.ignore({ packageName: "spark-weather", newVersion: "2.0.0" });
expect(snapshots.some((snapshot) => snapshot.hasRunningTasks)).toBe(true);
expect(
snapshots.some((snapshot) =>
snapshot.tasks.some(
(task: UpdateCenterServiceState["tasks"][number]) =>
task.taskKey === "aptss:spark-weather" &&
task.status === "completed",
),
),
).toBe(true);
expect(snapshots.at(-1)?.items[0]).toMatchObject({
taskKey: "aptss:spark-weather",
ignored: true,
newVersion: "2.0.0",
});
expect(snapshots.at(-1)?.items[0]).not.toHaveProperty("nextVersion");
});
it("service task snapshots keep localIcon and remoteIcon for queued work", async () => {
let releaseTask: (() => void) | undefined;
const service = createUpdateCenterService({
loadItems: async () => [
{
...createItem(),
localIcon: "/icons/weather.png",
remoteIcon: "https://example.com/weather.png",
},
],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
queue.markActiveTask(task.id, "installing");
queue.finishTask(task.id, "completed");
return task;
},
}),
});
await service.refresh();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
expect(service.getState().tasks).toMatchObject([
{
taskKey: "aptss:spark-weather",
localIcon: "/icons/weather.png",
remoteIcon: "https://example.com/weather.png",
status: "queued",
},
]);
releaseTask?.();
await startPromise;
});
it("concurrent start calls still serialize through one processing pipeline", async () => {
const startedTaskIds: number[] = [];
const releases: Array<() => void> = [];
const service = createUpdateCenterService({
loadItems: async () => [
createItem(),
{ ...createItem(), pkgname: "spark-clock" },
],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
startedTaskIds.push(task.id);
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releases.push(resolve);
});
queue.finishTask(task.id, "completed");
return task;
},
}),
});
await service.refresh();
const firstStart = service.start(["aptss:spark-weather"]);
const secondStart = service.start(["aptss:spark-clock"]);
await flushPromises();
expect(startedTaskIds).toEqual([1]);
releases.shift()?.();
await flushPromises();
expect(startedTaskIds).toEqual([1, 2]);
releases.shift()?.();
await Promise.all([firstStart, secondStart]);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "completed" },
{ taskKey: "aptss:spark-clock", status: "completed" },
]);
});
it("cancelling an active task stops it and leaves it cancelled", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({
loadItems: async () => [createItem()],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask,
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
return (
queue.getSnapshot().tasks.find((entry) => entry.id === task.id) ??
null
);
},
}),
});
await service.refresh();
const startPromise = service.start(["aptss:spark-weather"]);
await flushPromises();
await service.cancel("aptss:spark-weather");
await startPromise;
expect(cancelActiveTask).toHaveBeenCalledTimes(1);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "cancelled" },
]);
});
it("cancelling a queued task does not abort the currently active task", async () => {
let releaseTask: (() => void) | undefined;
const cancelActiveTask = vi.fn(() => {
releaseTask?.();
});
const service = createUpdateCenterService({
loadItems: async () => [
createItem(),
{ ...createItem(), pkgname: "spark-clock" },
],
createTaskRunner: (queue: UpdateCenterQueue) => ({
cancelActiveTask,
runNextTask: async () => {
const task = queue.getNextQueuedTask();
if (!task) {
return null;
}
queue.markActiveTask(task.id, "installing");
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
if (
queue.getSnapshot().tasks.find((entry) => entry.id === task.id)
?.status !== "cancelled"
) {
queue.finishTask(task.id, "completed");
}
return task;
},
}),
});
await service.refresh();
const activeStart = service.start(["aptss:spark-weather"]);
await flushPromises();
const queuedStart = service.start(["aptss:spark-clock"]);
await flushPromises();
await service.cancel("aptss:spark-clock");
expect(cancelActiveTask).not.toHaveBeenCalled();
releaseTask?.();
await Promise.all([activeStart, queuedStart]);
expect(service.getState().tasks).toMatchObject([
{ taskKey: "aptss:spark-weather", status: "completed" },
{ taskKey: "aptss:spark-clock", status: "cancelled" },
]);
});
it("superUserCmdProvider failure does not leave a task stuck in queued state", async () => {
const service = createUpdateCenterService({
loadItems: async () => [createItem()],
superUserCmdProvider: async () => {
throw new Error("pkexec unavailable");
},
createTaskRunner: () => ({
cancelActiveTask: vi.fn(),
runNextTask: async () => {
throw new Error(
"runner should not start when privilege lookup fails",
);
},
}),
});
await service.refresh();
await service.start(["aptss:spark-weather"]);
expect(service.getState()).toMatchObject({
hasRunningTasks: false,
tasks: [
{
taskKey: "aptss:spark-weather",
status: "failed",
errorMessage: "pkexec unavailable",
},
],
});
});
it("refresh exposes load-item failures as warnings", async () => {
const service = createUpdateCenterService({
loadItems: async () => {
throw new Error("apt list failed");
},
});
const snapshot = await service.refresh();
expect(snapshot).toMatchObject({
items: [],
warnings: ["apt list failed"],
hasRunningTasks: false,
});
});
it("refresh failure clears previously loaded items so stale updates are not actionable", async () => {
let shouldFailRefresh = false;
const service = createUpdateCenterService({
loadItems: async () => {
if (shouldFailRefresh) {
throw new Error("apt list failed");
}
return [createItem()];
},
});
expect(await service.refresh()).toMatchObject({
items: [{ taskKey: "aptss:spark-weather" }],
warnings: [],
});
shouldFailRefresh = true;
expect(await service.refresh()).toMatchObject({
items: [],
warnings: ["apt list failed"],
hasRunningTasks: false,
});
});
it("refresh preserves warnings returned alongside successful items", async () => {
const service = createUpdateCenterService({
loadItems: async () => ({
items: [createItem()],
warnings: ["apm unavailable, showing aptss updates only"],
}),
});
const snapshot = await service.refresh();
expect(snapshot).toMatchObject({
items: [
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
},
],
warnings: ["apm unavailable, showing aptss updates only"],
hasRunningTasks: false,
});
});
it("window ipcRenderer typing matches the preload facade only", async () => {
type IpcFacade = Window["ipcRenderer"];
type HasOn = IpcFacade extends { on: (...args: never[]) => unknown }
? true
: false;
type HasInvoke = IpcFacade extends { invoke: (...args: never[]) => unknown }
? true
: false;
type HasPostMessage = IpcFacade extends {
postMessage: (...args: never[]) => unknown;
}
? true
: false;
const typeShape: [HasOn, HasInvoke, HasPostMessage] = [true, true, false];
expect(typeShape).toEqual([true, true, false]);
});
it("default task runner forwards abort signals into download and install helpers", async () => {
vi.resetModules();
let downloadSignal: AbortSignal | undefined;
let installAborted = false;
let closeHandler: ((code: number | null) => void) | undefined;
vi.doMock(
"../../../../electron/main/backend/update-center/download",
() => ({
runAria2Download: vi.fn(async (context: { signal?: AbortSignal }) => {
downloadSignal = context.signal;
return { filePath: "/tmp/spark-weather.deb" };
}),
}),
);
vi.doMock("node:child_process", () => {
const spawn = vi.fn(() => {
const child = {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
kill: vi.fn(() => {
installAborted = true;
closeHandler?.(1);
}),
on: vi.fn(
(event: string, callback: (code: number | null) => void) => {
if (event === "close") {
closeHandler = callback;
}
},
),
};
return child;
});
return {
default: { spawn },
spawn,
};
});
const { createTaskRunner } =
await import("../../../../electron/main/backend/update-center/install");
const queue = createUpdateCenterQueue();
const item = {
...createItem(),
downloadUrl: "https://example.invalid/spark-weather.deb",
fileName: "spark-weather.deb",
};
queue.setItems([item]);
queue.enqueueItem(item);
const runner = createTaskRunner(queue);
const runPromise = runner.runNextTask();
await flushPromises();
expect(downloadSignal).toBeInstanceOf(AbortSignal);
runner.cancelActiveTask();
const settled = await Promise.race([
runPromise.then(() => true),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 50);
}),
]);
expect(installAborted).toBe(true);
expect(settled).toBe(true);
vi.doUnmock("../../../../electron/main/backend/update-center/download");
vi.doUnmock("node:child_process");
vi.resetModules();
});
});
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createUpdateCenterStore } from "@/modules/updateCenter";
const createSnapshot = (overrides = {}) => ({
items: [
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss" as const,
ignored: false,
},
],
tasks: [],
warnings: [],
hasRunningTasks: false,
...overrides,
});
describe("updateCenter store", () => {
const open = vi.fn();
const refresh = vi.fn();
const start = vi.fn();
const onState = vi.fn();
const offState = vi.fn();
beforeEach(() => {
open.mockReset();
refresh.mockReset();
start.mockReset();
onState.mockReset();
offState.mockReset();
Object.defineProperty(window, "updateCenter", {
configurable: true,
value: {
open,
refresh,
ignore: vi.fn(),
unignore: vi.fn(),
start,
cancel: vi.fn(),
getState: vi.fn(),
onState,
offState,
},
});
});
it("opens the modal with the initial snapshot", async () => {
const snapshot = createSnapshot();
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
expect(open).toHaveBeenCalledTimes(1);
expect(store.isOpen.value).toBe(true);
expect(store.snapshot.value).toEqual(snapshot);
expect(store.filteredItems.value).toEqual(snapshot.items);
});
it("starts only the selected non-ignored items", async () => {
const snapshot = createSnapshot({
items: [
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss" as const,
ignored: false,
},
{
taskKey: "apm:spark-clock",
packageName: "spark-clock",
displayName: "Spark Clock",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "apm" as const,
ignored: true,
},
],
});
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
store.toggleSelection("apm:spark-clock");
await store.startSelected();
expect(start).toHaveBeenCalledWith(["aptss:spark-weather"]);
});
it("blocks close requests while the snapshot reports running tasks", () => {
const store = createUpdateCenterStore();
store.isOpen.value = true;
store.snapshot.value = createSnapshot({ hasRunningTasks: true });
store.requestClose();
expect(store.isOpen.value).toBe(true);
expect(store.showCloseConfirm.value).toBe(true);
});
it("applies pushed snapshots from the main process", () => {
let listener:
| ((snapshot: ReturnType<typeof createSnapshot>) => void)
| null = null;
onState.mockImplementation((nextListener) => {
listener = nextListener;
});
const store = createUpdateCenterStore();
store.bind();
const pushedSnapshot = createSnapshot({
items: [
{
taskKey: "aptss:spark-music",
packageName: "spark-music",
displayName: "Spark Music",
currentVersion: "3.0.0",
newVersion: "3.1.0",
source: "aptss" as const,
ignored: false,
},
],
});
listener?.(pushedSnapshot);
expect(onState).toHaveBeenCalledTimes(1);
expect(store.snapshot.value).toEqual(pushedSnapshot);
expect(store.filteredItems.value).toEqual(pushedSnapshot.items);
store.unbind();
expect(offState).toHaveBeenCalledTimes(1);
});
it("prunes stale selected task keys when snapshots change", async () => {
open.mockResolvedValue(
createSnapshot({
items: [
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss" as const,
ignored: false,
},
{
taskKey: "apm:spark-clock",
packageName: "spark-clock",
displayName: "Spark Clock",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "apm" as const,
ignored: false,
},
],
}),
);
refresh.mockResolvedValue(
createSnapshot({
items: [
{
taskKey: "aptss:spark-music",
packageName: "spark-music",
displayName: "Spark Music",
currentVersion: "3.0.0",
newVersion: "3.1.0",
source: "aptss" as const,
ignored: false,
},
],
}),
);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
await store.refresh();
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
await store.startSelected();
expect(start).not.toHaveBeenCalled();
});
it("clears selection across a close and reopen cycle", async () => {
const snapshot = createSnapshot({
items: [
{
taskKey: "aptss:spark-weather",
packageName: "spark-weather",
displayName: "Spark Weather",
currentVersion: "1.0.0",
newVersion: "2.0.0",
source: "aptss" as const,
ignored: false,
},
],
});
open.mockResolvedValue(snapshot);
const store = createUpdateCenterStore();
await store.open();
store.toggleSelection("aptss:spark-weather");
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(true);
store.requestClose();
expect(store.isOpen.value).toBe(false);
await store.open();
expect(store.selectedTaskKeys.value.has("aptss:spark-weather")).toBe(false);
await store.startSelected();
expect(start).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,303 @@
import { describe, expect, it, vi } from "vitest";
import type { UpdateCenterItem } from "../../../../electron/main/backend/update-center/types";
import {
createTaskRunner,
buildLegacySparkUpgradeCommand,
installUpdateItem,
} from "../../../../electron/main/backend/update-center/install";
import { createUpdateCenterQueue } from "../../../../electron/main/backend/update-center/queue";
const childProcessMock = vi.hoisted(() => ({
spawnCalls: [] as Array<{ command: string; args: string[] }>,
}));
vi.mock("node:child_process", () => ({
default: {
spawn: vi.fn((command: string, args: string[] = []) => {
childProcessMock.spawnCalls.push({ command, args });
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(
(
event: string,
callback: ((code?: number) => void) | (() => void),
) => {
if (event === "close") {
callback(0);
}
},
),
};
}),
},
spawn: vi.fn((command: string, args: string[] = []) => {
childProcessMock.spawnCalls.push({ command, args });
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(
(event: string, callback: ((code?: number) => void) | (() => void)) => {
if (event === "close") {
callback(0);
}
},
),
};
}),
}));
const createAptssItem = (): UpdateCenterItem => ({
pkgname: "spark-weather",
source: "aptss",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
downloadUrl: "https://example.invalid/spark-weather_2.0.0_amd64.deb",
fileName: "spark-weather_2.0.0_amd64.deb",
});
const createApmItem = (): UpdateCenterItem => ({
pkgname: "spark-player",
source: "apm",
currentVersion: "1.0.0",
nextVersion: "2.0.0",
downloadUrl: "https://example.invalid/spark-player_2.0.0_amd64.deb",
fileName: "spark-player_2.0.0_amd64.deb",
});
describe("update-center task runner", () => {
it("runs download then install and marks the task as completed", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
const steps: string[] = [];
queue.setItems([item]);
const task = queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
runDownload: async (context) => {
steps.push(`download:${context.task.pkgname}`);
context.onLog("download-started");
context.onProgress(40);
return {
filePath: `/tmp/${context.item.fileName}`,
};
},
installItem: async (context) => {
steps.push(`install:${context.task.pkgname}`);
context.onLog("install-started");
},
});
await runner.runNextTask();
expect(steps).toEqual(["download:spark-weather", "install:spark-weather"]);
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
warnings: [],
tasks: [
{
id: task.id,
pkgname: "spark-weather",
status: "completed",
progress: 100,
logs: [
expect.objectContaining({ message: "download-started" }),
expect.objectContaining({ message: "install-started" }),
],
},
],
});
});
it("returns a direct aptss upgrade command instead of spark-update-tool", () => {
expect(
buildLegacySparkUpgradeCommand("spark-weather", "/usr/bin/pkexec"),
).toEqual({
execCommand: "/usr/bin/pkexec",
execParams: [
"/opt/spark-store/extras/shell-caller.sh",
"aptss",
"install",
"-y",
"spark-weather",
"--only-upgrade",
],
});
});
it("blocks close while a refresh or task is still running", () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
queue.startRefresh();
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
queue.finishRefresh(["metadata warning"]);
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
warnings: ["metadata warning"],
});
const task = queue.enqueueItem(item);
queue.markActiveTask(task.id, "downloading");
expect(queue.getSnapshot().hasRunningTasks).toBe(true);
queue.finishTask(task.id, "cancelled");
expect(queue.getSnapshot().hasRunningTasks).toBe(false);
});
it("propagates privilege escalation into the install path", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
const installCalls: Array<{ superUserCmd?: string; filePath?: string }> =
[];
queue.setItems([item]);
queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
superUserCmd: "/usr/bin/pkexec",
runDownload: async () => ({ filePath: "/tmp/spark-weather.deb" }),
installItem: async (context) => {
installCalls.push({
superUserCmd: context.superUserCmd,
filePath: context.filePath,
});
},
});
await runner.runNextTask();
expect(installCalls).toEqual([
{
superUserCmd: "/usr/bin/pkexec",
filePath: "/tmp/spark-weather.deb",
},
]);
});
it("fails fast for apm items without a file path", async () => {
const queue = createUpdateCenterQueue();
const item = {
...createApmItem(),
downloadUrl: undefined,
fileName: undefined,
};
queue.setItems([item]);
const task = queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
installItem: async (context) => {
throw new Error(`unexpected install for ${context.item.pkgname}`);
},
});
await runner.runNextTask();
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
tasks: [
{
id: task.id,
status: "failed",
error: "APM update task requires downloaded package metadata",
},
],
});
});
it("does not duplicate work across concurrent runNextTask calls", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
let releaseDownload: (() => void) | undefined;
const downloadGate = new Promise<void>((resolve) => {
releaseDownload = resolve;
});
const runDownload = vi.fn(async () => {
await downloadGate;
return { filePath: "/tmp/spark-weather.deb" };
});
const installItem = vi.fn(async () => {});
queue.setItems([item]);
queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
runDownload,
installItem,
});
const firstRun = runner.runNextTask();
const secondRun = runner.runNextTask();
releaseDownload?.();
const results = await Promise.all([firstRun, secondRun]);
expect(runDownload).toHaveBeenCalledTimes(1);
expect(installItem).toHaveBeenCalledTimes(1);
expect(results[1]).toBeNull();
});
it("marks the task as failed when install fails", async () => {
const queue = createUpdateCenterQueue();
const item = createAptssItem();
queue.setItems([item]);
const task = queue.enqueueItem(item);
const runner = createTaskRunner(queue, {
runDownload: async (context) => {
context.onProgress(30);
return { filePath: "/tmp/spark-weather.deb" };
},
installItem: async () => {
throw new Error("install exploded");
},
});
await runner.runNextTask();
expect(queue.getSnapshot()).toMatchObject({
hasRunningTasks: false,
tasks: [
{
id: task.id,
status: "failed",
progress: 30,
error: "install exploded",
logs: [expect.objectContaining({ message: "install exploded" })],
},
],
});
});
it("does not fall through to ssinstall for apm file installs", async () => {
childProcessMock.spawnCalls.length = 0;
await installUpdateItem({
item: createApmItem(),
filePath: "/tmp/spark-player.deb",
superUserCmd: "/usr/bin/pkexec",
});
expect(childProcessMock.spawnCalls).toEqual([
{
command: "/usr/bin/pkexec",
args: [
"/opt/spark-store/extras/shell-caller.sh",
"apm",
"ssinstall",
"/tmp/spark-player.deb",
],
},
]);
});
});
+4 -1
View File
@@ -442,7 +442,10 @@
import { computed, useAttrs, ref, watch } from "vue"; import { computed, useAttrs, ref, watch } from "vue";
import axios from "axios"; import axios from "axios";
import { useInstallFeedback, downloads } from "../global/downloadStatus"; import { useInstallFeedback, downloads } from "../global/downloadStatus";
import { APM_STORE_BASE_URL, getHybridDefaultOrigin } from "../global/storeConfig"; import {
APM_STORE_BASE_URL,
getHybridDefaultOrigin,
} from "../global/storeConfig";
import type { App } from "../global/typedefinition"; import type { App } from "../global/typedefinition";
const attrs = useAttrs(); const attrs = useAttrs();
+21 -1
View File
@@ -34,6 +34,7 @@
<!-- 应用数量较多时使用虚拟滚动 --> <!-- 应用数量较多时使用虚拟滚动 -->
<RecycleScroller <RecycleScroller
v-else-if="!loading" v-else-if="!loading"
ref="scrollerRef"
class="scroller" class="scroller"
:items="gridRows" :items="gridRows"
:item-size="itemHeight" :item-size="itemHeight"
@@ -77,16 +78,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from "vue"; import { computed, ref, onMounted, onUnmounted, nextTick, watch } from "vue";
import { RecycleScroller } from "vue-virtual-scroller"; import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import AppCard from "./AppCard.vue"; import AppCard from "./AppCard.vue";
import type { App } from "../global/typedefinition"; import type { App } from "../global/typedefinition";
interface RecycleScrollerInstance {
$el: HTMLElement;
}
const props = defineProps<{ const props = defineProps<{
apps: App[]; apps: App[];
loading: boolean; loading: boolean;
storeFilter?: "spark" | "apm" | "both"; storeFilter?: "spark" | "apm" | "both";
scrollKey?: string;
}>(); }>();
defineEmits<{ defineEmits<{
@@ -95,6 +101,7 @@ defineEmits<{
// //
const columns = ref(4); const columns = ref(4);
const scrollerRef = ref<RecycleScrollerInstance | null>(null);
// //
const updateColumns = () => { const updateColumns = () => {
@@ -114,6 +121,19 @@ onUnmounted(() => {
window.removeEventListener("resize", updateColumns); window.removeEventListener("resize", updateColumns);
}); });
watch(
() => props.scrollKey,
async (nextKey, prevKey) => {
if (nextKey === prevKey || prevKey === undefined) return;
if (props.loading || props.apps.length <= 50) return;
await nextTick();
if (scrollerRef.value) {
scrollerRef.value.$el.scrollTop = 0;
}
},
);
// //
const gridColumnsClass = computed(() => { const gridColumnsClass = computed(() => {
const map: Record<number, string> = { const map: Record<number, string> = {
+1 -1
View File
@@ -89,7 +89,7 @@
<div class="border-t border-slate-200 pt-4 dark:border-slate-800"> <div class="border-t border-slate-200 pt-4 dark:border-slate-800">
<button <button
v-if="apmAvailable && storeFilter !== 'spark'" v-if="storeFilter !== 'spark'"
type="button" type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800" class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('list')" @click="$emit('list')"
+2
View File
@@ -39,6 +39,7 @@
? 'bg-brand/10 text-brand dark:bg-brand/15' ? 'bg-brand/10 text-brand dark:bg-brand/15'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200' : 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
" "
:disabled="!apmAvailable"
@click="$emit('switch-origin', 'apm')" @click="$emit('switch-origin', 'apm')"
> >
APM 软件 APM 软件
@@ -183,6 +184,7 @@ defineProps<{
error: string; error: string;
activeOrigin: "apm" | "spark"; activeOrigin: "apm" | "spark";
storeFilter: "spark" | "apm" | "both"; storeFilter: "spark" | "apm" | "both";
apmAvailable: boolean;
}>(); }>();
defineEmits<{ defineEmits<{
-145
View File
@@ -1,145 +0,0 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
>
<div
class="w-full max-w-4xl max-h-[85vh] overflow-y-auto scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
软件更新
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
可更新的 APM 应用
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="loading"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i>
刷新
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="loading || apps.length === 0"
@click="$emit('toggle-all')"
>
<i class="fas fa-check-square"></i>
全选/全不选
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
:disabled="loading || !hasSelected"
@click="$emit('upgrade-selected')"
>
<i class="fas fa-upload"></i>
更新选中
</button>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700"
@click="$emit('close')"
aria-label="关闭"
>
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<div class="mt-6 space-y-4">
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
正在检查可更新应用
</div>
<div
v-else-if="error"
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
>
{{ error }}
</div>
<div
v-else-if="apps.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
暂无可更新应用
</div>
<div v-else class="space-y-3">
<label
v-for="app in apps"
:key="app.pkgname"
class="flex flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
v-model="app.selected"
:disabled="app.upgrading"
/>
<div>
<p class="font-semibold text-slate-900 dark:text-white">
{{ app.pkgname }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
当前 {{ app.currentVersion || "-" }} · 更新至
{{ app.newVersion || "-" }}
</p>
</div>
</div>
<div class="flex items-center gap-2 sm:ml-auto">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-200"
:disabled="app.upgrading"
@click.prevent="$emit('upgrade-one', app)"
>
<i class="fas fa-arrow-up"></i>
{{ app.upgrading ? "更新中…" : "更新" }}
</button>
</div>
</label>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { UpdateAppItem } from "../global/typedefinition";
defineProps<{
show: boolean;
apps: UpdateAppItem[];
loading: boolean;
error: string;
hasSelected: boolean;
}>();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emit = defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "toggle-all"): void;
(e: "upgrade-selected"): void;
(e: "upgrade-one", app: UpdateAppItem): void;
}>();
</script>
+93
View File
@@ -0,0 +1,93 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-6 lg:py-10"
>
<div
class="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<UpdateCenterToolbar
:search-query="store.searchQuery.value"
:selected-count="selectedCount"
@refresh="store.refresh"
@start-selected="emit('request-start-selected')"
@request-close="store.requestClose"
@update:search-query="emit('update:search-query', $event)"
/>
<div
v-if="store.snapshot.value.warnings.length > 0"
class="mx-6 mt-4 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<p
v-for="warning in store.snapshot.value.warnings"
:key="warning"
class="leading-6"
>
{{ warning }}
</p>
</div>
<div
class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<UpdateCenterList
:items="store.filteredItems.value"
:tasks="store.snapshot.value.tasks"
:selected-task-keys="store.selectedTaskKeys.value"
@toggle-selection="emit('toggle-selection', $event)"
/>
<UpdateCenterLogPanel :tasks="store.snapshot.value.tasks" />
</div>
<UpdateCenterMigrationConfirm
:show="store.showMigrationConfirm.value"
@close="emit('dismiss-migration-confirm')"
@confirm="emit('confirm-migration-start')"
/>
<UpdateCenterCloseConfirm
:show="store.showCloseConfirm.value"
@close="emit('dismiss-close-confirm')"
@confirm="emit('confirm-close')"
/>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { UpdateCenterStore } from "@/modules/updateCenter";
import UpdateCenterCloseConfirm from "./update-center/UpdateCenterCloseConfirm.vue";
import UpdateCenterList from "./update-center/UpdateCenterList.vue";
import UpdateCenterLogPanel from "./update-center/UpdateCenterLogPanel.vue";
import UpdateCenterMigrationConfirm from "./update-center/UpdateCenterMigrationConfirm.vue";
import UpdateCenterToolbar from "./update-center/UpdateCenterToolbar.vue";
const emit = defineEmits<{
(e: "update:search-query", value: string): void;
(e: "toggle-selection", taskKey: string): void;
(e: "request-start-selected"): void;
(e: "confirm-migration-start"): void;
(e: "dismiss-migration-confirm"): void;
(e: "confirm-close"): void;
(e: "dismiss-close-confirm"): void;
}>();
const props = defineProps<{
show: boolean;
store: UpdateCenterStore;
}>();
const selectedCount = computed(() => props.store.getSelectedItems().length);
</script>
@@ -0,0 +1,44 @@
<template>
<div
v-if="show"
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
>
<div
class="w-full max-w-md rounded-3xl border border-amber-200 bg-white p-6 shadow-2xl dark:border-amber-500/30 dark:bg-slate-900"
>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
关闭确认
</p>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
更新仍在进行中确定关闭此窗口吗
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
@click="$emit('close')"
>
继续查看
</button>
<button
type="button"
class="rounded-2xl bg-amber-500 px-4 py-2 text-sm font-semibold text-white"
@click="$emit('confirm')"
>
确认关闭
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
}>();
defineEmits<{
(e: "close"): void;
(e: "confirm"): void;
}>();
</script>
@@ -0,0 +1,163 @@
<template>
<label
class="flex flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
>
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 accent-brand focus:ring-brand"
:checked="selected"
:disabled="item.ignored === true"
@change="$emit('toggle-selection')"
/>
<div
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500"
>
<img
:src="iconSrc"
:alt="`${item.displayName} 图标`"
class="h-full w-full object-cover"
@error="handleIconError"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-slate-900 dark:text-white">
{{ item.displayName }}
</p>
<span
class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
{{ sourceLabel }}
</span>
<span
v-if="item.isMigration"
class="rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand"
>
将迁移到 APM
</span>
<span
v-if="item.ignored === true"
class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
>
已忽略
</span>
</div>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ item.packageName }} · 当前 {{ item.currentVersion }} · 更新至
{{ item.newVersion }}
</p>
<p
v-if="item.ignored === true"
class="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400"
>
已忽略的更新不会加入本次任务
</p>
</div>
<div
v-if="task"
class="text-right text-sm font-semibold text-slate-600 dark:text-slate-300"
>
<p>{{ statusLabel }}</p>
<p v-if="showProgress" class="mt-1">{{ progressText }}</p>
</div>
</div>
<div v-if="showProgress" class="space-y-2">
<div
class="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<div
class="h-full rounded-full bg-gradient-to-r from-brand to-brand-dark"
:style="progressStyle"
></div>
</div>
</div>
</label>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type {
UpdateCenterItem,
UpdateCenterTaskState,
} from "@/global/typedefinition";
const props = defineProps<{
item: UpdateCenterItem;
task?: UpdateCenterTaskState;
selected: boolean;
}>();
const PLACEHOLDER_ICON =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Crect width="48" height="48" rx="12" fill="%23e2e8f0"/%3E%3Cpath d="M17 31h14v2H17zm3-12h8a2 2 0 0 1 2 2v8H18v-8a2 2 0 0 1 2-2" fill="%2394a3b8"/%3E%3C/svg%3E';
const iconIndex = ref(0);
defineEmits<{
(e: "toggle-selection"): void;
}>();
const normalizeIconSrc = (icon: string): string => {
if (/^[a-z]+:\/\//i.test(icon)) {
return icon;
}
return icon.startsWith("/") ? `file://${icon}` : icon;
};
const iconCandidates = computed(() => {
return [props.item.localIcon, props.item.remoteIcon].filter(
(icon): icon is string => Boolean(icon),
);
});
const handleIconError = () => {
if (iconIndex.value < iconCandidates.value.length) {
iconIndex.value += 1;
}
};
watch(
[() => props.item, () => props.item.localIcon, () => props.item.remoteIcon],
() => {
iconIndex.value = 0;
},
);
const iconSrc = computed(() => {
const icon = iconCandidates.value[iconIndex.value];
return icon ? normalizeIconSrc(icon) : PLACEHOLDER_ICON;
});
const sourceLabel = computed(() => {
return props.item.source === "apm" ? "APM" : "传统deb";
});
const statusLabel = computed(() => {
switch (props.task?.status) {
case "downloading":
return "下载中";
case "installing":
return "安装中";
case "completed":
return "已完成";
case "failed":
return "失败";
case "cancelled":
return "已取消";
default:
return "待处理";
}
});
const showProgress = computed(() => {
return (
props.task?.status === "downloading" || props.task?.status === "installing"
);
});
const progressText = computed(() => `${props.task?.progress ?? 0}%`);
const progressStyle = computed(() => ({ width: progressText.value }));
</script>
@@ -0,0 +1,47 @@
<template>
<div
class="min-h-0 overflow-y-auto border-r border-slate-200/70 p-6 dark:border-slate-800/70"
>
<div
v-if="items.length === 0"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
暂无可展示的更新任务
</div>
<div v-else class="space-y-3">
<UpdateCenterItem
v-for="item in items"
:key="item.taskKey"
:item="item"
:task="taskMap.get(item.taskKey)"
:selected="selectedTaskKeys.has(item.taskKey)"
@toggle-selection="$emit('toggle-selection', item.taskKey)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type {
UpdateCenterItem as UpdateCenterItemModel,
UpdateCenterTaskState,
} from "@/global/typedefinition";
import UpdateCenterItem from "./UpdateCenterItem.vue";
const props = defineProps<{
items: UpdateCenterItemModel[];
tasks: UpdateCenterTaskState[];
selectedTaskKeys: Set<string>;
}>();
defineEmits<{
(e: "toggle-selection", taskKey: string): void;
}>();
const taskMap = computed(() => {
return new Map(props.tasks.map((task) => [task.taskKey, task]));
});
</script>
@@ -0,0 +1,45 @@
<template>
<aside
class="hidden min-h-0 flex-col border-t border-slate-200/70 bg-slate-50/70 p-6 lg:flex lg:border-t-0 dark:border-slate-800/70 dark:bg-slate-950/50"
>
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-slate-900 dark:text-white">
任务日志
</p>
<span class="text-xs text-slate-500 dark:text-slate-400"
>{{ tasks.length }} </span
>
</div>
<div class="mt-4 min-h-0 flex-1 space-y-4 overflow-y-auto">
<div
v-if="tasks.length === 0"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
暂无运行日志
</div>
<div
v-for="task in tasks"
:key="task.taskKey"
class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 dark:border-slate-800/70 dark:bg-slate-900/70"
>
<p class="text-sm font-semibold text-slate-900 dark:text-white">
{{ task.packageName }}
</p>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ task.status }}
</p>
<p class="mt-3 text-xs leading-5 text-slate-600 dark:text-slate-300">
{{ task.logs.at(-1)?.message || task.errorMessage || "等待日志输出" }}
</p>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import type { UpdateCenterTaskState } from "@/global/typedefinition";
defineProps<{
tasks: UpdateCenterTaskState[];
}>();
</script>
@@ -0,0 +1,44 @@
<template>
<div
v-if="show"
class="absolute inset-0 flex items-center justify-center bg-slate-950/45 px-4"
>
<div
class="w-full max-w-md rounded-3xl border border-slate-200/70 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
迁移确认
</p>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
部分传统 deb 更新将迁移到 APM 管理
</p>
<div class="mt-4 flex justify-end gap-3">
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200"
@click="$emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white"
@click="$emit('confirm')"
>
继续更新
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
}>();
defineEmits<{
(e: "close"): void;
(e: "confirm"): void;
}>();
</script>
@@ -0,0 +1,73 @@
<template>
<div
class="flex flex-col gap-4 border-b border-slate-200/70 px-6 py-5 dark:border-slate-800/70"
>
<div class="flex items-start gap-4">
<div class="flex-1">
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
软件更新
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
集中管理 APM 与传统 deb 更新任务
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="$emit('refresh')"
>
<i class="fas fa-sync-alt"></i>
刷新
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
:disabled="selectedCount === 0"
@click="$emit('start-selected')"
>
<i class="fas fa-play"></i>
更新选中 ({{ selectedCount }})
</button>
<button
type="button"
aria-label="关闭"
class="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-300"
@click="$emit('request-close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
<label class="block">
<span class="sr-only">搜索更新</span>
<input
:value="searchQuery"
type="search"
placeholder="搜索应用或包名"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-brand/60 focus:bg-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:focus:bg-slate-900"
@input="handleInput"
/>
</label>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
searchQuery: string;
selectedCount: number;
}>();
const emit = defineEmits<{
(e: "refresh"): void;
(e: "start-selected"): void;
(e: "request-close"): void;
(e: "update:search-query", value: string): void;
}>();
const handleInput = (event: Event): void => {
const target = event.target as HTMLInputElement | null;
emit("update:search-query", target?.value ?? props.searchQuery);
};
</script>
+4 -16
View File
@@ -102,7 +102,10 @@ export async function loadPriorityConfig(arch: string): Promise<void> {
}; };
} }
isPriorityConfigLoaded = true; isPriorityConfigLoaded = true;
console.log("[PriorityConfig] 已从服务器加载优先级配置:", dynamicPriorityConfig); console.log(
"[PriorityConfig] 已从服务器加载优先级配置:",
dynamicPriorityConfig,
);
} else { } else {
// 配置文件不存在,默认优先 Spark // 配置文件不存在,默认优先 Spark
console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先"); console.log("[PriorityConfig] 服务器无配置文件,使用默认 Spark 优先");
@@ -136,21 +139,6 @@ function resetPriorityConfig(): void {
isPriorityConfigLoaded = true; isPriorityConfigLoaded = true;
} }
/**
*
*/
function isConfigEmpty(): boolean {
const { sparkPriority, apmPriority } = dynamicPriorityConfig;
return (
sparkPriority.pkgnames.length === 0 &&
sparkPriority.categories.length === 0 &&
sparkPriority.tags.length === 0 &&
apmPriority.pkgnames.length === 0 &&
apmPriority.categories.length === 0 &&
apmPriority.tags.length === 0
);
}
/** /**
* *
* *
+67
View File
@@ -123,6 +123,73 @@ export interface UpdateAppItem {
upgrading?: boolean; upgrading?: boolean;
} }
export type UpdateSource = "aptss" | "apm";
export type UpdateCenterTaskStatus =
| "queued"
| "downloading"
| "installing"
| "completed"
| "failed"
| "cancelled";
export interface UpdateCenterItem {
taskKey: string;
packageName: string;
displayName: string;
currentVersion: string;
newVersion: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
ignored?: boolean;
downloadUrl?: string;
fileName?: string;
size?: number;
sha512?: string;
isMigration?: boolean;
migrationSource?: UpdateSource;
migrationTarget?: UpdateSource;
aptssVersion?: string;
}
export interface UpdateCenterTaskState {
taskKey: string;
packageName: string;
source: UpdateSource;
localIcon?: string;
remoteIcon?: string;
status: UpdateCenterTaskStatus;
progress: number;
logs: Array<{ time: number; message: string }>;
errorMessage: string;
}
export interface UpdateCenterSnapshot {
items: UpdateCenterItem[];
tasks: UpdateCenterTaskState[];
warnings: string[];
hasRunningTasks: boolean;
}
export interface UpdateCenterBridge {
open: () => Promise<UpdateCenterSnapshot>;
refresh: () => Promise<UpdateCenterSnapshot>;
ignore: (payload: {
packageName: string;
newVersion: string;
}) => Promise<void>;
unignore: (payload: {
packageName: string;
newVersion: string;
}) => Promise<void>;
start: (taskKeys: string[]) => Promise<void>;
cancel: (taskKey: string) => Promise<void>;
getState: () => Promise<UpdateCenterSnapshot>;
onState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
offState: (listener: (snapshot: UpdateCenterSnapshot) => void) => void;
}
/**************Below are type from main process ********************/ /**************Below are type from main process ********************/
export interface InstalledAppInfo { export interface InstalledAppInfo {
pkgname: string; pkgname: string;
+182
View File
@@ -0,0 +1,182 @@
import { computed, ref, type ComputedRef, type Ref } from "vue";
import type {
UpdateCenterItem,
UpdateCenterSnapshot,
} from "@/global/typedefinition";
const EMPTY_SNAPSHOT: UpdateCenterSnapshot = {
items: [],
tasks: [],
warnings: [],
hasRunningTasks: false,
};
export interface UpdateCenterStore {
isOpen: Ref<boolean>;
showCloseConfirm: Ref<boolean>;
showMigrationConfirm: Ref<boolean>;
searchQuery: Ref<string>;
selectedTaskKeys: Ref<Set<string>>;
snapshot: Ref<UpdateCenterSnapshot>;
filteredItems: ComputedRef<UpdateCenterItem[]>;
bind: () => void;
unbind: () => void;
open: () => Promise<void>;
refresh: () => Promise<void>;
toggleSelection: (taskKey: string) => void;
getSelectedItems: () => UpdateCenterItem[];
closeNow: () => void;
startSelected: () => Promise<void>;
requestClose: () => void;
}
const matchesSearch = (item: UpdateCenterItem, query: string): boolean => {
if (query.length === 0) {
return true;
}
const normalizedQuery = query.toLowerCase();
return [item.displayName, item.packageName, item.taskKey].some((value) =>
value.toLowerCase().includes(normalizedQuery),
);
};
export const createUpdateCenterStore = (): UpdateCenterStore => {
const isOpen = ref(false);
const showCloseConfirm = ref(false);
const showMigrationConfirm = ref(false);
const searchQuery = ref("");
const selectedTaskKeys = ref(new Set<string>());
const snapshot = ref<UpdateCenterSnapshot>(EMPTY_SNAPSHOT);
const resetSessionState = (): void => {
showCloseConfirm.value = false;
showMigrationConfirm.value = false;
searchQuery.value = "";
selectedTaskKeys.value = new Set();
};
const applySnapshot = (nextSnapshot: UpdateCenterSnapshot): void => {
const selectableTaskKeys = new Set(
nextSnapshot.items
.filter((item) => item.ignored !== true)
.map((item) => item.taskKey),
);
selectedTaskKeys.value = new Set(
[...selectedTaskKeys.value].filter((taskKey) =>
selectableTaskKeys.has(taskKey),
),
);
snapshot.value = nextSnapshot;
};
const filteredItems = computed(() => {
const query = searchQuery.value.trim();
return snapshot.value.items.filter((item) => matchesSearch(item, query));
});
const handleState = (nextSnapshot: UpdateCenterSnapshot): void => {
applySnapshot(nextSnapshot);
};
let isBound = false;
const bind = (): void => {
if (isBound) {
return;
}
window.updateCenter.onState(handleState);
isBound = true;
};
const unbind = (): void => {
if (!isBound) {
return;
}
window.updateCenter.offState(handleState);
isBound = false;
};
const open = async (): Promise<void> => {
resetSessionState();
const nextSnapshot = await window.updateCenter.open();
applySnapshot(nextSnapshot);
isOpen.value = true;
};
const refresh = async (): Promise<void> => {
const nextSnapshot = await window.updateCenter.refresh();
applySnapshot(nextSnapshot);
};
const toggleSelection = (taskKey: string): void => {
const item = snapshot.value.items.find(
(entry) => entry.taskKey === taskKey,
);
if (!item || item.ignored === true) {
return;
}
const nextSelection = new Set(selectedTaskKeys.value);
if (nextSelection.has(taskKey)) {
nextSelection.delete(taskKey);
} else {
nextSelection.add(taskKey);
}
selectedTaskKeys.value = nextSelection;
};
const getSelectedItems = (): UpdateCenterItem[] => {
return snapshot.value.items.filter(
(item) =>
selectedTaskKeys.value.has(item.taskKey) && item.ignored !== true,
);
};
const closeNow = (): void => {
resetSessionState();
isOpen.value = false;
};
const startSelected = async (): Promise<void> => {
const taskKeys = getSelectedItems().map((item) => item.taskKey);
if (taskKeys.length === 0) {
return;
}
await window.updateCenter.start(taskKeys);
};
const requestClose = (): void => {
if (snapshot.value.hasRunningTasks) {
showCloseConfirm.value = true;
return;
}
closeNow();
};
return {
isOpen,
showCloseConfirm,
showMigrationConfirm,
searchQuery,
selectedTaskKeys,
snapshot,
filteredItems,
bind,
unbind,
open,
refresh,
toggleSelection,
getSelectedItems,
closeNow,
startSelected,
requestClose,
};
};
+19 -28
View File
@@ -1,18 +1,30 @@
/* eslint-disable */ /* eslint-disable */
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import type { UpdateCenterBridge } from "@/global/typedefinition";
declare module "*.vue" { declare module "*.vue" {
import type { DefineComponent } from "vue"; import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>; const component: DefineComponent<{}, {}, any>;
export default component; export default component;
} }
interface Window { declare global {
// expose in the `electron/preload/index.ts` interface Window {
ipcRenderer: import("electron").IpcRenderer; // expose in the `electron/preload/index.ts`
apm_store: { ipcRenderer: IpcRendererFacade;
arch: string; apm_store: {
}; arch: string;
};
updateCenter: UpdateCenterBridge;
}
}
interface IpcRendererFacade {
on: import("electron").IpcRenderer["on"];
off: import("electron").IpcRenderer["off"];
send: import("electron").IpcRenderer["send"];
invoke: import("electron").IpcRenderer["invoke"];
} }
// IPC channel type definitions // IPC channel type definitions
@@ -22,25 +34,4 @@ declare interface IpcChannels {
declare const __APP_VERSION__: string; declare const __APP_VERSION__: string;
// vue-virtual-scroller type declarations export {};
declare module "vue-virtual-scroller" {
import { DefineComponent } from "vue";
export const RecycleScroller: DefineComponent<{
items: any[];
itemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScroller: DefineComponent<{
items: any[];
minItemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScrollerItem: DefineComponent<{}>;
}
+21
View File
@@ -0,0 +1,21 @@
declare module "vue-virtual-scroller" {
import type { DefineComponent } from "vue";
export const RecycleScroller: DefineComponent<{
items: unknown[];
itemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScroller: DefineComponent<{
items: unknown[];
minItemSize: number;
keyField?: string;
direction?: "vertical" | "horizontal";
buffer?: number;
}>;
export const DynamicScrollerItem: DefineComponent<Record<string, never>>;
}
+4 -1
View File
@@ -25,9 +25,12 @@
"include": [ "include": [
"src" "src"
], ],
"exclude": [
"src/__tests__"
],
"references": [ "references": [
{ {
"path": "./tsconfig.node.json" "path": "./tsconfig.node.json"
} }
] ]
} }