feat: init repo
38
.github/ISSUE_TEMPLATE/bug-提交.md
vendored
Normal 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
@ -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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: 功能 建议
|
||||
about: 为这个项目提出一个新点子
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**您的功能请求是否与目前所存在的问题有关?请描述一下**
|
||||
清晰简洁地描述问题是什么。例如,我总是感到很麻烦,当 [...]
|
||||
|
||||
**描述您想要的解决方案**
|
||||
清晰简洁地描述你想发生什么
|
||||
|
||||
**描述你考虑过的替代方案**
|
||||
清晰简洁地描述您考虑过的任何替代解决方案或功能
|
||||
|
||||
**附加内容**
|
||||
在此处添加有关功能请求的任何其他内容或屏幕截图
|
||||
61
.github/workflows/build.yml
vendored
Normal 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
@ -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
@ -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
@ -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
|
||||
674
LICENSE
Normal 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>.
|
||||
712
Open Wallpaper Engine.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
Open Wallpaper Engine.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -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>
|
||||
@ -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>
|
||||
161
Open Wallpaper Engine/AppDelegate.swift
Normal 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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
386
Open Wallpaper Engine/ContentView/Components/FilterResults.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
110
Open Wallpaper Engine/ContentView/Components/GifImage.swift
Normal 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
|
||||
}
|
||||
}
|
||||
97
Open Wallpaper Engine/ContentView/Components/TopTabBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
517
Open Wallpaper Engine/ContentView/Components/WorkshopView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
158
Open Wallpaper Engine/ContentView/ContentView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
107
Open Wallpaper Engine/ContentView/ImportPanels.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Open Wallpaper Engine/ContentView/MainWindow.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 = []
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
6059
Open Wallpaper Engine/Localizable.xcstrings
Normal file
140
Open Wallpaper Engine/MenuBars/MainMenu.swift
Normal 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)
|
||||
|
||||
}
|
||||
}
|
||||
166
Open Wallpaper Engine/MenuBars/StatusBar.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
#import "ObjCExceptionCatcher.h"
|
||||
@ -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@-->
|
||||
|
After Width: | Height: | Size: 36 KiB |
14
Open Wallpaper Engine/Open_Wallpaper_Engine.entitlements
Normal 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>
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 344 KiB |
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
18
Open Wallpaper Engine/Resources/Assets.xcassets/we.logo.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
18
Open Wallpaper Engine/Resources/Assets.xcassets/we.placeholder.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
BIN
Open Wallpaper Engine/Resources/WallpaperNotFound.mp4
Normal file
BIN
Open Wallpaper Engine/Resources/maxwell-cat.gif
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
297
Open Wallpaper Engine/Services/GlobalSettingsService.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
12
Open Wallpaper Engine/Services/ObjCExceptionCatcher.h
Normal 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
|
||||
15
Open Wallpaper Engine/Services/ObjCExceptionCatcher.m
Normal 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
|
||||
119
Open Wallpaper Engine/Services/SceneParsers/PKGParser.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
338
Open Wallpaper Engine/Services/SceneParsers/SceneModels.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
360
Open Wallpaper Engine/Services/SceneParsers/TEXParser.swift
Normal 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
|
||||
}
|
||||
}
|
||||
537
Open Wallpaper Engine/Services/SceneWallpaperViewModel.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
372
Open Wallpaper Engine/Services/SteamCmdService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
125
Open Wallpaper Engine/Services/VideoWallpaperViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
119
Open Wallpaper Engine/Services/WELogger.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
Open Wallpaper Engine/Services/WEProject.swift
Normal 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)
|
||||
}
|
||||
14
Open Wallpaper Engine/Services/WallpaperDirectory.swift
Normal 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
|
||||
}
|
||||
}
|
||||
180
Open Wallpaper Engine/Services/WallpaperViewModel.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Open Wallpaper Engine/Services/WallpaperWindowManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
54
Open Wallpaper Engine/Services/WebWallpaperViewModel.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
215
Open Wallpaper Engine/Services/WorkshopAPIService.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
109
Open Wallpaper Engine/Services/WorkshopViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
91
Open Wallpaper Engine/Services/ZipImporter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
73
Open Wallpaper Engine/SettingsView/AboutUsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
130
Open Wallpaper Engine/SettingsView/GeneralPage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
202
Open Wallpaper Engine/SettingsView/PerformancePage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
95
Open Wallpaper Engine/SettingsView/PluginsPage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
148
Open Wallpaper Engine/SettingsView/SettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
Open Wallpaper Engine/WallpaperView/SceneWallpaperView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
54
Open Wallpaper Engine/WallpaperView/VideoWallpaperView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
21
Open Wallpaper Engine/WallpaperView/WallpaperView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
69
Open Wallpaper Engine/WallpaperView/WebWallpaperView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Open Wallpaper Engine/main.swift
Normal 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()
|
||||
21
Open-Wallpaper-Engine-Info.plist
Normal 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
@ -0,0 +1,155 @@
|
||||
Open Wallpaper Engine(パッチ版)
|
||||
=========
|
||||
|
||||
[English](README.md) | [繁體中文](README.zh-TW.md) | **日本語**
|
||||
|
||||
[](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
@ -0,0 +1,155 @@
|
||||
Open Wallpaper Engine (Patched)
|
||||
=========
|
||||
|
||||
**English** | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md)
|
||||
|
||||
[](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
@ -0,0 +1,155 @@
|
||||
Open Wallpaper Engine(修補版)
|
||||
=========
|
||||
|
||||
[English](README.md) | **繁體中文** | [日本語](README.ja.md)
|
||||
|
||||
[](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 整合** — 自動偵測 steamcmd(Homebrew 或自訂路徑),未安裝時提供安裝指引
|
||||
- **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
|
||||
BIN
resources/change-signing-certificate.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
resources/import-menu.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
resources/import-panel.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
resources/wallpaper-with-windows.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
resources/wallpaper.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |