feat: init repo

This commit is contained in:
ParrotXray 2026-06-09 16:43:36 +08:00
commit c30fe12058
Signed by: ParrotXray
SSH Key Fingerprint: SHA256:OEnKoo72UOfrmZ3LVSBn9K/UfpEzNrl+q8JMLG9CaAI
100 changed files with 16396 additions and 0 deletions

38
.github/ISSUE_TEMPLATE/bug-提交.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug 提交
about: 创建一个报告来帮助我们改进
title: ''
labels: ''
assignees: ''
---
**描述错误**
对bug的清晰而简洁的描述
**重现过程**
重现触发bug的步骤
1. 进入 '...'
2. 点击 '....'
3. 滚动到 '....'
4. 发现错误
**预期行为**
清晰简洁地描述你期望发生的事情
**屏幕截图**
如果可以的话,强烈建议添加屏幕截图来帮助解决您的问题
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**附加内容**
在此处添加有关该问题的任何其他内容

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

20
.github/ISSUE_TEMPLATE/功能-建议.md vendored Normal file
View File

@ -0,0 +1,20 @@
---
name: 功能 建议
about: 为这个项目提出一个新点子
title: ''
labels: ''
assignees: ''
---
**您的功能请求是否与目前所存在的问题有关?请描述一下**
清晰简洁地描述问题是什么。例如,我总是感到很麻烦,当 [...]
**描述您想要的解决方案**
清晰简洁地描述你想发生什么
**描述你考虑过的替代方案**
清晰简洁地描述您考虑过的任何替代解决方案或功能
**附加内容**
在此处添加有关功能请求的任何其他内容或屏幕截图

61
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,61 @@
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Archive
run: |
xcodebuild archive \
-project "Open Wallpaper Engine.xcodeproj" \
-scheme "Open Wallpaper Engine" \
-destination "platform=macOS" \
-archivePath "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive" \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
- name: Bundle DepotDownloader into .app
run: |
APP=$(find "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive/Products" -name "*.app" | head -1)
LATEST=$(curl -s -H "Authorization: Bearer ${{ github.token }}" \
https://api.github.com/repos/SteamRE/DepotDownloader/releases/latest \
| jq -r '.tag_name')
if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
echo "Failed to resolve latest DepotDownloader tag"; exit 1
fi
echo "Bundling DepotDownloader $LATEST"
for ARCH in arm64 x64; do
DIR="$APP/Contents/Resources/depotdownloader/$ARCH"
mkdir -p "$DIR"
curl -sL "https://github.com/SteamRE/DepotDownloader/releases/download/${LATEST}/DepotDownloader-macos-${ARCH}.zip" \
-o "/tmp/depot_${ARCH}.zip"
unzip -q "/tmp/depot_${ARCH}.zip" -d "$DIR"
chmod +x "$DIR/DepotDownloader"
done
- name: Ad-hoc sign
run: |
APP=$(find "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive/Products" -name "*.app" | head -1)
codesign --force --deep --sign - "$APP"
- name: Package .app
run: |
APP=$(find "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive/Products" -name "*.app" | head -1)
ditto -c -k --keepParent "$APP" "Open-Wallpaper-Engine-macos.zip"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: Open-Wallpaper-Engine-macos
path: Open-Wallpaper-Engine-macos.zip
retention-days: 7

132
.github/workflows/nightly-build.yml vendored Normal file
View File

@ -0,0 +1,132 @@
name: Nightly Build
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
env:
UPSTREAM: https://github.com/Unayung/wallpaper-engine-mac.git
jobs:
check-version:
runs-on: ubuntu-24.04
outputs:
sha: ${{ steps.upstream.outputs.sha }}
short: ${{ steps.upstream.outputs.short }}
skip: ${{ steps.check.outputs.skip }}
steps:
- uses: actions/checkout@v4
- name: Get latest commit SHA from upstream
id: upstream
run: |
SHA=$(git ls-remote ${{ env.UPSTREAM }} refs/heads/main | cut -f1)
echo "sha=$SHA" >> $GITHUB_OUTPUT
echo "short=${SHA:0:7}" >> $GITHUB_OUTPUT
- name: Check if already built
id: check
run: |
LAST=$(cat .last-built-sha 2>/dev/null || echo "none")
if [ "$LAST" = "${{ steps.upstream.outputs.sha }}" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
build:
needs: check-version
if: needs.check-version.outputs.skip == 'false'
runs-on: macos-14
steps:
- name: Clone upstream
run: |
git clone ${{ env.UPSTREAM }} repo
cd repo && git checkout ${{ needs.check-version.outputs.sha }}
- name: Archive
run: |
xcodebuild archive \
-project "repo/Open Wallpaper Engine.xcodeproj" \
-scheme "Open Wallpaper Engine" \
-destination "platform=macOS" \
-archivePath "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive" \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
- name: Bundle DepotDownloader into .app
run: |
APP=$(find "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive/Products" -name "*.app" | head -1)
LATEST=$(curl -s -H "Authorization: Bearer ${{ github.token }}" \
https://api.github.com/repos/SteamRE/DepotDownloader/releases/latest \
| jq -r '.tag_name')
if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
echo "Failed to resolve latest DepotDownloader tag"; exit 1
fi
echo "Bundling DepotDownloader $LATEST"
for ARCH in arm64 x64; do
DIR="$APP/Contents/Resources/depotdownloader/$ARCH"
mkdir -p "$DIR"
curl -sL "https://github.com/SteamRE/DepotDownloader/releases/download/${LATEST}/DepotDownloader-macos-${ARCH}.zip" \
-o "/tmp/depot_${ARCH}.zip"
unzip -q "/tmp/depot_${ARCH}.zip" -d "$DIR"
chmod +x "$DIR/DepotDownloader"
done
- name: Ad-hoc sign
run: |
APP=$(find "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive/Products" -name "*.app" | head -1)
codesign --force --deep --sign - "$APP"
- name: Package .app
run: |
APP=$(find "$RUNNER_TEMP/OpenWallpaperEngine.xcarchive/Products" -name "*.app" | head -1)
ditto -c -k --keepParent "$APP" \
"Open-Wallpaper-Engine-macos-${{ needs.check-version.outputs.short }}.zip"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app-${{ needs.check-version.outputs.short }}
path: Open-Wallpaper-Engine-macos-${{ needs.check-version.outputs.short }}.zip
release:
needs: [check-version, build]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: app-${{ needs.check-version.outputs.short }}
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: nightly-${{ needs.check-version.outputs.short }}
name: "Nightly ${{ needs.check-version.outputs.short }}"
prerelease: true
body: |
Auto nightly build from [Unayung/wallpaper-engine-mac@${{ needs.check-version.outputs.sha }}](https://github.com/Unayung/wallpaper-engine-mac/commit/${{ needs.check-version.outputs.sha }})
> Ad-hoc 簽署版本(未經 Apple 公證),首次開啟方式:
> - **macOS 15 Sequoia+**:嘗試開啟後,前往「系統設定 → 隱私權與安全性」,點「仍要開啟」(Open Anyway)
> - **macOS 14 Sonoma 以下**:右鍵 → 開啟Open
> - **終端機(任何版本)**`xattr -rd com.apple.quarantine /Applications/Open\ Wallpaper\ Engine.app`
files: "*.zip"
- name: Save built SHA
run: |
echo "${{ needs.check-version.outputs.sha }}" > .last-built-sha
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .last-built-sha
git commit -m "chore: built ${{ needs.check-version.outputs.short }}"
git push

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Xcode
## User settings
xcuserdata/
## Build products
build/
DerivedData/
## OS junk
.DS_Store
Thumbs.db
## Playwright MCP
.playwright-mcp/

24
AUTHORS.md Normal file
View File

@ -0,0 +1,24 @@
Contributors to Open Wallpaper Engine
============================
Maintainers (sorted alphabetically)
---------------------------------------
Maintainers are actively supporting the project and have made substantial contributions to the repository.<br>
They have admin access to the repo and provide support reviewing issues and pull requests.
* **[MrWindDog](https://github.com/mrwinddog)**
* The Founder of this repo
* **[Haren Chen](https://github.com/haren724)**
* The Founder of the original repo
Contributors (sorted alphabetically)
-------------------------------------
[Full List of Contributors](https://github.com/Microsoft/Recommenders/graphs/contributors)
To contributors: please add your name to the list when you submit a patch to the project.
* **[1ris_W](https://github.com/Erica-Iris)**
* Help with Chinese i18n translation
* **[Klaus Zhu](https://github.com/klauszhu1105)**
* Designed the App's logo icons

1
CNAME Normal file
View File

@ -0,0 +1 @@
owe.haren724.top

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
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.
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.
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.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
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.
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.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. 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 <https://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
<https://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
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -0,0 +1,712 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
5E0700E62A859FAD00ADFE2E /* WallpaperNotFound.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = 5E0700E52A859FAD00ADFE2E /* WallpaperNotFound.mp4 */; };
5E2F1E892A57DF2D0081D69D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 5E2F1E882A57DF2D0081D69D /* Localizable.xcstrings */; };
5E32ABFE2A4D595D00A0EEEC /* FilterResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E32ABFD2A4D595D00A0EEEC /* FilterResults.swift */; };
5E51A43A2AA30D8B00E46E99 /* UnsafeWallpaper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E51A4392AA30D8B00E46E99 /* UnsafeWallpaper.swift */; };
5E51A43C2AA33F5500E46E99 /* GlobalSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E51A43B2AA33F5500E46E99 /* GlobalSettingsService.swift */; };
5E615E222A7D2F9700A37EB8 /* ImportPanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E615E212A7D2F9700A37EB8 /* ImportPanels.swift */; };
5E66A0DA2A8ADECC00EE79D6 /* TopTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0D92A8ADECC00EE79D6 /* TopTabBar.swift */; };
5E66A0DD2A8ADF7C00EE79D6 /* ExplorerTopBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0DC2A8ADF7C00EE79D6 /* ExplorerTopBar.swift */; };
5E66A0DF2A8AE3C900EE79D6 /* ContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0DE2A8AE3C900EE79D6 /* ContentViewModel.swift */; };
5E66A0E12A8BA18D00EE79D6 /* GifImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0E02A8BA18D00EE79D6 /* GifImage.swift */; };
5E66A0E42A8BA27600EE79D6 /* FilterResultsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0E32A8BA27600EE79D6 /* FilterResultsViewModel.swift */; };
5E66A0E62A8BA69B00EE79D6 /* WallpaperExplorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0E52A8BA69B00EE79D6 /* WallpaperExplorer.swift */; };
5E66A0E82A8BA95600EE79D6 /* WallpaperPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0E72A8BA95600EE79D6 /* WallpaperPreview.swift */; };
5E66A0EA2A8BAA5400EE79D6 /* ExplorerGlobalMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0E92A8BAA5400EE79D6 /* ExplorerGlobalMenu.swift */; };
5E66A0ED2A8BABAB00EE79D6 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E66A0EC2A8BABAB00EE79D6 /* DisplaySettings.swift */; };
5E7665262A2DC30E00E0E4B4 /* WEProject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7665252A2DC30D00E0E4B4 /* WEProject.swift */; };
5E76652A2A2E231400E0E4B4 /* AboutUsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7665292A2E231400E0E4B4 /* AboutUsView.swift */; };
5E7A89C02AAB9D4E00192EB0 /* maxwell-cat.gif in Resources */ = {isa = PBXBuildFile; fileRef = 5E7A89BF2AAB9D4E00192EB0 /* maxwell-cat.gif */; };
5E8C82032A9E15D0009E291B /* ExplorerItemMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E8C82022A9E15D0009E291B /* ExplorerItemMenu.swift */; };
5E98F6C72A81D8B200C12E34 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E98F6C62A81D8B200C12E34 /* MainWindow.swift */; };
5E9D40442A2EC68600AB8A0C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E9D40432A2EC68600AB8A0C /* AppDelegate.swift */; };
5EA310FC2A88E116001A65F4 /* SceneWallpaperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA310FB2A88E116001A65F4 /* SceneWallpaperView.swift */; };
5EA310FE2A88E135001A65F4 /* WebWallpaperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA310FD2A88E135001A65F4 /* WebWallpaperView.swift */; };
5EA969A32A7D4BA30027B963 /* FirstLaunchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA969A22A7D4BA30027B963 /* FirstLaunchView.swift */; };
5EAD1DB82A88DFB80067E1D2 /* VideoWallpaperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EAD1DB72A88DFB80067E1D2 /* VideoWallpaperView.swift */; };
5EAF3F702A870DEC00F85685 /* PerformancePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EAF3F6F2A870DEC00F85685 /* PerformancePage.swift */; };
5EAF3F722A870ED400F85685 /* GeneralPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EAF3F712A870ED400F85685 /* GeneralPage.swift */; };
5EAF3F742A87110700F85685 /* PluginsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EAF3F732A87110700F85685 /* PluginsPage.swift */; };
5EB152A12A82ADEB007EA845 /* Open_Wallpaper_Engine.docc in Sources */ = {isa = PBXBuildFile; fileRef = 5EB152A02A82ADEB007EA845 /* Open_Wallpaper_Engine.docc */; };
5EB4DB882A98055E000A1FEA /* ExplorerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB4DB872A980532000A1FEA /* ExplorerItem.swift */; };
5EC47E7E2A2DB2E700120CCC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5EC47E7D2A2DB2E700120CCC /* Assets.xcassets */; };
5EC47E812A2DB2E700120CCC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5EC47E802A2DB2E700120CCC /* Preview Assets.xcassets */; };
5EC47EAA2A2DB32900120CCC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC47EA92A2DB32900120CCC /* ContentView.swift */; };
5EC47EAC2A2DB3E500120CCC /* WallpaperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC47EAB2A2DB3E500120CCC /* WallpaperView.swift */; };
5EDEBA5E2A2E2A7B003470CB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EDEBA5D2A2E2A7B003470CB /* SettingsView.swift */; };
5EDF1E142A93B06B006617EA /* WorkingInProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EDF1E132A93B06B006617EA /* WorkingInProgress.swift */; };
5EDF1E162A93B4B6006617EA /* WallpaperDiscover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EDF1E152A93B4B6006617EA /* WallpaperDiscover.swift */; };
5EF28EBF2A8986AC0013ABBE /* WallpaperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF28EBE2A8986AC0013ABBE /* WallpaperViewModel.swift */; };
5EF28EC12A8990D90013ABBE /* VideoWallpaperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF28EC02A8990D90013ABBE /* VideoWallpaperViewModel.swift */; };
5EFBB0BB2A2F10EA00E39BEC /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFBB0BA2A2F10EA00E39BEC /* main.swift */; };
5EFC591D2A81ACBC005F97E2 /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFC591C2A81ACBC005F97E2 /* StatusBar.swift */; };
5EFC591F2A81ACC3005F97E2 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFC591E2A81ACC3005F97E2 /* MainMenu.swift */; };
DAEE29C62A9CB52100633898 /* WebWallpaperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEE29C52A9CB52100633898 /* WebWallpaperViewModel.swift */; };
AA1B2C3D4E5F600200ABCD01 /* PKGParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F600100ABCD01 /* PKGParser.swift */; };
AA1B2C3D4E5F600200ABCD02 /* TEXParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F600100ABCD02 /* TEXParser.swift */; };
AA1B2C3D4E5F600200ABCD03 /* SceneModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F600100ABCD03 /* SceneModels.swift */; };
AA1B2C3D4E5F600200ABCD04 /* SceneWallpaperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F600100ABCD04 /* SceneWallpaperViewModel.swift */; };
AA1B2C3D4E5F600200ABCD05 /* SpriteKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F600100ABCD05 /* SpriteKit.framework */; };
BB0000000000000000000003 /* ObjCExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* ObjCExceptionCatcher.m */; };
EE0000000000000000000001 /* WELogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0000000000000000000002 /* WELogger.swift */; };
CC0000000000000000000001 /* WallpaperDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000002 /* WallpaperDirectory.swift */; };
DD0000000000000000000001 /* WallpaperWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0000000000000000000002 /* WallpaperWindowManager.swift */; };
CC0000000000000000000003 /* ZipImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000004 /* ZipImporter.swift */; };
CC0000000000000000000005 /* SteamCmdService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000006 /* SteamCmdService.swift */; };
CC0000000000000000000007 /* WorkshopAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000008 /* WorkshopAPIService.swift */; };
CC0000000000000000000009 /* WorkshopViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000000000000000000000A /* WorkshopViewModel.swift */; };
CC000000000000000000000B /* WorkshopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000000000000000000000C /* WorkshopView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
5E0700E52A859FAD00ADFE2E /* WallpaperNotFound.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = WallpaperNotFound.mp4; sourceTree = "<group>"; };
5E1670732A88EBEF00D1B199 /* change-signing-certificate.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "change-signing-certificate.png"; sourceTree = "<group>"; };
5E1670742A88ED8400D1B199 /* import-menu.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "import-menu.png"; sourceTree = "<group>"; };
5E1670752A88EDF000D1B199 /* import-panel.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "import-panel.png"; sourceTree = "<group>"; };
5E2F1E882A57DF2D0081D69D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
5E32ABFD2A4D595D00A0EEEC /* FilterResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterResults.swift; sourceTree = "<group>"; };
5E51A4392AA30D8B00E46E99 /* UnsafeWallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsafeWallpaper.swift; sourceTree = "<group>"; };
5E51A43B2AA33F5500E46E99 /* GlobalSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSettingsService.swift; sourceTree = "<group>"; };
5E5460C62A83026D00655D85 /* wallpaper-with-windows.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wallpaper-with-windows.png"; sourceTree = "<group>"; };
5E5460C72A83027400655D85 /* wallpaper.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = wallpaper.png; sourceTree = "<group>"; };
5E6091F12A83E85D00D4C36C /* AUTHORS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = AUTHORS.md; sourceTree = "<group>"; };
5E615E212A7D2F9700A37EB8 /* ImportPanels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportPanels.swift; sourceTree = "<group>"; };
5E66A0D92A8ADECC00EE79D6 /* TopTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopTabBar.swift; sourceTree = "<group>"; };
5E66A0DC2A8ADF7C00EE79D6 /* ExplorerTopBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplorerTopBar.swift; sourceTree = "<group>"; };
5E66A0DE2A8AE3C900EE79D6 /* ContentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewModel.swift; sourceTree = "<group>"; };
5E66A0E02A8BA18D00EE79D6 /* GifImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifImage.swift; sourceTree = "<group>"; };
5E66A0E32A8BA27600EE79D6 /* FilterResultsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterResultsViewModel.swift; sourceTree = "<group>"; };
5E66A0E52A8BA69B00EE79D6 /* WallpaperExplorer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperExplorer.swift; sourceTree = "<group>"; };
5E66A0E72A8BA95600EE79D6 /* WallpaperPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperPreview.swift; sourceTree = "<group>"; };
5E66A0E92A8BAA5400EE79D6 /* ExplorerGlobalMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplorerGlobalMenu.swift; sourceTree = "<group>"; };
5E66A0EC2A8BABAB00EE79D6 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; };
5E7665252A2DC30D00E0E4B4 /* WEProject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WEProject.swift; sourceTree = "<group>"; };
5E7665292A2E231400E0E4B4 /* AboutUsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutUsView.swift; sourceTree = "<group>"; };
5E7A89BF2AAB9D4E00192EB0 /* maxwell-cat.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = "maxwell-cat.gif"; sourceTree = "<group>"; };
5E8C82022A9E15D0009E291B /* ExplorerItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplorerItemMenu.swift; sourceTree = "<group>"; };
5E98F6C62A81D8B200C12E34 /* MainWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
5E9D40432A2EC68600AB8A0C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
5EA310FB2A88E116001A65F4 /* SceneWallpaperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneWallpaperView.swift; sourceTree = "<group>"; };
5EA310FD2A88E135001A65F4 /* WebWallpaperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebWallpaperView.swift; sourceTree = "<group>"; };
5EA969A22A7D4BA30027B963 /* FirstLaunchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirstLaunchView.swift; sourceTree = "<group>"; };
5EAD1DB72A88DFB80067E1D2 /* VideoWallpaperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoWallpaperView.swift; sourceTree = "<group>"; };
5EAF3F6F2A870DEC00F85685 /* PerformancePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformancePage.swift; sourceTree = "<group>"; };
5EAF3F712A870ED400F85685 /* GeneralPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPage.swift; sourceTree = "<group>"; };
5EAF3F732A87110700F85685 /* PluginsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginsPage.swift; sourceTree = "<group>"; };
5EB152A02A82ADEB007EA845 /* Open_Wallpaper_Engine.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Open_Wallpaper_Engine.docc; sourceTree = "<group>"; };
5EB4DB872A980532000A1FEA /* ExplorerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExplorerItem.swift; sourceTree = "<group>"; };
5EC47E762A2DB2E600120CCC /* Open Wallpaper Engine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Open Wallpaper Engine.app"; sourceTree = BUILT_PRODUCTS_DIR; };
5EC47E7D2A2DB2E700120CCC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5EC47E802A2DB2E700120CCC /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
5EC47E872A2DB2E700120CCC /* Open_Wallpaper_Engine.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Open_Wallpaper_Engine.entitlements; sourceTree = "<group>"; };
5EC47EA92A2DB32900120CCC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
5EC47EAB2A2DB3E500120CCC /* WallpaperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperView.swift; sourceTree = "<group>"; };
5EDEBA5D2A2E2A7B003470CB /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5EDF1E132A93B06B006617EA /* WorkingInProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkingInProgress.swift; sourceTree = "<group>"; };
5EDF1E152A93B4B6006617EA /* WallpaperDiscover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperDiscover.swift; sourceTree = "<group>"; };
5EF28EBE2A8986AC0013ABBE /* WallpaperViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperViewModel.swift; sourceTree = "<group>"; };
5EF28EC02A8990D90013ABBE /* VideoWallpaperViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoWallpaperViewModel.swift; sourceTree = "<group>"; };
5EF4C24A2A4C9871009831E5 /* CNAME */ = {isa = PBXFileReference; lastKnownFileType = text; path = CNAME; sourceTree = "<group>"; };
5EF4C24B2A4C9872009831E5 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
5EF4C24C2A4C98FA009831E5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
5EFBB0BA2A2F10EA00E39BEC /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
5EFC591C2A81ACBC005F97E2 /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = "<group>"; };
5EFC591E2A81ACC3005F97E2 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = "<group>"; };
B4AD1D0F2AA24E8200471AC8 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = "<group>"; };
DAEE29C52A9CB52100633898 /* WebWallpaperViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebWallpaperViewModel.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F600100ABCD01 /* PKGParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKGParser.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F600100ABCD02 /* TEXParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TEXParser.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F600100ABCD03 /* SceneModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneModels.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F600100ABCD04 /* SceneWallpaperViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneWallpaperViewModel.swift; sourceTree = "<group>"; };
AA1B2C3D4E5F600100ABCD05 /* SpriteKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SpriteKit.framework; path = System/Library/Frameworks/SpriteKit.framework; sourceTree = SDKROOT; };
BB0000000000000000000001 /* ObjCExceptionCatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ObjCExceptionCatcher.h; sourceTree = "<group>"; };
BB0000000000000000000002 /* ObjCExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCExceptionCatcher.m; sourceTree = "<group>"; };
EE0000000000000000000002 /* WELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WELogger.swift; sourceTree = "<group>"; };
CC0000000000000000000002 /* WallpaperDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperDirectory.swift; sourceTree = "<group>"; };
DD0000000000000000000002 /* WallpaperWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperWindowManager.swift; sourceTree = "<group>"; };
CC0000000000000000000004 /* ZipImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipImporter.swift; sourceTree = "<group>"; };
CC0000000000000000000006 /* SteamCmdService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteamCmdService.swift; sourceTree = "<group>"; };
CC0000000000000000000008 /* WorkshopAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopAPIService.swift; sourceTree = "<group>"; };
CC000000000000000000000A /* WorkshopViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopViewModel.swift; sourceTree = "<group>"; };
CC000000000000000000000C /* WorkshopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
5EC47E732A2DB2E600120CCC /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AA1B2C3D4E5F600200ABCD05 /* SpriteKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
5E51A4382AA30D6A00E46E99 /* Alerts */ = {
isa = PBXGroup;
children = (
5E66A0EC2A8BABAB00EE79D6 /* DisplaySettings.swift */,
5E51A4392AA30D8B00E46E99 /* UnsafeWallpaper.swift */,
);
path = Alerts;
sourceTree = "<group>";
};
5E66A0DB2A8ADED400EE79D6 /* Components */ = {
isa = PBXGroup;
children = (
5E51A4382AA30D6A00E46E99 /* Alerts */,
5E66A0EB2A8BAA6500EE79D6 /* ContextMenus */,
5E66A0D92A8ADECC00EE79D6 /* TopTabBar.swift */,
5E66A0E52A8BA69B00EE79D6 /* WallpaperExplorer.swift */,
5EDF1E152A93B4B6006617EA /* WallpaperDiscover.swift */,
5E66A0E72A8BA95600EE79D6 /* WallpaperPreview.swift */,
5E66A0DC2A8ADF7C00EE79D6 /* ExplorerTopBar.swift */,
5EB4DB872A980532000A1FEA /* ExplorerItem.swift */,
5E66A0E02A8BA18D00EE79D6 /* GifImage.swift */,
5E32ABFD2A4D595D00A0EEEC /* FilterResults.swift */,
CC000000000000000000000C /* WorkshopView.swift */,
);
path = Components;
sourceTree = "<group>";
};
5E66A0E22A8BA23800EE79D6 /* ViewModels */ = {
isa = PBXGroup;
children = (
5E66A0DE2A8AE3C900EE79D6 /* ContentViewModel.swift */,
5E66A0E32A8BA27600EE79D6 /* FilterResultsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
5E66A0EB2A8BAA6500EE79D6 /* ContextMenus */ = {
isa = PBXGroup;
children = (
5E66A0E92A8BAA5400EE79D6 /* ExplorerGlobalMenu.swift */,
5E8C82022A9E15D0009E291B /* ExplorerItemMenu.swift */,
);
path = ContextMenus;
sourceTree = "<group>";
};
5E98F6C82A826E2700C12E34 /* ContentView */ = {
isa = PBXGroup;
children = (
5E98F6C62A81D8B200C12E34 /* MainWindow.swift */,
5EC47EA92A2DB32900120CCC /* ContentView.swift */,
5EA969A22A7D4BA30027B963 /* FirstLaunchView.swift */,
5E615E212A7D2F9700A37EB8 /* ImportPanels.swift */,
5E66A0DB2A8ADED400EE79D6 /* Components */,
5E66A0E22A8BA23800EE79D6 /* ViewModels */,
);
path = ContentView;
sourceTree = "<group>";
};
5E98F6C92A826E5300C12E34 /* Services */ = {
isa = PBXGroup;
children = (
AA1B2C3D4E5F600300ABCD01 /* SceneParsers */,
5E7665252A2DC30D00E0E4B4 /* WEProject.swift */,
5EF28EBE2A8986AC0013ABBE /* WallpaperViewModel.swift */,
5EF28EC02A8990D90013ABBE /* VideoWallpaperViewModel.swift */,
DAEE29C52A9CB52100633898 /* WebWallpaperViewModel.swift */,
AA1B2C3D4E5F600100ABCD04 /* SceneWallpaperViewModel.swift */,
5E51A43B2AA33F5500E46E99 /* GlobalSettingsService.swift */,
BB0000000000000000000001 /* ObjCExceptionCatcher.h */,
BB0000000000000000000002 /* ObjCExceptionCatcher.m */,
EE0000000000000000000002 /* WELogger.swift */,
CC0000000000000000000002 /* WallpaperDirectory.swift */,
DD0000000000000000000002 /* WallpaperWindowManager.swift */,
CC0000000000000000000004 /* ZipImporter.swift */,
CC0000000000000000000006 /* SteamCmdService.swift */,
CC0000000000000000000008 /* WorkshopAPIService.swift */,
CC000000000000000000000A /* WorkshopViewModel.swift */,
);
path = Services;
sourceTree = "<group>";
};
AA1B2C3D4E5F600300ABCD01 /* SceneParsers */ = {
isa = PBXGroup;
children = (
AA1B2C3D4E5F600100ABCD01 /* PKGParser.swift */,
AA1B2C3D4E5F600100ABCD02 /* TEXParser.swift */,
AA1B2C3D4E5F600100ABCD03 /* SceneModels.swift */,
);
path = SceneParsers;
sourceTree = "<group>";
};
5E98F6CA2A826E8B00C12E34 /* SettingsView */ = {
isa = PBXGroup;
children = (
5EDEBA5D2A2E2A7B003470CB /* SettingsView.swift */,
5EAF3F6F2A870DEC00F85685 /* PerformancePage.swift */,
5EAF3F712A870ED400F85685 /* GeneralPage.swift */,
5EAF3F732A87110700F85685 /* PluginsPage.swift */,
5E7665292A2E231400E0E4B4 /* AboutUsView.swift */,
);
path = SettingsView;
sourceTree = "<group>";
};
5E98F6CB2A826EB000C12E34 /* WallpaperView */ = {
isa = PBXGroup;
children = (
5EC47EAB2A2DB3E500120CCC /* WallpaperView.swift */,
5EAD1DB72A88DFB80067E1D2 /* VideoWallpaperView.swift */,
5EA310FB2A88E116001A65F4 /* SceneWallpaperView.swift */,
5EA310FD2A88E135001A65F4 /* WebWallpaperView.swift */,
);
path = WallpaperView;
sourceTree = "<group>";
};
5E98F6CC2A826ED500C12E34 /* Resources */ = {
isa = PBXGroup;
children = (
5E7A89BF2AAB9D4E00192EB0 /* maxwell-cat.gif */,
5E0700E52A859FAD00ADFE2E /* WallpaperNotFound.mp4 */,
5EC47E7D2A2DB2E700120CCC /* Assets.xcassets */,
);
path = Resources;
sourceTree = "<group>";
};
5EC47E6D2A2DB2E600120CCC = {
isa = PBXGroup;
children = (
B4AD1D0F2AA24E8200471AC8 /* .gitignore */,
5E6091F12A83E85D00D4C36C /* AUTHORS.md */,
5EF4C24A2A4C9871009831E5 /* CNAME */,
5EF4C24B2A4C9872009831E5 /* LICENSE */,
5EF4C24C2A4C98FA009831E5 /* README.md */,
5EEE8D5D2A7501C800DCE41A /* resources */,
5EC47E782A2DB2E600120CCC /* Open Wallpaper Engine */,
5EC47E772A2DB2E600120CCC /* Products */,
AA1B2C3D4E5F600100ABCD05 /* SpriteKit.framework */,
);
sourceTree = "<group>";
};
5EC47E772A2DB2E600120CCC /* Products */ = {
isa = PBXGroup;
children = (
5EC47E762A2DB2E600120CCC /* Open Wallpaper Engine.app */,
);
name = Products;
sourceTree = "<group>";
};
5EC47E782A2DB2E600120CCC /* Open Wallpaper Engine */ = {
isa = PBXGroup;
children = (
5EFBB0BA2A2F10EA00E39BEC /* main.swift */,
5E9D40432A2EC68600AB8A0C /* AppDelegate.swift */,
5EDF1E122A93B026006617EA /* GlobalComponents */,
5EFC591B2A81ABCF005F97E2 /* MenuBars */,
5E98F6C82A826E2700C12E34 /* ContentView */,
5E98F6CA2A826E8B00C12E34 /* SettingsView */,
5E98F6CB2A826EB000C12E34 /* WallpaperView */,
5E98F6C92A826E5300C12E34 /* Services */,
5E98F6CC2A826ED500C12E34 /* Resources */,
5EC47E7F2A2DB2E700120CCC /* Preview Content */,
5E2F1E882A57DF2D0081D69D /* Localizable.xcstrings */,
5EC47E872A2DB2E700120CCC /* Open_Wallpaper_Engine.entitlements */,
5EB152A02A82ADEB007EA845 /* Open_Wallpaper_Engine.docc */,
);
path = "Open Wallpaper Engine";
sourceTree = "<group>";
};
5EC47E7F2A2DB2E700120CCC /* Preview Content */ = {
isa = PBXGroup;
children = (
5EC47E802A2DB2E700120CCC /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
5EDF1E122A93B026006617EA /* GlobalComponents */ = {
isa = PBXGroup;
children = (
5EDF1E132A93B06B006617EA /* WorkingInProgress.swift */,
);
path = GlobalComponents;
sourceTree = "<group>";
};
5EEE8D5D2A7501C800DCE41A /* resources */ = {
isa = PBXGroup;
children = (
5E5460C72A83027400655D85 /* wallpaper.png */,
5E5460C62A83026D00655D85 /* wallpaper-with-windows.png */,
5E1670732A88EBEF00D1B199 /* change-signing-certificate.png */,
5E1670742A88ED8400D1B199 /* import-menu.png */,
5E1670752A88EDF000D1B199 /* import-panel.png */,
);
path = resources;
sourceTree = "<group>";
};
5EFC591B2A81ABCF005F97E2 /* MenuBars */ = {
isa = PBXGroup;
children = (
5EFC591C2A81ACBC005F97E2 /* StatusBar.swift */,
5EFC591E2A81ACC3005F97E2 /* MainMenu.swift */,
);
path = MenuBars;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
5EC47E752A2DB2E600120CCC /* Open Wallpaper Engine */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5EC47EA02A2DB2E700120CCC /* Build configuration list for PBXNativeTarget "Open Wallpaper Engine" */;
buildPhases = (
5EC47E722A2DB2E600120CCC /* Sources */,
5EC47E732A2DB2E600120CCC /* Frameworks */,
5EC47E742A2DB2E600120CCC /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Open Wallpaper Engine";
productName = "Open Wallpaper Engine";
productReference = 5EC47E762A2DB2E600120CCC /* Open Wallpaper Engine.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
5EC47E6E2A2DB2E600120CCC /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1430;
LastUpgradeCheck = 1500;
TargetAttributes = {
5EC47E752A2DB2E600120CCC = {
CreatedOnToolsVersion = 14.3.1;
};
};
};
buildConfigurationList = 5EC47E712A2DB2E600120CCC /* Build configuration list for PBXProject "Open Wallpaper Engine" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = 5EC47E6D2A2DB2E600120CCC;
productRefGroup = 5EC47E772A2DB2E600120CCC /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
5EC47E752A2DB2E600120CCC /* Open Wallpaper Engine */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
5EC47E742A2DB2E600120CCC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5E7A89C02AAB9D4E00192EB0 /* maxwell-cat.gif in Resources */,
5EC47E812A2DB2E700120CCC /* Preview Assets.xcassets in Resources */,
5E2F1E892A57DF2D0081D69D /* Localizable.xcstrings in Resources */,
5E0700E62A859FAD00ADFE2E /* WallpaperNotFound.mp4 in Resources */,
5EC47E7E2A2DB2E700120CCC /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5EC47E722A2DB2E600120CCC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5E8C82032A9E15D0009E291B /* ExplorerItemMenu.swift in Sources */,
5E66A0EA2A8BAA5400EE79D6 /* ExplorerGlobalMenu.swift in Sources */,
5E51A43C2AA33F5500E46E99 /* GlobalSettingsService.swift in Sources */,
5E9D40442A2EC68600AB8A0C /* AppDelegate.swift in Sources */,
5EDF1E142A93B06B006617EA /* WorkingInProgress.swift in Sources */,
5EB152A12A82ADEB007EA845 /* Open_Wallpaper_Engine.docc in Sources */,
5EFBB0BB2A2F10EA00E39BEC /* main.swift in Sources */,
5E7665262A2DC30E00E0E4B4 /* WEProject.swift in Sources */,
5E66A0E82A8BA95600EE79D6 /* WallpaperPreview.swift in Sources */,
5EFC591D2A81ACBC005F97E2 /* StatusBar.swift in Sources */,
5EFC591F2A81ACC3005F97E2 /* MainMenu.swift in Sources */,
5EC47EAC2A2DB3E500120CCC /* WallpaperView.swift in Sources */,
5E98F6C72A81D8B200C12E34 /* MainWindow.swift in Sources */,
5E51A43A2AA30D8B00E46E99 /* UnsafeWallpaper.swift in Sources */,
5E66A0E12A8BA18D00EE79D6 /* GifImage.swift in Sources */,
5EF28EBF2A8986AC0013ABBE /* WallpaperViewModel.swift in Sources */,
5E66A0DF2A8AE3C900EE79D6 /* ContentViewModel.swift in Sources */,
5E66A0E62A8BA69B00EE79D6 /* WallpaperExplorer.swift in Sources */,
5EA310FE2A88E135001A65F4 /* WebWallpaperView.swift in Sources */,
5EAD1DB82A88DFB80067E1D2 /* VideoWallpaperView.swift in Sources */,
5EB4DB882A98055E000A1FEA /* ExplorerItem.swift in Sources */,
5E66A0ED2A8BABAB00EE79D6 /* DisplaySettings.swift in Sources */,
5EA969A32A7D4BA30027B963 /* FirstLaunchView.swift in Sources */,
5E66A0E42A8BA27600EE79D6 /* FilterResultsViewModel.swift in Sources */,
5EC47EAA2A2DB32900120CCC /* ContentView.swift in Sources */,
5EA310FC2A88E116001A65F4 /* SceneWallpaperView.swift in Sources */,
DAEE29C62A9CB52100633898 /* WebWallpaperViewModel.swift in Sources */,
5E32ABFE2A4D595D00A0EEEC /* FilterResults.swift in Sources */,
5EAF3F722A870ED400F85685 /* GeneralPage.swift in Sources */,
5EDF1E162A93B4B6006617EA /* WallpaperDiscover.swift in Sources */,
5E66A0DD2A8ADF7C00EE79D6 /* ExplorerTopBar.swift in Sources */,
5EAF3F742A87110700F85685 /* PluginsPage.swift in Sources */,
5EDEBA5E2A2E2A7B003470CB /* SettingsView.swift in Sources */,
5E66A0DA2A8ADECC00EE79D6 /* TopTabBar.swift in Sources */,
5E76652A2A2E231400E0E4B4 /* AboutUsView.swift in Sources */,
5E615E222A7D2F9700A37EB8 /* ImportPanels.swift in Sources */,
5EAF3F702A870DEC00F85685 /* PerformancePage.swift in Sources */,
5EF28EC12A8990D90013ABBE /* VideoWallpaperViewModel.swift in Sources */,
AA1B2C3D4E5F600200ABCD01 /* PKGParser.swift in Sources */,
AA1B2C3D4E5F600200ABCD02 /* TEXParser.swift in Sources */,
AA1B2C3D4E5F600200ABCD03 /* SceneModels.swift in Sources */,
AA1B2C3D4E5F600200ABCD04 /* SceneWallpaperViewModel.swift in Sources */,
BB0000000000000000000003 /* ObjCExceptionCatcher.m in Sources */,
EE0000000000000000000001 /* WELogger.swift in Sources */,
CC0000000000000000000001 /* WallpaperDirectory.swift in Sources */,
DD0000000000000000000001 /* WallpaperWindowManager.swift in Sources */,
CC0000000000000000000003 /* ZipImporter.swift in Sources */,
CC0000000000000000000005 /* SteamCmdService.swift in Sources */,
CC0000000000000000000007 /* WorkshopAPIService.swift in Sources */,
CC0000000000000000000009 /* WorkshopViewModel.swift in Sources */,
CC000000000000000000000B /* WorkshopView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
5EC47E9E2A2DB2E700120CCC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
5EC47E9F2A2DB2E700120CCC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
5EC47EA12A2DB2E700120CCC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = "Open Wallpaper Engine/Open_Wallpaper_Engine.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Open Wallpaper Engine/Preview Content\"";
DEVELOPMENT_TEAM = 35BWY46G6T;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open-Wallpaper-Engine-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Wallpaper Engine";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.8.1;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = "com.winddog.wallpaper-engine";
PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Open Wallpaper Engine/Open Wallpaper Engine-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
5EC47EA22A2DB2E700120CCC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = "Open Wallpaper Engine/Open_Wallpaper_Engine.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Open Wallpaper Engine/Preview Content\"";
DEVELOPMENT_TEAM = 35BWY46G6T;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open-Wallpaper-Engine-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Wallpaper Engine";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 0.8.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.winddog.wallpaper-engine";
PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Open Wallpaper Engine/Open Wallpaper Engine-Bridging-Header.h";
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
5EC47E712A2DB2E600120CCC /* Build configuration list for PBXProject "Open Wallpaper Engine" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5EC47E9E2A2DB2E700120CCC /* Debug */,
5EC47E9F2A2DB2E700120CCC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5EC47EA02A2DB2E700120CCC /* Build configuration list for PBXNativeTarget "Open Wallpaper Engine" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5EC47EA12A2DB2E700120CCC /* Debug */,
5EC47EA22A2DB2E700120CCC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 5EC47E6E2A2DB2E600120CCC /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5EC47E752A2DB2E600120CCC"
BuildableName = "Open Wallpaper Engine.app"
BlueprintName = "Open Wallpaper Engine"
ReferencedContainer = "container:Open Wallpaper Engine.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5EC47E8B2A2DB2E700120CCC"
BuildableName = "Open Wallpaper EngineTests.xctest"
BlueprintName = "Open Wallpaper EngineTests"
ReferencedContainer = "container:Open Wallpaper Engine.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5EC47E952A2DB2E700120CCC"
BuildableName = "Open Wallpaper EngineUITests.xctest"
BlueprintName = "Open Wallpaper EngineUITests"
ReferencedContainer = "container:Open Wallpaper Engine.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5EC47E752A2DB2E600120CCC"
BuildableName = "Open Wallpaper Engine.app"
BlueprintName = "Open Wallpaper Engine"
ReferencedContainer = "container:Open Wallpaper Engine.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5EC47E752A2DB2E600120CCC"
BuildableName = "Open Wallpaper Engine.app"
BlueprintName = "Open Wallpaper Engine"
ReferencedContainer = "container:Open Wallpaper Engine.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,161 @@
import Cocoa
import SwiftUI
import WebKit
import Combine
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
var statusItem: NSStatusItem!
var settingsWindow: NSWindow!
var mainWindowController: MainWindowController!
var wallpaperWindowManager: WallpaperWindowManager!
// Backward-compatible proxy so GlobalSettingsService keeps compiling unchanged
var wallpaperWindows: [String: NSWindow] { wallpaperWindowManager.windows }
var contentViewModel = ContentViewModel()
var wallpaperViewModel = WallpaperViewModel()
var globalSettingsViewModel = GlobalSettingsViewModel()
var importOpenPanel: NSOpenPanel!
var eventHandler: Any?
var cancellables = Set<AnyCancellable>()
static var shared = AppDelegate()
func applicationWillFinishLaunching(_ notification: Notification) {
// Run as menu bar agent: no Dock icon, Cmd+Q does not accidentally quit
NSApp.setActivationPolicy(.accessory)
setSettingsWindow()
wallpaperWindowManager = WallpaperWindowManager(viewModel: wallpaperViewModel)
wallpaperWindowManager.setup()
setMainMenu()
setStatusMenu()
self.mainWindowController = MainWindowController()
AppDelegate.shared.setEventHandler()
}
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let dockMenu = self.statusItem.menu?.copy() as! NSMenu?
dockMenu?.items.removeLast() // Remove `Quit` menu item
return dockMenu
}
func applicationDidFinishLaunching(_ notification: Notification) {
wallpaperWindowManager.showAll()
}
func applicationDidBecomeActive(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !self.mainWindowController.window.isVisible && !settingsWindow.isVisible {
self.mainWindowController.window?.makeKeyAndOrderFront(nil)
}
return true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return false
}
@objc func openSettingsWindow() {
NSApp.activate(ignoringOtherApps: true)
self.settingsWindow.center()
self.settingsWindow.makeKeyAndOrderFront(nil)
}
@objc func openMainWindow() {
self.mainWindowController.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@MainActor @objc func toggleFilter() {
self.contentViewModel.isFilterReveal.toggle()
}
func setSettingsWindow() {
self.settingsWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
self.settingsWindow.title = "Settings"
self.settingsWindow.isReleasedWhenClosed = false
self.settingsWindow.toolbarStyle = .preference
self.settingsWindow.delegate = self
let toolbar = NSToolbar(identifier: "SettingsToolbar")
toolbar.delegate = self
toolbar.selectedItemIdentifier = SettingsToolbarIdentifiers.performance
self.settingsWindow.toolbar = toolbar
self.settingsWindow.contentView = NSHostingView(rootView: SettingsView().environmentObject(self.globalSettingsViewModel))
}
func windowWillClose(_ notification: Notification) {
globalSettingsViewModel.reset()
}
func setEventHandler() {
// Only monitor event types we actually handle .any causes main thread starvation
let relevantEvents: NSEvent.EventTypeMask = [
.scrollWheel, .mouseMoved, .mouseEntered, .mouseExited,
.leftMouseUp, .rightMouseUp, .leftMouseDown,
.leftMouseDragged, .rightMouseDragged
]
self.eventHandler = NSEvent.addGlobalMonitorForEvents(matching: relevantEvents) { [weak self] event in
guard let self = self,
let frontmostApplication = NSWorkspace.shared.frontmostApplication,
frontmostApplication.bundleIdentifier == "com.apple.finder" else { return }
let mouseLocation = NSEvent.mouseLocation
guard let targetWindow = self.wallpaperWindows.values.first(where: { $0.frame.contains(mouseLocation) }),
let webview = targetWindow.contentView?.subviews.first?.subviews.first,
webview is WKWebView else { return }
switch event.type {
case .scrollWheel:
webview.scrollWheel(with: event)
case .mouseMoved:
webview.mouseMoved(with: event)
case .mouseEntered:
webview.mouseEntered(with: event)
case .mouseExited:
webview.mouseExited(with: event)
case .leftMouseUp, .rightMouseUp:
webview.mouseUp(with: event)
case .leftMouseDown:
webview.mouseDown(with: event)
case .leftMouseDragged, .rightMouseDragged:
webview.mouseDragged(with: event)
default:
break
}
}
}
}
/// Non-interactive window that stays behind all other windows.
class WallpaperWindow: NSWindow {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
enum SettingsToolbarIdentifiers {
static let performance = NSToolbarItem.Identifier(rawValue: "performance")
static let general = NSToolbarItem.Identifier(rawValue: "general")
static let plugins = NSToolbarItem.Identifier(rawValue: "plugins")
static let about = NSToolbarItem.Identifier(rawValue: "about")
}

View File

@ -0,0 +1,208 @@
//
// DisplaySettingsView.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import SwiftUI
struct DisplaySettings: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
@ObservedObject var wallpaperViewModel: WallpaperViewModel
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
self.wallpaperViewModel = AppDelegate.shared.wallpaperViewModel
}
var body: some View {
VStack(spacing: 16) {
Button {
viewModel.isDisplaySettingsReveal = false
} label: {
Image(systemName: "chevron.up")
.font(.largeTitle)
.bold()
}
.buttonStyle(.link)
Text("Display Settings")
.font(.largeTitle)
Text("Click a display to select it, then choose a wallpaper from the library.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
// Monitor layout
MonitorLayoutView(wallpaperViewModel: wallpaperViewModel)
.frame(maxHeight: 200)
// Selected screen info
if let screen = NSScreen.screens.first(where: { WallpaperViewModel.screenId(for: $0) == wallpaperViewModel.selectedScreenId }) {
let screenId = wallpaperViewModel.selectedScreenId
let wp = wallpaperViewModel.wallpaper(for: screenId)
VStack(spacing: 8) {
HStack {
Text(WallpaperViewModel.screenName(for: screen))
.font(.headline)
Text("\(Int(screen.frame.width))x\(Int(screen.frame.height))")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Toggle("Enabled", isOn: Binding(
get: { wallpaperViewModel.isScreenEnabled(screenId) },
set: { _ in wallpaperViewModel.toggleScreen(screenId) }
))
.toggleStyle(.switch)
.controlSize(.small)
}
if wallpaperViewModel.isScreenEnabled(screenId) {
HStack {
// Preview thumbnail
GifImage(contentsOf: previewURL(for: wp), animates: false)
.resizable()
.aspectRatio(16/9, contentMode: .fit)
.frame(height: 60)
.cornerRadius(4)
.background(Color(nsColor: .controlBackgroundColor))
VStack(alignment: .leading, spacing: 4) {
Text(wp.project.title.isEmpty ? "No wallpaper" : wp.project.title)
.font(.callout)
.fontWeight(.medium)
Text(wp.project.type.isEmpty ? "" : wp.project.type.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Remove") {
wallpaperViewModel.wallpapers.removeValue(forKey: screenId)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(wp.project == .invalid)
}
} else {
Text("Wallpaper display is disabled on this screen.")
.font(.callout)
.foregroundStyle(.tertiary)
}
}
.padding()
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
}
Spacer()
}
.padding(.horizontal, 40)
}
private func previewURL(for wallpaper: WEWallpaper) -> URL {
if let project = try? JSONDecoder().decode(
WEProject.self,
from: Data(contentsOf: wallpaper.wallpaperDirectory.appending(path: "project.json"))
) {
return wallpaper.wallpaperDirectory.appending(path: project.preview)
}
return Bundle.main.url(forResource: "WallpaperNotFound", withExtension: "mp4")!
}
}
// MARK: - Monitor Layout View
private struct MonitorLayoutView: View {
@ObservedObject var wallpaperViewModel: WallpaperViewModel
var body: some View {
let screens = NSScreen.screens
let bounds = combinedBounds(screens)
GeometryReader { geo in
let scale = min(
geo.size.width / max(bounds.width, 1),
geo.size.height / max(bounds.height, 1)
) * 0.85
ZStack {
ForEach(screens, id: \.self) { screen in
let screenId = WallpaperViewModel.screenId(for: screen)
let isSelected = screenId == wallpaperViewModel.selectedScreenId
let isEnabled = wallpaperViewModel.isScreenEnabled(screenId)
let frame = screen.frame
let x = (frame.origin.x - bounds.origin.x) * scale
let y = (bounds.height - (frame.origin.y - bounds.origin.y) - frame.height) * scale
let w = frame.width * scale
let h = frame.height * scale
MonitorRectangle(
name: WallpaperViewModel.screenName(for: screen),
wallpaperTitle: wallpaperViewModel.wallpaper(for: screenId).project.title,
isSelected: isSelected,
isEnabled: isEnabled,
isMain: screen == .main
)
.frame(width: w, height: h)
.position(x: x + w / 2 + (geo.size.width - bounds.width * scale) / 2,
y: y + h / 2 + (geo.size.height - bounds.height * scale) / 2)
.onTapGesture {
wallpaperViewModel.selectedScreenId = screenId
}
}
}
}
}
private func combinedBounds(_ screens: [NSScreen]) -> CGRect {
screens.reduce(.zero) { $0.union($1.frame) }
}
}
// MARK: - Monitor Rectangle
private struct MonitorRectangle: View {
let name: String
let wallpaperTitle: String
let isSelected: Bool
let isEnabled: Bool
let isMain: Bool
var body: some View {
RoundedRectangle(cornerRadius: 6)
.fill(isEnabled ? Color(nsColor: .controlBackgroundColor) : Color(nsColor: .separatorColor).opacity(0.3))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isSelected ? Color.accentColor : Color(nsColor: .separatorColor), lineWidth: isSelected ? 3 : 1)
)
.overlay {
VStack(spacing: 2) {
HStack(spacing: 4) {
if isMain {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Text(name)
.font(.caption)
.fontWeight(.medium)
}
if isEnabled {
Text(wallpaperTitle.isEmpty ? "No wallpaper" : wallpaperTitle)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Disabled")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(4)
}
}
}

View File

@ -0,0 +1,98 @@
//
// UnsafeWallpaper.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/9/2.
//
import SwiftUI
struct UnsafeWallpaper: View {
@Environment(\.dismiss) var dismiss
var wallpaper: WEWallpaper
@State var seconds: Int = 5
@State var isIgnored = false
var typeStringDict: [String : String] =
[
"web": "Web Page",
"application": "Application"
]
init(wallpaper: WEWallpaper) {
self.wallpaper = wallpaper
}
var body: some View {
VStack(spacing: 0) {
Text("Opening Unkown \(typeStringDict[wallpaper.project.type.lowercased()] ?? "Wallpaper")")
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.font(.title2)
Divider()
HStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .red)
.shadow(radius: 6)
.frame(maxWidth: 100)
VStack(alignment: .leading, spacing: 10) {
Text("You are about to open an external \((typeStringDict[wallpaper.project.type.lowercased()] ?? "unkown source type file").lowercased()) as a wallpaper:")
Text("\(wallpaper.wallpaperDirectory.path(percentEncoded: false) + wallpaper.project.file)").bold()
Text("Open Wallpaper Engine has no control over this file, you must ensure that it comes from a rellable source before proceeding.")
Text(seconds > 0 ? "Please wait \(seconds) seconds." : "Please be aware of malware.")
Toggle("Don't ask again for this wallpaper", isOn: $isIgnored)
}
.frame(maxWidth: .infinity)
}
.frame(maxHeight: .infinity)
.padding(.horizontal)
Divider()
HStack {
Button {
AppDelegate.shared.wallpaperViewModel.currentWallpaper =
AppDelegate.shared.wallpaperViewModel.nextCurrentWallpaper
if isIgnored {
var trustedWallpapers =
UserDefaults.standard.array(forKey: "TrustedWallpapers") as? [String] ?? [String]()
trustedWallpapers.append(AppDelegate.shared.wallpaperViewModel.nextCurrentWallpaper.wallpaperDirectory.path(percentEncoded: false))
UserDefaults.standard.set(trustedWallpapers, forKey: "TrustedWallpapers")
}
dismiss()
} label: {
Text("Proceed")
.padding(.horizontal, 10)
}
.animation(.default, value: seconds)
.buttonStyle(.borderedProminent)
.tint(.red)
.disabled(seconds > 0 ? true : false)
Button {
dismiss()
} label: {
Text("Cancel")
.padding(.horizontal, 10)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.onAppear {
let _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
if self.seconds <= 0 {
timer.invalidate()
} else {
self.seconds -= 1
}
}
}
}
}

View File

@ -0,0 +1,50 @@
//
// ExplorerGlobalMenu.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import SwiftUI
struct ExplorerGlobalMenu: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
@ObservedObject var wallpaperViewModel: WallpaperViewModel
init(contentViewModel viewModel: ContentViewModel, wallpaperViewModel: WallpaperViewModel) {
self.wallpaperViewModel = wallpaperViewModel
self.viewModel = viewModel
}
var body: some View {
Section {
Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FileManager.default.wallpapersDirectory.path(percentEncoded: false))
} label: {
Label("Open All in Finder", systemImage: "folder.badge.gearshape")
}
Menu("View") {
Section {
Picker("Icon Size", selection: $viewModel.explorerIconSize) {
Text("Small Icons").tag(Double(100))
Text("Medium Icons").tag(Double(125))
Text("Large Icons").tag(Double(150))
}
.pickerStyle(.inline)
}
Section {
Picker("Titles per page", selection: $viewModel.wallpapersPerPage) {
Text("10 per page").tag(10)
Text("25 per page").tag(25)
Text("50 per page").tag(50)
Text("1 per page (developer)").tag(1)
Text("1 per page (developer)").tag(2)
}
.pickerStyle(.inline)
}
}
}
.labelStyle(.titleAndIcon)
}
}

View File

@ -0,0 +1,90 @@
//
// ExplorerItemMenu.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/29.
//
import SwiftUI
struct ExplorerItemMenu: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
@ObservedObject var wallpaperViewModel: WallpaperViewModel
var hoveredWallpaper: WEWallpaper
init(contentViewModel viewModel: ContentViewModel, wallpaperViewModel: WallpaperViewModel, current hoveredWallpaper: WEWallpaper) {
self.wallpaperViewModel = wallpaperViewModel
self.viewModel = viewModel
self.hoveredWallpaper = hoveredWallpaper
}
var body: some View {
Group {
Section {
Button {
viewModel.hoveredWallpaper = hoveredWallpaper
viewModel.isUnsubscribeConfirming = true
} label: {
Label("Unsubscribe", systemImage: "xmark")
}
if viewModel.selectedWallpapers.count > 1 {
Button(role: .destructive) {
viewModel.isBatchUnsubscribeConfirming = true
} label: {
Label("Unsubscribe Selected (\(viewModel.selectedWallpapers.count))", systemImage: "xmark.circle")
}
}
Button {
} label: {
Label("Add to Favorites", systemImage: "heart.fill")
}.disabled(true)
}
Section {
Button {
} label: {
Label("Open in Workshop", systemImage: "cloud.fill")
}.disabled(true)
Menu("Related Wallpapers") {
Link(destination: URL(string: "https://github.com/haren724/open-wallpaper-engine-mac")!) {
Label("Browse All By", systemImage: "person.fill")
}
Link(destination: URL(string: "https://github.com/haren724/open-wallpaper-engine-mac")!) {
Label("Browse Presets", systemImage: "cloud.fill")
}
}.disabled(true)
Menu("Report & Block") {
Button(role: .destructive) {
} label: {
Label("Report", systemImage: "exclamationmark.triangle.fill")
}
Button {
} label: {
Label("Manage Blocklist", systemImage: "hand.raised.fill")
}
}.disabled(true)
}
Section {
Button {
} label: {
Label("Assign Hotkey", systemImage: "command.square")
}.disabled(true)
Button {
NSWorkspace.shared.selectFile(nil,
inFileViewerRootedAtPath: hoveredWallpaper.wallpaperDirectory.path(percentEncoded: false))
} label: {
Label("Open in Finder", systemImage: "folder.badge.gearshape")
}
}
}
.labelStyle(.titleAndIcon)
}
}

View File

@ -0,0 +1,74 @@
//
// ExplorerItem.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/25.
//
import SwiftUI
struct ExplorerItem: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
@ObservedObject var wallpaperViewModel: WallpaperViewModel
@AppStorage("TestAnimates") var animates = false
var wallpaper: WEWallpaper
var index: Int
var body: some View {
ZStack(alignment: .bottom) {
GifImage(contentsOf: { (url: URL) in
if let selectedProject = try? JSONDecoder()
.decode(WEProject.self, from: Data(contentsOf: url.appending(path: "project.json"))) {
return url.appending(path: selectedProject.preview)
}
return Bundle.main.url(forResource: "WallpaperNotFound", withExtension: "mp4")!
}(wallpaper.wallpaperDirectory), animates: animates)
.resizable()
.scaleEffect(viewModel.imageScaleIndex == index ? 1.2 : 1.0)
.aspectRatio(1.0, contentMode: .fit)
.clipped()
Text(wallpaper.project.title)
.lineLimit(2)
.frame(maxWidth: .infinity, minHeight: 30)
.padding(4)
.background(Color(white: 0, opacity: viewModel.imageScaleIndex == index ? 0.6 : 0.45))
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(Color.white)
// Spacer()
// .onHover { onHover in
// if onHover {
// viewModel.imageScaleIndex = index
// } else {
// viewModel.imageScaleIndex = -1
// }
// }
}
.selected(wallpaper.wallpaperDirectory == wallpaperViewModel.currentWallpaper.wallpaperDirectory)
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.blue, lineWidth: viewModel.isSelected(wallpaper) ? 3 : 0)
)
.overlay(alignment: .topLeading) {
if viewModel.isSelected(wallpaper) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .blue)
.padding(4)
}
}
.border(Color.accentColor, width: viewModel.imageScaleIndex == index ? 1.0 : 0)
.onTapGesture {
if NSEvent.modifierFlags.contains(.command) {
viewModel.toggleSelection(for: wallpaper)
} else {
viewModel.clearSelection()
wallpaperViewModel.nextCurrentWallpaper = wallpaper
}
}
}
}

View File

@ -0,0 +1,58 @@
//
// ExplorerTopBar.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import SwiftUI
struct ExplorerTopBar: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
@EnvironmentObject var globalSettingsViewModel: GlobalSettingsViewModel
init(contentViewModel viewModel: ContentViewModel) {
self.viewModel = viewModel
}
var body: some View {
HStack {
TextField("Search", text: $viewModel.searchText)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
Button {
viewModel.isFilterReveal.toggle()
} label: {
Label("Filter Results", systemImage: "checklist.checked")
}
.buttonStyle(.borderedProminent)
if globalSettingsViewModel.settings.autoRefresh {
Button {
viewModel.refresh()
} label: {
Image(systemName: "arrow.triangle.2.circlepath")
}
}
Spacer()
Button {
if viewModel.sortingSequence == .decrease {
viewModel.sortingSequence = .increase
} else {
viewModel.sortingSequence = .decrease
}
} label: {
Image(systemName: viewModel.sortingSequence == .increase ?
"arrowtriangle.down.fill" : "arrowtriangle.up.fill")
}
.buttonStyle(.plain)
Picker("Sort By", selection: $viewModel.sortingBy) {
ForEach(WEWallpaperSortingMethod.allCases) { method in
Text(method.rawValue).tag(method.rawValue)
}
}
.labelsHidden()
.frame(width: 160)
}
}
}

View File

@ -0,0 +1,386 @@
//
// FilterResults.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/6/29.
//
import SwiftUI
struct FilterSection<Content>: View where Content: View {
private let content: Content
private let alignment: HorizontalAlignment
private var spacing: CGFloat?
private let titleKey: LocalizedStringKey
@State private var isExpanded: Bool = true
init(_ titleKey: LocalizedStringKey, alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
self.content = content()
self.alignment = alignment
self.spacing = spacing
self.titleKey = titleKey
}
var body: some View {
VStack(alignment: self.alignment, spacing: self.spacing) {
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
HStack {
Image(systemName: "arrowtriangle.down.fill")
.font(.caption)
.rotationEffect(isExpanded ? .zero : .degrees(-90.0))
.animation(.spring(), value: isExpanded)
Text(self.titleKey)
Spacer()
}
}
.buttonStyle(.plain)
if isExpanded {
content.padding(.leading, (self.alignment == .leading) ? 10 : 0)
}
}
}
}
struct FilterResults: View {
@ObservedObject var viewModel: FilterResultsViewModel
var body: some View {
VStack {
ScrollView {
VStack(spacing: 30) {
Button {
viewModel.reset()
} label: {
Label("Reset Filters", systemImage: "arrow.triangle.2.circlepath")
.frame(maxWidth: .infinity)
.padding(.vertical, 5)
}
.buttonStyle(.borderedProminent)
VStack(alignment: .leading) {
Group {
ForEach(Array(zip(FRShowOnly.allOptions.indices, FRShowOnly.allOptions)), id: \.0) { (i, option) in
let (option, image) = option
let color: Color = {
if i == 0 {
return Color.green
} else if i == 1 {
return Color.pink
} else if i == 2 {
return Color.orange
} else {
return Color.accentColor
}
}()
Toggle(isOn: Binding<Bool>(get: {
viewModel.showOnly.contains(FRShowOnly(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.showOnly.insert(FRShowOnly(rawValue: 1 << i))
} else {
viewModel.showOnly.remove(FRShowOnly(rawValue: 1 << i))
}
print(String(describing: viewModel.showOnly))
})) {
HStack(spacing: 2) {
Image(systemName: image)
.foregroundStyle(color)
Text(option)
}
}
.disabled(i != 0 ? true : false)
}
}
.toggleStyle(.checkbox)
}
.padding(.all)
.padding(.top)
.overlay {
ZStack {
Rectangle()
.stroke(lineWidth: 1)
.foregroundStyle(Color(nsColor: NSColor.unemphasizedSelectedTextBackgroundColor))
.padding(.top, 8)
VStack {
HStack {
Text("Show Only:")
.background(Color(nsColor: NSColor.windowBackgroundColor))
.padding(.leading, 5)
Spacer()
}
Spacer()
}
}
}
VStack(spacing: 15) {
FilterSection("Type", alignment: .leading) {
ForEach(Array(zip(FRType.allOptions.indices, FRType.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.type.contains(FRType(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.type.insert(FRType(rawValue: 1 << i))
} else {
viewModel.type.remove(FRType(rawValue: 1 << i))
}
print(String(describing: viewModel.type))
}))
}
}
FilterSection("Age Rating", alignment: .leading) {
ForEach(Array(zip(FRAgeRating.allOptions.indices, FRAgeRating.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.ageRating.contains(FRAgeRating(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.ageRating.insert(FRAgeRating(rawValue: 1 << i))
} else {
viewModel.ageRating.remove(FRAgeRating(rawValue: 1 << i))
}
print(String(describing: viewModel.ageRating))
}))
}
}
Group {
FilterSection("Resolution", alignment: .leading) {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 3) {
Text("Widescreen")
.bold()
HStack {
Button("All") {
viewModel.widescreenResolution = .all
}
Button("None") {
viewModel.widescreenResolution = .none
}
}
.buttonStyle(.link)
}
.padding(.top, 5)
Group {
ForEach(Array(zip(FRWidescreenResolution.allOptions.indices, FRWidescreenResolution.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.widescreenResolution.contains(FRWidescreenResolution(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.widescreenResolution.insert(FRWidescreenResolution(rawValue: 1 << i))
} else {
viewModel.widescreenResolution.remove(FRWidescreenResolution(rawValue: 1 << i))
}
print(String(describing: viewModel.widescreenResolution))
}))
}
}
.toggleStyle(.checkbox)
Divider()
.overlay(Color.accentColor)
}
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 3) {
Text("Ultra Widescreen")
.bold()
HStack {
Button("All") {
viewModel.ultraWidescreenResolution = .all
}
Button("None") {
viewModel.ultraWidescreenResolution = .none
}
}
.buttonStyle(.link)
}
.padding(.top, 5)
Group {
ForEach(Array(zip(FRUltraWidescreenResolution.allOptions.indices, FRUltraWidescreenResolution.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.ultraWidescreenResolution.contains(FRUltraWidescreenResolution(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.ultraWidescreenResolution.insert(FRUltraWidescreenResolution(rawValue: 1 << i))
} else {
viewModel.ultraWidescreenResolution.remove(FRUltraWidescreenResolution(rawValue: 1 << i))
}
print(String(describing: viewModel.ultraWidescreenResolution))
}))
}
}
.toggleStyle(.checkbox)
Divider()
.overlay(Color.accentColor)
}
VStack(alignment: .leading) {
// MARK: - have trouble
VStack(alignment: .leading, spacing: 3) {
Text("Dual Monitor")
.bold()
HStack {
Button("All") {
viewModel.dualscreenResolution = .all
}
Button("None") {
viewModel.dualscreenResolution = .none
}
}
.buttonStyle(.link)
}
.padding(.top, 5)
Group {
ForEach(Array(zip(FRDualscreenResolution.allOptions.indices, FRDualscreenResolution.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.dualscreenResolution.contains(FRDualscreenResolution(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.dualscreenResolution.insert(FRDualscreenResolution(rawValue: 1 << i))
} else {
viewModel.dualscreenResolution.remove(FRDualscreenResolution(rawValue: 1 << i))
}
print(String(describing: viewModel.dualscreenResolution))
}))
}
}
.toggleStyle(.checkbox)
Divider()
.overlay(Color.accentColor)
}
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 3) {
Text("Triple Monitor")
.bold()
HStack {
Button("All") {
viewModel.triplescreenResolution = .all
}
Button("None") {
viewModel.triplescreenResolution = .none
}
}
.buttonStyle(.link)
}
.padding(.top, 5)
Group {
ForEach(Array(zip(FRTriplescreenResolution.allOptions.indices, FRTriplescreenResolution.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.triplescreenResolution.contains(FRTriplescreenResolution(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.triplescreenResolution.insert(FRTriplescreenResolution(rawValue: 1 << i))
} else {
viewModel.triplescreenResolution.remove(FRTriplescreenResolution(rawValue: 1 << i))
}
print(String(describing: viewModel.triplescreenResolution))
}))
}
}
.toggleStyle(.checkbox)
Divider()
.overlay(Color.accentColor)
}
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 3) {
Text("Potrait Monitor / Phone")
.bold()
HStack {
Button("All") {
viewModel.potraitscreenResolution = .all
}
Button("None") {
viewModel.potraitscreenResolution = .none
}
}
.buttonStyle(.link)
}
.padding(.top, 5)
Group {
ForEach(Array(zip(FRPortraitScreenResolution.allOptions.indices, FRPortraitScreenResolution.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.potraitscreenResolution.contains(FRPortraitScreenResolution(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.potraitscreenResolution.insert(FRPortraitScreenResolution(rawValue: 1 << i))
} else {
viewModel.potraitscreenResolution.remove(FRPortraitScreenResolution(rawValue: 1 << i))
}
print(String(describing: viewModel.potraitscreenResolution))
}))
}
}
.toggleStyle(.checkbox)
Divider()
.overlay(Color.accentColor)
}
Group {
ForEach(Array(zip(FRMiscResolution.allOptions.indices, FRMiscResolution.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.miscResolution.contains(FRMiscResolution(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.miscResolution.insert(FRMiscResolution(rawValue: 1 << i))
} else {
viewModel.miscResolution.remove(FRMiscResolution(rawValue: 1 << i))
}
print(String(describing: viewModel.miscResolution))
}))
}
}
.toggleStyle(.checkbox)
}
FilterSection("Source", alignment: .leading) {
Group {
ForEach(Array(zip(FRSource.allOptions.indices, FRSource.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.source.contains(FRSource(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.source.insert(FRSource(rawValue: 1 << i))
} else {
viewModel.source.remove(FRSource(rawValue: 1 << i))
}
print(String(describing: viewModel.source))
}))
}
}
.toggleStyle(.checkbox)
}
FilterSection("Tags", alignment: .leading) {
HStack {
Button("All") {
viewModel.tag = .all
}
Button("None") {
viewModel.tag = .none
}
}
.buttonStyle(.link)
Group {
ForEach(Array(zip(FRTag.allOptions.indices, FRTag.allOptions)), id: \.0) { (i, option) in
Toggle(option, isOn: Binding<Bool>(get: {
viewModel.tag.contains(FRTag(rawValue: 1 << i))
}, set: {
if $0 {
viewModel.tag.insert(FRTag(rawValue: 1 << i))
} else {
viewModel.tag.remove(FRTag(rawValue: 1 << i))
}
print(String(describing: viewModel.tag))
}))
}
}
.toggleStyle(.checkbox)
}
}
.disabled(true)
}
}
.padding(.trailing)
}
.lineLimit(1)
}
Divider()
}
}

View File

@ -0,0 +1,110 @@
//
// GifImage.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import Cocoa
import SwiftUI
struct GifImage: NSViewRepresentable {
var gifName: String?
var gifUrl: URL?
var isResizable: Bool = false
var contentMode: ContentMode = .fill
var animates: Bool
init(_ gifName: String, animates: Bool = true) {
self.gifName = gifName
self.animates = animates
}
init(contentsOf url: URL, animates: Bool = true) {
self.gifUrl = url
self.animates = animates
}
func makeNSView(context: Context) -> NSImageView {
let nsView = NSImageView()
nsView.canDrawSubviewsIntoLayer = true
nsView.imageScaling = .scaleProportionallyUpOrDown
nsView.animates = animates
if let gifName = self.gifName {
if let url = Bundle.main.url(forResource: gifName, withExtension: "gif") {
if let image = NSImage(contentsOf: url) {
let gifRep = image.representations[0] as? NSBitmapImageRep
gifRep?.setProperty(.loopCount, withValue: 0)
nsView.image = image
}
}
}
if let gifUrl = self.gifUrl {
if let image = NSImage(contentsOf: gifUrl) {
let gifRep = image.representations[0] as? NSBitmapImageRep
gifRep?.setProperty(.loopCount, withValue: 0)
nsView.image = image
}
}
return nsView
}
func updateNSView(_ nsView: NSImageView, context: Context) {
nsView.animates = animates
updateModifiers(nsView)
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSImageView, context: Context) -> CGSize? {
if !self.isResizable {
return nsView.sizeThatFits(nsView.frame.size)
} else {
guard let width = proposal.width, let height = proposal.height else { return nil }
return CGSize(width: width, height: height)
}
}
private func updateModifiers(_ nsView: NSImageView) {
if let gifName = self.gifName {
if let url = Bundle.main.url(forResource: gifName, withExtension: "gif") {
if let image = NSImage(contentsOf: url) {
let gifRep = image.representations[0] as? NSBitmapImageRep
gifRep?.setProperty(.loopCount, withValue: 0)
nsView.image = image
}
}
}
if let gifUrl = self.gifUrl {
if let image = NSImage(contentsOf: gifUrl) {
let gifRep = image.representations[0] as? NSBitmapImageRep
gifRep?.setProperty(.loopCount, withValue: 0)
nsView.image = image
}
}
if self.isResizable {
switch self.contentMode {
case .fill:
nsView.imageScaling = .scaleAxesIndependently
case .fit:
nsView.imageScaling = .scaleProportionallyUpOrDown
}
}
}
func resizable(capInsets: EdgeInsets = EdgeInsets(), resizingMode: Image.ResizingMode = .stretch) -> Self {
var view = self
view.isResizable = true
return view
}
func aspectRatio(_ aspectRatio: CGFloat? = nil, contentMode: ContentMode) -> Self {
var view = self
view.contentMode = contentMode
return view
}
}

View File

@ -0,0 +1,97 @@
//
// TopTabBar.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import SwiftUI
struct TopTabBar: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
init(contentViewModel viewModel: ContentViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack(spacing: 0) {
HStack {
HStack(alignment: .bottom) {
Button {
viewModel.topTabBarSelection = 0
} label: {
Label("Installed", systemImage: "square.and.arrow.down.fill")
.contentShape(Rectangle())
.foregroundStyle(viewModel.topTabBarSelection == 0 || viewModel.topTabBarHoverSelection == 0 ? .white : .primary)
.font(.title2)
.padding(4)
}
.background(viewModel.topTabBarSelection == 0 ? Color.blue : Color.clear)
.background(viewModel.topTabBarHoverSelection == 0 ? Color.blue : Color.clear)
.overlay(Rectangle()
.stroke(lineWidth: 2)
.foregroundStyle(Color.accentColor))
.onHover { hovering in
if hovering {
viewModel.topTabBarHoverSelection = 0
} else {
viewModel.topTabBarHoverSelection = -1
}
}
Button {
viewModel.topTabBarSelection = 1
} label: {
Label("Workshop", systemImage: "cloud.fill")
.contentShape(Rectangle())
.foregroundStyle(viewModel.topTabBarSelection == 1 ? .white : .primary)
.foregroundStyle(viewModel.topTabBarHoverSelection == 1 ? .white : .primary)
.font(.title3)
.padding(4)
}
.background(viewModel.topTabBarSelection == 1 ? Color.blue : Color.clear)
.background(viewModel.topTabBarHoverSelection == 1 ? Color.blue : Color.clear)
.overlay(Rectangle()
.stroke(lineWidth: 2)
.foregroundStyle(Color.accentColor))
.onHover { hovering in
if hovering {
viewModel.topTabBarHoverSelection = 1
} else {
viewModel.topTabBarHoverSelection = -1
}
}
}
.animation(.default, value: viewModel.topTabBarSelection)
.animation(.default, value: viewModel.topTabBarHoverSelection)
.fixedSize()
.buttonStyle(.plain)
Spacer()
.frame(minWidth: 10)
Group {
Divider()
Button {
viewModel.isDisplaySettingsReveal = true
} label: {
Label("Displays", systemImage: "display")
.contentShape(Rectangle())
}
Divider()
Button{
AppDelegate.shared.openSettingsWindow()
} label: {
Label("Settings", systemImage: "gearshape.fill")
.contentShape(Rectangle())
}
Divider()
}
.fixedSize()
.buttonStyle(.plain)
}
.offset(x: 1)
Divider()
.frame(height: 4)
.overlay(Color.accentColor)
}
}
}

View File

@ -0,0 +1,25 @@
//
// WallpaperDiscover.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/21.
//
import SwiftUI
struct WallpaperDiscover: View {
var body: some View {
ScrollView {
WorkingInProgress()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 50)
}
}
}
struct WallpaperDiscover_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: .init(isStaging: true, topTabBarSelection: 1), wallpaperViewModel: .init())
.environmentObject(GlobalSettingsViewModel())
}
}

View File

@ -0,0 +1,89 @@
//
// WallpaperExplorer.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import SwiftUI
struct WallpaperExplorer: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
@ObservedObject var wallpaperViewModel: WallpaperViewModel
init(contentViewModel viewModel: ContentViewModel, wallpaperViewModel: WallpaperViewModel) {
self.viewModel = viewModel
self.wallpaperViewModel = wallpaperViewModel
}
var body: some View {
ScrollView {
// MARK: Items
if viewModel.autoRefreshWallpapers.isEmpty {
HStack {
Spacer()
Text("""
No wallpapers found for your search.
Expand or reset the categories in the filter sidebar or try another search term.
""")
.font(.title)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
.lineLimit(nil)
.lineSpacing(10)
Spacer()
}
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 50)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: viewModel.explorerIconSize,
maximum: viewModel.explorerIconSize * 2)
)], alignment: .leading) {
ForEach(Array(viewModel.autoRefreshWallpapers.enumerated()), id: \.0) { (index, wallpaper) in
ExplorerItem(viewModel: viewModel, wallpaperViewModel: wallpaperViewModel, wallpaper: wallpaper, index: index)
.contextMenu {
ExplorerItemMenu(contentViewModel: viewModel, wallpaperViewModel: wallpaperViewModel, current: wallpaper)
ExplorerGlobalMenu(contentViewModel: viewModel, wallpaperViewModel: wallpaperViewModel)
}
.animation(.spring(), value: viewModel.imageScaleIndex)
// .animation(.spring(), value: wallpaperViewModel.currentWallpaper.rawValue)
}
}
.padding(.trailing)
}
}
.overlay {
VStack {
Spacer()
HStack {
ForEach(0..<viewModel.maxPage, id: \.self) { page in
Button("\(page + 1)") {
viewModel.currentPage = page + 1
}
}
}
.padding(.bottom)
}
}
}
}
// MARK: - View Modifiers Extension
struct SelectedItem: ViewModifier {
var selected: Bool
init(_ selected: Bool) {
self.selected = selected
}
func body(content: Content) -> some View {
return content
.border(Color.accentColor, width: selected ? 3 : 0)
}
}
extension View {
func selected(_ selected: Bool = true) -> some View {
return modifier(SelectedItem(selected))
}
}

View File

@ -0,0 +1,379 @@
//
// WallpaperPreview.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import SwiftUI
struct WallpaperPreview: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
@ObservedObject var wallpaperViewModel: WallpaperViewModel
@Environment(\.undoManager) var undoManager
@State var isEditingId = ""
@State var title = ""
@State var newTag = ""
@State var hoveredTag: String?
@State var isTagsHovered = false
init(contentViewModel viewModel: ContentViewModel, wallpaperViewModel: WallpaperViewModel) {
self.viewModel = viewModel
self.wallpaperViewModel = wallpaperViewModel
}
var wallpaperSize: String {
guard let sizeBytes = try? wallpaperViewModel.currentWallpaper.wallpaperDirectory.directoryTotalAllocatedSize(includingSubfolders: true)
else {
return "??? MB"
}
return ByteCountFormatter.string(fromByteCount: Int64(sizeBytes), countStyle: .file)
}
var body: some View {
VStack {
ScrollView {
VStack(spacing: 16) {
VStack(spacing: 10) {
GifImage(contentsOf: { (url: URL) in
if let selectedProject = try? JSONDecoder()
.decode(WEProject.self, from: Data(contentsOf: url.appending(path: "project.json"))) {
return url.appending(path: selectedProject.preview)
}
return Bundle.main.url(forResource: "WallpaperNotFound", withExtension: "mp4")!
}(wallpaperViewModel.currentWallpaper.wallpaperDirectory))
.resizable()
.aspectRatio(contentMode: .fit)
.background(Color(nsColor: NSColor.controlBackgroundColor))
.frame(width: 280, height: 280)
.clipShape(RoundedRectangle(cornerRadius: 16.0))
.border(Color.white, width: 4)
HStack {
if isEditingId == "title" {
TextField("Wallpaper Title", text: $title)
.onSubmit {
var wallpaper = wallpaperViewModel.currentWallpaper
wallpaper.project.title = title
guard let data = try? JSONEncoder().encode(wallpaper.project) else { return }
try? data.write(to: wallpaper.wallpaperDirectory.appending(path: "project.json"), options: .atomic)
wallpaperViewModel.currentWallpaper = wallpaper
isEditingId = ""
}
} else {
Text(wallpaperViewModel.currentWallpaper.project.title.isEmpty ? "Untitled" : wallpaperViewModel.currentWallpaper.project.title)
.frame(minWidth: 50)
.id("title")
.lineLimit(1)
.onTapGesture(count: 2) {
title = wallpaperViewModel.currentWallpaper.project.title
isEditingId = "title"
}
Image(systemName: "square.and.pencil")
}
}
}
if let workshopurl = wallpaperViewModel.currentWallpaper.project.workshopurl,
!workshopurl.isEmpty,
let url = URL(string: workshopurl) {
HStack {
Image("we.placeholder")
.resizable()
.frame(width: 32, height: 32)
Link("Steam Workshop", destination: url)
.font(.caption)
}
}
HStack {
Text(wallpaperViewModel.currentWallpaper.project.type)
Text(wallpaperSize)
}
.font(.footnote)
ViewThatFits(in: .horizontal) {
tags.animation(.spring(), value: isTagsHovered)
ScrollView(.horizontal, showsIndicators: false) {
tags.animation(.spring(), value: isTagsHovered)
}
}
.onHover { isTagsHovered = $0 }
if isEditingId == "tags" {
HStack {
Button {
newTag = ""
isEditingId = ""
} label: {
Image(systemName: "arrow.uturn.backward")
}
TextField("New Tag", text: $newTag)
.onSubmit {
defer {
newTag = ""
isEditingId = ""
}
guard !newTag.isEmpty else { return }
var wallpaper = wallpaperViewModel.currentWallpaper
var tags = wallpaper.project.tags ?? []
tags = Array(Set(tags)) // remove duplicate items
tags.append(newTag)
tags = Array(Set(tags)) // remove duplicate items
wallpaper.project.tags = tags.sorted()
guard let data = try? JSONEncoder().encode(wallpaper.project) else { return }
try? data.write(to: wallpaper.wallpaperDirectory.appending(path: "project.json"), options: .atomic)
wallpaperViewModel.currentWallpaper = wallpaper
}
}
}
VStack(spacing: 3) {
Button {
viewModel.hoveredWallpaper = wallpaperViewModel.currentWallpaper
viewModel.isUnsubscribeConfirming = true
} label: {
Label("Delete", systemImage: "trash")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
if let workshopurl = wallpaperViewModel.currentWallpaper.project.workshopurl,
!workshopurl.isEmpty,
let url = URL(string: workshopurl) {
HStack(spacing: 3) {
Button {
NSWorkspace.shared.open(url)
} label: {
Label("Comment", systemImage: "text.badge.star")
.frame(maxWidth: .infinity)
}
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(workshopurl, forType: .string)
} label: {
Image(systemName: "doc.on.doc.fill")
}
}
}
}
// MARK: Properties
HStack(spacing: 3) {
Text("Properties")
VStack {
Divider()
.frame(height: 1)
.overlay(Color.accentColor)
}
}
VStack(spacing: 16) {
ColorPicker(selection: .constant(.red), supportsOpacity: true) {
HStack {
Label("Scheme Color", systemImage: "paintpalette.fill")
Spacer()
}
}
.opacity(0.5)
.disabled(true)
switch wallpaperViewModel.currentWallpaper.project.type.lowercased() {
case "video":
HStack {
Label("Volume", systemImage: "speaker.wave.3.fill")
Spacer()
Slider(value: $wallpaperViewModel.playVolume, in: 0...1).frame(width: 100)
Text(String(format: "%.0f", wallpaperViewModel.playVolume * 100) + "%")
.frame(width: 35)
}
HStack {
Label("Playback Rate", systemImage: "play.fill")
Spacer()
Slider(value: $wallpaperViewModel.playRate, in: 0...2, step: 0.1).frame(width: 100)
Text(String(format: "%.01fx", wallpaperViewModel.playRate))
.frame(width: 35)
}
case "web":
EmptyView()
default:
EmptyView()
}
}
VStack(spacing: 3) {
HStack(spacing: 3) {
Text("Your Presets")
VStack {
Divider()
.frame(height: 1)
.overlay(Color.accentColor)
}
}
Group {
HStack(spacing: 3) {
Button { } label: {
Label("Load", systemImage: "folder.fill")
.frame(maxWidth: .infinity)
}
Button { } label: {
Label("Save", systemImage: "square.and.arrow.down.fill")
.frame(maxWidth: .infinity)
}
}
Button { } label: {
Label("Apply to all Wallpapers", systemImage: "list.bullet.rectangle.fill")
.frame(maxWidth: .infinity)
}
Button { } label: {
Label("Share JSON", systemImage: "arrow.2.squarepath")
.frame(maxWidth: .infinity)
}
Button { } label: {
Label("Reset", systemImage: "arrow.triangle.2.circlepath")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
.disabled(true)
}
}
.blur(radius: wallpaperViewModel.currentWallpaper.project == .invalid ? 16.0 : 0)
.overlay {
if wallpaperViewModel.currentWallpaper.project == .invalid {
Text("Please select a valid wallpaper")
}
}
.disabled(wallpaperViewModel.currentWallpaper.project == .invalid ? true : false)
.animation(.default, value: wallpaperViewModel.currentWallpaper.project)
.padding([.horizontal, .top])
}
HStack {
Spacer()
Button {
AppDelegate.shared.mainWindowController.close()
} label: {
Text("OK").frame(width: 50)
}
.buttonStyle(.borderedProminent)
Button {
AppDelegate.shared.mainWindowController.close()
} label: {
Text("Cancel").frame(width: 50)
}
}
.padding()
}
}
/// Shows all tags about current wallpaper in horizontal
var tags: some View {
HStack {
if let tags = wallpaperViewModel.currentWallpaper.project.tags {
ForEach(tags, id: \.self) { tag in
Text(tag)
.padding(5)
.background {
RoundedRectangle(cornerRadius: 25.0)
.colorInvert()
.foregroundStyle(Color.primary)
RoundedRectangle(cornerRadius: 25.0)
.stroke(Color.secondary, lineWidth: 1.6)
}
.overlay(alignment: .topTrailing) {
if hoveredTag == tag {
Button {
var wallpaper = wallpaperViewModel.currentWallpaper
guard var tags = wallpaper.project.tags else { return } // else case seems impossible, however much safer
tags = Array(Set(tags)) // remove duplicate items
guard let index = tags.firstIndex(where: { $0 == tag }) else { return }
tags.remove(at: index)
wallpaper.project.tags = tags
guard let data = try? JSONEncoder().encode(wallpaper.project) else { return }
try? data.write(to: wallpaper.wallpaperDirectory.appending(path: "project.json"), options: .atomic)
wallpaperViewModel.currentWallpaper = wallpaper
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(.white, .red)
.symbolRenderingMode(.palette)
.offset(x: 5, y: -2.5)
}
}
.onHover { hovered in
if hovered {
hoveredTag = tag
} else {
hoveredTag = nil
}
}
}
} else {
Text("No Tags")
.foregroundStyle(Color.secondary)
}
if isTagsHovered {
Button {
isEditingId = "tags"
} label: {
Image(systemName: "plus")
.font(.body)
}
.buttonStyle(.plain)
}
}
.font(.footnote)
.lineLimit(1)
}
}
extension URL {
/// check if the URL is a directory and if it is reachable
func isDirectoryAndReachable() throws -> Bool {
guard try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true else {
return false
}
return try checkResourceIsReachable()
}
/// returns total allocated size of a the directory including its subFolders or not
func directoryTotalAllocatedSize(includingSubfolders: Bool = false) throws -> Int? {
guard try isDirectoryAndReachable() else { return nil }
if includingSubfolders {
guard
let urls = FileManager.default.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] else { return nil }
return try urls.lazy.reduce(0) {
(try $1.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) + $0
}
}
return try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil).lazy.reduce(0) {
(try $1.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
.totalFileAllocatedSize ?? 0) + $0
}
}
}

View File

@ -0,0 +1,517 @@
import SwiftUI
struct WorkshopView: SubviewOfContentView {
@ObservedObject var viewModel: ContentViewModel
init(contentViewModel viewModel: ContentViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack(spacing: 0) {
if !viewModel.steamCmd.isInstalled {
SteamCmdNotInstalledView(steamCmd: viewModel.steamCmd)
} else if !viewModel.steamCmd.isLoggedIn {
SteamLoginView(steamCmd: viewModel.steamCmd)
} else {
WorkshopBrowserView(viewModel: viewModel.workshopVM)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - DepotDownloader Not Installed
private struct SteamCmdNotInstalledView: View {
@ObservedObject var steamCmd: SteamCmdService
@State private var isCopied = false
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("DepotDownloader Not Found")
.font(.title2)
.bold()
Text("Steam Workshop requires DepotDownloader to download wallpapers.\nInstall it with Homebrew:")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
HStack {
Text("brew install depotdownloader")
.font(.system(.body, design: .monospaced))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("brew install depotdownloader", forType: .string)
isCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { isCopied = false }
} label: {
Image(systemName: isCopied ? "checkmark" : "doc.on.doc")
}
.buttonStyle(.bordered)
}
Divider().frame(width: 200)
Text("Or locate an existing DepotDownloader binary:")
.font(.callout)
.foregroundStyle(.secondary)
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = "Select the DepotDownloader executable"
if panel.runModal() == .OK, let url = panel.url {
steamCmd.setCustomPath(url.path)
}
}
.buttonStyle(.bordered)
if let error = steamCmd.pathError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
Button("Re-detect") {
steamCmd.detectSteamCmd()
}
.buttonStyle(.link)
.font(.caption)
}
.padding(40)
}
}
// MARK: - Steam Login
private struct SteamLoginView: View {
@ObservedObject var steamCmd: SteamCmdService
@State private var username = ""
@State private var password = ""
@State private var guardCode = ""
@State private var showGuardCode = false
@State private var hasAPIKey = !WorkshopAPIService.loadAPIKey().isEmpty
var body: some View {
VStack(spacing: 16) {
Image(systemName: "person.badge.key")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("Steam Login")
.font(.title2)
.bold()
Text("Log in with your Steam account to browse and download wallpapers.\nYou must own Wallpaper Engine on Steam.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.font(.callout)
VStack(spacing: 10) {
TextField("Steam Username", text: $username)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
VStack(spacing: 4) {
Text("A Steam Web API key is required to browse and download wallpapers.")
.font(.caption)
APIKeyInputView(buttonTitle: "Save", onSave: {
hasAPIKey = !WorkshopAPIService.loadAPIKey().isEmpty
})
}
if showGuardCode {
TextField("Steam Guard Code", text: $guardCode)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
}
if let error = steamCmd.loginError {
Text(error)
.foregroundStyle(.red)
.font(.caption)
if error.contains("Steam Guard") && !showGuardCode {
Button("Enter Steam Guard Code") {
showGuardCode = true
}
.buttonStyle(.link)
}
}
HStack(spacing: 12) {
Button("Log In") {
steamCmd.login(
username: username,
password: password,
guardCode: showGuardCode ? guardCode : nil
)
}
.buttonStyle(.borderedProminent)
.disabled(username.isEmpty || password.isEmpty || !hasAPIKey || steamCmd.isLoggingIn)
if !username.isEmpty {
Button("Use Cached Session") {
steamCmd.loginWithCachedSession(username: username)
}
.buttonStyle(.bordered)
.disabled(!hasAPIKey || steamCmd.isLoggingIn)
}
}
if steamCmd.isLoggingIn {
ProgressView()
.controlSize(.small)
Text("Authenticating with Steam...")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(40)
}
}
// MARK: - Workshop Browser
private struct WorkshopBrowserView: View {
@ObservedObject var viewModel: WorkshopViewModel
var body: some View {
VStack(spacing: 8) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search wallpapers...", text: $viewModel.searchText)
.textFieldStyle(.plain)
.onSubmit {
viewModel.currentPage = 1
Task { await viewModel.search() }
}
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
viewModel.currentPage = 1
Task { await viewModel.search() }
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
Picker("Sort", selection: $viewModel.sortOrder) {
ForEach(WorkshopSortOrder.allCases) { order in
Text(order.displayName).tag(order)
}
}
.frame(width: 160)
.onChange(of: viewModel.sortOrder) { _ in
viewModel.currentPage = 1
Task { await viewModel.search() }
}
}
.padding(8)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
.padding(.horizontal)
// Tag filters
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
tagGroup("Rating:", WorkshopViewModel.contentRatingTags)
Divider().frame(height: 20)
tagGroup("Type:", WorkshopViewModel.typeTags)
Divider().frame(height: 20)
tagGroup("", WorkshopViewModel.genreTags)
}
.padding(.horizontal)
}
// Results
if viewModel.isLoading && viewModel.items.isEmpty {
Spacer()
ProgressView("Searching Workshop...")
Spacer()
} else if viewModel.needsAPIKey {
Spacer()
APIKeySetupView {
Task { await viewModel.search() }
}
Spacer()
} else if let error = viewModel.errorMessage, viewModel.items.isEmpty {
Spacer()
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.title)
.foregroundStyle(.secondary)
Text(error)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
} else if viewModel.items.isEmpty {
Spacer()
VStack(spacing: 12) {
Image(systemName: "sparkle.magnifyingglass")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("Search the Steam Workshop")
.font(.title3)
.foregroundStyle(.secondary)
Text("Find wallpapers by name, tag, or browse trending content.")
.font(.callout)
.foregroundStyle(.tertiary)
}
Spacer()
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: 300))], spacing: 12) {
ForEach(viewModel.items) { item in
WorkshopItemCard(item: item, viewModel: viewModel)
}
}
.padding()
if !viewModel.items.isEmpty {
Button("Load More") {
Task { await viewModel.loadMore() }
}
.buttonStyle(.bordered)
.disabled(viewModel.isLoading)
.padding(.bottom)
}
}
}
}
.task {
if viewModel.items.isEmpty {
await viewModel.search()
}
}
}
private func tagGroup(_ label: String, _ tags: [String]) -> some View {
HStack(spacing: 4) {
if !label.isEmpty {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(tags, id: \.self) { tag in
Button {
viewModel.toggleTag(tag)
viewModel.currentPage = 1
Task { await viewModel.search() }
} label: {
Text(tag)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(viewModel.selectedTags.contains(tag) ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
.foregroundStyle(viewModel.selectedTags.contains(tag) ? .white : .primary)
.cornerRadius(12)
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - Workshop Item Card
private struct WorkshopItemCard: View {
let item: WorkshopItem
@ObservedObject var viewModel: WorkshopViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Preview image
AsyncImage(url: item.previewImageURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.clipped()
case .failure:
placeholder
default:
placeholder
.overlay(ProgressView().controlSize(.small))
}
}
.frame(height: 120)
.clipped()
// Info
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.caption)
.fontWeight(.medium)
.lineLimit(2)
HStack {
if !item.tags.isEmpty {
Text(item.tags.prefix(2).joined(separator: ", "))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if item.subscriptions > 0 {
Label("\(formatCount(item.subscriptions))", systemImage: "heart")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// Download button
downloadButton
}
.padding(8)
}
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
}
@ViewBuilder
private var downloadButton: some View {
let state = viewModel.downloadState(for: item)
switch state {
case .downloading(let status):
HStack(spacing: 4) {
ProgressView().controlSize(.mini)
Text(status)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
case .completed:
Label("Downloaded", systemImage: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
case .failed(let msg):
VStack(alignment: .leading) {
Label("Failed", systemImage: "xmark.circle")
.font(.caption2)
.foregroundStyle(.red)
Text(msg).font(.caption2).foregroundStyle(.secondary)
}
case .none:
if viewModel.steamCmd.isLoggedIn {
Button {
viewModel.download(item: item)
} label: {
Label("Download", systemImage: "arrow.down.circle")
.font(.caption)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Text("Login to download")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var placeholder: some View {
Rectangle()
.fill(Color(nsColor: .separatorColor))
.aspectRatio(16/9, contentMode: .fill)
}
private func formatCount(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
}
}
// MARK: - API Key Setup (first-time onboarding, not an error)
private struct APIKeySetupView: View {
var onSave: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "key.fill")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("Steam Web API Key Required")
.font(.title2)
.bold()
Text("To browse the Steam Workshop, you need a free Steam Web API key.\nGet one at steamcommunity.com/dev/apikey — it only takes a minute.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 380)
APIKeyInputView(onSave: onSave)
Link("Open steamcommunity.com/dev/apikey", destination: URL(string: "https://steamcommunity.com/dev/apikey")!)
.font(.caption)
}
.padding(40)
}
}
// MARK: - API Key Input
private struct APIKeyInputView: View {
@State private var apiKey = WorkshopAPIService.loadAPIKey()
var buttonTitle: LocalizedStringKey = "Save & Search"
var onSave: () -> Void
var body: some View {
VStack(spacing: 8) {
HStack {
TextField("Steam Web API Key", text: $apiKey)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
.onSubmit { save() }
Button(buttonTitle) { save() }
.buttonStyle(.borderedProminent)
.disabled(apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
}
HStack(spacing: 4) {
Text("Get a free key at")
.font(.caption)
.foregroundStyle(.tertiary)
Link("steamcommunity.com/dev/apikey", destination: URL(string: "https://steamcommunity.com/dev/apikey")!)
.font(.caption)
}
}
}
private func save() {
let trimmed = apiKey.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
WorkshopAPIService.saveAPIKey(trimmed)
onSave()
}
}

View File

@ -0,0 +1,158 @@
//
// ContentView.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/6/5.
//
import SwiftUI
protocol SubviewOfContentView: View {
var viewModel: ContentViewModel { get set }
// init(contentViewModel viewModel: ContentViewModel)
}
struct ContentView: View {
@EnvironmentObject var globalSettingsViewModel: GlobalSettingsViewModel
@ObservedObject var viewModel: ContentViewModel
@ObservedObject var wallpaperViewModel: WallpaperViewModel
@State var isDropTargeted = false
@State var isParseFinished = false
@State var isFilterReveal = true
@State var isDockIconHidden = false
@State var project: WEProject!
@State var projectUrl: URL!
@State var greet: String = "Hello, world!"
var body: some View {
ZStack {
HSplitView {
if viewModel.isStaging {
VStack(spacing: 5) {
TopTabBar(contentViewModel: viewModel)
switch viewModel.topTabBarSelection {
case 0:
ExplorerTopBar(contentViewModel: viewModel)
.environmentObject(globalSettingsViewModel)
HStack(spacing: 0) {
HStack(spacing: 0) {
// MARK: Filter Results
FilterResults(viewModel: viewModel)
}
.frame(width: viewModel.isFilterReveal ? 225 : 0)
.opacity(viewModel.isFilterReveal ? 1 : 0)
.animation(.spring(), value: viewModel.isFilterReveal)
WallpaperExplorer(contentViewModel: viewModel, wallpaperViewModel: wallpaperViewModel)
.onDrop(of: [.fileURL], delegate: viewModel)
.contextMenu {
ExplorerGlobalMenu(contentViewModel: viewModel, wallpaperViewModel: wallpaperViewModel)
}
.padding(.leading, viewModel.isFilterReveal ? 10 : 0)
}
.animation(.default, value: viewModel.isFilterReveal)
case 1:
WorkshopView(contentViewModel: viewModel)
default:
fatalError()
}
if viewModel.topTabBarSelection == 0 {
HStack {
Button {
AppDelegate.shared.openImportFromFolderPanel()
} label: {
Label("Open Wallpaper", systemImage: "arrow.up.bin.fill")
.frame(width: 220)
}
Spacer()
}
}
}
.padding()
WallpaperPreview(contentViewModel: viewModel, wallpaperViewModel: wallpaperViewModel)
.frame(maxWidth: 320)
}
}
.opacity(viewModel.isStaging ? 1 : 0)
.blur(radius: viewModel.isStaging ? 0 : 2.0)
// indicate that this view is initializing
if !viewModel.isStaging {
HStack(spacing: 20) {
Text("Power Saving Mode, Sleeping...")
.font(.largeTitle)
}
}
}
.confirmationDialog("Unsubscribe Confirmation",
isPresented: $viewModel.isUnsubscribeConfirming) {
if let url = viewModel.hoveredWallpaper?.wallpaperDirectory {
Button("Delete Immediately", role: .destructive) {
try? FileManager.default.removeItem(at: url)
wallpaperViewModel.removeWallpaperFromAllScreens(directory: url)
viewModel.hoveredWallpaper = nil
}
Button("Move to Trash") {
try? FileManager.default.trashItem(at: url, resultingItemURL: nil)
wallpaperViewModel.removeWallpaperFromAllScreens(directory: url)
viewModel.hoveredWallpaper = nil
}
}
Button("Cancel", role: .cancel) {
viewModel.hoveredWallpaper = nil
}
} message: {
Text("\(viewModel.hoveredWallpaper?.project.title ?? "invalid wallpaper")")
}
.confirmationDialog("Batch Unsubscribe Confirmation",
isPresented: $viewModel.isBatchUnsubscribeConfirming) {
Button("Delete All \(viewModel.selectedWallpapers.count) Immediately", role: .destructive) {
for url in viewModel.selectedWallpapers {
try? FileManager.default.removeItem(at: url)
wallpaperViewModel.removeWallpaperFromAllScreens(directory: url)
}
viewModel.clearSelection()
}
Button("Move All \(viewModel.selectedWallpapers.count) to Trash") {
for url in viewModel.selectedWallpapers {
try? FileManager.default.trashItem(at: url, resultingItemURL: nil)
wallpaperViewModel.removeWallpaperFromAllScreens(directory: url)
}
viewModel.clearSelection()
}
Button("Cancel", role: .cancel) {}
} message: {
let items = viewModel.selectedWallpaperItems()
let names = items.prefix(3).map(\.project.title).joined(separator: ", ")
let suffix = items.count > 3 ? " and \(items.count - 3) more" : ""
Text("Unsubscribe \(items.count) wallpapers: \(names)\(suffix)")
}
.alert(isPresented: $viewModel.importAlertPresented, error: viewModel.importAlertError) {
}
.sheet(isPresented: $viewModel.isDisplaySettingsReveal) {
DisplaySettings(viewModel: viewModel)
.padding()
.frame(width: 520, height: 450)
}
.sheet(isPresented: $viewModel.isUnsafeWallpaperWarningPresented) {
UnsafeWallpaper(wallpaper: wallpaperViewModel.nextCurrentWallpaper)
.frame(width: 600, height: 300)
}
.frame(minWidth: 1000, minHeight: 640, idealHeight: 800)
.environment(\.locale, globalSettingsViewModel.locale)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: .init(isStaging: true), wallpaperViewModel: .init())
.environmentObject(GlobalSettingsViewModel())
}
}

View File

@ -0,0 +1,107 @@
//
// ImportPanels.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/4.
//
import Cocoa
import UniformTypeIdentifiers
struct WPImportError: LocalizedError {
var errorDescription: String?
var failureReason: String?
var helpAnchor: String?
var recoverySuggestion: String?
static let permissionDenied = WPImportError(errorDescription: "Permission Denied",
failureReason: "This app doesn't have the permission to access to the folder(s) you selected",
helpAnchor: "File Permission",
recoverySuggestion: "Try enable it in 'System Settings' - 'Privacy & Security'")
static let doesNotContainWallpaper = WPImportError(errorDescription: "No Wallpaper(s) Inside",
failureReason: "Maybe you selected the wrong folder which doesn't contain any wallpapers",
helpAnchor: "Contents in Folder(s)",
recoverySuggestion: "Check the folder(s) you selected and try again")
static let unkown = WPImportError(errorDescription: "Unkown Error",
failureReason: "",
helpAnchor: "",
recoverySuggestion: "")
}
extension AppDelegate {
@objc func openImportFromFolderPanel() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = true
panel.allowedContentTypes = [.folder, .zip]
panel.beginSheetModal(for: self.mainWindowController.window) { [weak self] response in
if response != .OK { return }
guard !panel.urls.isEmpty else { return }
let fm = FileManager.default
let docsDir = fm.wallpapersDirectory
var wallpaperURLs: [URL] = []
var zipURLs: [URL] = []
for url in panel.urls {
if url.pathExtension.lowercased() == "zip" {
zipURLs.append(url)
} else if fm.fileExists(atPath: url.appending(path: "project.json").path) {
wallpaperURLs.append(url)
} else {
// Scan immediate children for wallpaper folders
guard let children = try? fm.contentsOfDirectory(
at: url, includingPropertiesForKeys: [.isDirectoryKey],
options: .skipsHiddenFiles
) else { continue }
for child in children {
var isDir: ObjCBool = false
if fm.fileExists(atPath: child.path, isDirectory: &isDir),
isDir.boolValue,
fm.fileExists(atPath: child.appending(path: "project.json").path) {
wallpaperURLs.append(child)
}
}
}
}
guard !wallpaperURLs.isEmpty || !zipURLs.isEmpty else {
DispatchQueue.main.async {
self?.contentViewModel.alertImportModal(which: .doesNotContainWallpaper)
}
return
}
DispatchQueue.main.async {
for url in wallpaperURLs {
let dest = docsDir.appending(path: url.lastPathComponent)
if !fm.fileExists(atPath: dest.path) {
try? fm.copyItem(at: url, to: dest)
}
}
for url in zipURLs {
ZipImporter.importZip(at: url)
}
}
}
}
@objc func openImportFromFoldersPanel() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = true
panel.beginSheetModal(for: self.mainWindowController.window) { response in
if response != .OK { return }
print(String(describing: panel.urls))
DispatchQueue.main.async {
self.contentViewModel.wallpaperUrls.append(contentsOf: panel.urls)
}
}
}
}

View File

@ -0,0 +1,68 @@
//
// MainWindowController.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/8.
//
import Cocoa
import SwiftUI
class MainWindowController: NSWindowController, NSWindowDelegate {
override var window: NSWindow! {
get {
return super.window
}
set {
super.window = newValue
}
}
override init(window: NSWindow?) {
super.init(window: NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false))
self.window.delegate = self
self.window.isReleasedWhenClosed = false
self.window.title = "Open Wallpaper Engine \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String)"
self.window.titlebarAppearsTransparent = true
self.window.setFrameAutosaveName("MainWindow")
self.window.isMovableByWindowBackground = true
self.window.contentView = NSHostingView(rootView: ContentView(
viewModel: AppDelegate.shared.contentViewModel,
wallpaperViewModel: AppDelegate.shared.wallpaperViewModel
).environmentObject(AppDelegate.shared.globalSettingsViewModel)
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func windowDidLoad() {
super.windowDidLoad()
}
func windowWillClose(_ notification: Notification) {
AppDelegate.shared.contentViewModel.isStaging = false
}
func windowDidResignKey(_ notification: Notification) {
// if !AppDelegate.shared.contentViewModel.isDisplaySettingsReveal {
// AppDelegate.shared.contentViewModel.isStaging = false
// }
}
func windowDidResignMain(_ notification: Notification) {
// AppDelegate.shared.contentViewModel.isStaging = false
}
func windowDidBecomeKey(_ notification: Notification) {
DispatchQueue.main.async {
withAnimation {
AppDelegate.shared.contentViewModel.isStaging = true
}
}
}
}

View File

@ -0,0 +1,470 @@
//
// ContentViewModel.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import AVKit
import Combine
import SwiftUI
import UniformTypeIdentifiers
class ContentViewModel: ObservableObject, DropDelegate {
@AppStorage("SortingBy") var sortingBy: WEWallpaperSortingMethod = .name
@AppStorage("SortingSequence") var sortingSequence: WEWallpaperSortingSequence = .increase
@AppStorage("FRShowOnly") public var showOnly = FRShowOnly.all
@AppStorage("FRType") public var type = FRType.all
@AppStorage("FRAgeRating") public var ageRating = FRAgeRating.all
@AppStorage("FRWidescreenResolution") public var widescreenResolution = FRWidescreenResolution.all
@AppStorage("FRUltraWidescreenResolution") public var ultraWidescreenResolution = FRUltraWidescreenResolution.all
@AppStorage("FRDualscreenResolution") public var dualscreenResolution = FRDualscreenResolution.all
@AppStorage("FRTriplescreenResolution") public var triplescreenResolution = FRTriplescreenResolution.all
@AppStorage("FRPortraitScreenResolution") public var potraitscreenResolution = FRPortraitScreenResolution.all
@AppStorage("FRMiscResolution") public var miscResolution = FRMiscResolution.all
@AppStorage("FRSource") public var source = FRSource.all
@AppStorage("FRTag") public var tag = FRTag.all
@AppStorage("FilterReveal") var isFilterReveal = false
@AppStorage("WallpaperURLs") var wallpaperUrls = [URL]()
@AppStorage("SelectedIndex") var selectedIndex = 0
@AppStorage("ExplorerIconSize") var explorerIconSize: Double = 200
@Published var isDisplaySettingsReveal = false
@Published var importAlertPresented = false
@Published var isStaging = false
@Published var topTabBarSelection: Int = 0
@Published var topTabBarHoverSelection: Int = -1
@Published var imageScaleIndex: Int = -1
@Published var wallpapers = [WEWallpaper]()
@Published var isUnsafeWallpaperWarningPresented = false
@Published var hoveredWallpaper: WEWallpaper?
@Published var isUnsubscribeConfirming = false
@Published var selectedWallpapers = Set<URL>()
@Published var isBatchUnsubscribeConfirming = false
lazy var steamCmd: SteamCmdService = {
let svc = SteamCmdService()
steamCmdCancellable = svc.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
}
return svc
}()
lazy var workshopVM: WorkshopViewModel = WorkshopViewModel(steamCmd: steamCmd)
private var steamCmdCancellable: AnyCancellable?
@Published var searchText = ""
@AppStorage("WallpapersPerPage") var wallpapersPerPage: Int = 50
var importAlertError: WPImportError? = nil
convenience init(isStaging: Bool, topTabBarSelection: Int = 0) {
self.init()
let wallpapers = autoRefreshWallpapers
self.isStaging = isStaging
self.topTabBarSelection = topTabBarSelection
self.wallpapers = wallpapers
}
/// current page index number is starting from '1'
@Published public var currentPage: Int = 1
// {
// willSet {
// self.currentPage = newValue > self.maxPage ? self.maxPage : newValue
// }
// }
private var urls: [URL] {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: FileManager.default.wallpapersDirectory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles
) else {
return []
}
return contents
}
/// Show all the wallpaper inside application wallpaper directory, without being filtered
private var allWallpapers: [WEWallpaper] {
self.urls.map({ url in
if let data = try? Data(contentsOf: url.appending(path: "project.json")), let project = try? JSONDecoder().decode(WEProject.self, from: data) {
return WEWallpaper(using: project, where: url)
} else {
return WEWallpaper(using: .invalid, where: url)
}
})
}
private var searchedWallpapers: [WEWallpaper] {
allWallpapers.filter { wallpaper in
let project = wallpaper.project
let searchText = searchText.lowercased()
guard !searchText.isEmpty else { return true }
guard !project.title.lowercased().contains(searchText) else { return true }
guard !project.type.lowercased().contains(searchText) else { return true }
if let description = project.description?.lowercased() {
guard !description.contains(searchText) else { return true }
}
if let tags = project.tags {
guard !tags.allSatisfy({ $0.lowercased().contains(searchText) })
else { return true }
}
if let workshopid = project.workshopid {
guard !workshopid.rawValue.contains(searchText) else { return true }
}
guard !wallpaper.wallpaperDirectory.lastPathComponent
.lowercased()
.contains(searchText) else { return true }
return false
}
}
private var filteredWallpapers: [WEWallpaper] {
searchedWallpapers.filter { wallpaper in
// Show Only
var showOnly = FRShowOnly.none
if let approved = wallpaper.project.approved, approved { showOnly.insert(.approved) }
guard self.showOnly.contains(showOnly) else { return false }
// Type
var type = FRType.none
switch wallpaper.project.type.lowercased() {
case "video":
type = .video
case "scene":
type = .scene
case "web":
type = .web
case "application":
type = .application
default:
break
}
guard self.type.contains(type) else { return false }
//
// Age Rating
var ageRating: FRAgeRating
switch wallpaper.project.contentrating {
case "Everyone":
ageRating = .everyone
case "Questionable":
ageRating = .partialNudity
case "Mature":
ageRating = .mature
default:
ageRating = .none
}
guard self.ageRating.contains(ageRating) else { return false }
// Tags
var tags = FRTag.none
var transformedTags: [FRTag] = []
if let someTags = wallpaper.project.tags {
// tags = someTags.map { tag in
// switch tag.lowercased() {
// case "abstract":
// return FRTag.abstract
// case "animal":
// return FRTag.animal
// case "anime":
// return FRTag.anime
// case "cartoon":
// return FRTag.cartoon
// case "cgi":
// return FRTag.cgi
// case "cyberpunk":
// return FRTag.cyberpunk
// case "fantasy":
// return FRTag.fantasy
// case "game":
// return FRTag.game
// case "girls":
// return FRTag.girls
// case "guys":
// return FRTag.guys
// case "landscape":
// return FRTag.landscape
// case "medieval":
// return FRTag.medieval
// case "memes":
// return FRTag.memes
// case "mmd":
// return FRTag.mmd
// case "music":
// return FRTag.music
// case "nature":
// return FRTag.nature
// case "pixelart":
// return FRTag.pixelArt
// case "relaxing":
// return FRTag.relaxing
// case "retro":
// return FRTag.retro
// case "sci-fi":
// return FRTag.sciFi
// case "sports":
// return FRTag.sports
// case "technology":
// return FRTag.technology
// case "television":
// return FRTag.television
// case "vehicle":
// return FRTag.vehicle
// default:
// return FRTag.unspecifiedGenre
// }
// }
} else {
tags = .none
}
guard self.tag != .none else { return false }
// Finish Filtering
return true
}
}
private var sortedWallpapers: [WEWallpaper] {
filteredWallpapers.sorted {
switch sortingBy {
case .name:
if $0.project.title <= $1.project.title,
sortingSequence == .increase
{ return false }
if $0.project.title >= $1.project.title,
sortingSequence == .decrease
{ return false }
return true
case .rating:
if $0.project.contentrating ?? "0" <= $1.project.contentrating ?? "0",
sortingSequence == .increase
{ return false }
if $0.project.contentrating ?? "0" >= $1.project.contentrating ?? "0",
sortingSequence == .decrease
{ return false }
return true
// case .favorite:
// return false
case .fileSize:
if $0.wallpaperSize <= $1.wallpaperSize,
sortingSequence == .increase
{ return false }
if $0.project.title >= $1.project.title,
sortingSequence == .decrease
{ return false }
return true
// case .subDate:
// return false
// case .lastUpdated:
// return false
}
}
}
/// Provide wallpapers information for UI, being filtered by FilterResults and divided in pages
public var autoRefreshWallpapers: [WEWallpaper] {
sortedWallpapers
// let startIndex = (self.currentPage - 1) * self.wallpapersPerPage
// let filteredWallpapers = self.filteredWallpapers
// let clip = filteredWallpapers[startIndex..<filteredWallpapers.endIndex]
// return Array(clip.prefix(self.wallpapersPerPage))
}
/// Caculates the maximium possible page index for all wallpapers in your application wallpaper directory
var maxPage: Int {
Int(self.filteredWallpapers.count / self.wallpapersPerPage)
}
func toggleSelection(for wallpaper: WEWallpaper) {
let url = wallpaper.wallpaperDirectory
if selectedWallpapers.contains(url) {
selectedWallpapers.remove(url)
} else {
selectedWallpapers.insert(url)
}
}
func clearSelection() {
selectedWallpapers.removeAll()
}
func isSelected(_ wallpaper: WEWallpaper) -> Bool {
selectedWallpapers.contains(wallpaper.wallpaperDirectory)
}
func selectedWallpaperItems() -> [WEWallpaper] {
autoRefreshWallpapers.filter { selectedWallpapers.contains($0.wallpaperDirectory) }
}
func toggleFilter() {
isFilterReveal.toggle()
}
func alertImportModal(which error: WPImportError) {
self.importAlertError = error
self.importAlertPresented = true
}
func warningUnsafeWallpaperModal(which wallpaper: WEWallpaper) {
self.isUnsafeWallpaperWarningPresented = true
}
func dropUpdated(info: DropInfo) -> DropProposal? {
let proposal = DropProposal(operation: .copy)
return proposal
}
func performDrop(info: DropInfo) -> Bool {
guard let itemProvider = info.itemProviders(for: [UTType.fileURL]).first
else {
alertImportModal(which: .unkown)
return false
}
itemProvider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { [weak self] item, _ in
guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil)
else {
self?.alertImportModal(which: .unkown)
return
}
// Do something with the file url
// remember to dispatch on main in case of a @State change
guard let wallpaper = try? FileWrapper(url: url)
else{
self?.alertImportModal(which: .unkown)
return
}
if wallpaper.isDirectory {
guard wallpaper.fileWrappers?["project.json"] != nil
else{
self?.alertImportModal(which: .doesNotContainWallpaper)
return
}
DispatchQueue.main.async {
try? FileManager.default.copyItem(
at: url,
to: FileManager.default.wallpapersDirectory
.appending(path: url.lastPathComponent)
)
}
} else if wallpaper.isRegularFile, url.pathExtension.lowercased() == "zip" {
DispatchQueue.main.async {
let count = ZipImporter.importZip(at: url)
if count == 0 {
self?.alertImportModal(which: .doesNotContainWallpaper)
}
}
} else if wallpaper.isRegularFile { // hello.mp4
guard let filename = wallpaper.filename, [".mp4", ".mov"].contains(filename.suffix(4).lowercased()) else { return }
let wallpaperDirectoryWrapper = FileWrapper(directoryWithFileWrappers: [filename: wallpaper])
let projectData = WEProject(file: filename,
preview: "preview.jpg",
title: String(filename.prefix(filename.count - 4)),
type: "video")
// Generate a thumbnail (preview) image for importing video wallpaper
let asset = AVAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
let time = CMTimeMake(value: 1, timescale: 1) //
imageGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { (_, cgImage, _, _, error) in
if let error = error {
print(error)
} else if let cgImage = cgImage {
if let data = NSBitmapImageRep(cgImage: cgImage).representation(using: NSBitmapImageRep.FileType.jpeg, properties: [:]) {
wallpaperDirectoryWrapper.addRegularFile(withContents: data, preferredFilename: "preview.jpg")
wallpaperDirectoryWrapper.addRegularFile(withContents: try! JSONEncoder().encode(projectData), preferredFilename: "project.json")
// Write to Work Directory
DispatchQueue.main.async {
do {
try wallpaperDirectoryWrapper.write(
to: FileManager.default.wallpapersDirectory.appending(path: String(filename.prefix(filename.count - 4))),
originalContentsURL: nil)
} catch {
print(error)
return
}
}
}
}
}
}
}
return true
}
public func refresh() {
self.wallpapers = autoRefreshWallpapers
}
/// Provide a filter reset to default function, usually being used to show all wallpapers without filtered
public func reset() {
self.showOnly = .none // notice it's show ONLY, it acts oppositely to the others
self.type = .all
self.ageRating = .all
self.type = .all
self.ageRating = .all
self.widescreenResolution = .all
self.ultraWidescreenResolution = .all
self.dualscreenResolution = .all
self.triplescreenResolution = .all
self.potraitscreenResolution = .all
self.miscResolution = .all
self.source = .all
self.tag = .all
}
}
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}

View File

@ -0,0 +1,271 @@
//
// FilterResultsViewModel.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/15.
//
import SwiftUI
typealias FilterResultsViewModel = ContentViewModel
protocol FilterResultsModel: OptionSet where Element == Self, RawValue == Int {
static var allOptions: [String] { get }
}
struct FRShowOnly: OptionSet {
let rawValue: Int
static let allOptions = [
("Approved", "trophy.fill"),
("My Favourites", "heart.fill"),
("Mobile Compatible", "iphone.gen3"),
("Audio Responsive", ""),
("Customizable", "")
]
static let approved = FRShowOnly(rawValue: 1 << 0)
static let myFavourites = FRShowOnly(rawValue: 1 << 1)
static let mobileCompatible = FRShowOnly(rawValue: 1 << 2)
static let audioResponsive = FRShowOnly(rawValue: 1 << 3)
static let customizable = FRShowOnly(rawValue: 1 << 4)
static let all: FRShowOnly = [.approved, myFavourites, mobileCompatible, .audioResponsive, .customizable]
static let none: FRShowOnly = []
}
struct FRType: FilterResultsModel {
let rawValue: Int
static let allOptions = [
"Scene",
"Video",
"Web",
"Application"
// "Wallpaper",
// "Preset"
]
static let scene = FRType(rawValue: 1 << 0)
static let video = FRType(rawValue: 1 << 1)
static let web = FRType(rawValue: 1 << 2)
static let application = FRType(rawValue: 1 << 3)
// static let wallpaper = FRType(rawValue: 1 << 4)
// static let preset = FRType(rawValue: 1 << 5)
static let all: FRType = [.scene, .video, .web, .application/*, .wallpaper, .preset*/]
static let none: FRType = []
}
struct FRAgeRating: FilterResultsModel {
let rawValue: Int
static let allOptions = [
"Everyone",
"Partial Nudity",
"Mature"
]
static let everyone = FRAgeRating(rawValue: 1 << 0)
static let partialNudity = FRAgeRating(rawValue: 1 << 1)
static let mature = FRAgeRating(rawValue: 1 << 2)
static let all: FRAgeRating = [.everyone, .partialNudity, .mature]
static let none: FRAgeRating = []
}
struct FRWidescreenResolution: FilterResultsModel {
let rawValue: Int
static let allOptions = [
"StandardDefinition",
"1280x720",
"1920x1080-FullHD",
"2560x1440",
"3840x2160-4K"
]
static let standardDefinition = Self.init(rawValue: 1 << 0)
static let resolution1280x720 = Self.init(rawValue: 1 << 1)
static let resolution1920x1080 = Self.init(rawValue: 1 << 2)
static let resolution2560x1440 = Self.init(rawValue: 1 << 3)
static let resolution3840x2160 = Self.init(rawValue: 1 << 4)
static let all: Self = [.standardDefinition, resolution1280x720, resolution1920x1080, .resolution2560x1440, .resolution3840x2160]
static let none: Self = []
}
struct FRUltraWidescreenResolution: FilterResultsModel {
let rawValue: Int
static let allOptions: [String] = [
"Ultrawide Standard",
"2560x1080",
"3440x1440",
]
static let ultrawideStandard = FRUltraWidescreenResolution(rawValue: 1 << 0)
static let resolution2560x1080 = FRUltraWidescreenResolution(rawValue: 1 << 1)
static let resolution3440x1440 = FRUltraWidescreenResolution(rawValue: 1 << 2)
static let all: FRUltraWidescreenResolution = [.ultrawideStandard, resolution2560x1080, .resolution3440x1440]
static let none: FRUltraWidescreenResolution = []
}
struct FRDualscreenResolution: FilterResultsModel {
let rawValue: Int
static let allOptions: [String] = [
"Dual Standard",
"3840x1080",
"5120x1440",
"7680x2160"
]
static let dualStandard = Self.init(rawValue: 1 << 0)
static let resolution3840x1080 = Self.init(rawValue: 1 << 1)
static let resolution5120x1440 = Self.init(rawValue: 1 << 2)
static let resolution7680x2160 = Self.init(rawValue: 1 << 3)
static let all: Self = [.dualStandard, .resolution3840x1080, .resolution5120x1440, .resolution7680x2160]
static let none: Self = []
}
struct FRTriplescreenResolution: FilterResultsModel {
let rawValue: Int
static let allOptions: [String] = [
"Triple Standard",
"4096x768",
"5760x1080",
"7680x1440",
"11520x2160"
]
static let tripleStandard = FRTriplescreenResolution(rawValue: 1 << 0)
static let resolution4096x768 = FRTriplescreenResolution(rawValue: 1 << 1)
static let resolution5760x1080 = FRTriplescreenResolution(rawValue: 1 << 2)
static let resolution7680x1440 = FRTriplescreenResolution(rawValue: 1 << 3)
static let resolution11520x2160 = FRTriplescreenResolution(rawValue: 1 << 4)
static let all: FRTriplescreenResolution = [.tripleStandard, resolution4096x768, resolution5760x1080, resolution7680x1440, resolution11520x2160]
static let none: FRTriplescreenResolution = []
}
struct FRPortraitScreenResolution: FilterResultsModel {
let rawValue: Int
static let allOptions = [
"PotraitStandard",
"720x1280",
"1080x1920",
"1440x2560",
"2160x3840"
]
static let portraitStandard = Self.init(rawValue: 1 << 0)
static let resolution720x1280 = Self.init(rawValue: 1 << 1)
static let resolution1080x1920 = Self.init(rawValue: 1 << 2)
static let resolution1440x2560 = Self.init(rawValue: 1 << 3)
static let resolution2160x3840 = Self.init(rawValue: 1 << 4)
static let all: Self = [.portraitStandard, .resolution720x1280, .resolution1080x1920, .resolution1440x2560, .resolution2160x3840]
static let none: Self = []
}
struct FRMiscResolution: FilterResultsModel {
let rawValue: Int
static let allOptions = [
"OtherResolution",
"DynamicResolution"
]
static let otherResolution = Self.init(rawValue: 1 << 0)
static let dynamicResolution = Self.init(rawValue: 1 << 1)
static let all: Self = [.otherResolution, .dynamicResolution]
static let none: Self = []
}
struct FRSource: FilterResultsModel {
let rawValue: Int
static let allOptions = [
"Official",
"Workshop",
"MyWallpapers"
]
static let official = Self.init(rawValue: 1 << 0)
static let workshop = Self.init(rawValue: 1 << 1)
static let myWallpapers = Self.init(rawValue: 1 << 2)
static let all: Self = [.official, .workshop, .myWallpapers]
static let none: Self = []
}
struct FRTag: FilterResultsModel {
let rawValue: Int
static let allOptions = [
"Abstract",
"Animal",
"Anime",
"Cartoon",
"CGI",
"Cyberpunk",
"Fantasy",
"Game",
"Girls",
"Guys",
"Landscape",
"Medieval",
"Memes",
"MMD",
"Music",
"Nature",
"PixelArt",
"Relaxing",
"Retro",
"Sci-Fi",
"Sports",
"Technology",
"Television",
"Vehicle",
"UnspecifiedGenre"
]
static let abstract = FRTag(rawValue: 1 << 0)
static let animal = FRTag(rawValue: 1 << 1)
static let anime = FRTag(rawValue: 1 << 2)
static let cartoon = FRTag(rawValue: 1 << 3)
static let cgi = FRTag(rawValue: 1 << 4)
static let cyberpunk = FRTag(rawValue: 1 << 5)
static let fantasy = FRTag(rawValue: 1 << 6)
static let game = FRTag(rawValue: 1 << 7)
static let girls = FRTag(rawValue: 1 << 8)
static let guys = FRTag(rawValue: 1 << 9)
static let landscape = FRTag(rawValue: 1 << 10)
static let medieval = FRTag(rawValue: 1 << 11)
static let memes = FRTag(rawValue: 1 << 12)
static let mmd = FRTag(rawValue: 1 << 13)
static let music = FRTag(rawValue: 1 << 14)
static let nature = FRTag(rawValue: 1 << 15)
static let pixelArt = FRTag(rawValue: 1 << 16)
static let relaxing = FRTag(rawValue: 1 << 17)
static let retro = FRTag(rawValue: 1 << 18)
static let sciFi = FRTag(rawValue: 1 << 19)
static let sports = FRTag(rawValue: 1 << 20)
static let technology = FRTag(rawValue: 1 << 21)
static let television = FRTag(rawValue: 1 << 22)
static let vehicle = FRTag(rawValue: 1 << 23)
static let unspecifiedGenre = FRTag(rawValue: 1 << 24)
static let all: FRTag = [
.abstract, .animal, .anime, .cartoon, .cgi, .cyberpunk, .fantasy, .game, .girls,
.guys, .landscape, .medieval, .memes, .mmd, .music, .nature, .pixelArt, .relaxing,
.retro, .sciFi, .sports, .technology, .television, .vehicle, .unspecifiedGenre
]
static let none: FRTag = []
}

View File

@ -0,0 +1,37 @@
//
// WorkingInProgress.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/21.
//
import SwiftUI
struct WorkingInProgress: View {
@State var bigGearAngle = 0.0
@State var smallGearAngle = 0.0
var body: some View {
VStack {
ZStack {
Image(systemName: "gearshape")
.font(.system(size: 48))
.rotationEffect(.degrees(bigGearAngle + 30))
.offset(x: -20, y: -10)
Image(systemName: "gearshape")
.font(.system(size: 32, weight: .semibold))
.rotationEffect(.degrees(smallGearAngle))
.offset(x: 20, y: 10)
}
.padding()
.onAppear {
withAnimation(.linear(duration: 5).repeatForever(autoreverses: false)) {
bigGearAngle = 360
smallGearAngle = -360
}
}
Text("Working on it")
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,140 @@
//
// Menu.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/8.
//
import Cocoa
extension AppDelegate {
func setMainMenu() {
//
let appMenu = NSMenuItem()
appMenu.submenu = NSMenu(title: "Open Wallpaper Engine")
appMenu.submenu?.items = [
//
.init(title: String(localized: "About Open Wallpaper Engine"), action: #selector(self.showAboutUs), keyEquivalent: ""),
.separator(),
.init(title: String(localized: "Settings..."), action: #selector(openSettingsWindow), keyEquivalent: ","),
.separator(),
.init(title: String(localized: "Quit"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"),
.separator(),
.init(title: String(localized: "Hide"), action: #selector(NSApplication.hide(_:)), keyEquivalent: "h"),
{
let item = NSMenuItem(title: String(localized: "Hide Others"), action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h")
item.keyEquivalentModifierMask = [.command, .option]
return item
}()
]
//
let importMenu = NSMenuItem(title: String(localized: "Import"), action: nil, keyEquivalent: "")
importMenu.submenu = NSMenu()
importMenu.submenu?.items = [
.init(title: String(localized: "Wallpaper from Folder"), action: #selector(openImportFromFolderPanel), keyEquivalent: "i"),
.init(title: String(localized: "Wallpapers in Folders"), action: nil, keyEquivalent: "")
]
//
let fileMenu = NSMenuItem()
fileMenu.submenu = NSMenu(title: String(localized: "File"))
fileMenu.submenu?.items = [
//
importMenu,
.separator(),
.init(title: String(localized: "Close Window"), action: #selector(AppDelegate.shared.mainWindowController.window.performClose), keyEquivalent: "w")
]
// Edit Menu
let editMenu = NSMenuItem()
editMenu.submenu = NSMenu(title: String(localized: "Edit"))
editMenu.submenu?.items = [
.init(title: String(localized: "Undo"), action: #selector(UndoManager.undo), keyEquivalent: "z"),
.init(title: String(localized: "Redo"), action: #selector(UndoManager.redo), keyEquivalent: "Z"),
.separator(),
.init(title: String(localized: "Cut"), action: #selector(NSText.cut(_:)), keyEquivalent: "x"),
.init(title: String(localized: "Copy"), action: #selector(NSText.copy(_:)), keyEquivalent: "c"),
.init(title: String(localized: "Paste"), action: #selector(NSText.paste(_:)), keyEquivalent: "v"),
.init(title: String(localized: "Delete All"), action: #selector(NSText.delete(_:)), keyEquivalent: String(NSBackspaceCharacter)),
.separator(),
.init(title: String(localized: "Select All"), action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
]
//
let viewMenu = NSMenuItem()
viewMenu.submenu = NSMenu(title: String(localized: "View"))
viewMenu.submenu?.items = [
{
let item = NSMenuItem(title: String(localized: "Show Filter Results"), action: #selector(self.toggleFilter), keyEquivalent: "s")
item.keyEquivalentModifierMask = [.command, .control]
return item
}(),
.separator(),
{
let item = NSMenuItem(title: String(localized: "Enter Full Screen"), action: #selector(AppDelegate.shared.mainWindowController.window.toggleFullScreen(_:)), keyEquivalent: "f")
item.keyEquivalentModifierMask = [.command, .control]
return item
}()
]
//
let windowMenu = NSMenuItem()
windowMenu.submenu = NSMenu(title: String(localized: "Window"))
windowMenu.submenu?.items = [
{
let item = NSMenuItem(title: String(localized: "Wallpaper Explorer"), action: #selector(openMainWindow), keyEquivalent: "1")
item.keyEquivalentModifierMask = [.command, .shift]
return item
}()
]
// Debug Submenu -> Help Menu
let debugMenu = NSMenuItem(title: String(localized: "Debug"), action: nil, keyEquivalent: "")
debugMenu.submenu = NSMenu()
debugMenu.submenu?.items = [
.init(title: String(localized: "Toggle Desktop Wallpaper Window (Debug)"), action: #selector(toggleDesktopWallpaperWindow), keyEquivalent: ""),
.init(title: String(localized: "Reset All Trusted Wallpapers"), action: #selector(resetTrustedWallpapers), keyEquivalent: "")
]
// Help Menu
let helpMenu = NSMenuItem()
helpMenu.submenu = NSMenu(title: String(localized: "Help"))
helpMenu.submenu?.items = [
debugMenu
]
// Main Menu
let mainMenu = NSMenu()
mainMenu.items = [
appMenu,
fileMenu,
editMenu,
viewMenu,
windowMenu,
helpMenu
]
NSApplication.shared.mainMenu = mainMenu
}
@objc func toggleDesktopWallpaperWindow() {
if wallpaperWindows.values.first?.isVisible == true {
for window in wallpaperWindows.values { window.orderOut(nil) }
} else {
for window in wallpaperWindows.values { window.orderFront(nil) }
}
}
@objc func resetTrustedWallpapers() {
UserDefaults.standard.set([String](), forKey: "TrustedWallpapers")
}
}
extension NSMenuItem {
public convenience init(title: String, systemImage: String, action: Selector?, keyEquivalent: String) {
self.init(title: title, action: action, keyEquivalent: keyEquivalent)
self.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: nil)
}
}

View File

@ -0,0 +1,166 @@
import Cocoa
extension AppDelegate {
@objc func mute() {
self.wallpaperViewModel.playVolume = 0
}
@objc func unmute() {
self.wallpaperViewModel.playVolume = self.wallpaperViewModel.lastPlayVolume == 0 ? 1 : self.wallpaperViewModel.lastPlayVolume
}
@objc func pause() {
self.wallpaperViewModel.playRate = 0
}
@objc func resume() {
self.wallpaperViewModel.playRate = self.wallpaperViewModel.lastPlayRate == 0 ? 1 : self.wallpaperViewModel.lastPlayRate
}
@objc func takeScreenshot() {
try! Process.run(URL(filePath: "/usr/sbin/screencapture"), arguments: ["-Cmup", "~/Picturesscreenshot.png"])
}
@objc func browseWorkshop() {
self.contentViewModel.topTabBarSelection = 1
openMainWindow()
}
@objc func openSupportWebpage() {
NSWorkspace.shared.open(URL(string: "https://github.com/haren724/open-wallpaper-engine-mac/wiki")!)
}
@objc func selectRecentWallpaper(_ sender: NSMenuItem) {
guard let wallpaper = sender.representedObject as? WEWallpaper else { return }
wallpaperViewModel.nextCurrentWallpaper = wallpaper
}
func buildRecentWallpapersMenu() -> NSMenu {
let menu = NSMenu(title: String(localized: "Recent Wallpapers"))
let recents = wallpaperViewModel.recentWallpapers
if recents.isEmpty {
menu.addItem(NSMenuItem(title: String(localized: "No recent wallpapers"), action: nil, keyEquivalent: ""))
} else {
for wallpaper in recents {
let title = wallpaper.project.title.isEmpty ? "Untitled" : wallpaper.project.title
let typeLabel = wallpaper.project.type.isEmpty ? "" : " (\(wallpaper.project.type.capitalized))"
let item = NSMenuItem(title: "\(title)\(typeLabel)", action: #selector(selectRecentWallpaper(_:)), keyEquivalent: "")
item.representedObject = wallpaper
item.target = self
menu.addItem(item)
}
}
return menu
}
func setStatusMenu() {
let recentWallpapersMenuItem = NSMenuItem(title: String(localized: "Recent Wallpapers"), action: nil, keyEquivalent: "")
recentWallpapersMenuItem.submenu = buildRecentWallpapersMenu()
let menu = NSMenu()
menu.delegate = self
let isMuted = wallpaperViewModel.playVolume == 0
let isPaused = wallpaperViewModel.playRate == 0
menu.items = [
.init(title: String(localized: "Show Open Wallpaper Engine"),
systemImage: "photo",
action: #selector(openMainWindow),
keyEquivalent: "o"),
recentWallpapersMenuItem,
.separator(),
.init(title: String(localized: "Browse Workshop"),
systemImage: "globe",
action: #selector(browseWorkshop),
keyEquivalent: "w"),
.init(title: String(localized: "Settings"),
systemImage: "gearshape.fill",
action: #selector(openSettingsWindow),
keyEquivalent: ","),
.separator(),
.init(title: String(localized: "Support & FAQ"),
systemImage: "person.fill.questionmark",
action: #selector(openSupportWebpage),
keyEquivalent: "i"),
.separator(),
isMuted
? .init(title: String(localized: "Unmute"), systemImage: "speaker.fill", action: #selector(AppDelegate.shared.unmute), keyEquivalent: "m")
: .init(title: String(localized: "Mute"), systemImage: "speaker.slash.fill", action: #selector(AppDelegate.shared.mute), keyEquivalent: "m"),
isPaused
? .init(title: String(localized: "Resume"), systemImage: "play.fill", action: #selector(resume), keyEquivalent: "p")
: .init(title: String(localized: "Pause"), systemImage: "pause.fill", action: #selector(pause), keyEquivalent: "p"),
.init(title: String(localized: "Quit"),
systemImage: "power",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
]
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
self.statusItem.menu = menu
if let button = self.statusItem.button {
if let image = NSImage(named: "we.logo") {
image.isTemplate = true
button.image = image
} else {
button.image = NSImage(systemSymbolName: "play.desktopcomputer", accessibilityDescription: nil)
}
}
bindMenuLabels()
}
// Keep Mute/Unmute and Pause/Resume labels in sync with ViewModel state.
// The ViewModel only publishes new values; this layer decides what to show.
private func bindMenuLabels() {
wallpaperViewModel.$playVolume
.removeDuplicates { ($0 == 0) == ($1 == 0) } // only react to muted unmuted transitions
.receive(on: DispatchQueue.main)
.sink { [weak self] volume in
guard let menu = self?.statusItem.menu else { return }
let isMuted = volume == 0
if let idx = menu.items.firstIndex(where: { $0.title == String(localized: "Mute") || $0.title == String(localized: "Unmute") }) {
menu.items[idx] = isMuted
? .init(title: String(localized: "Unmute"), systemImage: "speaker.fill", action: #selector(AppDelegate.shared.unmute), keyEquivalent: "m")
: .init(title: String(localized: "Mute"), systemImage: "speaker.slash.fill", action: #selector(AppDelegate.shared.mute), keyEquivalent: "m")
}
}
.store(in: &cancellables)
wallpaperViewModel.$playRate
.removeDuplicates { ($0 == 0) == ($1 == 0) }
.receive(on: DispatchQueue.main)
.sink { [weak self] rate in
guard let menu = self?.statusItem.menu else { return }
let isPaused = rate == 0
if let idx = menu.items.firstIndex(where: { $0.title == "Pause" || $0.title == "Resume" }) {
menu.items[idx] = isPaused
? .init(title: "Resume", systemImage: "play.fill", action: #selector(AppDelegate.shared.resume), keyEquivalent: "p")
: .init(title: "Pause", systemImage: "pause.fill", action: #selector(AppDelegate.shared.pause), keyEquivalent: "p")
}
}
.store(in: &cancellables)
}
}
// MARK: - NSMenuDelegate refresh Recent Wallpapers on menu open
extension AppDelegate: NSMenuDelegate {
func menuWillOpen(_ menu: NSMenu) {
if let recentItem = menu.items.first(where: { $0.title == String(localized: "Recent Wallpapers") }) {
recentItem.submenu = buildRecentWallpapersMenu()
}
}
}

View File

@ -0,0 +1 @@
#import "ObjCExceptionCatcher.h"

View File

@ -0,0 +1,22 @@
# ``Open_Wallpaper_Engine``
@Metadata {
@DisplayName("Open Wallpaper Engine")
@PageImage(
purpose: icon,
source: "OpenWallpaperEngine-icon",
alt: "A technology icon representing the OpenWallpaperEngine framework.")
@PageColor(blue)
}
An open source wallpaper engine for Mac
## Overview
<!--@START_MENU_TOKEN@-->Text<!--@END_MENU_TOKEN@-->
## Topics
### <!--@START_MENU_TOKEN@-->Group<!--@END_MENU_TOKEN@-->
- <!--@START_MENU_TOKEN@-->``Symbol``<!--@END_MENU_TOKEN@-->

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.files.downloads.read-only</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 25.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 24.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 23.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 22.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 2.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 3.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32 copy 4.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "ax2kv-dhxen_1024x1024x32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "8690b2ede34b3c98175e93c8ddec0961.png",
"idiom" : "mac",
"scale" : "1x"
},
{
"filename" : "992522fdde859e532b1d063fbe3f3762.png",
"idiom" : "mac",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "OpenWallpaperEngineicon-icon@2x copy 1.png",
"idiom" : "mac",
"scale" : "1x"
},
{
"filename" : "OpenWallpaperEngineicon-icon@2x copy.png",
"idiom" : "mac",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

View File

@ -0,0 +1,297 @@
//
// GlobalSettingsService.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/9/2.
//
import Cocoa
import Combine
import SwiftUI
import ServiceManagement
enum GSQuality {
case low, medium, high, ultra
}
enum GSPlayback: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case keepRunning, mute, pause, stop
}
enum GSAntiAliasingQuality: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case none, msaa_x2, msaa_x4, msaa_x8
}
enum GSPostProcessingQuality: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case disabled, enabled, ultra
}
enum GSTextureResolutionQuality: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case highQuality, highPerformance, automatic
}
enum GSAppearance: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case light, dark, followSystem
}
enum GSLocalization: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case en_US, zh_CN, zh_TW, ja, followSystem
}
enum GSVideoFramework: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case avkit
}
enum GSProcessPiority: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case normal, belowNormal
}
enum GSLogLevel: String, CaseIterable, Identifiable, Codable {
var id: Self { self }
case error, verbose, none
}
struct GlobalSettings: Codable, Equatable {
// MARK: Playback
var otherApplicationFocused = GSPlayback.keepRunning
var otherApplicationFullscreen = GSPlayback.keepRunning
var otherApplicationPlayingAudio = GSPlayback.keepRunning
var displayAsleep = GSPlayback.keepRunning
var laptopOnBattery = GSPlayback.keepRunning
// MARK: Quality
var antiAliasing = GSAntiAliasingQuality.msaa_x2
var postProcessing = GSPostProcessingQuality.disabled
var textureResolution = GSTextureResolutionQuality.automatic
var reflections = false
var fps: Double = 30
// MARK: Automatic Setup
var autoStart = false
var safeMode = false
// MARK: Basic Setup
var language = GSLocalization.followSystem
// MARK: macOS
var adjustMenuBarTint = true
// MARK: Appearance
var appearance = GSAppearance.followSystem
// MARK: Audio
var audioOutput = true
var reloadWhenChangingOutputDevice = true // Not putting in use
// MARK: Video
var videoFramework = GSVideoFramework.avkit
// MARK: Advanced
var processPiority = GSProcessPiority.normal // Not putting in use
var pauseOnVRAMExhausted = false // Not putting in use
var restartAfterCrashing = false // Not putting in use
// MARK: Developer
var logLevel = GSLogLevel.none
// MARK: Misc
var autoRefresh = true
}
class GlobalSettingsViewModel: ObservableObject {
@Published var settings: GlobalSettings
{
didSet { save(); validate() }
}
@Published var selection = 0
var locale: Locale {
switch settings.language {
case .en_US: return Locale(identifier: "en")
case .zh_CN: return Locale(identifier: "zh-Hans")
case .zh_TW: return Locale(identifier: "zh-Hant")
case .ja: return Locale(identifier: "ja")
case .followSystem: return .current
}
}
var didFinishLaunchingNotificationCancellable: Cancellable?
var didActivateApplicationNotificationCancellable: Cancellable?
var didCurrentWallpaperChangeCancellable: Cancellable?
var didAddToLoginItemCancellable: Cancellable?
var didChangeAdjustMenuBarTintCancellable: Cancellable?
init() {
if let data = UserDefaults.standard.data(forKey: "GlobalSettings"),
let settings = try? JSONDecoder().decode(GlobalSettings.self, from: data) {
self.settings = settings
} else {
self.settings = GlobalSettings()
}
// Add observers
self.didFinishLaunchingNotificationCancellable =
NotificationCenter.default.publisher(for: NSApplication.didFinishLaunchingNotification)
.sink { [weak self] _ in self?.didFinishLaunchingNotification() }
}
deinit {
didActivateApplicationNotificationCancellable?.cancel()
didFinishLaunchingNotificationCancellable?.cancel()
didCurrentWallpaperChangeCancellable?.cancel()
didAddToLoginItemCancellable?.cancel()
didChangeAdjustMenuBarTintCancellable?.cancel()
}
func didFinishLaunchingNotification() {
self.didActivateApplicationNotificationCancellable =
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didActivateApplicationNotification)
.sink { [weak self] _ in self?.activateApplicationDidChange() }
self.didCurrentWallpaperChangeCancellable =
AppDelegate.shared.wallpaperViewModel.$wallpapers
.sink { [weak self] wallpapers in
let mainId = WallpaperViewModel.mainScreenId()
if let wp = wallpapers[mainId] {
self?.didCurrentWallpaperChange(wp)
}
}
self.didAddToLoginItemCancellable =
self.$settings
.removeDuplicates { $0.autoStart == $1.autoStart }
.map { $0.autoStart }
.sink { [weak self] in self?.didAddToLoginItem($0) }
self.didChangeAdjustMenuBarTintCancellable =
self.$settings
.removeDuplicates { $0.adjustMenuBarTint == $1.adjustMenuBarTint }
.map { $0.adjustMenuBarTint }
.sink { [weak self] in self?.didChangeAdjustMenuBarTint($0) }
self.validate()
}
func didAddToLoginItem(_ added: Bool) {
let appService = SMAppService.mainApp
do {
if added {
try appService.register()
} else {
try appService.unregister()
}
} catch {
print(error)
}
}
func didChangeAdjustMenuBarTint(_ newValue: Bool) {}
func didCurrentWallpaperChange(_ newValue: WEWallpaper) {}
func reset() {
settings = (try? JSONDecoder()
.decode(GlobalSettings.self,
from: UserDefaults.standard.data(forKey: "GlobalSettings")
?? Data()))
?? GlobalSettings()
}
func save() {
let data = try! JSONEncoder().encode(settings)
print(String(describing: String(data: data, encoding: .utf8)))
UserDefaults.standard.set(data, forKey: "GlobalSettings")
}
func setQuality(_ quality: GSQuality) {
switch quality {
case .low:
self.settings.antiAliasing = .none
self.settings.postProcessing = .disabled
self.settings.textureResolution = .highQuality
self.settings.fps = 10
self.settings.reflections = false
case .medium:
self.settings.antiAliasing = .none
self.settings.postProcessing = .enabled
self.settings.textureResolution = .highQuality
self.settings.fps = 15
self.settings.reflections = true
case .high:
self.settings.antiAliasing = .msaa_x2
self.settings.postProcessing = .enabled
self.settings.textureResolution = .highQuality
self.settings.fps = 25
self.settings.reflections = true
case .ultra:
self.settings.antiAliasing = .msaa_x2
self.settings.postProcessing = .ultra
self.settings.textureResolution = .highQuality
self.settings.fps = 30
self.settings.reflections = true
}
}
private func validate() {
switch settings.appearance {
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
case .followSystem:
NSApp.appearance = nil
}
WELogger.shared.level = settings.logLevel
}
func activateApplicationDidChange() {
guard let frontmostApplication = NSWorkspace.shared.frontmostApplication else { return }
switch frontmostApplication.bundleIdentifier {
case "com.apple.finder", Bundle.main.bundleIdentifier:
globalSettingsWhenApplicationDidBecomeActive()
default:
switch self.settings.otherApplicationFocused {
case .mute:
AppDelegate.shared.mute()
case .pause:
AppDelegate.shared.pause()
case .stop:
AppDelegate.shared.pause()
for window in AppDelegate.shared.wallpaperWindows.values { window.orderOut(nil) }
case .keepRunning:
break
}
}
}
func globalSettingsWhenApplicationDidBecomeActive() {
switch self.settings.otherApplicationFocused {
case .mute:
AppDelegate.shared.unmute()
case .pause:
AppDelegate.shared.resume()
case .stop:
AppDelegate.shared.resume()
for window in AppDelegate.shared.wallpaperWindows.values { window.orderFront(nil) }
case .keepRunning:
break
}
}
private func saveAndValidate() {
save()
validate()
}
}

View File

@ -0,0 +1,12 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface ObjCExceptionCatcher : NSObject
/// Execute a block, catching any ObjC NSException. Returns YES on success.
+ (BOOL)performSafe:(void (NS_NOESCAPE ^)(void))block;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,15 @@
#import "ObjCExceptionCatcher.h"
@implementation ObjCExceptionCatcher
+ (BOOL)performSafe:(void (NS_NOESCAPE ^)(void))block {
@try {
block();
return YES;
} @catch (NSException *exception) {
NSLog(@"[ObjCExceptionCatcher] Caught: %@", exception.reason);
return NO;
}
}
@end

View File

@ -0,0 +1,119 @@
//
// PKGParser.swift
// Open Wallpaper Engine
//
// Parse Wallpaper Engine PKGV archive files.
// Format: length-prefixed "PKGVxxxx" header, entry count,
// then per entry: length-prefixed path + offset + length.
// File data follows contiguously after the header table.
//
import Foundation
struct PKGEntry {
let path: String
let offset: UInt32
let length: UInt32
}
class PKGParser {
private let data: Data
private let entries: [PKGEntry]
private let dataBaseOffset: Int
init(data: Data) throws {
self.data = data
// Copy data into contiguous array for safe, aligned reads
let bytes = [UInt8](data)
var cursor = 0
func readUInt32() throws -> UInt32 {
guard cursor + 4 <= bytes.count else {
throw PKGError.unexpectedEndOfFile
}
let value = UInt32(bytes[cursor])
| (UInt32(bytes[cursor+1]) << 8)
| (UInt32(bytes[cursor+2]) << 16)
| (UInt32(bytes[cursor+3]) << 24)
cursor += 4
return value
}
func readString(length: Int) throws -> String {
guard length >= 0, cursor + length <= bytes.count else {
throw PKGError.unexpectedEndOfFile
}
let slice = bytes[cursor..<cursor+length]
cursor += length
return String(bytes: slice, encoding: .utf8)
?? String(bytes: slice, encoding: .isoLatin1)
?? ""
}
// Read header string (e.g. "PKGV0013")
let headerLen = try readUInt32()
guard headerLen < 100 else { throw PKGError.invalidMagic("(header too long: \(headerLen))") }
let header = try readString(length: Int(headerLen))
guard header.hasPrefix("PKGV") else {
throw PKGError.invalidMagic(header)
}
// Read entry count
let entryCount = try readUInt32()
guard entryCount < 100_000 else { throw PKGError.unexpectedEndOfFile }
// Read file table
var parsedEntries: [PKGEntry] = []
parsedEntries.reserveCapacity(Int(entryCount))
for _ in 0..<entryCount {
let pathLen = try readUInt32()
guard pathLen < 10_000 else { throw PKGError.unexpectedEndOfFile }
let path = try readString(length: Int(pathLen))
let offset = try readUInt32()
let length = try readUInt32()
parsedEntries.append(PKGEntry(path: path, offset: offset, length: length))
}
self.entries = parsedEntries
self.dataBaseOffset = cursor
}
convenience init(url: URL) throws {
let data = try Data(contentsOf: url)
try self.init(data: data)
}
var fileList: [String] {
entries.map(\.path)
}
func extractFile(named name: String) -> Data? {
guard let entry = entries.first(where: { $0.path == name }) else {
return nil
}
let start = dataBaseOffset + Int(entry.offset)
let end = start + Int(entry.length)
guard end <= data.count else { return nil }
return data[start..<end]
}
func extractJSON<T: Decodable>(named name: String, as type: T.Type) throws -> T? {
guard let fileData = extractFile(named: name) else { return nil }
return try JSONDecoder().decode(type, from: fileData)
}
}
enum PKGError: Error, LocalizedError {
case invalidMagic(String)
case unexpectedEndOfFile
var errorDescription: String? {
switch self {
case .invalidMagic(let got):
return "Invalid PKG header: expected PKGV*, got '\(got)'"
case .unexpectedEndOfFile:
return "Unexpected end of PKG file"
}
}
}

View File

@ -0,0 +1,338 @@
//
// SceneModels.swift
// Open Wallpaper Engine
//
// Data models for Wallpaper Engine scene.json structure.
// Decoded from scene.pkg scene.json and referenced JSON files.
//
import Foundation
// MARK: - Top-level Scene
struct WEScene: Codable {
var camera: WECamera
var general: WESceneGeneral
var objects: [WESceneObject]
var version: Int?
}
struct WECamera: Codable {
var center: String?
var eye: String?
var up: String?
}
struct WESceneGeneral: Codable {
var clearcolor: String?
var orthogonalprojection: WEOrthogonalProjection?
var ambientcolor: String?
var skylightcolor: String?
// These fields can be Bool, Int, or an object {"user":..,"value":..} in different wallpapers.
// We only need the String fields above for rendering, so skip strict decoding of the rest.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Use try? because these fields can be plain strings OR {"user":..,"value":..} objects
clearcolor = try? container.decodeIfPresent(String.self, forKey: .clearcolor)
orthogonalprojection = try? container.decodeIfPresent(WEOrthogonalProjection.self, forKey: .orthogonalprojection)
ambientcolor = try? container.decodeIfPresent(String.self, forKey: .ambientcolor)
skylightcolor = try? container.decodeIfPresent(String.self, forKey: .skylightcolor)
}
enum CodingKeys: String, CodingKey {
case clearcolor, orthogonalprojection, ambientcolor, skylightcolor
}
}
struct WEOrthogonalProjection: Codable {
var width: Int
var height: Int
}
// MARK: - Scene Objects
/// Many WE scene fields can be either a plain value or a {"script":"..","value":..} object.
/// This wrapper decodes the plain value and silently ignores script objects.
private func decodeFlexible<T: Decodable>(_ type: T.Type, container: KeyedDecodingContainer<WESceneObject.CodingKeys>, key: WESceneObject.CodingKeys) -> T? {
try? container.decodeIfPresent(T.self, forKey: key)
}
struct WESceneObject: Codable {
// Common
var id: Int?
var name: String?
var origin: String?
var scale: String?
var angles: String?
var visible: Bool?
// Image objects
var image: String? // path to model JSON
var alpha: Double?
var brightness: Double?
var color: String?
var colorBlendMode: Int?
var size: String?
var alignment: String?
var solid: Bool?
var copybackground: Bool?
var parallaxDepth: String?
var perspective: Bool?
// Particle objects
var particle: String? // path to particle JSON
var instanceoverride: WEInstanceOverride?
// Sound objects
var sound: [String]? // array of audio file paths
// Post-process effects applied to this layer
var effects: [WEEffect]?
enum CodingKeys: String, CodingKey {
case id, name, origin, scale, angles, visible
case image, alpha, brightness, color, colorBlendMode, size, alignment
case solid, copybackground, parallaxDepth, perspective
case particle, instanceoverride
case sound, effects
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
// Fields that are always simple types
id = try? c.decodeIfPresent(Int.self, forKey: .id)
name = try? c.decodeIfPresent(String.self, forKey: .name)
image = try? c.decodeIfPresent(String.self, forKey: .image)
particle = try? c.decodeIfPresent(String.self, forKey: .particle)
instanceoverride = try? c.decodeIfPresent(WEInstanceOverride.self, forKey: .instanceoverride)
sound = try? c.decodeIfPresent([String].self, forKey: .sound)
effects = try? c.decodeIfPresent([WEEffect].self, forKey: .effects)
// Fields that may be simple values or {"script":..,"value":..} objects
origin = try? c.decodeIfPresent(String.self, forKey: .origin)
scale = try? c.decodeIfPresent(String.self, forKey: .scale)
angles = try? c.decodeIfPresent(String.self, forKey: .angles)
visible = try? c.decodeIfPresent(Bool.self, forKey: .visible)
alpha = try? c.decodeIfPresent(Double.self, forKey: .alpha)
brightness = try? c.decodeIfPresent(Double.self, forKey: .brightness)
color = try? c.decodeIfPresent(String.self, forKey: .color)
colorBlendMode = try? c.decodeIfPresent(Int.self, forKey: .colorBlendMode)
size = try? c.decodeIfPresent(String.self, forKey: .size)
alignment = try? c.decodeIfPresent(String.self, forKey: .alignment)
solid = try? c.decodeIfPresent(Bool.self, forKey: .solid)
copybackground = try? c.decodeIfPresent(Bool.self, forKey: .copybackground)
parallaxDepth = try? c.decodeIfPresent(String.self, forKey: .parallaxDepth)
perspective = try? c.decodeIfPresent(Bool.self, forKey: .perspective)
}
}
struct WEInstanceOverride: Codable {
var id: Int?
var colorn: String?
var rate: WEScriptValue?
var size: Double?
}
struct WEScriptValue: Codable {
var script: String?
var value: Double?
init(from decoder: Decoder) throws {
// Can be just a number or an object with script+value
if let container = try? decoder.singleValueContainer(),
let num = try? container.decode(Double.self) {
self.value = num
self.script = nil
} else {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.script = try container.decodeIfPresent(String.self, forKey: .script)
self.value = try container.decodeIfPresent(Double.self, forKey: .value)
}
}
enum CodingKeys: String, CodingKey {
case script, value
}
}
// MARK: - Model / Material
struct WEModel: Codable {
var autosize: Bool?
var material: String? // path to material JSON
var fullscreen: Bool?
var width: Int?
var height: Int?
var puppet: String?
}
struct WEMaterial: Codable {
var passes: [WEMaterialPass]?
}
// WE shader constant value scalar number or vector (e.g. "g_Color": [1,0,0,1])
enum WEShaderValue: Codable {
case number(Double)
case vector([Double])
var doubleValue: Double? {
switch self {
case .number(let n): return n
case .vector(let v): return v.first
}
}
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let n = try? c.decode(Double.self) { self = .number(n); return }
if let v = try? c.decode([Double].self) { self = .vector(v); return }
self = .number(0)
}
func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .number(let n): try c.encode(n)
case .vector(let v): try c.encode(v)
}
}
}
struct WEMaterialPass: Codable {
var blending: String? // "translucent", "additive"
var shader: String?
var textures: [String?]? // nullable entries = unbound texture slots
var combos: [String: Int]?
var constantshadervalues: [String: WEShaderValue]?
var cullmode: String?
var depthtest: String?
var depthwrite: String?
}
// Effect applied to a scene object (post-process pass referencing an effect JSON file)
struct WEEffect: Codable {
var id: Int?
var name: String?
var file: String? // path to effect.json
var visible: Bool?
}
// MARK: - Particle System
struct WEParticleSystem: Codable {
var emitter: [WEParticleEmitter]?
var initializer: [WEParticleInitializer]?
var `operator`: [WEParticleOperator]?
var renderer: [WEParticleRenderer]?
var material: String?
var maxcount: Int?
var flags: Int?
var starttime: Double?
var animationmode: String?
var sequencemultiplier: Double?
}
struct WEParticleEmitter: Codable {
var id: Int?
var name: String?
var rate: Double?
var origin: String?
var directions: String?
var distancemax: Double?
var distancemin: Double?
var speedmax: Double?
var speedmin: Double?
}
struct WEParticleInitializer: Codable {
var id: Int?
var name: String?
var min: WEFlexValue?
var max: WEFlexValue?
}
/// A value that can be either a number or a string (e.g. "0 -3000 0")
enum WEFlexValue: Codable {
case number(Double)
case string(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let num = try? container.decode(Double.self) {
self = .number(num)
} else if let str = try? container.decode(String.self) {
self = .string(str)
} else {
self = .number(0)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .number(let n): try container.encode(n)
case .string(let s): try container.encode(s)
}
}
var doubleValue: Double {
switch self {
case .number(let n): return n
case .string(let s): return Double(s) ?? 0
}
}
var vectorValue: (Double, Double, Double) {
switch self {
case .number(let n): return (n, n, n)
case .string(let s): return s.parseVector3()
}
}
}
struct WEParticleOperator: Codable {
var id: Int?
var name: String?
var gravity: String?
var drag: Double?
var fadeintime: Double?
var fadeouttime: Double?
}
struct WEParticleRenderer: Codable {
var id: Int?
var name: String? // "sprite", "spritetrail"
var length: Double?
var maxlength: Double?
}
// MARK: - String Parsing Helpers
extension String {
/// Parse "x y z" space-separated vector string
func parseVector3() -> (Double, Double, Double) {
let parts = self.split(separator: " ").compactMap { Double($0) }
return (
parts.count > 0 ? parts[0] : 0,
parts.count > 1 ? parts[1] : 0,
parts.count > 2 ? parts[2] : 0
)
}
/// Parse "x y" space-separated 2D vector
func parseVector2() -> (Double, Double) {
let parts = self.split(separator: " ").compactMap { Double($0) }
return (
parts.count > 0 ? parts[0] : 0,
parts.count > 1 ? parts[1] : 0
)
}
/// Parse "r g b" color string (0-1 range) to NSColor
func parseColor() -> (r: Double, g: Double, b: Double) {
let v = self.parseVector3()
return (r: v.0, g: v.1, b: v.2)
}
}

View File

@ -0,0 +1,360 @@
// TEXParser.swift Wallpaper Engine TEXV texture container parser.
//
// Binary layout (sequential, all little-endian):
// "TEXV000X\0" version-tagged magic
// "TEXI000X\0" metadata section tag
// format(4) flags(4) texW(4) texH(4) imgW(4) imgH(4) reserved(4)
// "TEXB000X\0" pixel-data section tag (version determines structure)
// [version-specific header] [per-mipmap entries]
//
// TEXI format codes (confirmed from linux-wallpaperengine / repkg):
// 0=RGBA8888 1=RGB888 2=RGB565
// 4=DXT5(BC3) 6=DXT3(BC2) 7=DXT1(BC1) 8=RG88 9=R8 12=BC7
//
// TEXB versions:
// 0001 unknown(4) mipmapCount(4); per mip: w h size data
// 0002 unknown(4) mipmapCount(4); per mip: w h compFlag decompSz compSz data
// 0003 unknown(4) freeImgFmt(4) mipmapCount(4); per mip: same as 0002
// 0004 unknown(4) freeImgFmt(4) isVideoMp4(4) imageCount(4);
// per image: mipmapCount(4); per mip: V4header w h compFlag decompSz compSz data
//
// LZ4 raw-block compression (compFlag==1) decompressed with Apple Compression.framework.
import Cocoa
import Foundation
import Compression
class TEXParser {
// MARK: - Public types
enum TextureFormat: UInt32 {
case rgba8888 = 0, rgb888 = 1, rgb565 = 2
case dxt5 = 4, dxt3 = 6, dxt1 = 7
case rg88 = 8, r8 = 9, bc7 = 12
}
// MARK: - Init
private let raw: [UInt8]
private var pos = 0
init(data: Data) { raw = [UInt8](data) }
// MARK: - Public API
/// Returns an NSImage for static textures, nil for video textures or unsupported formats.
func extractImage() -> NSImage? {
pos = 0
if let (img, _) = parseTEXV() { return img }
return scanForEmbeddedImage()
}
/// Returns raw MP4 bytes when the texture contains an embedded video (TEXB0004 isVideoMp4=1).
func extractVideoData() -> Data? {
pos = 0
return parseTEXV()?.1
}
// MARK: - Main parse
// Returns (image, videoData) at most one is non-nil.
private func parseTEXV() -> (NSImage?, Data?)? {
pos = 0
guard readTag("TEXV") != nil, readTag("TEXI") != nil else { return nil }
guard let fmtRaw = readU32(), let _ = readU32(), // flags
let texW = readU32(), let texH = readU32(),
let imgW = readU32(), let imgH = readU32(),
let _ = readU32() // reserved
else { return nil }
let format = TextureFormat(rawValue: fmtRaw) ?? .rgba8888
let mipW0 = Int(imgW > 0 ? imgW : texW)
let mipH0 = Int(imgH > 0 ? imgH : texH)
guard let texbVerStr = readTag("TEXB") else { return nil }
let ver = texbVersionNum(texbVerStr)
guard let _ = readU32() else { return nil } // common unknown
var freeImgFmt: Int32 = -1
var isVideoMp4 = false
if ver >= 3 { guard let f = readI32() else { return nil }; freeImgFmt = f }
if ver >= 4 {
guard let vid = readU32(), let _ = readU32() else { return nil }
isVideoMp4 = vid != 0
}
guard let _ = readU32() else { return nil } // mipmapCount (we only need the first)
if ver >= 4 { guard skipV4MipHeader() else { return nil } }
// Use mip-level dimensions if available; fall back to header dimensions.
let mipWidth, mipHeight: Int
if let mw = readU32(), let mh = readU32() {
mipWidth = Int(mw); mipHeight = Int(mh)
} else {
mipWidth = mipW0; mipHeight = mipH0
}
let payload: [UInt8]
if ver >= 2 {
guard let compFlag = readU32(), let decompSz = readU32(),
let compSz = readU32() else { return nil }
guard let src = readBytes(Int(compSz)) else { return nil }
if compFlag == 1 {
guard let dec = lz4Decompress(src, expected: Int(decompSz)) else { return nil }
payload = dec
} else {
payload = src
}
} else {
guard let sz = readU32(), let src = readBytes(Int(sz)) else { return nil }
payload = src
}
if isVideoMp4 { return (nil, Data(payload)) }
// FreeImage-backed (JPEG / PNG stored as-is in the mip slot)
if ver >= 3 && freeImgFmt >= 0 {
return (NSImage(data: Data(payload)), nil)
}
return (decodePixels(payload, format: format, w: mipWidth, h: mipHeight), nil)
}
// MARK: - Pixel decode
private func decodePixels(_ src: [UInt8], format: TextureFormat, w: Int, h: Int) -> NSImage? {
switch format {
case .dxt1: return dxt1Image(src, w: w, h: h)
case .dxt3: return dxt3Image(src, w: w, h: h)
case .dxt5: return dxt5Image(src, w: w, h: h)
case .rgba8888: return makeNSImage(rgba: src, w: w, h: h)
case .rgb888:
guard src.count >= w * h * 3 else { return nil }
var rgba = [UInt8](repeating: 255, count: w * h * 4)
for i in 0..<(w * h) {
rgba[i*4] = src[i*3]; rgba[i*4+1] = src[i*3+1]; rgba[i*4+2] = src[i*3+2]
}
return makeNSImage(rgba: rgba, w: w, h: h)
default:
return NSImage(data: Data(src))
}
}
// MARK: - DXT1 (BC1) 8 bytes/block, 1-bit alpha
private func dxt1Image(_ src: [UInt8], w: Int, h: Int) -> NSImage? {
guard w > 0, h > 0 else { return nil }
var rgba = [UInt8](repeating: 255, count: w * h * 4)
let bw = (w+3)/4, bh = (h+3)/4
var off = 0
for by in 0..<bh {
for bx in 0..<bw {
guard off + 8 <= src.count else { break }
writeColorBlock(src, off: off, rgba: &rgba, bx: bx, by: by, w: w, h: h, forceOpaque: false)
off += 8
}
}
return makeNSImage(rgba: rgba, w: w, h: h)
}
// MARK: - DXT3 (BC2) 16 bytes/block, explicit 4-bit alpha
private func dxt3Image(_ src: [UInt8], w: Int, h: Int) -> NSImage? {
guard w > 0, h > 0 else { return nil }
var rgba = [UInt8](repeating: 255, count: w * h * 4)
let bw = (w+3)/4, bh = (h+3)/4
var off = 0
for by in 0..<bh {
for bx in 0..<bw {
guard off + 16 <= src.count else { break }
writeColorBlock(src, off: off+8, rgba: &rgba, bx: bx, by: by, w: w, h: h, forceOpaque: true)
for row in 0..<4 {
for col in 0..<4 {
let px = bx*4+col, py = by*4+row
guard px < w, py < h else { continue }
let pi = row*4+col
let nib = pi%2==0 ? src[off+pi/2]&0xF : src[off+pi/2]>>4
rgba[(py*w+px)*4+3] = UInt8(Int(nib)*255/15)
}
}
off += 16
}
}
return makeNSImage(rgba: rgba, w: w, h: h)
}
// MARK: - DXT5 (BC3) 16 bytes/block, interpolated alpha
private func dxt5Image(_ src: [UInt8], w: Int, h: Int) -> NSImage? {
guard w > 0, h > 0 else { return nil }
var rgba = [UInt8](repeating: 255, count: w * h * 4)
let bw = (w+3)/4, bh = (h+3)/4
var off = 0
for by in 0..<bh {
for bx in 0..<bw {
guard off + 16 <= src.count else { break }
let a0 = src[off], a1 = src[off+1]
var aPal = [UInt8](repeating: 0, count: 8)
aPal[0] = a0; aPal[1] = a1
if a0 > a1 {
for i in 2..<8 { aPal[i] = UInt8((Int(a0)*(8-i)+Int(a1)*(i-1)+3)/7) }
} else {
for i in 2..<6 { aPal[i] = UInt8((Int(a0)*(6-i)+Int(a1)*(i-1)+2)/5) }
aPal[6] = 0; aPal[7] = 255
}
// 16 alpha indices packed as 3 bits each in 6 bytes (little-endian bit stream)
var aBits: UInt64 = 0
for i in 0..<6 { aBits |= UInt64(src[off+2+i]) << (i*8) }
writeColorBlock(src, off: off+8, rgba: &rgba, bx: bx, by: by, w: w, h: h, forceOpaque: true)
for row in 0..<4 {
for col in 0..<4 {
let px = bx*4+col, py = by*4+row
guard px < w, py < h else { continue }
let aIdx = Int((aBits >> ((row*4+col)*3)) & 0x7)
rgba[(py*w+px)*4+3] = aPal[aIdx]
}
}
off += 16
}
}
return makeNSImage(rgba: rgba, w: w, h: h)
}
// MARK: - Shared DXT color block writer
private func writeColorBlock(_ src: [UInt8], off: Int, rgba: inout [UInt8],
bx: Int, by: Int, w: Int, h: Int, forceOpaque: Bool) {
let c0 = UInt16(src[off]) | UInt16(src[off+1])<<8
let c1 = UInt16(src[off+2]) | UInt16(src[off+3])<<8
var pal = [[UInt8]](repeating: [0,0,0,255], count: 4)
pal[0] = rgb565(c0); pal[1] = rgb565(c1)
if forceOpaque || c0 > c1 {
pal[2] = blend(pal[0], pal[1], 1, 3)
pal[3] = blend(pal[0], pal[1], 2, 3)
} else {
pal[2] = blend(pal[0], pal[1], 1, 2)
pal[3] = [0, 0, 0, 0]
}
for row in 0..<4 {
let ib = src[off+4+row]
for col in 0..<4 {
let px = bx*4+col, py = by*4+row
guard px < w, py < h else { continue }
let ci = Int((ib >> (col*2)) & 0x3)
let o = (py*w+px)*4
rgba[o] = pal[ci][0]; rgba[o+1] = pal[ci][1]
rgba[o+2] = pal[ci][2]; rgba[o+3] = pal[ci][3]
}
}
}
private func rgb565(_ c: UInt16) -> [UInt8] {
let r = UInt8(((c >> 11) & 0x1F) * 255 / 31)
let g = UInt8(((c >> 5) & 0x3F) * 255 / 63)
let b = UInt8( (c & 0x1F) * 255 / 31)
return [r, g, b, 255]
}
// Lerp: result = a*(d-n)/d + b*n/d, integer rounding
private func blend(_ a: [UInt8], _ b: [UInt8], _ n: Int, _ d: Int) -> [UInt8] {
let r = UInt8((Int(a[0])*(d-n) + Int(b[0])*n + d/2) / d)
let g = UInt8((Int(a[1])*(d-n) + Int(b[1])*n + d/2) / d)
let b_ = UInt8((Int(a[2])*(d-n) + Int(b[2])*n + d/2) / d)
return [r, g, b_, 255]
}
// MARK: - LZ4 raw-block decompression
private func lz4Decompress(_ src: [UInt8], expected: Int) -> [UInt8]? {
guard expected > 0 else { return [] }
var dst = [UInt8](repeating: 0, count: expected)
let n: Int = src.withUnsafeBytes { s in
dst.withUnsafeMutableBytes { d in
compression_decode_buffer(
d.baseAddress!.assumingMemoryBound(to: UInt8.self), expected,
s.baseAddress!.assumingMemoryBound(to: UInt8.self), src.count,
nil, COMPRESSION_LZ4_RAW)
}
}
guard n > 0 else { return nil }
return n < expected ? Array(dst.prefix(n)) : dst
}
// MARK: - CGImage / NSImage creation
private func makeNSImage(rgba: [UInt8], w: Int, h: Int) -> NSImage? {
guard !rgba.isEmpty, w > 0, h > 0 else { return nil }
guard let prov = CGDataProvider(data: Data(rgba) as CFData) else { return nil }
guard let cg = CGImage(
width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: w*4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
provider: prov, decode: nil, shouldInterpolate: true, intent: .defaultIntent)
else { return nil }
return NSImage(cgImage: cg, size: NSSize(width: w, height: h))
}
// MARK: - Binary reader helpers
private func readU32() -> UInt32? {
guard pos+4 <= raw.count else { return nil }
let v = UInt32(raw[pos]) | UInt32(raw[pos+1])<<8 | UInt32(raw[pos+2])<<16 | UInt32(raw[pos+3])<<24
pos += 4; return v
}
private func readI32() -> Int32? { readU32().map { Int32(bitPattern: $0) } }
private func readBytes(_ n: Int) -> [UInt8]? {
guard n >= 0, pos+n <= raw.count else { return nil }
let s = Array(raw[pos..<pos+n]); pos += n; return s
}
/// Scan forward up to 32 bytes for "PREFIXxxxx\0"; advance past null; return version string.
private func readTag(_ prefix: String) -> String? {
let pb = [UInt8](prefix.utf8)
guard pb.count > 0 else { return nil }
let limit = min(pos + 32, raw.count - pb.count)
while pos <= limit {
if raw[pos..<pos+pb.count].elementsEqual(pb) {
pos += pb.count
var ver = ""
while pos < raw.count { let b = raw[pos]; pos += 1; if b == 0 { break }; ver.append(Character(UnicodeScalar(b))) }
return ver
}
pos += 1
}
return nil
}
private func texbVersionNum(_ s: String) -> Int { Int(s) ?? 1 }
/// TEXB0004 per-mipmap preamble: unknown(4) unknown(4) null-term-JSON unknown(4)
private func skipV4MipHeader() -> Bool {
guard readU32() != nil, readU32() != nil else { return false }
while pos < raw.count { if raw[pos] == 0 { pos += 1; break }; pos += 1 }
return readU32() != nil
}
// MARK: - Fallback: scan raw bytes for embedded JPEG / PNG
private func scanForEmbeddedImage() -> NSImage? {
var i = 0
while i < raw.count - 3 {
if raw[i] == 0xFF && raw[i+1] == 0xD8 {
if let img = NSImage(data: Data(raw[i...])) { return img }
}
if raw[i] == 0x89 && raw[i+1] == 0x50 && raw[i+2] == 0x4E && raw[i+3] == 0x47 {
if let img = NSImage(data: Data(raw[i...])) { return img }
}
i += 1
}
return nil
}
}

View File

@ -0,0 +1,537 @@
import AVFoundation
import SpriteKit
import SwiftUI
class SceneWallpaperViewModel: ObservableObject {
// MARK: - Public state
var currentWallpaper: WEWallpaper {
willSet { loadScene(from: newValue) }
}
@Published var skScene: SKScene?
// MARK: - Private state
private var pkgParser: PKGParser?
// Retained video players (SKVideoNode holds a weak ref only)
private var videoPlayers: [AVQueuePlayer] = []
private var videoLoopers: [AVPlayerLooper] = []
private var tempVideoFiles: [URL] = []
// Background audio
private var audioPlayers: [AVAudioPlayer] = []
// MARK: - Init / deinit
init(wallpaper: WEWallpaper) {
self.currentWallpaper = wallpaper
registerObservers()
loadScene(from: wallpaper)
}
deinit {
NotificationCenter.default.removeObserver(self)
NSWorkspace.shared.notificationCenter.removeObserver(self)
videoPlayers.forEach { $0.pause() }
audioPlayers.forEach { $0.stop() }
for url in tempVideoFiles { try? FileManager.default.removeItem(at: url) }
}
// MARK: - Observers
private func registerObservers() {
let ws = NSWorkspace.shared.notificationCenter
ws.addObserver(self, selector: #selector(systemWillSleep(_:)),
name: NSWorkspace.screensDidSleepNotification, object: nil)
ws.addObserver(self, selector: #selector(systemDidWake(_:)),
name: NSWorkspace.didWakeNotification, object: nil)
ws.addObserver(self, selector: #selector(spaceDidChange(_:)),
name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
ws.addObserver(self, selector: #selector(spaceDidChange(_:)),
name: NSWorkspace.didActivateApplicationNotification, object: nil)
}
// MARK: - Scene loading
func loadScene(from wallpaper: WEWallpaper) {
let dir = wallpaper.wallpaperDirectory
let sceneFile = wallpaper.project.file
let pkgName = (sceneFile as NSString).deletingPathExtension + ".pkg"
let pkgURL = dir.appending(path: pkgName)
let looseURL = dir.appending(path: sceneFile)
// Tear down previous media before loading new scene
videoPlayers.forEach { $0.pause() }
videoPlayers = []; videoLoopers = []
for url in tempVideoFiles { try? FileManager.default.removeItem(at: url) }
tempVideoFiles = []
audioPlayers.forEach { $0.stop() }
audioPlayers = []
var scene: WEScene?
if FileManager.default.fileExists(atPath: pkgURL.path(percentEncoded: false)) {
do {
let parser = try PKGParser(url: pkgURL)
pkgParser = parser
scene = try parser.extractJSON(named: sceneFile, as: WEScene.self)
} catch {
WELogger.shared.error("[SceneVM] PKG parse failed: \(error)")
}
} else if FileManager.default.fileExists(atPath: looseURL.path(percentEncoded: false)) {
pkgParser = nil
scene = try? JSONDecoder().decode(WEScene.self, from: Data(contentsOf: looseURL))
}
guard let scene else {
WELogger.shared.verbose("[SceneVM] No scene data found in \(sceneFile)")
return
}
WELogger.shared.verbose("[SceneVM] \(scene.objects.count) objects in \(sceneFile)")
let built = buildSKScene(from: scene, dir: dir)
DispatchQueue.main.async { self.skScene = built }
}
// MARK: - SpriteKit scene builder
private func buildSKScene(from scene: WEScene, dir: URL) -> SKScene {
let proj = scene.general.orthogonalprojection ?? WEOrthogonalProjection(width: 1920, height: 1080)
let size = CGSize(width: proj.width, height: proj.height)
let sk = SKScene(size: size)
sk.scaleMode = .aspectFill
// clearcolor is the GL clear color; default to black when absent.
// ambientcolor is a shader uniform (g_LightAmbientColor), not a background tint.
let cc = scene.general.clearcolor ?? "0 0 0"
let c = cc.parseColor()
sk.backgroundColor = NSColor(red: c.r, green: c.g, blue: c.b, alpha: 1)
var hasVisibleImage = false
for obj in scene.objects {
guard obj.visible != false else { continue }
if let _ = obj.sound {
loadSoundObject(obj, dir: dir)
} else if obj.image != nil {
if let node = buildImageNode(obj, sceneSize: size, dir: dir) {
sk.addChild(node)
if let sprite = node as? SKSpriteNode, sprite.blendMode != .add { hasVisibleImage = true }
if node is SKVideoNode { hasVisibleImage = true }
}
} else if obj.particle != nil {
if let node = buildParticleNode(obj, dir: dir, sceneSize: size) {
sk.addChild(node)
}
}
}
if !hasVisibleImage, let preview = loadPreviewImage(dir: dir) {
let node = SKSpriteNode(texture: SKTexture(image: preview))
node.size = size
node.position = CGPoint(x: size.width/2, y: size.height/2)
sk.addChild(node)
}
return sk
}
// MARK: - Image objects
private func buildImageNode(_ obj: WESceneObject, sceneSize: CGSize, dir: URL) -> SKNode? {
guard let imagePath = obj.image else { return nil }
// copybackground objects capture the rendered scene as a texture (post-process).
// We can't replicate this; rendering them as sprites causes additive over-brightness.
if obj.copybackground == true { return nil }
let model: WEModel? = loadJSON(path: imagePath, dir: dir)
guard let materialPath = model?.material else { return nil }
let material: WEMaterial? = loadJSON(path: materialPath, dir: dir)
// textures is [String?]? first non-nil element is the primary texture
guard let textureName = (material?.passes?.first?.textures?.compactMap { $0 })?.first else { return nil }
// Determine display size.
// fullscreen flag or copybackground means the object covers the whole scene.
let nodeSize: CGSize
if model?.fullscreen == true {
nodeSize = sceneSize
} else if let sizeStr = obj.size {
let (w, h) = sizeStr.parseVector2()
nodeSize = CGSize(width: w, height: h)
} else if let mw = model?.width, let mh = model?.height, mw > 0, mh > 0 {
nodeSize = CGSize(width: mw, height: mh)
} else {
nodeSize = sceneSize
}
// WE origin is pixel space: (0,0) top-left, Y-down. SK: (0,0) bottom-left, Y-up.
var nodePos = CGPoint(x: sceneSize.width/2, y: sceneSize.height/2)
var nodeZ: CGFloat = 0
if let originStr = obj.origin {
let (x, y, z) = originStr.parseVector3()
nodePos = CGPoint(x: x, y: sceneSize.height - y)
nodeZ = CGFloat(z)
}
// Try loading the texture could be static image or embedded video
let pass = material?.passes?.first
guard let tex = loadTexture(named: textureName, materialDir: materialPath, dir: dir) else { return nil }
let node: SKNode
switch tex {
case .image(let image):
node = buildSpriteNode(image: image, obj: obj, size: nodeSize, position: nodePos, pass: pass)
case .video(let mp4Data):
guard let vn = buildVideoNode(mp4Data: mp4Data, size: nodeSize, position: nodePos) else { return nil }
node = vn
}
node.zPosition = nodeZ
if let angStr = obj.angles {
let (_, _, rz) = angStr.parseVector3()
// WE angles are degrees, Y-down (CW). SK is Y-up (CCW), so negate.
node.zRotation = CGFloat(-rz * .pi / 180)
}
return node
}
private func buildSpriteNode(image: NSImage, obj: WESceneObject, size: CGSize,
position: CGPoint, pass: WEMaterialPass?) -> SKSpriteNode {
let base = SKTexture(image: image)
let node = SKSpriteNode(texture: base)
node.size = size
node.position = position
node.alpha = CGFloat(obj.alpha ?? 1.0)
if let scaleStr = obj.scale {
let (sx, sy, _) = scaleStr.parseVector3()
if sx != 0 { node.xScale = CGFloat(sx) }
if sy != 0 { node.yScale = CGFloat(sy) }
}
if let colorStr = obj.color {
let c = colorStr.parseColor()
node.color = NSColor(red: c.r, green: c.g, blue: c.b, alpha: 1)
node.colorBlendFactor = (obj.colorBlendMode ?? 0) > 0 ? 1.0 : 0.0
}
// Brightness < 1 darkens by blending toward black; > 1 is clamped (needs shader).
if let b = obj.brightness, b < 1.0, b >= 0 {
node.color = .black
node.colorBlendFactor = CGFloat(1.0 - b)
}
// Alignment anchorPoint: WE names the corner that the origin point refers to.
switch obj.alignment {
case "topleft": node.anchorPoint = CGPoint(x: 0, y: 1)
case "topcenter": node.anchorPoint = CGPoint(x: 0.5, y: 1)
case "topright": node.anchorPoint = CGPoint(x: 1, y: 1)
case "centerleft": node.anchorPoint = CGPoint(x: 0, y: 0.5)
case "centerright": node.anchorPoint = CGPoint(x: 1, y: 0.5)
case "bottomleft": node.anchorPoint = CGPoint(x: 0, y: 0)
case "bottomcenter": node.anchorPoint = CGPoint(x: 0.5, y: 0)
case "bottomright": node.anchorPoint = CGPoint(x: 1, y: 0)
default: break // "center" / nil = default (0.5, 0.5)
}
switch pass?.blending {
case "additive": node.blendMode = .add
case "normal": node.blendMode = .replace // opaque, no alpha compositing
default: node.blendMode = .alpha // "translucent" and unset
}
// Sprite-sheet animation: WE uses horizontal strips when SPRITE/ANIMATION combo is set
if let combos = pass?.combos,
(combos["SPRITE"] != nil || combos["ANIMATION"] != nil),
let frameVal = pass?.constantshadervalues?["g_Frames"]?.doubleValue {
let frames = Int(frameVal)
if frames > 1 {
let fps = pass?.constantshadervalues?["g_FrameRate"]?.doubleValue ?? 24.0
node.run(spriteAnimation(base: base, frames: frames, fps: fps))
}
}
return node
}
private func spriteAnimation(base: SKTexture, frames: Int, fps: Double) -> SKAction {
let fw = 1.0 / CGFloat(frames)
var textures = [SKTexture]()
for i in 0..<frames {
let rect = CGRect(x: CGFloat(i) * fw, y: 0, width: fw, height: 1)
textures.append(SKTexture(rect: rect, in: base))
}
let tpf = max(1.0/60.0, 1.0/fps)
return SKAction.repeatForever(.animate(with: textures, timePerFrame: tpf))
}
private func buildVideoNode(mp4Data: Data, size: CGSize, position: CGPoint) -> SKVideoNode? {
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("\(UUID().uuidString).mp4")
do {
try mp4Data.write(to: tmpURL)
tempVideoFiles.append(tmpURL)
} catch {
WELogger.shared.error("[SceneVM] Failed to write temp video: \(error)")
return nil
}
let asset = AVURLAsset(url: tmpURL)
let item = AVPlayerItem(asset: asset)
let player = AVQueuePlayer()
let looper = AVPlayerLooper(player: player, templateItem: item)
videoPlayers.append(player)
videoLoopers.append(looper)
let node = SKVideoNode(avPlayer: player)
node.size = size
node.position = position
player.play()
return node
}
// MARK: - Sound objects
private func loadSoundObject(_ obj: WESceneObject, dir: URL) {
guard let paths = obj.sound else { return }
for path in paths {
var audioURL: URL?
if let parser = pkgParser, let data = parser.extractFile(named: path) {
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("\(UUID().uuidString).\((path as NSString).pathExtension)")
if (try? data.write(to: tmp)) != nil {
tempVideoFiles.append(tmp)
audioURL = tmp
}
} else {
let candidate = dir.appending(path: path)
if FileManager.default.fileExists(atPath: candidate.path(percentEncoded: false)) {
audioURL = candidate
}
}
guard let url = audioURL else { continue }
if let player = try? AVAudioPlayer(contentsOf: url) {
player.numberOfLoops = -1 // loop indefinitely
player.volume = Float(AppDelegate.shared.wallpaperViewModel.playVolume)
player.play()
audioPlayers.append(player)
}
}
}
// MARK: - Particle objects
private func buildParticleNode(_ obj: WESceneObject, dir: URL, sceneSize: CGSize) -> SKNode? {
guard let particlePath = obj.particle else { return nil }
let ps: WEParticleSystem? = loadJSON(path: particlePath, dir: dir)
guard let ps else { return nil }
let emitter = SKEmitterNode()
if let materialPath = ps.material {
let mat: WEMaterial? = loadJSON(path: materialPath, dir: dir)
if let texName = mat?.passes?.first?.textures?.first ?? nil {
if let result = loadTexture(named: texName, materialDir: materialPath, dir: dir),
case .image(let img) = result {
emitter.particleTexture = SKTexture(image: img)
} else if let img = generateProceduralTexture(named: texName) {
emitter.particleTexture = SKTexture(image: img)
}
}
if let blending = mat?.passes?.first?.blending {
emitter.particleBlendMode = blending == "additive" ? .add : .alpha
}
}
if let em = ps.emitter?.first {
emitter.particleBirthRate = CGFloat(em.rate ?? 100)
if let overrideRate = obj.instanceoverride?.rate?.value {
emitter.particleBirthRate *= CGFloat(overrideRate)
}
if em.name == "sphererandom" {
let dist = CGFloat(em.distancemax ?? 100)
emitter.particlePositionRange = CGVector(dx: dist*2, dy: dist*2)
}
}
for ini in ps.initializer ?? [] {
switch ini.name {
case "lifetimerandom":
let lo = ini.min?.doubleValue ?? 1, hi = ini.max?.doubleValue ?? 1
emitter.particleLifetime = CGFloat((lo+hi)/2)
emitter.particleLifetimeRange = CGFloat(hi-lo)
case "sizerandom":
let lo = ini.min?.doubleValue ?? 1, hi = ini.max?.doubleValue ?? 1
let avg = (lo+hi)/2, mul = obj.instanceoverride?.size ?? 1.0
emitter.particleSize = CGSize(width: avg*mul, height: avg*mul)
emitter.particleScaleRange = CGFloat((hi-lo)/avg) * CGFloat(mul)
case "velocityrandom":
let minV = ini.min?.vectorValue ?? (0,0,0)
let maxV = ini.max?.vectorValue ?? (0,0,0)
let ax = (minV.0+maxV.0)/2, ay = (minV.1+maxV.1)/2
let speed = sqrt(ax*ax+ay*ay)
emitter.particleSpeed = CGFloat(speed)
emitter.particleSpeedRange = CGFloat(abs(maxV.1-minV.1)/2)
if speed > 0 { emitter.emissionAngle = CGFloat(atan2(-ay, ax)); emitter.emissionAngleRange = 0.1 }
case "alpharandom":
let lo = ini.min?.doubleValue ?? 1, hi = ini.max?.doubleValue ?? 1
emitter.particleAlpha = CGFloat((lo+hi)/2)
emitter.particleAlphaRange = CGFloat(hi-lo)
case "colorrandom":
if let maxC = ini.max?.vectorValue {
emitter.particleColor = NSColor(red: maxC.0/255, green: maxC.1/255, blue: maxC.2/255, alpha: 1)
}
default: break
}
}
for op in ps.operator ?? [] {
switch op.name {
case "movement":
if let gStr = op.gravity {
let (gx, gy, gz) = gStr.parseVector3()
emitter.xAcceleration = CGFloat(gx)
emitter.yAcceleration = CGFloat(gy != 0 && gz == 0 ? -gy : -gz)
}
case "alphafade":
if let fo = op.fadeouttime, fo < 1.0 {
emitter.particleAlphaSpeed = CGFloat(-1.0 / max(emitter.particleLifetime * CGFloat(1-fo), 0.1))
}
default: break
}
}
if let renderer = ps.renderer?.first, renderer.name == "spritetrail" {
let len = CGFloat(renderer.maxlength ?? 50)
emitter.particleSize = CGSize(width: 2, height: len)
emitter.particleRotation = emitter.emissionAngle
}
if let originStr = obj.origin {
let (x, y, z) = originStr.parseVector3()
emitter.position = CGPoint(x: x, y: sceneSize.height - y)
emitter.zPosition = CGFloat(z)
}
if let angStr = obj.angles {
let (_, _, rz) = angStr.parseVector3()
emitter.zRotation = CGFloat(-rz * .pi / 180)
}
if let scaleStr = obj.scale {
let (sx, sy, _) = scaleStr.parseVector3()
emitter.xScale = CGFloat(sx); emitter.yScale = CGFloat(sy)
}
emitter.numParticlesToEmit = 0
return emitter
}
// MARK: - Asset loading
private enum TextureContent {
case image(NSImage)
case video(Data)
}
private func loadTexture(named name: String, materialDir: String, dir: URL) -> TextureContent? {
let matDirPath = (materialDir as NSString).deletingLastPathComponent
var texPaths = [String]()
if !matDirPath.isEmpty { texPaths.append("\(matDirPath)/\(name).tex") }
let root = matDirPath.split(separator: "/").first.map(String.init) ?? "materials"
let rootPath = "\(root)/\(name).tex"
if !texPaths.contains(rootPath) { texPaths.append(rootPath) }
texPaths.append("\(name).tex")
for texPath in texPaths {
if let parser = pkgParser, let data = parser.extractFile(named: texPath) {
WELogger.shared.verbose("[SceneVM] TEX from PKG '\(texPath)' \(data.count)B")
let tex = TEXParser(data: Data(data))
if let mp4 = tex.extractVideoData() { return .video(mp4) }
if let img = tex.extractImage() { return .image(img) }
WELogger.shared.verbose("[SceneVM] TEXParser returned nil for '\(texPath)'")
}
let texURL = dir.appending(path: texPath)
if let data = try? Data(contentsOf: texURL) {
let tex = TEXParser(data: data)
if let mp4 = tex.extractVideoData() { return .video(mp4) }
if let img = tex.extractImage() { return .image(img) }
}
}
// Fallback: raw image file (png/jpg/gif)
for ext in ["png", "jpg", "jpeg", "gif"] {
let imgPath = matDirPath.isEmpty ? "\(name).\(ext)" : "\(matDirPath)/\(name).\(ext)"
if let parser = pkgParser, let data = parser.extractFile(named: imgPath),
let img = NSImage(data: data) { return .image(img) }
let imgURL = dir.appending(path: imgPath)
if let img = NSImage(contentsOf: imgURL) { return .image(img) }
}
WELogger.shared.verbose("[SceneVM] No texture found for '\(name)'")
return nil
}
private func loadJSON<T: Decodable>(path: String, dir: URL) -> T? {
if let parser = pkgParser, let data = parser.extractFile(named: path) {
return try? JSONDecoder().decode(T.self, from: data)
}
return (try? Data(contentsOf: dir.appending(path: path))).flatMap { try? JSONDecoder().decode(T.self, from: $0) }
}
private func loadPreviewImage(dir: URL) -> NSImage? {
for name in ["preview.jpg", "preview.png", "preview.gif"] {
if let img = NSImage(contentsOf: dir.appending(path: name)) { return img }
}
return nil
}
private func generateProceduralTexture(named name: String) -> NSImage? {
let size: CGSize
switch name {
case "particle/drop": size = CGSize(width: 4, height: 16)
default: size = CGSize(width: 32, height: 32)
}
return generateRadialGradient(size: size, color: .white)
}
private func generateRadialGradient(size: CGSize, color: NSColor) -> NSImage {
let img = NSImage(size: size)
img.lockFocus()
let ctx = NSGraphicsContext.current!.cgContext
let cs = CGColorSpaceCreateDeviceRGB()
let rgb = color.usingColorSpace(.deviceRGB) ?? color
let colors = [
CGColor(colorSpace: cs, components: [rgb.redComponent, rgb.greenComponent, rgb.blueComponent, rgb.alphaComponent])!,
CGColor(colorSpace: cs, components: [rgb.redComponent, rgb.greenComponent, rgb.blueComponent, 0])!
] as CFArray
let grad = CGGradient(colorsSpace: cs, colors: colors, locations: [0, 1])!
let center = CGPoint(x: size.width/2, y: size.height/2)
ctx.drawRadialGradient(grad, startCenter: center, startRadius: 0,
endCenter: center, endRadius: min(size.width, size.height)/2, options: [])
img.unlockFocus()
return img
}
// MARK: - System events
@objc func systemWillSleep(_ notification: Notification) {
skScene?.isPaused = true
videoPlayers.forEach { $0.pause() }
audioPlayers.forEach { $0.pause() }
}
@objc func systemDidWake(_ notification: Notification) {
skScene?.isPaused = false
let rate = AppDelegate.shared.wallpaperViewModel.playRate
if rate > 0 {
videoPlayers.forEach { $0.play() }
audioPlayers.forEach { $0.play() }
}
}
@objc func spaceDidChange(_ notification: Notification) {
guard AppDelegate.shared.wallpaperViewModel.playRate > 0 else { return }
skScene?.isPaused = false
// Kick video nodes in case Stage Manager froze them
videoPlayers.forEach { $0.play() }
}
}

View File

@ -0,0 +1,372 @@
import Foundation
import Combine
class SteamCmdService: ObservableObject {
@Published var steamCmdPath: String?
@Published var isLoggedIn = false
@Published var steamUsername: String = ""
@Published var loginError: String?
@Published var isLoggingIn = false
@Published var downloadProgress: [String: DownloadState] = [:]
@Published var pathError: String?
enum DownloadState: Equatable {
case downloading(status: String)
case completed
case failed(String)
}
private static let lastUsernameKey = "SteamLastUsername"
private var sessionPassword: String = ""
init() {
detectSteamCmd()
attemptCachedLogin()
}
// MARK: - Detection
func detectSteamCmd() {
if let custom = UserDefaults.standard.string(forKey: "SteamCmdPath"),
FileManager.default.isExecutableFile(atPath: custom) {
steamCmdPath = custom
return
}
// Bundled DepotDownloader architecture-specific sub-directory
if let res = Bundle.main.resourcePath {
#if arch(arm64)
let arch = "arm64"
#else
let arch = "x64"
#endif
let bundled = "\(res)/depotdownloader/\(arch)/DepotDownloader"
if FileManager.default.isExecutableFile(atPath: bundled) {
steamCmdPath = bundled
return
}
}
let searchPaths = [
"/opt/homebrew/bin/DepotDownloader",
"/usr/local/bin/DepotDownloader",
"/opt/homebrew/bin/depotdownloader",
"/usr/local/bin/depotdownloader",
]
for path in searchPaths {
if FileManager.default.isExecutableFile(atPath: path) {
steamCmdPath = path
return
}
}
DispatchQueue.global(qos: .utility).async { [weak self] in
for name in ["DepotDownloader", "depotdownloader"] {
let p = Process(); let pipe = Pipe()
p.executableURL = URL(fileURLWithPath: "/usr/bin/which")
p.arguments = [name]
p.standardOutput = pipe
p.standardError = FileHandle.nullDevice
try? p.run(); p.waitUntilExit()
guard p.terminationStatus == 0 else { continue }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let path = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty {
DispatchQueue.main.async { self?.steamCmdPath = path }
return
}
}
}
}
var isInstalled: Bool { steamCmdPath != nil }
func setCustomPath(_ path: String) {
guard FileManager.default.fileExists(atPath: path) else {
pathError = "File not found at selected path."
return
}
if !FileManager.default.isExecutableFile(atPath: path) {
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path)
}
pathError = nil
UserDefaults.standard.set(path, forKey: "SteamCmdPath")
steamCmdPath = path
}
// MARK: - Auth
private func attemptCachedLogin() {
guard isInstalled else { return }
if let saved = UserDefaults.standard.string(forKey: Self.lastUsernameKey), !saved.isEmpty {
loginWithCachedSession(username: saved)
}
}
func login(username: String, password: String, guardCode: String? = nil) {
guard steamCmdPath != nil else { return }
isLoggingIn = true
loginError = nil
steamUsername = username
sessionPassword = password
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
let tempDir = FileManager.default.temporaryDirectory
.appending(path: "depot_auth_\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let (output, _) = self.runDepotDownloader(
arguments: [
"-app", "431960", "-pubfile", "0",
"-username", username, "-password", password,
"-remember-password", "-dir", tempDir.path,
],
guardCode: guardCode,
timeout: 60
)
DispatchQueue.main.async {
self.isLoggingIn = false
if output.contains("InvalidPassword") || output.contains("Invalid Password") {
self.loginError = "Invalid username or password"
} else if output.contains("STEAM GUARD!") && (guardCode?.isEmpty ?? true) {
self.loginError = "Steam Guard code required"
} else if output.contains("AccountNotFound") {
self.loginError = "Account not found"
} else if output.contains("Done!") || output.contains("Using app branch")
|| output.contains("Logging '") {
self.isLoggedIn = true
self.loginError = nil
UserDefaults.standard.set(username, forKey: Self.lastUsernameKey)
} else {
self.loginError = "Login failed. Check credentials and try again."
}
}
}
}
func loginWithCachedSession(username: String) {
guard steamCmdPath != nil else { return }
isLoggingIn = true
loginError = nil
steamUsername = username
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
let tempDir = FileManager.default.temporaryDirectory
.appending(path: "depot_auth_\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let (output, _) = self.runDepotDownloader(
arguments: [
"-app", "431960", "-pubfile", "0",
"-username", username,
"-remember-password", "-dir", tempDir.path,
],
guardCode: nil,
timeout: 30
)
DispatchQueue.main.async {
self.isLoggingIn = false
if output.contains("Access token was rejected") || output.contains("InvalidPassword") {
self.loginError = "Cached session expired. Please log in with password."
} else if output.contains("Done!") || output.contains("Using app branch")
|| output.contains("Logging '") {
self.isLoggedIn = true
UserDefaults.standard.set(username, forKey: Self.lastUsernameKey)
} else {
self.loginError = "Cached session expired. Please log in with password."
}
}
}
}
// MARK: - Download
func downloadWorkshopItem(workshopId: String) {
guard let cmdPath = steamCmdPath, isLoggedIn else { return }
downloadProgress[workshopId] = .downloading(status: "Starting download...")
let tempDir = FileManager.default.temporaryDirectory.appending(path: "depot_\(workshopId)")
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
try? FileManager.default.removeItem(at: tempDir)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
var args = [
"-app", "431960", "-pubfile", workshopId,
"-username", self.steamUsername,
"-remember-password", "-dir", tempDir.path,
]
if !self.sessionPassword.isEmpty {
args += ["-password", self.sessionPassword]
}
let process = Process()
let stdoutPipe = Pipe(); let stderrPipe = Pipe(); let stdinPipe = Pipe()
process.executableURL = URL(fileURLWithPath: cmdPath)
process.arguments = args
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.standardInput = stdinPipe
var fullOutput = ""
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] fh in
let data = fh.availableData
guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return }
fullOutput += chunk
if let status = self?.parseProgress(chunk) {
DispatchQueue.main.async { self?.downloadProgress[workshopId] = .downloading(status: status) }
}
}
stderrPipe.fileHandleForReading.readabilityHandler = { fh in
let data = fh.availableData
guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return }
fullOutput += chunk
}
do {
try process.run()
process.waitUntilExit()
} catch {
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
DispatchQueue.main.async {
self.downloadProgress[workshopId] = .failed("Failed to start: \(error.localizedDescription)")
}
return
}
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
fullOutput += String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
fullOutput += String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
print("DepotDownloader [\(workshopId)] exit=\(process.terminationStatus)\n\(fullOutput)")
DispatchQueue.main.async {
if fullOutput.contains("Total downloaded") || fullOutput.contains("100.00%") {
self.downloadProgress[workshopId] = .downloading(status: "Copying to library...")
let fm = FileManager.default
let dest = fm.wallpapersDirectory.appending(path: workshopId)
do {
if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) }
// Copy files excluding .DepotDownloader staging folder
try fm.createDirectory(at: dest, withIntermediateDirectories: true)
let contents = try fm.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil)
for item in contents where item.lastPathComponent != ".DepotDownloader" {
try fm.copyItem(at: item, to: dest.appending(path: item.lastPathComponent))
}
self.downloadProgress[workshopId] = .completed
} catch {
self.downloadProgress[workshopId] = .failed("Copy failed: \(error.localizedDescription)")
}
} else {
let errorLine = fullOutput.components(separatedBy: "\n")
.first { $0.lowercased().contains("error") || $0.contains("FAILED") }
?? "Download failed"
self.downloadProgress[workshopId] = .failed(errorLine)
}
try? FileManager.default.removeItem(at: tempDir)
}
}
}
// MARK: - Private helpers
private func runDepotDownloader(
arguments: [String],
guardCode: String?,
timeout: TimeInterval
) -> (output: String, exitCode: Int32) {
guard let cmdPath = steamCmdPath else { return ("", -1) }
let process = Process()
let stdoutPipe = Pipe(); let stderrPipe = Pipe(); let stdinPipe = Pipe()
process.executableURL = URL(fileURLWithPath: cmdPath)
process.arguments = arguments
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.standardInput = stdinPipe
var combinedOutput = ""
var guardCodeWritten = false
let readQueue = DispatchQueue(label: "depot.pipe.read")
stdoutPipe.fileHandleForReading.readabilityHandler = { fh in
let data = fh.availableData
guard !data.isEmpty else { return }
if let s = String(data: data, encoding: .utf8) { readQueue.sync { combinedOutput += s } }
}
// Steam Guard prompts come from stderr
stderrPipe.fileHandleForReading.readabilityHandler = { fh in
let data = fh.availableData
guard !data.isEmpty else { return }
if let chunk = String(data: data, encoding: .utf8) {
readQueue.sync { combinedOutput += chunk }
let needsGuard = chunk.contains("STEAM GUARD!") || chunk.contains("2 factor auth")
|| chunk.contains("authentication code")
if needsGuard && !guardCodeWritten {
if let code = guardCode, !code.isEmpty {
guardCodeWritten = true
stdinPipe.fileHandleForWriting.write(Data((code + "\n").utf8))
} else {
// No code available kill immediately instead of waiting for timeout
process.terminate()
}
}
}
}
do { try process.run() } catch {
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
return ("Failed to run DepotDownloader: \(error.localizedDescription)", -1)
}
let wg = DispatchGroup(); wg.enter()
DispatchQueue.global().async { process.waitUntilExit(); wg.leave() }
if wg.wait(timeout: .now() + timeout) == .timedOut { process.terminate() }
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
let remainOut = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let remainErr = stderrPipe.fileHandleForReading.readDataToEndOfFile()
readQueue.sync {
combinedOutput += String(data: remainOut, encoding: .utf8) ?? ""
combinedOutput += String(data: remainErr, encoding: .utf8) ?? ""
}
return (readQueue.sync { combinedOutput }, process.terminationStatus)
}
private func parseProgress(_ output: String) -> String? {
for line in output.components(separatedBy: "\n") {
let t = line.trimmingCharacters(in: .whitespaces)
// DepotDownloader stdout format: " 5.23% path/to/file"
if let match = t.range(of: #"^(\d+\.\d+)%"#, options: .regularExpression) {
let pctStr = String(t[match]).replacingOccurrences(of: "%", with: "")
if let pct = Double(pctStr) {
return String(format: "Downloading... %.0f%%", min(pct, 100))
}
}
if t.contains("Total downloaded") { return "Download complete, importing..." }
if t.contains("Connecting to Steam") { return "Connecting..." }
if t.contains("Logging '") { return "Authenticating..." }
if t.contains("Done!") { return "Preparing download..." }
if t.contains("Processing depot") || t.contains("Downloading depot") { return "Fetching manifest..." }
if t.contains("Pre-allocating") { return "Allocating space..." }
}
return nil
}
}

View File

@ -0,0 +1,125 @@
import AVKit
import SwiftUI
import Combine
class VideoWallpaperViewModel: ObservableObject {
var currentWallpaper: WEWallpaper {
didSet {
if let oldItem = self.player.currentItem {
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: oldItem)
}
let newItem = AVPlayerItem(url: currentWallpaper.wallpaperDirectory.appending(path: currentWallpaper.project.file))
self.player.replaceCurrentItem(with: newItem)
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinishPlaying(_:)), name: .AVPlayerItemDidPlayToEndTime, object: newItem)
// Force-apply rate and volume replaceCurrentItem resets player to paused
self.player.rate = self.playRate
self.player.volume = self.playVolume
}
}
var playRate: Float = 0 {
didSet {
self.player.rate = playRate
}
}
var playVolume: Float = 0 {
didSet {
self.player.volume = playVolume
}
}
var player = AVPlayer()
private var cancellables = Set<AnyCancellable>()
// Retry schedule (seconds) for unexpected pauses (e.g. Stage Manager freeze)
private static let resumeDelays: [Double] = [0.2, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0, 10.0, 20.0, 30.0]
init(wallpaper currentWallpaper: WEWallpaper) {
self.currentWallpaper = currentWallpaper
self.player = AVPlayer(url: currentWallpaper.wallpaperDirectory.appending(path: currentWallpaper.project.file))
self.player.automaticallyWaitsToMinimizeStalling = false
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinishPlaying(_:)), name: .AVPlayerItemDidPlayToEndTime, object: self.player.currentItem)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(systemWillSleep(_:)), name: NSWorkspace.screensDidSleepNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(systemDidWake(_:)), name: NSWorkspace.didWakeNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceDidChange(_:)), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceDidChange(_:)), name: NSWorkspace.didActivateApplicationNotification, object: nil)
let wvm = AppDelegate.shared.wallpaperViewModel
wvm.$playRate
.receive(on: DispatchQueue.main)
.sink { [weak self] rate in
self?.playRate = rate
}
.store(in: &cancellables)
wvm.$playVolume
.receive(on: DispatchQueue.main)
.sink { [weak self] volume in
self?.playVolume = volume
}
.store(in: &cancellables)
player.publisher(for: \.timeControlStatus)
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
guard let self, status == .paused, self.playRate > 0 else { return }
WELogger.shared.verbose("AVPlayer unexpected pause — scheduling resume")
self.scheduleResume(attempt: 0)
}
.store(in: &cancellables)
}
// Keeps retrying until the player is actually playing or all delays are exhausted.
// Each successful re-kick resets the schedule if the system pauses again (timeControlStatus fires).
private func scheduleResume(attempt: Int) {
let delays = VideoWallpaperViewModel.resumeDelays
guard attempt < delays.count, playRate > 0 else {
if playRate > 0 {
WELogger.shared.error("AVPlayer resume exhausted all \(VideoWallpaperViewModel.resumeDelays.count) attempts")
}
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + delays[attempt]) { [weak self] in
guard let self, self.playRate > 0 else { return }
if self.player.timeControlStatus == .paused {
WELogger.shared.verbose("AVPlayer resume attempt \(attempt + 1) — re-kicking rate")
self.player.rate = self.playRate
self.scheduleResume(attempt: attempt + 1)
} else {
WELogger.shared.verbose("AVPlayer resumed after attempt \(attempt + 1)")
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
@objc private func playerDidFinishPlaying(_ notification: Notification) {
self.player.seek(to: CMTime.zero)
self.player.rate = self.playRate
}
@objc private func playerDidStopPlaying(_ notification: Notification) {
self.player.rate = self.playRate
}
@objc func systemWillSleep(_ notification: Notification) {
self.player.rate = 0
}
@objc func systemDidWake(_ notification: Notification) {
self.player.rate = self.playRate
}
@objc func spaceDidChange(_ notification: Notification) {
guard playRate > 0 else { return }
// Seek to current position forces AVPlayerLayer to push a fresh frame to the
// compositor, recovering from the Stage Manager snapshot freeze without waiting
// for the system to spontaneously refresh (which can take over a minute).
player.seek(to: player.currentTime()) { [weak self] _ in
guard let self, self.playRate > 0 else { return }
self.player.rate = self.playRate
}
}
}

View File

@ -0,0 +1,119 @@
import Foundation
final class WELogger {
static let shared = WELogger()
private let queue = DispatchQueue(label: "WELogger", qos: .utility)
private var fileHandle: FileHandle?
private var activeDate: String = ""
private let logDir: URL = {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dir = support.appendingPathComponent("Open Wallpaper Engine/Logs", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
// Set from main thread only (GlobalSettingsViewModel.validate)
var level: GSLogLevel = .none {
didSet {
guard oldValue != level else { return }
if level == .none {
queue.async { [weak self] in
self?.fileHandle?.closeFile()
self?.fileHandle = nil
}
} else if oldValue == .none {
queue.async { [weak self] in self?.openTodayFile() }
}
}
}
var logDirURL: URL { logDir }
var todayLogURL: URL { logDir.appendingPathComponent("app-\(todayString).log") }
private var todayString: String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.string(from: Date())
}
private init() {}
private func openTodayFile() {
let today = todayString
activeDate = today
let url = logDir.appendingPathComponent("app-\(today).log")
if !FileManager.default.fileExists(atPath: url.path) {
FileManager.default.createFile(atPath: url.path, contents: nil)
}
fileHandle?.closeFile()
fileHandle = try? FileHandle(forWritingTo: url)
fileHandle?.seekToEndOfFile()
let header = "\n=== Session started \(Date()) ===\n"
fileHandle?.write(header.data(using: .utf8) ?? Data())
pruneOldLogs()
}
// Delete log files older than 7 days
private func pruneOldLogs() {
let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
guard let files = try? FileManager.default.contentsOfDirectory(
at: logDir, includingPropertiesForKeys: nil) else { return }
for file in files where file.pathExtension == "log" {
let name = file.deletingPathExtension().lastPathComponent // "app-2026-06-01"
let datePart = String(name.dropFirst(4)) // "2026-06-01"
if let date = df.date(from: datePart), date < cutoff {
try? FileManager.default.removeItem(at: file)
}
}
}
func verbose(_ message: @autoclosure () -> String) {
guard level == .verbose else { return }
write(message(), tag: "VERBOSE")
}
func error(_ message: @autoclosure () -> String) {
guard level != .none else { return }
write(message(), tag: "ERROR")
}
private static let tsFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private func write(_ message: String, tag: String) {
let ts = WELogger.tsFormatter.string(from: Date())
let line = "[\(ts)] [\(tag)] \(message)\n"
queue.async { [weak self] in
guard let self else { return }
// Rotate at midnight
let today = todayString
if today != activeDate {
openTodayFile()
}
fileHandle?.write(line.data(using: .utf8) ?? Data())
}
}
func clear() {
queue.async { [weak self] in
guard let self else { return }
guard let files = try? FileManager.default.contentsOfDirectory(
at: logDir, includingPropertiesForKeys: nil) else { return }
files.filter { $0.pathExtension == "log" }
.forEach { try? FileManager.default.removeItem(at: $0) }
if level != .none {
fileHandle?.closeFile()
fileHandle = nil
openTodayFile()
}
}
}
}

View File

@ -0,0 +1,190 @@
//
// WEProject.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/6/5.
//
import SwiftUI
import ImageIO
struct WEProjectPropertyOption: Codable, Equatable, Hashable {
var label: String
var value: String
}
struct WEProjectProperty: Codable, Equatable, Hashable {
// optional
var condition: String?
var index: Int?
var options: [WEProjectPropertyOption]?
var order: Int?
// must have
var text: String
var type: String
var value: String
}
struct WEProjectProperties: Codable, Equatable, Hashable {
var schemecolor: WEProjectProperty?
}
struct WEProjectGeneral: Codable, Equatable, Hashable {
var properties: WEProjectProperties
}
enum WorkshopId: Codable, Equatable, Hashable, RawRepresentable {
case int(Int)
case string(String)
var rawValue: String {
switch self {
case .int(let x):
return String(x)
case .string(let x):
return x
}
}
init?(rawValue: String) {
guard rawValue.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil }
self = .string(rawValue)
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let x = try? container.decode(Int.self) {
self = .int(x)
return
}
if let x = try? container.decode(String.self) {
self = .string(x)
return
}
throw DecodingError.typeMismatch(Self.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for Workshop ID"))
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .int(let x):
try container.encode(x)
case .string(let x):
try container.encode(x)
}
}
}
struct WEProject: Codable, Equatable, Hashable {
var approved: Bool?
var contentrating: String?
var description: String?
var file: String
var general: WEProjectGeneral?
var preview: String
var tags: [String]?
var title: String
var visibility: String?
var workshopid: WorkshopId?
var workshopurl: String?
var type: String
var version: Int?
static let invalid = Self(file: "",
preview: "",
title: "Error",
type: "video")
}
struct WEWallpaper: Codable, RawRepresentable, Identifiable {
var id: Int { self.project.hashValue }
var rawValue: String {
do {
let rawValueData = try JSONEncoder().encode(self)
return String(data: rawValueData, encoding: .utf8)!
} catch {
print(error)
return ""
}
}
var wallpaperDirectory: URL
var project: WEProject
var wallpaperSize: Int {
guard let sizeBytes = try? self.wallpaperDirectory.directoryTotalAllocatedSize(includingSubfolders: true)
else { return 0 }
return sizeBytes
}
init(using project: WEProject, where url: URL) {
self.wallpaperDirectory = url
self.project = project
}
enum CodingKeys: CodingKey {
case wallpaperDirectory
case project
// <all the other elements too>
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.wallpaperDirectory = try container.decode(URL.self, forKey: .wallpaperDirectory)
self.project = try container.decode(WEProject.self, forKey: .project)
// <and so on>
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(wallpaperDirectory, forKey: .wallpaperDirectory)
try container.encode(project, forKey: .project)
// <and so on>
}
init?(rawValue: String) {
if let rawValueData = rawValue.data(using: .utf8),
let wallpaper = try? JSONDecoder().decode(WEWallpaper.self, from: rawValueData) {
self = wallpaper
} else {
return nil
}
}
}
enum WEWallpaperSortingMethod: String, CaseIterable, Identifiable {
var id: Self { self }
case name = "Name"
case rating = "Rating"
// case favorite = "Favorite"
case fileSize = "File Size"
// case subDate = "Subscription Date"
// case lastUpdated = "Last Updated"
}
enum WEWallpaperSortingSequence: Int {
case decrease = 0, increase = 1
}
enum WEInitError: Error {
enum WEJSONProjectInitError: Error {
case notFound, corrupted, mismatched, unkownError
}
enum WEResourcesInitError: Error {
case notFound, mismatchedFormat, corrupted, unkownError
}
enum WEPreviewInitError: Error {
case notFound, notImage, unkownError
}
case badDirectoryPath
case JSONProject(was: WEJSONProjectInitError)
case resources(was: WEResourcesInitError)
case preview(was: WEPreviewInitError)
}

View File

@ -0,0 +1,14 @@
import Foundation
extension FileManager {
/// The dedicated directory for storing wallpaper packages.
/// Located at `~/Documents/Open Wallpaper Engine/`, created automatically if missing.
var wallpapersDirectory: URL {
let dir = urls(for: .documentDirectory, in: .userDomainMask)[0]
.appending(path: "Open Wallpaper Engine")
if !fileExists(atPath: dir.path) {
try? createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
}

View File

@ -0,0 +1,180 @@
import SwiftUI
class WallpaperViewModel: ObservableObject {
@Published var nextCurrentWallpaper: WEWallpaper =
WEWallpaper(using: .invalid, where: Bundle.main.url(forResource: "WallpaperNotFound", withExtension: "mp4")!) {
willSet {
if ["web", "application"].contains(newValue.project.type) {
if let trustedWallpapers = UserDefaults.standard.array(forKey: "TrustedWallpapers") as? [String],
trustedWallpapers.contains(newValue.wallpaperDirectory.path(percentEncoded: false)) {
self.setWallpaper(newValue, for: selectedScreenId)
} else {
AppDelegate.shared.contentViewModel.warningUnsafeWallpaperModal(which: newValue)
}
} else {
self.setWallpaper(newValue, for: selectedScreenId)
}
}
}
/// Per-screen wallpaper assignments, keyed by CGDirectDisplayID as String.
@Published var wallpapers: [String: WEWallpaper] = [:] {
didSet { saveWallpapers() }
}
@Published var enabledScreens: Set<String> = [] {
didSet {
UserDefaults.standard.set(Array(enabledScreens), forKey: "EnabledScreens")
}
}
@Published var selectedScreenId: String = ""
static let defaultWallpaper = WEWallpaper(using: .invalid, where: Bundle.main.url(forResource: "WallpaperNotFound", withExtension: "mp4")!)
// MARK: - Recent wallpapers
private static let maxRecents = 10
private static let recentsKey = "RecentWallpapers"
@Published var recentWallpapers: [WEWallpaper] = []
private func loadRecents() {
guard let data = UserDefaults.standard.data(forKey: Self.recentsKey),
let saved = try? JSONDecoder().decode([WEWallpaper].self, from: data) else { return }
recentWallpapers = saved.filter { $0.project != .invalid }
}
private func saveRecents() {
if let data = try? JSONEncoder().encode(recentWallpapers) {
UserDefaults.standard.set(data, forKey: Self.recentsKey)
}
}
func addToRecents(_ wallpaper: WEWallpaper) {
guard wallpaper.project != .invalid else { return }
recentWallpapers.removeAll { $0.wallpaperDirectory == wallpaper.wallpaperDirectory }
recentWallpapers.insert(wallpaper, at: 0)
if recentWallpapers.count > Self.maxRecents {
recentWallpapers = Array(recentWallpapers.prefix(Self.maxRecents))
}
saveRecents()
}
// MARK: - Wallpaper access
var currentWallpaper: WEWallpaper {
get {
wallpapers[selectedScreenId] ?? Self.defaultWallpaper
}
set {
setWallpaper(newValue, for: selectedScreenId)
}
}
func wallpaper(for screenId: String) -> WEWallpaper {
wallpapers[screenId] ?? Self.defaultWallpaper
}
func setWallpaper(_ wallpaper: WEWallpaper, for screenId: String) {
wallpapers[screenId] = wallpaper
addToRecents(wallpaper)
}
func isScreenEnabled(_ screenId: String) -> Bool {
enabledScreens.contains(screenId)
}
func toggleScreen(_ screenId: String) {
if enabledScreens.contains(screenId) {
enabledScreens.remove(screenId)
} else {
enabledScreens.insert(screenId)
}
AppDelegate.shared.wallpaperWindowManager.rebuild()
}
/// Remove a wallpaper from all screens (e.g., when unsubscribing).
func removeWallpaperFromAllScreens(directory: URL) {
for (key, wp) in wallpapers {
if wp.wallpaperDirectory == directory {
wallpapers[key] = Self.defaultWallpaper
}
}
}
var lastPlayRate: Float = 1.0
@Published public var playRate: Float =
UserDefaults.standard.object(forKey: "WPPlayRate") != nil
? UserDefaults.standard.float(forKey: "WPPlayRate")
: 1.0
{
didSet {
lastPlayRate = oldValue == 0 ? lastPlayRate : oldValue
UserDefaults.standard.set(playRate, forKey: "WPPlayRate")
}
}
var lastPlayVolume: Float = 1.0
@Published public var playVolume: Float =
UserDefaults.standard.object(forKey: "WPPlayVolume") != nil
? UserDefaults.standard.float(forKey: "WPPlayVolume")
: 1.0
{
didSet {
lastPlayVolume = oldValue == 0 ? lastPlayVolume : oldValue
UserDefaults.standard.set(playVolume, forKey: "WPPlayVolume")
}
}
init() {
if let data = UserDefaults.standard.data(forKey: "ScreenWallpapers"),
let saved = try? JSONDecoder().decode([String: WEWallpaper].self, from: data) {
// Filter out any compound keys (screenId_spaceId) from previous per-space experiment
self.wallpapers = saved.filter { !$0.key.contains("_") }
}
// Migrate legacy single wallpaper
else if let json = UserDefaults.standard.data(forKey: "CurrentWallpaper"),
let wallpaper = try? JSONDecoder().decode(WEWallpaper.self, from: json) {
let mainId = Self.mainScreenId()
self.wallpapers = [mainId: wallpaper]
}
if let saved = UserDefaults.standard.array(forKey: "EnabledScreens") as? [String] {
self.enabledScreens = Set(saved)
} else {
self.enabledScreens = Set(NSScreen.screens.map { Self.screenId(for: $0) })
}
self.selectedScreenId = Self.mainScreenId()
loadRecents()
}
// MARK: - Screen ID helpers
static func screenId(for screen: NSScreen) -> String {
let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? 0
return String(displayId)
}
static func mainScreenId() -> String {
guard let main = NSScreen.main else { return "0" }
return screenId(for: main)
}
static func screenName(for screen: NSScreen) -> String {
screen.localizedName
}
// MARK: - Persistence
private func saveWallpapers() {
if let data = try? JSONEncoder().encode(wallpapers) {
UserDefaults.standard.set(data, forKey: "ScreenWallpapers")
}
// Keep legacy key updated for backward compat
if let data = try? JSONEncoder().encode(currentWallpaper) {
UserDefaults.standard.set(data, forKey: "CurrentWallpaper")
}
}
}

View File

@ -0,0 +1,70 @@
import Cocoa
import SwiftUI
final class WallpaperWindowManager {
private(set) var windows: [String: NSWindow] = [:]
private let viewModel: WallpaperViewModel
private var screenChangeObserver: NSObjectProtocol?
init(viewModel: WallpaperViewModel) {
self.viewModel = viewModel
screenChangeObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in self?.screensChanged() }
}
deinit {
if let observer = screenChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
func setup() {
for screen in NSScreen.screens {
let screenId = WallpaperViewModel.screenId(for: screen)
guard viewModel.isScreenEnabled(screenId) else { continue }
windows[screenId] = makeWindow(for: screen, screenId: screenId)
}
}
func showAll() {
windows.values.forEach { $0.orderFront(nil) }
}
func rebuild() {
windows.values.forEach { $0.close() }
windows.removeAll()
setup()
showAll()
}
private func screensChanged() {
let connectedIds = Set(NSScreen.screens.map { WallpaperViewModel.screenId(for: $0) })
for id in connectedIds where !viewModel.enabledScreens.contains(id) {
viewModel.enabledScreens.insert(id)
}
rebuild()
}
private func makeWindow(for screen: NSScreen, screenId: String) -> NSWindow {
let window = WallpaperWindow()
window.styleMask = [.borderless, .fullSizeContentView]
window.level = NSWindow.Level(Int(CGWindowLevelForKey(.desktopWindow)))
window.collectionBehavior = [.stationary, .canJoinAllSpaces, .fullScreenAuxiliary]
window.isMovable = false
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.canHide = false
window.canBecomeVisibleWithoutLogin = true
window.isReleasedWhenClosed = false
window.ignoresMouseEvents = true
window.setFrame(screen.frame, display: true)
window.contentView = NSHostingView(
rootView: WallpaperView(viewModel: viewModel, screenId: screenId)
)
return window
}
}

View File

@ -0,0 +1,54 @@
import WebKit
import SwiftUI
class WebWallpaperViewModel: NSObject, ObservableObject, WKNavigationDelegate {
var currentWallpaper: WEWallpaper
var fileUrl: URL {
currentWallpaper.wallpaperDirectory.appending(path: currentWallpaper.project.file)
}
var readAccessURL: URL {
currentWallpaper.wallpaperDirectory
}
weak var webView: WKWebView?
init(wallpaper: WEWallpaper) {
self.currentWallpaper = wallpaper
super.init()
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(systemWillSleep(_:)), name: NSWorkspace.screensDidSleepNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(systemDidWake(_:)), name: NSWorkspace.didWakeNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceDidChange(_:)), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceDidChange(_:)), name: NSWorkspace.didActivateApplicationNotification, object: nil)
}
deinit {
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// Allow navigation to external URLs (e.g. YouTube embeds from URL-based web wallpapers)
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let javascriptStyle = "var css = '*{-webkit-touch-callout:none;-webkit-user-select:none}'; var head = document.head || document.getElementsByTagName('head')[0]; var style = document.createElement('style'); style.type = 'text/css'; style.appendChild(document.createTextNode(css)); head.appendChild(style);"
webView.evaluateJavaScript(javascriptStyle, completionHandler: nil)
}
@objc func systemWillSleep(_ notification: Notification) {}
@objc func systemDidWake(_ notification: Notification) {
webView?.reload()
}
@objc func spaceDidChange(_ notification: Notification) {
// Resume any paused HTML5 video/audio after Mission Control transition
webView?.evaluateJavaScript(
"document.querySelectorAll('video,audio').forEach(m => { if(m.paused) m.play(); })",
completionHandler: nil
)
}
}

View File

@ -0,0 +1,215 @@
import Foundation
struct WorkshopItem: Identifiable, Codable {
let id: String
let title: String
let previewURL: String?
let tags: [String]
let subscriptions: Int
let fileSize: Int
let creatorAppId: Int?
let description: String?
var previewImageURL: URL? {
guard let urlString = previewURL else { return nil }
return URL(string: urlString)
}
}
enum WorkshopSortOrder: Int, CaseIterable, Identifiable {
case trending = 0
case mostRecent = 1
case mostPopular = 2
case mostSubscribed = 3
var id: Int { rawValue }
var displayName: String {
switch self {
case .trending: return "Trending"
case .mostRecent: return "Most Recent"
case .mostPopular: return "Most Popular"
case .mostSubscribed: return "Most Subscribed"
}
}
var queryType: Int {
switch self {
case .trending: return 3 // RankedByTrend
case .mostRecent: return 1 // RankedByPublicationDate
case .mostPopular: return 0 // RankedByVote
case .mostSubscribed: return 9 // RankedByTotalUniqueSubscriptions
}
}
/// When search_text is provided, Steam requires query_type=12 (RankedByTextSearch)
func queryTypeForSearch(hasText: Bool) -> Int {
hasText ? 12 : queryType
}
}
class WorkshopAPIService {
static let wallpaperEngineAppId = 431960
/// Search workshop items using the public Steam API.
/// GetPublishedFileDetails doesn't require an API key for basic queries.
func searchItems(
query: String = "",
tags: [String] = [],
sortOrder: WorkshopSortOrder = .trending,
page: Int = 1,
perPage: Int = 20
) async throws -> [WorkshopItem] {
// Use ISteamRemoteStorage/GetPublishedFileDetails for specific IDs
// Use the public search endpoint for browsing
var components = URLComponents(string: "https://api.steampowered.com/IPublishedFileService/QueryFiles/v1/")!
let hasSearchText = !query.isEmpty
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "query_type", value: "\(sortOrder.queryTypeForSearch(hasText: hasSearchText))"),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "numperpage", value: "\(perPage)"),
URLQueryItem(name: "appid", value: "\(Self.wallpaperEngineAppId)"),
URLQueryItem(name: "return_tags", value: "true"),
URLQueryItem(name: "return_previews", value: "true"),
URLQueryItem(name: "return_metadata", value: "true"),
URLQueryItem(name: "return_short_description", value: "true"),
]
if !query.isEmpty {
queryItems.append(URLQueryItem(name: "search_text", value: query))
}
for (index, tag) in tags.enumerated() {
queryItems.append(URLQueryItem(name: "requiredtags[\(index)]", value: tag))
}
let apiKey = Self.loadAPIKey()
if !apiKey.isEmpty {
queryItems.append(URLQueryItem(name: "key", value: apiKey))
}
components.queryItems = queryItems
guard let url = components.url else {
throw WorkshopAPIError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw WorkshopAPIError.requestFailed
}
if httpResponse.statusCode == 403 {
throw apiKey.isEmpty ? WorkshopAPIError.noAPIKey : WorkshopAPIError.invalidAPIKey
}
guard httpResponse.statusCode == 200 else {
throw WorkshopAPIError.httpError(httpResponse.statusCode)
}
return try parseQueryResponse(data)
}
/// Get details for specific workshop items by their IDs.
func getItemDetails(workshopIds: [String]) async throws -> [WorkshopItem] {
let url = URL(string: "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
var bodyParts = ["itemcount=\(workshopIds.count)"]
for (index, id) in workshopIds.enumerated() {
bodyParts.append("publishedfileids[\(index)]=\(id)")
}
request.httpBody = bodyParts.joined(separator: "&").data(using: .utf8)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw WorkshopAPIError.requestFailed
}
return try parseFileDetailsResponse(data)
}
// MARK: - API Key
private static let apiKeyDefault = "SteamWebAPIKey"
static func loadAPIKey() -> String {
UserDefaults.standard.string(forKey: apiKeyDefault) ?? ""
}
static func saveAPIKey(_ key: String) {
UserDefaults.standard.set(key, forKey: apiKeyDefault)
}
// MARK: - Response Parsing
private func parseQueryResponse(_ data: Data) throws -> [WorkshopItem] {
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let response = json["response"] as? [String: Any],
let files = response["publishedfiledetails"] as? [[String: Any]]
else {
return []
}
return files.compactMap { parseFileDict($0) }
}
private func parseFileDetailsResponse(_ data: Data) throws -> [WorkshopItem] {
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let response = json["response"] as? [String: Any],
let files = response["publishedfiledetails"] as? [[String: Any]]
else {
return []
}
return files.compactMap { parseFileDict($0) }
}
private func parseFileDict(_ dict: [String: Any]) -> WorkshopItem? {
guard let publishedFileId = dict["publishedfileid"] as? String,
let title = dict["title"] as? String
else { return nil }
let previewURL = dict["preview_url"] as? String
let tags: [String] = (dict["tags"] as? [[String: Any]])?.compactMap { $0["tag"] as? String } ?? []
let subscriptions = dict["subscriptions"] as? Int ?? dict["lifetime_subscriptions"] as? Int ?? 0
let fileSize = dict["file_size"] as? Int ?? 0
let creatorAppId = dict["creator_app_id"] as? Int
let description = dict["short_description"] as? String ?? dict["description"] as? String
return WorkshopItem(
id: publishedFileId,
title: title,
previewURL: previewURL,
tags: tags,
subscriptions: subscriptions,
fileSize: fileSize,
creatorAppId: creatorAppId,
description: description
)
}
}
enum WorkshopAPIError: LocalizedError {
case invalidURL
case requestFailed
case noAPIKey
case invalidAPIKey
case httpError(Int)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid API URL"
case .requestFailed: return "API request failed — check your network connection"
case .noAPIKey: return "Steam Web API key required.\nGet a free key at steamcommunity.com/dev/apikey\nthen enter it below."
case .invalidAPIKey: return "Invalid API key.\nGet a valid key at steamcommunity.com/dev/apikey"
case .httpError(let code): return "Steam API returned HTTP \(code)"
}
}
}

View File

@ -0,0 +1,109 @@
import Foundation
import SwiftUI
import Combine
class WorkshopViewModel: ObservableObject {
@Published var items: [WorkshopItem] = []
@Published var searchText = ""
@Published var sortOrder: WorkshopSortOrder = .trending
@Published var isLoading = false
@Published var errorMessage: String?
@Published var needsAPIKey = false
@Published var currentPage = 1
@Published var selectedTags: [String] = ["Everyone"]
let steamCmd: SteamCmdService
private let api = WorkshopAPIService()
private var cancellable: AnyCancellable?
static let contentRatingTags = ["Everyone", "Questionable", "Mature"]
static let typeTags = ["Scene", "Video", "Web", "Application"]
static let genreTags = [
"Abstract", "Animal", "Anime", "Cartoon", "CGI",
"Cyberpunk", "Fantasy", "Game", "Girls", "Guys",
"Landscape", "Medieval", "Memes", "MMD", "Music",
"Nature", "Pixel Art", "Relaxing", "Retro", "Sci-Fi",
"Sports", "Technology", "Television", "Vehicle",
]
static let resolutionTags = [
"1920 x 1080", "2560 x 1440", "3840 x 2160",
"3440 x 1440", "1440 x 2560",
]
init(steamCmd: SteamCmdService) {
self.steamCmd = steamCmd
// Forward steamCmd changes (e.g. downloadProgress) to trigger view updates
self.cancellable = steamCmd.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
@MainActor
func search() async {
isLoading = true
errorMessage = nil
needsAPIKey = false
do {
let results = try await api.searchItems(
query: searchText,
tags: selectedTags,
sortOrder: sortOrder,
page: currentPage
)
items = results
} catch WorkshopAPIError.noAPIKey {
needsAPIKey = true
} catch WorkshopAPIError.invalidAPIKey {
needsAPIKey = true
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
@MainActor
func loadMore() async {
currentPage += 1
isLoading = true
do {
let results = try await api.searchItems(
query: searchText,
tags: selectedTags,
sortOrder: sortOrder,
page: currentPage
)
items.append(contentsOf: results)
} catch WorkshopAPIError.noAPIKey {
needsAPIKey = true
} catch WorkshopAPIError.invalidAPIKey {
needsAPIKey = true
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func download(item: WorkshopItem) {
steamCmd.downloadWorkshopItem(workshopId: item.id)
}
func downloadState(for item: WorkshopItem) -> SteamCmdService.DownloadState? {
steamCmd.downloadProgress[item.id]
}
func toggleTag(_ tag: String) {
if selectedTags.contains(tag) {
selectedTags.removeAll { $0 == tag }
} else {
selectedTags.append(tag)
}
currentPage = 1
}
}

View File

@ -0,0 +1,91 @@
import Foundation
enum ZipImporter {
/// Extracts a zip file and copies any wallpaper folders (containing project.json) to the wallpapers directory.
/// Returns the number of wallpapers successfully imported.
@discardableResult
static func importZip(at zipURL: URL) -> Int {
let fm = FileManager.default
let tempDir = fm.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? fm.removeItem(at: tempDir) }
// Extract zip using macOS built-in ditto
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.arguments = ["-xk", zipURL.path, tempDir.path]
do {
try process.run()
process.waitUntilExit()
} catch {
print("ZipImporter: ditto failed: \(error)")
return 0
}
guard process.terminationStatus == 0 else {
print("ZipImporter: ditto exited with status \(process.terminationStatus)")
return 0
}
// Find wallpaper folders inside extracted content
let wallpaperURLs = findWallpaperFolders(in: tempDir)
let dest = fm.wallpapersDirectory
var imported = 0
for url in wallpaperURLs {
let target = dest.appending(path: url.lastPathComponent)
if !fm.fileExists(atPath: target.path) {
do {
try fm.copyItem(at: url, to: target)
imported += 1
} catch {
print("ZipImporter: copy failed for \(url.lastPathComponent): \(error)")
}
}
}
return imported
}
/// Recursively searches for directories containing project.json, up to 3 levels deep.
private static func findWallpaperFolders(in directory: URL) -> [URL] {
let fm = FileManager.default
var results: [URL] = []
// Check if this directory itself is a wallpaper
if fm.fileExists(atPath: directory.appending(path: "project.json").path) {
return [directory]
}
// Check immediate children
guard let children = try? fm.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.isDirectoryKey],
options: .skipsHiddenFiles
) else { return [] }
for child in children {
var isDir: ObjCBool = false
guard fm.fileExists(atPath: child.path, isDirectory: &isDir), isDir.boolValue else { continue }
if fm.fileExists(atPath: child.appending(path: "project.json").path) {
results.append(child)
} else {
// One more level deep (zip may have a wrapper folder)
if let grandchildren = try? fm.contentsOfDirectory(
at: child, includingPropertiesForKeys: [.isDirectoryKey],
options: .skipsHiddenFiles
) {
for grandchild in grandchildren {
var isSubDir: ObjCBool = false
if fm.fileExists(atPath: grandchild.path, isDirectory: &isSubDir),
isSubDir.boolValue,
fm.fileExists(atPath: grandchild.appending(path: "project.json").path) {
results.append(grandchild)
}
}
}
}
}
return results
}
}

View File

@ -0,0 +1,73 @@
//
// AboutUsView.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/6/5.
//
import SwiftUI
extension AppDelegate {
@objc func showAboutUs() {
let window = NSWindow()
window.styleMask = [.closable, .titled]
window.isReleasedWhenClosed = false
window.title = ""
window.contentView = NSHostingView(rootView: AboutUsView())
window.center()
window.makeKeyAndOrderFront(nil)
}
}
struct AboutUsView: View {
var body: some View {
VStack(spacing: 50) {
HStack {
Image(nsImage: NSImage(named: "AppIcon")!)
Divider().frame(maxHeight: 100)
VStack(alignment: .leading) {
Text("Open Wallpaper Engine").bold().font(.title)
Text("Wallpaper Engine for Mac").font(.footnote)
}
}
VStack(spacing: 12) {
Text("version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String)")
Divider().frame(width: 200)
Text("Contributors")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
creditRow("Haren Chen", handle: "haren724", role: "Original creator")
creditRow("MrWindDog", handle: "MrWindDog", role: "Upstream maintainer")
creditRow("Chen Chia Yang", handle: "Unayung", role: "Scene rendering, Workshop, multi-display")
creditRow("1ris_W", handle: "Erica-Iris", role: "Chinese i18n")
creditRow("Klaus Zhu", handle: "klauszhu1105", role: "App logo icons")
}
.font(.caption)
}
}
.frame(width: 420, height: 380)
}
}
extension AboutUsView {
private func creditRow(_ name: String, handle: String, role: String) -> some View {
HStack(spacing: 4) {
Link("@\(handle)", destination: URL(string: "https://github.com/\(handle)")!)
.frame(width: 120, alignment: .leading)
Text("")
.foregroundStyle(.tertiary)
Text(role)
.foregroundStyle(.secondary)
}
}
}
struct AboutUsView_Previews: PreviewProvider {
static var previews: some View {
AboutUsView()
}
}

View File

@ -0,0 +1,130 @@
//
// GeneralPage.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/12.
//
import SwiftUI
struct GeneralPage: SettingsPage {
@ObservedObject var viewModel: GlobalSettingsViewModel
init(globalSettings viewModel: GlobalSettingsViewModel) {
self.viewModel = viewModel
}
var body: some View {
Form {
// MARK: Automatic Startup
Section {
Toggle("Start with macOS", isOn: $viewModel.settings.autoStart)
// Toggle("Safe start after hibernation", isOn: $viewModel.settings.safeMode)
} header: {
Label("Automatic Startup", systemImage: "star.fill")
}
// MARK: Basic Setup
Section {
Picker("Language", selection: $viewModel.settings.language) {
Text("Follow System").tag(GSLocalization.followSystem)
Text("English").tag(GSLocalization.en_US)
Text("Chinese Simplified").tag(GSLocalization.zh_CN)
Text("Traditional Chinese").tag(GSLocalization.zh_TW)
Text("Japanese").tag(GSLocalization.ja)
}
} header: {
Label("Basic Setup", systemImage: "gearshape.fill")
}
// MARK: macOS
Section {
Toggle("Adjust Menu Bar Color", isOn: $viewModel.settings.adjustMenuBarTint)
} header: {
Label("macOS", systemImage: "apple.logo")
}
// MARK: Appearance
Section {
Picker("Theme", selection: $viewModel.settings.appearance) {
Text("Light").tag(GSAppearance.light)
Text("Dark").tag(GSAppearance.dark)
Text("Auto").tag(GSAppearance.followSystem)
}
} header: {
Label("Appearance", systemImage: "paintpalette.fill")
}
// MARK: Audio
Section {
Toggle(isOn: $viewModel.settings.audioOutput) {
Text("Audio Output")
}.disabled(true)
Toggle(isOn: $viewModel.settings.reloadWhenChangingOutputDevice) {
Text("Reload when changing output device")
}.disabled(true)
} header: {
Label("Audio", systemImage: "speaker.3.fill")
}
// MARK: Video
Section {
Picker("Video Framework", selection: $viewModel.settings.videoFramework) {
Text("Apple AVKit").tag(GSVideoFramework.avkit)
}
} header: {
Label("Video", systemImage: "film")
}
// MARK: Advanced
Section {
Picker("Process Piority", selection: $viewModel.settings.processPiority) {
Text("Normal").tag(GSProcessPiority.normal)
Text("Below Normal").tag(GSProcessPiority.belowNormal)
}
Toggle("Pause when VRAM is exhausted", isOn: $viewModel.settings.pauseOnVRAMExhausted)
Toggle("Restart after crashing", isOn: $viewModel.settings.restartAfterCrashing)
} header: {
Label("Advanced", systemImage: "wrench.and.screwdriver.fill")
}
// MARK: Developers
Section {
Picker("Log Level", selection: $viewModel.settings.logLevel) {
Text("None").tag(GSLogLevel.none)
Text("Errors Only").tag(GSLogLevel.error)
Text("Verbose").tag(GSLogLevel.verbose)
}
if viewModel.settings.logLevel != .none {
HStack {
Text(WELogger.shared.logDirURL.path)
.foregroundStyle(.secondary)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button("Show") {
NSWorkspace.shared.activateFileViewerSelecting(
[WELogger.shared.todayLogURL])
}
Button("Clear") {
WELogger.shared.clear()
}
.foregroundStyle(.red)
}
}
} header: {
Label("Developer", systemImage: "number")
}
// MARK: Reset
Section {
HStack {
Text("Reset Config")
Spacer()
Button {
viewModel.settings = GlobalSettings()
} label: {
Text("Reset").frame(width: 100)
}
.tint(Color.red)
.buttonStyle(.borderedProminent)
}
} header: {
Label("Reset", systemImage: "exclamationmark.triangle.fill")
}
}.formStyle(.grouped)
}
}

View File

@ -0,0 +1,202 @@
//
// PerformancePage.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/12.
//
import SwiftUI
struct PerformancePage: SettingsPage {
@ObservedObject var viewModel: GlobalSettingsViewModel
@State private var isEditingFPS = false
init(globalSettings viewModel: GlobalSettingsViewModel) {
self.viewModel = viewModel
}
var body: some View {
Form {
Section {
Picker("Other Application Focused:", selection: $viewModel.settings.otherApplicationFocused) {
Text("Keep Running").tag(GSPlayback.keepRunning)
Text("Mute").tag(GSPlayback.mute)
Text("Pause").tag(GSPlayback.pause)
}
Picker("Other Application Fullscreen:", selection: $viewModel.settings.otherApplicationFullscreen) {
Text("Keep Running").tag(GSPlayback.keepRunning)
Text("Mute").tag(GSPlayback.mute)
Text("Pause").tag(GSPlayback.pause)
Text("Stop (free memory)").tag(GSPlayback.stop)
}
Picker("Other Application Playing Audio:", selection: $viewModel.settings.otherApplicationPlayingAudio) {
Text("Keep Running").tag(GSPlayback.keepRunning)
Text("Mute").tag(GSPlayback.mute)
Text("Pause").tag(GSPlayback.pause)
}
Picker("Display asleep", selection: $viewModel.settings.displayAsleep) {
Text("Keep Running").tag(GSPlayback.keepRunning)
Text("Pause").tag(GSPlayback.pause)
Text("Stop (free memory)").tag(GSPlayback.stop)
}
Picker("Laptop on battery", selection: $viewModel.settings.laptopOnBattery) {
Text("Keep Running").tag(GSPlayback.keepRunning)
Text("Pause").tag(GSPlayback.pause)
Text("Stop (free memory)").tag(GSPlayback.stop)
}
HStack {
Text("Application Rules")
Spacer()
Button {
} label: {
Text("Edit").frame(width: 100)
}
.buttonStyle(.borderedProminent)
.disabled(true)
}
} header: {
Label("Playback", systemImage: "play.fill")
}
Section {
HStack(spacing: 1) {
Button {
viewModel.setQuality(.low)
} label: {
Text("Low").frame(maxWidth: .infinity)
}
Divider()
Button {
viewModel.setQuality(.medium)
} label: {
Text("Medium").frame(maxWidth: .infinity)
}
Divider()
Button {
viewModel.setQuality(.high)
} label: {
Text("High").frame(maxWidth: .infinity)
}
Divider()
Button {
viewModel.setQuality(.ultra)
} label: {
Text("Ultra").frame(maxWidth: .infinity)
}
}
.padding(6)
.background(Color(nsColor: NSColor.unemphasizedSelectedContentBackgroundColor))
.buttonStyle(.borderless)
.clipShape(RoundedRectangle(cornerRadius: 5.0))
Picker("Anti-aliasing", selection: $viewModel.settings.antiAliasing) {
Text("None").tag(GSAntiAliasingQuality.none)
Text("MSAA x2").tag(GSAntiAliasingQuality.msaa_x2)
Text("MSAA x4").tag(GSAntiAliasingQuality.msaa_x4)
Text("MSAA x8").tag(GSAntiAliasingQuality.msaa_x8)
}
.overlay {
HStack {
Spacer(); Spacer()
if viewModel.settings.antiAliasing == .msaa_x8 {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.help("×8 MSAA is only recommended for powerful high-end desktop graphics cards.")
}
Spacer()
}
}
Picker("Post-Processing", selection: $viewModel.settings.postProcessing) {
Text("Disabled").tag(GSPostProcessingQuality.disabled)
Text("Enabled").tag(GSPostProcessingQuality.enabled)
Text("Ultra").tag(GSPostProcessingQuality.ultra)
}
.overlay {
HStack {
Spacer(); Spacer()
if viewModel.settings.postProcessing == .ultra {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
.help("Ultra mode adds HDR bloom to supported wallpapers and is only recommended for powerful high-end desktop graphics cards.")
}
Spacer()
}
}
Picker("Texture Resolution", selection: $viewModel.settings.textureResolution) {
Text("High Quality").tag(GSTextureResolutionQuality.highQuality)
Text("High Performance").tag(GSTextureResolutionQuality.highPerformance)
Text("Automatic").tag(GSTextureResolutionQuality.automatic)
}
HStack {
Text("FPS")
Spacer()
Slider(value: $viewModel.settings.fps, in: 10...120)
.frame(width: 150)
if isEditingFPS {
TextField("FPS", text: Binding<String>(get: {
String(format: "%.00f", viewModel.settings.fps)
}, set: {
if let newValue = Double($0) {
if newValue > 120 {
viewModel.settings.fps = 120
} else if newValue < 10 {
viewModel.settings.fps = 10
} else {
viewModel.settings.fps = newValue
}
}
}))
.textFieldStyle(.roundedBorder)
.labelsHidden()
.frame(width: 50, height: 20)
.onSubmit {
isEditingFPS = false
}
} else {
Text(String(format: "%.00f", viewModel.settings.fps))
.frame(width: 25)
.onTapGesture(count: 2) {
isEditingFPS = true
}
}
}
.overlay {
HStack {
Spacer(); Spacer()
if !isEditingFPS {
if viewModel.settings.fps > 60 {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.help("High FPS may slow down your PC! We're serious, this is too much 🔥.")
} else if viewModel.settings.fps > 30 {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
.help("High FPS may slow down your PC!")
}
}
Spacer()
}
}
HStack {
Text("Reflections")
Spacer()
Toggle("Reflection", isOn: $viewModel.settings.reflections)
.toggleStyle(.checkbox)
.labelsHidden()
}
} header: {
Label("Quality", systemImage: "memorychip.fill")
Text("Currently, these settings below are designed for scene wallpapers \nand may not work as expect ")
}
}
.formStyle(.grouped)
}
}

View File

@ -0,0 +1,95 @@
//
// PluginsPage.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/12.
//
import SwiftUI
struct PluginsPage: SettingsPage {
@ObservedObject var viewModel: GlobalSettingsViewModel
@State var bigGearAngle = 0.0
@State var smallGearAngle = 0.0
@AppStorage("TestAnimates") var animates = false
@State var isExpanded = false
init(globalSettings viewModel: GlobalSettingsViewModel) {
self.viewModel = viewModel
}
var body: some View {
Form {
Section {
VStack(spacing: 20) {
Toggle("Animates", isOn: $animates)
if isExpanded {
HStack {
GifImage("maxwell-cat", animates: animates)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 100, maxHeight: 100)
.padding(4)
.background(Material.thin)
.clipShape(RoundedRectangle(cornerRadius: 16.0))
VStack(alignment: .leading, spacing: 10) {
Text("This plugin enables animation of GIF thumbnail images in wallpaper explorer")
Spacer()
Text("􀄪 You can toggle this to have a preview")
Spacer()
Text("!!! Notice that this may affects performance !!!")
.bold()
}
.frame(maxWidth: .infinity)
}
}
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
VStack {
if isExpanded {
Image(systemName: "chevron.up")
.bold()
.imageScale(.large)
.foregroundStyle(.secondary)
} else {
Text("Description...")
}
}
}
.tint(.accentColor)
.buttonStyle(.borderless)
.frame(maxWidth: .infinity)
}
Text("Come out soon...")
} header: {
Label("Internal", systemImage: "square.dashed.inset.filled")
}
Section {
Text("None")
} header: {
Label("Third-party", systemImage: "person.3.fill")
} footer: {
Text("Those all settings above don't have to be saved for taking effect.")
}
}
.formStyle(.grouped)
}
}
struct PluginPage_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
.environmentObject({ () -> GlobalSettingsViewModel in
let viewModel = GlobalSettingsViewModel()
viewModel.selection = 2
return viewModel
}())
.frame(width: 500, height: 600)
}
}

View File

@ -0,0 +1,148 @@
//
// SettingsView.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/6/5.
//
import Cocoa
import SwiftUI
protocol SettingsPage: View {
var viewModel: GlobalSettingsViewModel { get set }
init(globalSettings: GlobalSettingsViewModel)
}
extension AppDelegate {
@objc func jumpToPerformance() {
self.globalSettingsViewModel.selection = 0
}
@objc func jumpToGeneral() {
self.globalSettingsViewModel.selection = 1
}
@objc func jumpToPlugins() {
self.globalSettingsViewModel.selection = 2
}
@objc func jumpToAbout() {
self.globalSettingsViewModel.selection = 3
}
}
struct SettingsView: View {
@EnvironmentObject var viewModel: GlobalSettingsViewModel
var body: some View {
VStack {
Group {
switch viewModel.selection {
case 0:
PerformancePage(globalSettings: viewModel)
case 1:
GeneralPage(globalSettings: viewModel)
case 2:
PluginsPage(globalSettings: viewModel)
case 3:
AboutUsView()
default:
fatalError()
}
}
.frame(minHeight: 400, maxHeight: 800)
HStack {
if let savedSettings = try? JSONDecoder()
.decode(GlobalSettings.self,
from: UserDefaults.standard.data(forKey: "GlobalSettings")
?? Data()), viewModel.settings != savedSettings {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.yellow)
Text("Edited")
.foregroundStyle(.secondary)
}
Spacer()
Button {
viewModel.save()
AppDelegate.shared.settingsWindow.close()
} label: {
Text("OK").frame(width: 50)
}
.buttonStyle(.borderedProminent)
Button {
/*here should be a call of viewModel.reset() but I move it to the delegate */
AppDelegate.shared.settingsWindow.close()
} label: {
Text("Cancel").frame(width: 50)
}
}
.padding(20)
}
.frame(minWidth: 500)
.environment(\.locale, viewModel.locale)
}
}
extension AppDelegate: NSToolbarDelegate {
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[SettingsToolbarIdentifiers.performance, SettingsToolbarIdentifiers.general, SettingsToolbarIdentifiers.plugins, SettingsToolbarIdentifiers.about]
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[SettingsToolbarIdentifiers.performance, SettingsToolbarIdentifiers.general, SettingsToolbarIdentifiers.plugins, SettingsToolbarIdentifiers.about]
}
func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[SettingsToolbarIdentifiers.performance, SettingsToolbarIdentifiers.general, SettingsToolbarIdentifiers.plugins, SettingsToolbarIdentifiers.about]
}
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier)
switch itemIdentifier {
case SettingsToolbarIdentifiers.performance:
toolbarItem.action = #selector(jumpToPerformance)
toolbarItem.image = NSImage(systemSymbolName: "speedometer", accessibilityDescription: nil)
toolbarItem.label = String(localized: "Performance")
case SettingsToolbarIdentifiers.general:
toolbarItem.action = #selector(jumpToGeneral)
toolbarItem.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: nil)
toolbarItem.label = String(localized: "General")
case SettingsToolbarIdentifiers.plugins:
toolbarItem.action = #selector(jumpToPlugins)
toolbarItem.image = NSImage(systemSymbolName: "puzzlepiece.extension", accessibilityDescription: nil)
toolbarItem.label = String(localized: "Plugins")
case SettingsToolbarIdentifiers.about:
toolbarItem.action = #selector(jumpToAbout)
toolbarItem.image = NSImage(systemSymbolName: "person.3", accessibilityDescription: nil)
toolbarItem.label = String(localized: "About")
default:
fatalError()
}
toolbarItem.isBordered = false
return toolbarItem
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
.environmentObject({ () -> GlobalSettingsViewModel in
let viewModel = GlobalSettingsViewModel()
viewModel.selection = 2
return viewModel
}())
.frame(width: 500, height: 600)
}
}

View File

@ -0,0 +1,57 @@
//
// SceneWallpaperView.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/13.
//
import Cocoa
import SwiftUI
import SpriteKit
struct SceneWallpaperView: NSViewRepresentable {
@ObservedObject var wallpaperViewModel: WallpaperViewModel
@StateObject var viewModel: SceneWallpaperViewModel
let screenId: String
init(wallpaperViewModel: WallpaperViewModel, screenId: String) {
self.wallpaperViewModel = wallpaperViewModel
self.screenId = screenId
self._viewModel = StateObject(wrappedValue: SceneWallpaperViewModel(wallpaper: wallpaperViewModel.wallpaper(for: screenId)))
}
func makeNSView(context: Context) -> SKView {
let skView = SKView(frame: .zero)
skView.ignoresSiblingOrder = true
skView.allowsTransparency = false
skView.preferredFramesPerSecond = Int(AppDelegate.shared.globalSettingsViewModel.settings.fps)
if let scene = viewModel.skScene {
skView.presentScene(scene)
}
return skView
}
func updateNSView(_ skView: SKView, context: Context) {
let selectedWallpaper = wallpaperViewModel.wallpaper(for: screenId)
let currentWallpaper = viewModel.currentWallpaper
// Update scene if wallpaper changed
if selectedWallpaper.wallpaperDirectory.appending(path: selectedWallpaper.project.file)
!= currentWallpaper.wallpaperDirectory.appending(path: currentWallpaper.project.file) {
viewModel.currentWallpaper = selectedWallpaper
}
// Present scene if available and not already presented
if let scene = viewModel.skScene, skView.scene !== scene {
skView.presentScene(scene)
}
// Update FPS
skView.preferredFramesPerSecond = Int(AppDelegate.shared.globalSettingsViewModel.settings.fps)
// Pause/resume based on play rate
skView.isPaused = wallpaperViewModel.playRate == 0
}
}

View File

@ -0,0 +1,54 @@
//
// VideoWallpaperView.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/8/13.
//
import Cocoa
import SwiftUI
import AVKit
struct VideoWallpaperView: NSViewRepresentable {
@ObservedObject var wallpaperViewModel: WallpaperViewModel
@StateObject var viewModel: VideoWallpaperViewModel
let screenId: String
init(wallpaperViewModel: WallpaperViewModel, screenId: String) {
self.wallpaperViewModel = wallpaperViewModel
self.screenId = screenId
self._viewModel = StateObject(wrappedValue: VideoWallpaperViewModel(wallpaper: wallpaperViewModel.wallpaper(for: screenId)))
}
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.player = viewModel.player
// make the video boundary extends to fit the full screen without black background border
view.videoGravity = .resizeAspectFill
// hide any unneeded ui component, we want just the video output
view.controlsStyle = .none
// make sure this video player won't show any info in the system control center
view.updatesNowPlayingInfoCenter = false
// mark the flag as unneeded, improve performance and reduce power drain
view.allowsVideoFrameAnalysis = false
return view
}
func updateNSView(_ nsView: AVPlayerView, context: Context) {
let selectedWallpaper = wallpaperViewModel.wallpaper(for: screenId)
let currentWallpaper = viewModel.currentWallpaper
if selectedWallpaper.wallpaperDirectory.appending(path: selectedWallpaper.project.file) != currentWallpaper.wallpaperDirectory.appending(path: currentWallpaper.project.file) {
viewModel.currentWallpaper = selectedWallpaper
}
viewModel.playRate = wallpaperViewModel.playRate
viewModel.playVolume = wallpaperViewModel.playVolume
}
}

View File

@ -0,0 +1,21 @@
import Cocoa
import SwiftUI
struct WallpaperView: View {
@ObservedObject var viewModel: WallpaperViewModel
let screenId: String
var body: some View {
let wallpaper = viewModel.wallpaper(for: screenId)
switch wallpaper.project.type.lowercased() {
case "video":
VideoWallpaperView(wallpaperViewModel: viewModel, screenId: screenId)
case "scene":
SceneWallpaperView(wallpaperViewModel: viewModel, screenId: screenId)
case "web":
WebWallpaperView(wallpaperViewModel: viewModel, screenId: screenId)
default:
EmptyView()
}
}
}

View File

@ -0,0 +1,69 @@
import Cocoa
import SwiftUI
import WebKit
struct WebWallpaperView: NSViewRepresentable {
@ObservedObject var wallpaperViewModel: WallpaperViewModel
@StateObject var viewModel: WebWallpaperViewModel
let screenId: String
init(wallpaperViewModel: WallpaperViewModel, screenId: String) {
self.wallpaperViewModel = wallpaperViewModel
self.screenId = screenId
self._viewModel = StateObject(wrappedValue: WebWallpaperViewModel(wallpaper: wallpaperViewModel.wallpaper(for: screenId)))
}
func makeNSView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
Self.enableFileAccess(on: configuration)
configuration.allowsAirPlayForMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
let nsView = WKWebView(frame: .zero, configuration: configuration)
nsView.navigationDelegate = viewModel
viewModel.webView = nsView
Self.loadWallpaper(nsView, viewModel: viewModel)
return nsView
}
/// Load wallpaper uses loadHTMLString for URL-based wallpapers (YouTube/Vimeo)
/// so the origin isn't file://, or loadFileURL for local wallpapers.
private static func loadWallpaper(_ webView: WKWebView, viewModel: WebWallpaperViewModel) {
let fileUrl = viewModel.fileUrl
if let html = try? String(contentsOf: fileUrl, encoding: .utf8),
html.contains("youtube.com") || html.contains("vimeo.com") {
// Load as HTML string with https origin so YouTube/Vimeo embeds work
webView.loadHTMLString(html, baseURL: URL(string: "https://localhost"))
} else {
webView.loadFileURL(fileUrl, allowingReadAccessTo: viewModel.readAccessURL)
}
}
/// Enable file:// cross-origin access for WebGL wallpapers.
/// Tries multiple private WebKit key variants, catching ObjC exceptions for each.
private static func enableFileAccess(on configuration: WKWebViewConfiguration) {
let prefs = configuration.preferences
// Key variants across macOS versions
let fileAccessKeys = ["allowFileAccessFromFileURLs", "_allowFileAccessFromFileURLs"]
let universalAccessKeys = ["allowUniversalAccessFromFileURLs", "_allowUniversalAccessFromFileURLs"]
for key in fileAccessKeys {
if ObjCExceptionCatcher.performSafe({ prefs.setValue(true, forKey: key) }) { break }
}
for key in universalAccessKeys {
if ObjCExceptionCatcher.performSafe({ prefs.setValue(true, forKey: key) }) { break }
}
}
func updateNSView(_ nsView: WKWebView, context: Context) {
let selectedWallpaper = wallpaperViewModel.wallpaper(for: screenId)
let currentWallpaper = viewModel.currentWallpaper
if selectedWallpaper.wallpaperDirectory.appending(path: selectedWallpaper.project.file) != currentWallpaper.wallpaperDirectory.appending(path: currentWallpaper.project.file) {
viewModel.currentWallpaper = selectedWallpaper
Self.loadWallpaper(nsView, viewModel: viewModel)
}
}
}

View File

@ -0,0 +1,11 @@
//
// main.swift
// Open Wallpaper Engine
//
// Created by Haren on 2023/6/6.
//
import Cocoa
NSApplication.shared.delegate = AppDelegate.shared
NSApplication.shared.run()

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>haren724.top</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>

155
README.ja.md Normal file
View File

@ -0,0 +1,155 @@
Open Wallpaper Engineパッチ版
=========
[English](README.md) | [繁體中文](README.zh-TW.md) | **日本語**
[![GitHub license](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
[Open Wallpaper Engine](https://github.com/MrWindDog/wallpaper-engine-mac) のパッチフォークです。macOS 向けにシーン壁紙のレンダリングと Web 壁紙の修正を追加しています。
> **注意:** 本プロジェクトは Steam の商用版 Wallpaper Engine とは無関係です。Steam Workshop の壁紙アセットを表示できるオープンソースの macOS アプリケーションです。
## 関連プロジェクト
- **[Open Wallpaper Engine for Linux](https://github.com/Unayung/simple-linux-wallpaperengine-gui)** — [linux-wallpaperengine](https://github.com/Almamu/linux-wallpaperengine) 向けの PyQt6 GUI。Steam Workshop 統合と UI デザインは本 macOS バージョンから移植されました。
## クレジット
本プロジェクトは以下の貢献者の成果に基づいています:
- **[MrWindDog](https://github.com/MrWindDog)** — 上流 [wallpaper-engine-mac](https://github.com/MrWindDog/wallpaper-engine-mac) フォークのメンテナー、新機能と UI 改善を追加
- **[Haren Chen](https://github.com/haren724)** — [open-wallpaper-engine-mac](https://github.com/haren724/open-wallpaper-engine-mac) のオリジナル作者、コアアーキテクチャを構築SwiftUI、動画壁紙再生、インポートシステム、プレイリスト UI
- **[1ris_W](https://github.com/Erica-Iris)** — 中国語 i18n 翻訳
- **[Klaus Zhu](https://github.com/klauszhu1105)** — アプリロゴアイコン
- **[Chen Chia Yang](https://github.com/Unayung)** — シーン壁紙レンダリング、Web 壁紙修正、Steam Workshop 統合、マルチディスプレイ対応、Zip インポート
[GPL-3.0](LICENSE) ライセンス(オリジナルプロジェクトと同一)。
## 0.8.0 の新機能
### マルチディスプレイ対応
接続された各モニターに異なる壁紙を割り当て、画面ごとに有効/無効を制御できます。
- **ディスプレイ設定パネル** — 接続されたすべての画面をビジュアルレイアウトで表示、クリックで選択
- **画面ごとの壁紙** — 各ディスプレイで異なる壁紙を独立して表示
- **有効/無効トグル** — モニターごとに壁紙のオン/オフを切り替え
- **自動検出** — 新しいモニターは接続時に自動的に検出・有効化
### マルチデスクトップ対応
壁紙がすべての macOS デスクトップSpacesで連続再生されるようになりました。デスクトップ切り替え時も中断しません。
### 最近使った壁紙メニュー
ステータスバーメニューから壁紙を素早く切り替えられます。最近使用した10件の壁紙にワンクリックでアクセスできます。
### 再生設定 — 修正済み
パフォーマンス再生設定(他のアプリがフォーカスされた時の一時停止/ミュート/停止)がすべての壁紙タイプで正しく動作するようになりました。
### Steam Workshop ブラウザ
アプリ内から直接 Steam Workshop の壁紙を閲覧、検索、ダウンロードできます。
- **検索とフィルター** — 名前で検索、コンテンツレーティングEveryone/Questionable/Mature、タイプScene/Video/Web、ジャンルタグでフィルター
- **ソートオプション** — トレンド、最新、人気順、サブスクライブ数順
- **steamcmd 統合** — steamcmd を自動検出Homebrew またはカスタムパス)、未インストール時はインストール手順を表示
- **Steam ログイン** — パスワード、Steam Guard、キャッシュセッション認証に対応
- **ダウンロード進捗表示** — リアルタイムステータス更新(認証中、ダウンロード %、検証、コピー中)
- **安全なデフォルト** — コンテンツレーティングを「Everyone」に設定し、成人向けコンテンツをフィルタリング
### Zip インポート
`.zip` ファイルから壁紙パッケージを直接インポート。手動解凍は不要です。ファイル > インポートおよびドラッグ&ドロップに対応。
### 複数選択と一括解除
Cmd+クリックで複数の壁紙を選択し、右クリックで一括サブスクライブ解除。
### 壁紙ストレージの分離
壁紙は `~/Documents/Open Wallpaper Engine/` に保存されるようになり、Documents ディレクトリを直接使用しなくなりました。リポジトリをクローンした際の「error」壁紙を防止します。
## パッチ内容
### Web 壁紙 — グレー/空白レンダリングの修正
WebGL ベースの壁紙は `WKWebView` がローカルファイルアクセスをブロックしていたため、グレーの矩形として表示されていました。
**修正:** WKWebView 設定で `allowFileAccessFromFileURLs``allowUniversalAccessFromFileURLs` を有効にし、WebGL シェーダーがローカルテクスチャファイルを読み込めるようにしました。
### シーン壁紙 — ゼロから実装
シーン壁紙Steam Workshop で最も一般的なタイプは完全に未実装で、「Hello, World!」のみ表示されていました。
**新しい実装:**
- **PKG パーサー** — Wallpaper Engine の PKGV アーカイブ形式を読み取り、scene.json、モデル、マテリアル、テクスチャを抽出
- **TEX パーサー** — TEXV0005 テクスチャコンテナを読み取り、TEXI/TEXB セクションから埋め込み JPEG/PNG 画像を抽出
- **Scene JSON デコーダー** — scene.json を解析、ポリモーフィックフィールド(値がプレーンタイプまたは `{"script":..,"value":..}` オブジェクト)を柔軟に処理
- **SpriteKit レンダラー** — シーン画像レイヤーを SKSpriteNode としてレンダリング、位置、サイズ、アルファ、カラーティント、ブレンドモードを正確に処理
- **プレビューフォールバック** — テクスチャを抽出できない場合は preview.jpg/png/gif にフォールバック
- **TEXI 形式検出** — デコードできない DXT 圧縮テクスチャを迅速に識別してスキップ
### インポート — フォルダインポートの修正
インポートパネルが個別の壁紙フォルダと複数の壁紙を含む親ディレクトリの両方を正しく処理するようになりました。
## 現在の制限事項
- **DXT テクスチャ** — DXT1/DXT5 圧縮テクスチャTEXI 形式 4/7/8を使用する壁紙はレンダリングできません。ソフトウェアデコンプレッサーまたは Metal ベースのレンダリングが必要な GPU ネイティブ圧縮形式です。プレビュー画像にフォールバックします。
- **パーティクルエフェクト** — シーンパーティクルシステム(雨、雪、スパークル)は解析されますが、視覚的な問題を避けるためレンダリングで無効化されています。
- **オーディオリアクティブスクリプト** — Wallpaper Engine の JavaScript ベースのオーディオ視覚化スクリプトは実行されません。スクリプト付きプロパティは静的な `value` にフォールバックします。
- **シェーダーエフェクト** — カスタム GLSL シェーダー(ブルーム、ブラー、カラー補正)は適用されません。
- **カメラパララックス** — マウス追従カメラ移動は未実装です。
- **アニメーションシーン** — スプライトアニメーションとタイムラインベースのオブジェクトアニメーションはサポートされていません。
- **一部の JPEG サムネイル** — 少数の TEXB 形式 1 ファイルに macOS がデコードできない非標準 JPEG データが含まれています。
## サポートされている壁紙タイプ
| タイプ | ステータス |
|--------|------------|
| 動画 (.mp4, .webm) | 動作中(オリジナル) |
| Web (HTML/WebGL) | 動作中(パッチ済み) |
| シーン(静的画像) | 動作中(新機能) |
| シーン(パーティクル) | 部分対応(無効化) |
| シーンDXT テクスチャ) | プレビューフォールバック |
| アプリケーション | 未サポート |
## ソースからビルド
### 前提条件
- macOS >= 13.0
- Xcode >= 14.4
- Xcode Command Line Tools
### 手順
```sh
git clone https://github.com/unayung/wallpaper-engine-mac
cd wallpaper-engine-mac
open "Open Wallpaper Engine.xcodeproj"
```
Xcode で署名証明書を自分のものに変更するか「Sign to Run Locally」を選択し、`Cmd + R` でビルド・実行します。
## 使い方
### Steam Workshop から閲覧・ダウンロード
1. steamcmd をインストール(`brew install steamcmd`)するか、既存のバイナリを指定
2. **Workshop** タブに切り替え、Steam アカウントでログインWallpaper Engine の所有が必要)
3. プロンプトが表示されたら [Steam Web API キー](https://steamcommunity.com/dev/apikey) を入力
4. 検索、フィルターし、**Download** をクリックして壁紙をダウンロード
### ローカルファイルからインポート
- **フォルダ:** ファイル > フォルダからインポート — `project.json` を含む壁紙フォルダを選択
- **Zip** ファイル > インポート またはドラッグ&ドロップで `.zip` ファイルを読み込み
- **手動:** 壁紙フォルダを `~/Documents/Open Wallpaper Engine/` に直接コピー
## 変更ファイル(上流との差分)
**変更:**
- `WebWallpaperView.swift` — WKWebView ファイルアクセス設定
- `WallpaperView.swift` — シーン壁紙ディスパッチ
- `SceneWallpaperView.swift` — SpriteKit NSViewRepresentable に書き換え
- `ImportPanels.swift` — フォルダインポートロジック修正
**追加:**
- `Services/SceneParsers/PKGParser.swift` — PKGV アーカイブパーサー
- `Services/SceneParsers/TEXParser.swift` — TEXV テクスチャパーサー
- `Services/SceneParsers/SceneModels.swift` — Scene JSON データモデル
- `Services/SceneWallpaperViewModel.swift` — シーン読み込みと SpriteKit レンダリング
- `Services/SteamCmdService.swift` — steamcmd 検出、ログイン、Workshop ダウンロード
- `Services/WorkshopAPIService.swift` — Steam Web API クライアント
- `Services/WorkshopViewModel.swift` — Workshop ブラウザ状態管理
- `Services/WallpaperDirectory.swift` — 集中壁紙ストレージパス
- `Services/ZipImporter.swift` — Zip ファイル解凍とインポート
- `ContentView/Components/WorkshopView.swift` — Workshop ブラウザ UI

155
README.md Normal file
View File

@ -0,0 +1,155 @@
Open Wallpaper Engine (Patched)
=========
**English** | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md)
[![GitHub license](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
A patched fork of [Open Wallpaper Engine](https://github.com/MrWindDog/wallpaper-engine-mac) for macOS, adding scene wallpaper rendering and web wallpaper fixes.
> **Note:** This is NOT affiliated with the commercial Wallpaper Engine on Steam. This is an open-source macOS app that can display wallpaper assets from Wallpaper Engine's Steam Workshop.
## Related Projects
- **[Open Wallpaper Engine for Linux](https://github.com/Unayung/simple-linux-wallpaperengine-gui)** — A PyQt6 GUI for [linux-wallpaperengine](https://github.com/Almamu/linux-wallpaperengine), with Steam Workshop integration and UI design ported from this macOS version.
## Credits
This project is built on top of the work of:
- **[MrWindDog](https://github.com/MrWindDog)** — Maintainer of the upstream [wallpaper-engine-mac](https://github.com/MrWindDog/wallpaper-engine-mac) fork, added new features and UI refinements
- **[Haren Chen](https://github.com/haren724)** — Original creator of [open-wallpaper-engine-mac](https://github.com/haren724/open-wallpaper-engine-mac), built the core app architecture (SwiftUI, video wallpaper playback, import system, playlist UI)
- **[1ris_W](https://github.com/Erica-Iris)** — Chinese i18n translation
- **[Klaus Zhu](https://github.com/klauszhu1105)** — App logo icons
- **[Chen Chia Yang](https://github.com/Unayung)** — Scene wallpaper rendering, web wallpaper fixes, Steam Workshop integration, multi-display support, zip import
Licensed under [GPL-3.0](LICENSE), same as the original project.
## What's New in 0.8.0
### Multi-Display Support
Assign different wallpapers to each connected monitor with per-screen enable/disable control.
- **Display Settings panel** — Visual monitor layout showing all connected screens, click to select
- **Per-screen wallpaper** — Each display can show a different wallpaper independently
- **Enable/disable toggle** — Turn wallpaper on or off per monitor
- **Auto-detect** — New monitors are automatically detected and enabled when connected
### Multi-Desktop Support
Wallpapers now display across all macOS desktops (Spaces) with continuous playback — no interruption when switching desktops.
### Recent Wallpapers Menu
Quickly switch wallpapers from the status bar menu. The last 10 wallpapers you've used are listed for one-click access.
### Playback Settings — Fixed
Performance playback settings (pause/mute/stop when other apps are focused) now work correctly for all wallpaper types.
### Steam Workshop Browser
Browse, search, and download wallpapers directly from the Steam Workshop without leaving the app.
- **Search & filter** — Search by name, filter by content rating (Everyone/Questionable/Mature), type (Scene/Video/Web), and genre tags
- **Sort options** — Trending, Most Recent, Most Popular, Most Subscribed
- **steamcmd integration** — Auto-detects steamcmd (Homebrew or custom path), with install instructions if not found
- **Steam login** — Supports password, Steam Guard, and cached session authentication
- **Download with progress** — Real-time status updates during download (authenticating, downloading %, validating, copying)
- **Safe defaults** — Content rating defaults to "Everyone" to filter out mature content
### Zip Import
Import wallpaper packages directly from `.zip` files — no need to manually extract first. Works via File > Import and drag-and-drop.
### Multi-Select & Batch Unsubscribe
Cmd+click to select multiple wallpapers, then right-click to batch unsubscribe.
### Wallpaper Storage Isolation
Wallpapers are now stored in `~/Documents/Open Wallpaper Engine/` instead of the raw Documents directory, preventing "error" wallpapers when cloning the repo on a fresh machine.
## What's Patched
### Web Wallpapers — Fixed gray/blank rendering
WebGL-based wallpapers rendered as gray rectangles because `WKWebView` blocked local file access for textures and assets.
**Fix:** Enabled `allowFileAccessFromFileURLs` and `allowUniversalAccessFromFileURLs` on the WKWebView configuration, allowing WebGL shaders to load local texture files.
### Scene Wallpapers — Implemented from scratch
Scene wallpapers (the most common type on Steam Workshop) were completely unimplemented — just showed "Hello, World!".
**New implementation includes:**
- **PKG parser** — Reads Wallpaper Engine's PKGV archive format to extract scene.json, models, materials, and textures
- **TEX parser** — Reads TEXV0005 texture containers, extracts embedded JPEG/PNG image data from TEXI/TEXB sections
- **Scene JSON decoder** — Parses scene.json with flexible decoding that handles Wallpaper Engine's polymorphic fields (values can be plain types or `{"script":..,"value":..}` objects)
- **SpriteKit renderer** — Renders scene image layers as SKSpriteNodes with correct positioning, sizing, alpha, color tinting, and blend modes
- **Preview fallback** — Falls back to preview.jpg/png/gif when textures can't be extracted
- **TEXI format detection** — Quickly identifies and skips DXT-compressed textures that can't be decoded
### Import — Fixed folder import
The import panel now correctly handles both individual wallpaper folders and parent directories containing multiple wallpapers.
## Current Limitations
- **DXT textures** — Wallpapers using DXT1/DXT5 compressed textures (TEXI format 4/7/8) cannot be rendered. These are GPU-native compressed formats that require either a software decompressor or Metal-based rendering. The app falls back to the preview image for these wallpapers.
- **Particle effects** — Scene particle systems (rain, snow, sparkles) are parsed but disabled in rendering to avoid visual artifacts. The particle mapping code exists but needs refinement.
- **Audio-reactive scripts** — Wallpaper Engine's JavaScript-based audio visualization scripts are not executed. Properties with scripts fall back to their static `value`.
- **Shader effects** — Custom GLSL shaders (bloom, blur, color correction) are not applied.
- **Camera parallax** — Mouse-tracking camera movement is not implemented.
- **Animated scenes** — Sprite animations and timeline-based object animations are not supported.
- **Some JPEG thumbnails** — A small number of TEXB format 1 files contain non-standard JPEG data that macOS cannot decode. These are typically DXT-compressed textures misidentified as format 1.
## Supported Wallpaper Types
| Type | Status |
|------|--------|
| Video (.mp4, .webm) | Working (original) |
| Web (HTML/WebGL) | Working (patched) |
| Scene (static images) | Working (new) |
| Scene (particles) | Partial (disabled) |
| Scene (DXT textures) | Preview fallback |
| Application | Not supported |
## Build from Source
### Prerequisites
- macOS >= 13.0
- Xcode >= 14.4
- Xcode Command Line Tools
### Steps
```sh
git clone https://github.com/unayung/wallpaper-engine-mac
cd wallpaper-engine-mac
open "Open Wallpaper Engine.xcodeproj"
```
In Xcode, change the signing certificate to your own or select "Sign to Run Locally", then press `Cmd + R` to build and run.
## Usage
### Browse & Download from Steam Workshop
1. Install steamcmd (`brew install steamcmd`) or point the app to an existing binary
2. Switch to the **Workshop** tab and log in with your Steam account (must own Wallpaper Engine)
3. Enter a [Steam Web API key](https://steamcommunity.com/dev/apikey) when prompted
4. Search, filter, and click **Download** on any wallpaper
### Import from Local Files
- **Folder:** File > Import from Folder — select wallpaper folders containing `project.json`
- **Zip:** File > Import or drag-and-drop a `.zip` file containing wallpaper packages
- **Manual:** Copy wallpaper folders directly into `~/Documents/Open Wallpaper Engine/`
## Files Changed (vs upstream)
**Modified:**
- `WebWallpaperView.swift` — WKWebView file access configuration
- `WallpaperView.swift` — Scene wallpaper dispatch
- `SceneWallpaperView.swift` — Rewritten as SpriteKit NSViewRepresentable
- `ImportPanels.swift` — Folder import logic fix
**Added:**
- `Services/SceneParsers/PKGParser.swift` — PKGV archive parser
- `Services/SceneParsers/TEXParser.swift` — TEXV texture parser
- `Services/SceneParsers/SceneModels.swift` — Scene JSON data models
- `Services/SceneWallpaperViewModel.swift` — Scene loading and SpriteKit rendering
- `Services/SteamCmdService.swift` — steamcmd detection, login, and workshop download
- `Services/WorkshopAPIService.swift` — Steam Web API client for workshop browsing
- `Services/WorkshopViewModel.swift` — Workshop browser state management
- `Services/WallpaperDirectory.swift` — Centralized wallpaper storage path
- `Services/ZipImporter.swift` — Zip file extraction and import
- `ContentView/Components/WorkshopView.swift` — Workshop browser UI

155
README.zh-TW.md Normal file
View File

@ -0,0 +1,155 @@
Open Wallpaper Engine修補版
=========
[English](README.md) | **繁體中文** | [日本語](README.ja.md)
[![GitHub license](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
基於 [Open Wallpaper Engine](https://github.com/MrWindDog/wallpaper-engine-mac) 的修補分支,為 macOS 加入場景桌布渲染與網頁桌布修復。
> **注意:** 本專案與 Steam 上的商業版 Wallpaper Engine 無關。這是一個開源的 macOS 應用程式,可顯示來自 Wallpaper Engine Steam 創意工坊的桌布素材。
## 相關專案
- **[Open Wallpaper Engine for Linux](https://github.com/Unayung/simple-linux-wallpaperengine-gui)** — 基於 [linux-wallpaperengine](https://github.com/Almamu/linux-wallpaperengine) 的 PyQt6 圖形介面Steam 工作坊整合與 UI 設計移植自本 macOS 版本。
## 致謝
本專案建立於以下貢獻者的成果之上:
- **[MrWindDog](https://github.com/MrWindDog)** — 上游 [wallpaper-engine-mac](https://github.com/MrWindDog/wallpaper-engine-mac) 分支的維護者,新增功能與 UI 優化
- **[Haren Chen](https://github.com/haren724)** — [open-wallpaper-engine-mac](https://github.com/haren724/open-wallpaper-engine-mac) 原作者建構核心架構SwiftUI、影片桌布播放、匯入系統、播放清單 UI
- **[1ris_W](https://github.com/Erica-Iris)** — 中文 i18n 翻譯
- **[Klaus Zhu](https://github.com/klauszhu1105)** — 應用程式圖示
- **[Chen Chia Yang](https://github.com/Unayung)** — 場景桌布渲染、網頁桌布修復、Steam 創意工坊整合、多螢幕支援、Zip 匯入
採用 [GPL-3.0](LICENSE) 授權,與原始專案相同。
## 0.8.0 新功能
### 多螢幕支援
為每個連接的螢幕指定不同的桌布,並可個別啟用或停用。
- **顯示器設定面板** — 以視覺化佈局顯示所有連接的螢幕,點擊選取
- **個別螢幕桌布** — 每個螢幕可獨立顯示不同的桌布
- **啟用/停用切換** — 可針對每個螢幕開啟或關閉桌布
- **自動偵測** — 新連接的螢幕會自動偵測並啟用
### 多桌面支援
桌布現在可在所有 macOS 桌面(空間)上顯示並持續播放,切換桌面時不會中斷。
### 最近使用的桌布選單
可從狀態列選單快速切換桌布。最近使用的 10 個桌布可一鍵存取。
### 播放設定 — 已修復
效能播放設定(切換應用程式時暫停/靜音/停止)現在對所有桌布類型均可正常運作。
### Steam 創意工坊瀏覽器
直接在應用程式內瀏覽、搜尋及下載 Steam 創意工坊的桌布。
- **搜尋與篩選** — 依名稱搜尋依內容分級Everyone/Questionable/Mature、類型Scene/Video/Web及風格標籤篩選
- **排序選項** — 熱門趨勢、最新發布、最受歡迎、最多訂閱
- **steamcmd 整合** — 自動偵測 steamcmdHomebrew 或自訂路徑),未安裝時提供安裝指引
- **Steam 登入** — 支援密碼、Steam Guard 及快取 Session 驗證
- **下載進度顯示** — 即時狀態更新(驗證中、下載百分比、驗證、複製中)
- **安全預設** — 內容分級預設為「Everyone」過濾成人內容
### Zip 匯入
直接匯入 `.zip` 桌布套件,無需手動解壓縮。支援 檔案 > 匯入 及拖放操作。
### 多選與批次取消訂閱
Cmd+點擊選取多個桌布,右鍵選擇批次取消訂閱。
### 桌布儲存隔離
桌布現在儲存在 `~/Documents/Open Wallpaper Engine/`,不再使用原始 Documents 目錄避免克隆專案時出現「error」桌布。
## 修補內容
### 網頁桌布 — 修復灰色/空白渲染
基於 WebGL 的桌布因 `WKWebView` 阻擋本地檔案存取而顯示為灰色方塊。
**修復:** 在 WKWebView 設定中啟用 `allowFileAccessFromFileURLs``allowUniversalAccessFromFileURLs`,允許 WebGL 著色器載入本地紋理檔案。
### 場景桌布 — 從零開始實作
場景桌布Steam 創意工坊最常見的類型原本完全未實作——僅顯示「Hello, World!」。
**新實作包括:**
- **PKG 解析器** — 讀取 Wallpaper Engine 的 PKGV 封存格式,提取 scene.json、模型、材質和紋理
- **TEX 解析器** — 讀取 TEXV0005 紋理容器,從 TEXI/TEXB 區段提取嵌入的 JPEG/PNG 圖片
- **Scene JSON 解碼器** — 解析 scene.json靈活處理多態欄位值可為純類型或 `{"script":..,"value":..}` 物件)
- **SpriteKit 渲染器** — 將場景圖層渲染為 SKSpriteNode正確處理定位、尺寸、透明度、色彩調整和混合模式
- **預覽回退** — 無法提取紋理時回退至 preview.jpg/png/gif
- **TEXI 格式偵測** — 快速識別並跳過無法解碼的 DXT 壓縮紋理
### 匯入 — 修復資料夾匯入
匯入面板現在可正確處理單一桌布資料夾和包含多個桌布的父目錄。
## 目前限制
- **DXT 紋理** — 使用 DXT1/DXT5 壓縮紋理TEXI 格式 4/7/8的桌布無法渲染。這些是需要軟體解壓縮器或 Metal 渲染的 GPU 原生壓縮格式。此類桌布會回退至預覽圖。
- **粒子效果** — 場景粒子系統(雨、雪、閃光)已解析但在渲染中停用,以避免視覺問題。
- **音訊互動腳本** — Wallpaper Engine 基於 JavaScript 的音訊視覺化腳本不會執行。帶腳本的屬性回退至靜態 `value`
- **著色器效果** — 自定義 GLSL 著色器(泛光、模糊、色彩校正)未套用。
- **相機視差** — 滑鼠追蹤相機移動未實作。
- **動畫場景** — 精靈動畫和基於時間軸的物件動畫不支援。
- **部分 JPEG 縮圖** — 少數 TEXB 格式 1 檔案包含 macOS 無法解碼的非標準 JPEG 資料。
## 支援的桌布類型
| 類型 | 狀態 |
|------|------|
| 影片 (.mp4, .webm) | 正常運作(原始) |
| 網頁 (HTML/WebGL) | 正常運作(已修補) |
| 場景(靜態圖片) | 正常運作(新功能) |
| 場景(粒子) | 部分支援(已停用) |
| 場景DXT 紋理) | 預覽回退 |
| 應用程式 | 不支援 |
## 從原始碼建置
### 前置需求
- macOS >= 13.0
- Xcode >= 14.4
- Xcode Command Line Tools
### 步驟
```sh
git clone https://github.com/unayung/wallpaper-engine-mac
cd wallpaper-engine-mac
open "Open Wallpaper Engine.xcodeproj"
```
在 Xcode 中將簽署憑證更改為您自己的或選擇「Sign to Run Locally」然後按 `Cmd + R` 建置並執行。
## 使用方式
### 從 Steam 創意工坊瀏覽與下載
1. 安裝 steamcmd`brew install steamcmd`)或指向現有的二進位檔
2. 切換到 **Workshop** 分頁,使用 Steam 帳號登入(必須擁有 Wallpaper Engine
3. 出現提示時輸入 [Steam Web API 金鑰](https://steamcommunity.com/dev/apikey)
4. 搜尋、篩選,然後點擊 **Download** 下載桌布
### 從本地檔案匯入
- **資料夾:** 檔案 > 從資料夾匯入——選擇包含 `project.json` 的桌布資料夾
- **Zip** 檔案 > 匯入 或拖放包含桌布套件的 `.zip` 檔案
- **手動:** 直接將桌布資料夾複製到 `~/Documents/Open Wallpaper Engine/`
## 變更的檔案(相對上游)
**修改:**
- `WebWallpaperView.swift` — WKWebView 檔案存取設定
- `WallpaperView.swift` — 場景桌布分派
- `SceneWallpaperView.swift` — 改寫為 SpriteKit NSViewRepresentable
- `ImportPanels.swift` — 資料夾匯入邏輯修復
**新增:**
- `Services/SceneParsers/PKGParser.swift` — PKGV 封存解析器
- `Services/SceneParsers/TEXParser.swift` — TEXV 紋理解析器
- `Services/SceneParsers/SceneModels.swift` — Scene JSON 資料模型
- `Services/SceneWallpaperViewModel.swift` — 場景載入與 SpriteKit 渲染
- `Services/SteamCmdService.swift` — steamcmd 偵測、登入與創意工坊下載
- `Services/WorkshopAPIService.swift` — Steam Web API 客戶端
- `Services/WorkshopViewModel.swift` — 創意工坊瀏覽器狀態管理
- `Services/WallpaperDirectory.swift` — 集中式桌布儲存路徑
- `Services/ZipImporter.swift` — Zip 檔案解壓與匯入
- `ContentView/Components/WorkshopView.swift` — 創意工坊瀏覽器 UI

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
resources/import-menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
resources/import-panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

BIN
resources/wallpaper.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB