From 281eb9bccea2842d59c8f4e922e2ae1a156393e7 Mon Sep 17 00:00:00 2001
From: PathGao
Date: Wed, 16 Sep 2026 20:21:17 +0800
Subject: [PATCH 1/2] Consolidate repository layout and script app builds
---
.editorconfig | 5 +-
CONTRIBUTING.md => .github/CONTRIBUTING.md | 29 +-
.github/dependabot.yml | 2 +-
.github/workflows/ci.yml | 22 +-
.github/workflows/release.yml | 15 +-
.periphery.yml | 111 -------
Config.xcconfig | 8 -
Nifro/Nifro.icon/Assets/Rectangle 2 2.svg | 12 -
Nifro/Nifro.icon/icon.json | 56 ----
Package.swift | 21 +-
README.md | 36 ++-
README.zh-Hans.md | 30 +-
{Nifro => Sources/Nifro}/App/Actions.swift | 0
{Nifro => Sources/Nifro}/App/App.swift | 0
{Nifro => Sources/Nifro}/App/AppState.swift | 0
.../Nifro}/App/BrowsingModeShortcut.swift | 0
{Nifro => Sources/Nifro}/App/Constants.swift | 0
{Nifro => Sources/Nifro}/App/Events.swift | 0
{Nifro => Sources/Nifro}/App/Intents.swift | 0
.../Nifro}/App/RestoreDefaults.swift | 0
{Nifro => Sources/Nifro}/App/Shortcuts.swift | 0
.../Nifro}/App/URLCommands.swift | 0
.../AppIcon.appiconset/Contents.json | 0
.../AppIcon.appiconset/icon_128x128.png | Bin
.../AppIcon.appiconset/icon_128x128@2x.png | Bin
.../AppIcon.appiconset/icon_16x16.png | Bin
.../AppIcon.appiconset/icon_16x16@2x.png | Bin
.../AppIcon.appiconset/icon_256x256.png | Bin
.../AppIcon.appiconset/icon_256x256@2x.png | Bin
.../AppIcon.appiconset/icon_32x32.png | Bin
.../AppIcon.appiconset/icon_32x32@2x.png | Bin
.../AppIcon.appiconset/icon_512x512.png | Bin
.../AppIcon.appiconset/icon_512x512@2x.png | Bin
.../Nifro}/Assets.xcassets/Contents.json | 0
.../MenuBarIcon.imageset/Contents.json | 0
.../MenuBarIcon.imageset/MenuBarIcon.svg | 0
{Nifro => Sources/Nifro}/Info.plist | 4 +-
.../Nifro}/InternetAccessPolicy.json | 0
.../Nifro}/Localizable.xcstrings | 0
{Nifro => Sources/Nifro}/Nifro.entitlements | 0
.../Preview Assets.xcassets/Contents.json | 0
.../Nifro}/Screens/AboutSettings.swift | 0
.../Nifro}/Screens/AddWebsiteScreen.swift | 0
.../Nifro}/Screens/DisplayPanel.swift | 0
.../Screens/DisplayPanelController.swift | 0
.../Nifro}/Screens/DisplayPanelModel.swift | 0
.../Nifro}/Screens/IntervalField.swift | 0
.../Nifro}/Screens/PanelControls.swift | 0
.../Nifro}/Screens/SettingHelp.swift | 0
.../Nifro}/Screens/SettingsScreen.swift | 0
.../Nifro}/Screens/SiteGalleryScreen.swift | 0
.../Nifro}/Screens/WebsitesScreen.swift | 0
.../Nifro}/Screens/WelcomeScreen.swift | 0
{Nifro => Sources/Nifro}/Sites/Playlist.swift | 0
.../Nifro}/Sites/PlaylistCopying.swift | 0
.../Nifro}/Sites/RotationBehaviour.swift | 0
.../Nifro}/Sites/SiteCatalog.generated.swift | 0
.../Nifro}/Sites/SiteCatalog.swift | 0
{Nifro => Sources/Nifro}/Sites/Website.swift | 0
.../Nifro}/Sites/WebsiteIconFetcher.swift | 0
.../Nifro}/Sites/WebsiteReport.swift | 0
.../Nifro}/Sites/WebsitesController.swift | 0
.../Nifro}/Support/AppInfo.swift | 0
.../Nifro}/Support/DiskBudget.swift | 0
.../Nifro}/Support/Display.swift | 0
.../Nifro}/Support/Extensions.swift | 0
.../Nifro}/Support/Geometry.swift | 0
.../Nifro}/Support/ImageCache.swift | 0
.../Nifro}/Support/ImageSampling.swift | 0
.../Nifro}/Support/Language.swift | 0
.../Nifro}/Support/MenuSupport.swift | 0
.../Nifro}/Support/Rotation.swift | 0
.../Nifro}/Support/RotationInterval.swift | 0
.../Nifro}/Support/RotationMode.swift | 0
.../Nifro}/Support/Schedule.swift | 0
.../Nifro}/Support/ScrollableTextView.swift | 0
.../Support/SecurityScopedBookmarks.swift | 0
.../Nifro}/Support/SystemEvents.swift | 0
.../Nifro}/Support/URLCommand.swift | 0
.../Nifro}/Support/UpdateCheck.swift | 0
.../Nifro}/Support/VideoEmbed.swift | 0
.../Nifro}/Visibility/DimWhenUnfocused.swift | 0
.../Nifro}/Visibility/MenuBarBand.swift | 0
.../Nifro}/Wallpaper/ContentRules.swift | 0
.../Nifro}/Wallpaper/DesktopWindow.swift | 0
.../Nifro}/Wallpaper/SSWebView.swift | 0
.../Nifro}/Wallpaper/ScrollRestoration.swift | 0
.../Nifro}/Wallpaper/SwapLoading.swift | 0
.../Nifro}/Wallpaper/WallpaperScene.swift | 0
.../Nifro}/Wallpaper/WebViewController.swift | 0
.../Nifro}/Zoom/CropSelection.swift | 0
.../Nifro}/Zoom/CropSelectionView.swift | 0
{Nifro => Sources/Nifro}/Zoom/PageView.swift | 0
.../Nifro}/Zoom/WebsiteSettings.swift | 0
.../ShareExtension}/Info.plist | 2 +
.../ShareExtension}/ShareController.swift | 2 +-
.../ShareExtension.entitlements | 0
.../ShareExtension}/Utilities.swift | 0
Tests/AudioControlTests.swift | 2 +-
Tests/CSSInjectionTests.swift | 2 +-
Tests/ClearWebsiteDataTests.swift | 6 +-
Tests/CropLifetimeTests.swift | 2 +-
Tests/DefaultsFallbackTests.swift | 4 +-
Tests/DisableSurvivesAQuitTests.swift | 2 +-
Tests/EditingDoesNotSwitchTests.swift | 2 +-
Tests/FirstPaintRevealTests.swift | 2 +-
Tests/GalleryInstalledStateTests.swift | 2 +-
Tests/IconComposerTests.swift | 6 +-
Tests/IconFetchCostTests.swift | 2 +-
Tests/IconFetchLifetimeTests.swift | 2 +-
Tests/InflectionTests.swift | 2 +-
Tests/LanguageDefaultTests.swift | 8 +-
Tests/LoadingIndicatorTests.swift | 12 +-
Tests/NavigationStateLifetimeTests.swift | 12 +-
Tests/NifroTests.swift | 2 +-
Tests/OrphanStringsTests.swift | 6 +-
Tests/PanelControlStateTests.swift | 2 +-
Tests/PerPageKeyTests.swift | 2 +-
Tests/PlaceholderTests.swift | 4 +-
Tests/PlaylistRulesTests.swift | 2 +-
Tests/PlaylistStorageTests.swift | 4 +-
Tests/PrivateWebKitKeysTests.swift | 2 +-
Tests/RestoreDefaultsTests.swift | 14 +-
Tests/SceneTimerLifetimeTests.swift | 2 +-
Tests/ScopeTests.swift | 2 +-
Tests/ShuffleTests.swift | 2 +-
Tests/SwitchedOffTests.swift | 2 +-
Tests/TimerToleranceTests.swift | 2 +-
Tests/UnreadSettingsTests.swift | 6 +-
Tests/WebsiteDecodingTests.swift | 8 +-
Tests/ZoomSummaryTests.swift | 2 +-
Tools/Config/App.xcconfig | 8 +
Tools/Config/Periphery.yml | 20 ++
.swiftlint.yml => Tools/Config/SwiftLint.yml | 53 +---
.../Nifro.xcodeproj}/project.pbxproj | 46 +--
.../contents.xcworkspacedata | 0
.../xcshareddata/IDEWorkspaceChecks.plist | 0
.../xcshareddata/WorkspaceSettings.xcsettings | 0
.../xcshareddata/swiftpm/Package.resolved | 0
.../xcshareddata/xcschemes/Nifro.xcscheme | 6 +-
.../xcschemes/ShareExtension.xcscheme | 8 +-
Tools/README.md | 13 +-
Tools/build-local.sh | 79 +----
Tools/check-strings.py | 2 +-
Tools/check-url-scheme.py | 41 +--
Tools/generate-site-catalog.py | 2 +-
assets/README.md | 4 +-
build.sh | 63 ++++
CHANGELOG.md => docs/CHANGELOG.md | 0
docs/PLAYLIST-REFACTOR.md | 297 +++---------------
docs/RELEASE.md | 21 +-
docs/VISUAL-SYSTEM.md | 20 +-
docs/VISUAL-SYSTEM.zh-Hans.md | 20 +-
docs/shelved/MULTI-DISPLAY-SYNC.md | 6 +-
154 files changed, 387 insertions(+), 809 deletions(-)
rename CONTRIBUTING.md => .github/CONTRIBUTING.md (86%)
delete mode 100644 .periphery.yml
delete mode 100644 Config.xcconfig
delete mode 100644 Nifro/Nifro.icon/Assets/Rectangle 2 2.svg
delete mode 100644 Nifro/Nifro.icon/icon.json
rename {Nifro => Sources/Nifro}/App/Actions.swift (100%)
rename {Nifro => Sources/Nifro}/App/App.swift (100%)
rename {Nifro => Sources/Nifro}/App/AppState.swift (100%)
rename {Nifro => Sources/Nifro}/App/BrowsingModeShortcut.swift (100%)
rename {Nifro => Sources/Nifro}/App/Constants.swift (100%)
rename {Nifro => Sources/Nifro}/App/Events.swift (100%)
rename {Nifro => Sources/Nifro}/App/Intents.swift (100%)
rename {Nifro => Sources/Nifro}/App/RestoreDefaults.swift (100%)
rename {Nifro => Sources/Nifro}/App/Shortcuts.swift (100%)
rename {Nifro => Sources/Nifro}/App/URLCommands.swift (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_128x128.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_16x16.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_256x256.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_32x32.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_512x512.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/Contents.json (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/MenuBarIcon.imageset/Contents.json (100%)
rename {Nifro => Sources/Nifro}/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg (100%)
rename {Nifro => Sources/Nifro}/Info.plist (88%)
rename {Nifro => Sources/Nifro}/InternetAccessPolicy.json (100%)
rename {Nifro => Sources/Nifro}/Localizable.xcstrings (100%)
rename {Nifro => Sources/Nifro}/Nifro.entitlements (100%)
rename {Nifro => Sources/Nifro}/Preview Content/Preview Assets.xcassets/Contents.json (100%)
rename {Nifro => Sources/Nifro}/Screens/AboutSettings.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/AddWebsiteScreen.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/DisplayPanel.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/DisplayPanelController.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/DisplayPanelModel.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/IntervalField.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/PanelControls.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/SettingHelp.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/SettingsScreen.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/SiteGalleryScreen.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/WebsitesScreen.swift (100%)
rename {Nifro => Sources/Nifro}/Screens/WelcomeScreen.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/Playlist.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/PlaylistCopying.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/RotationBehaviour.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/SiteCatalog.generated.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/SiteCatalog.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/Website.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/WebsiteIconFetcher.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/WebsiteReport.swift (100%)
rename {Nifro => Sources/Nifro}/Sites/WebsitesController.swift (100%)
rename {Nifro => Sources/Nifro}/Support/AppInfo.swift (100%)
rename {Nifro => Sources/Nifro}/Support/DiskBudget.swift (100%)
rename {Nifro => Sources/Nifro}/Support/Display.swift (100%)
rename {Nifro => Sources/Nifro}/Support/Extensions.swift (100%)
rename {Nifro => Sources/Nifro}/Support/Geometry.swift (100%)
rename {Nifro => Sources/Nifro}/Support/ImageCache.swift (100%)
rename {Nifro => Sources/Nifro}/Support/ImageSampling.swift (100%)
rename {Nifro => Sources/Nifro}/Support/Language.swift (100%)
rename {Nifro => Sources/Nifro}/Support/MenuSupport.swift (100%)
rename {Nifro => Sources/Nifro}/Support/Rotation.swift (100%)
rename {Nifro => Sources/Nifro}/Support/RotationInterval.swift (100%)
rename {Nifro => Sources/Nifro}/Support/RotationMode.swift (100%)
rename {Nifro => Sources/Nifro}/Support/Schedule.swift (100%)
rename {Nifro => Sources/Nifro}/Support/ScrollableTextView.swift (100%)
rename {Nifro => Sources/Nifro}/Support/SecurityScopedBookmarks.swift (100%)
rename {Nifro => Sources/Nifro}/Support/SystemEvents.swift (100%)
rename {Nifro => Sources/Nifro}/Support/URLCommand.swift (100%)
rename {Nifro => Sources/Nifro}/Support/UpdateCheck.swift (100%)
rename {Nifro => Sources/Nifro}/Support/VideoEmbed.swift (100%)
rename {Nifro => Sources/Nifro}/Visibility/DimWhenUnfocused.swift (100%)
rename {Nifro => Sources/Nifro}/Visibility/MenuBarBand.swift (100%)
rename {Nifro => Sources/Nifro}/Wallpaper/ContentRules.swift (100%)
rename {Nifro => Sources/Nifro}/Wallpaper/DesktopWindow.swift (100%)
rename {Nifro => Sources/Nifro}/Wallpaper/SSWebView.swift (100%)
rename {Nifro => Sources/Nifro}/Wallpaper/ScrollRestoration.swift (100%)
rename {Nifro => Sources/Nifro}/Wallpaper/SwapLoading.swift (100%)
rename {Nifro => Sources/Nifro}/Wallpaper/WallpaperScene.swift (100%)
rename {Nifro => Sources/Nifro}/Wallpaper/WebViewController.swift (100%)
rename {Nifro => Sources/Nifro}/Zoom/CropSelection.swift (100%)
rename {Nifro => Sources/Nifro}/Zoom/CropSelectionView.swift (100%)
rename {Nifro => Sources/Nifro}/Zoom/PageView.swift (100%)
rename {Nifro => Sources/Nifro}/Zoom/WebsiteSettings.swift (100%)
rename {ShareExtension => Sources/ShareExtension}/Info.plist (91%)
rename {ShareExtension => Sources/ShareExtension}/ShareController.swift (89%)
rename {ShareExtension => Sources/ShareExtension}/ShareExtension.entitlements (100%)
rename {ShareExtension => Sources/ShareExtension}/Utilities.swift (100%)
create mode 100644 Tools/Config/App.xcconfig
create mode 100644 Tools/Config/Periphery.yml
rename .swiftlint.yml => Tools/Config/SwiftLint.yml (72%)
rename {Nifro.xcodeproj => Tools/Nifro.xcodeproj}/project.pbxproj (97%)
rename {Nifro.xcodeproj => Tools/Nifro.xcodeproj}/project.xcworkspace/contents.xcworkspacedata (100%)
rename {Nifro.xcodeproj => Tools/Nifro.xcodeproj}/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (100%)
rename {Nifro.xcodeproj => Tools/Nifro.xcodeproj}/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings (100%)
rename {Nifro.xcodeproj => Tools/Nifro.xcodeproj}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved (100%)
rename {Nifro.xcodeproj => Tools/Nifro.xcodeproj}/xcshareddata/xcschemes/Nifro.xcscheme (92%)
rename {Nifro.xcodeproj => Tools/Nifro.xcodeproj}/xcshareddata/xcschemes/ShareExtension.xcscheme (92%)
create mode 100755 build.sh
rename CHANGELOG.md => docs/CHANGELOG.md (100%)
diff --git a/.editorconfig b/.editorconfig
index 741b9bd..091e83f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -6,12 +6,11 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
-# Swift is written with tabs here, which is what Xcode does by default and what SwiftLint checks.
+# Match the Swift source style.
[*.swift]
indent_style = tab
-# YAML forbids tabs outright, and adding a site entry — the contribution this project asks for most —
-# is editing a YAML file. An editor told to indent with tabs turns that into a syntax error.
+# YAML requires spaces.
[*.{yml,yaml,json}]
indent_style = space
indent_size = 2
diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md
similarity index 86%
rename from CONTRIBUTING.md
rename to .github/CONTRIBUTING.md
index 0c5251b..dcfc179 100644
--- a/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -41,17 +41,25 @@ the first one is being discussed.
## Building it
-You need Xcode 26 or later — CI fails outright below that, so a change built
-on an older one is a red run rather than a review. Nothing else is required to
-build: dependencies come in through Swift Package Manager and Xcode resolves
-them on first open.
+Install Xcode 26 or later. The build uses its resource and App Intents tools;
+Command Line Tools alone are insufficient. You do not need to open the IDE.
-```
+```sh
git clone https://github.com/PathGao/Nifro.git
-open Nifro/Nifro.xcodeproj
+cd Nifro
+./build.sh
```
-Then build and run the `Nifro` scheme.
+The app is written to `.xcode-build/Build/Products/Release/Nifro.app`. Development
+builds use `com.pathgao.nifro.dev` and `nifro-dev:` so they have separate preferences
+and sandbox storage from the installed release. They are ad-hoc signed; macOS may
+ask again for permissions after a rebuild. Nothing is installed or launched.
+
+`./build.sh ci` builds unsigned Debug code. `./build.sh release` uses the production
+identity and requires `APPLE_SIGNING_IDENTITY` and `APPLE_TEAM_ID`. For notarization
+and publishing, see [the release guide](../docs/RELEASE.md).
+Use `./build.sh --help` for options. Xcode remains available for debugging with
+the `Nifro` scheme.
- **Swift 6 language mode.** Concurrency errors are errors, not warnings. If
you hit one, the fix is almost always to move the work onto the actor that
@@ -60,7 +68,7 @@ Then build and run the `Nifro` scheme.
it. There is no back-compatibility to preserve.
- **SwiftLint** runs as a build phase, so a clean build means a clean lint.
Install it with `brew install swiftlint`; without it the build phase warns
- and continues. The rules live in `.swiftlint.yml`, which uses `only_rules`
+ and continues. The rules live in `Tools/Config/SwiftLint.yml`, which uses `only_rules`
— the enabled set is exactly what is listed there. If a rule is genuinely
wrong for your code, say so in the pull request rather than adding a
`swiftlint:disable` in silence.
@@ -82,7 +90,7 @@ falls on the description of what you ran. See below.
## Where things live
```
-Nifro/
+Sources/Nifro/
├── App/ entry point, state, events, menus, Shortcuts, URL commands
├── Wallpaper/ the window, the web view, loading, content rules, scroll restoration
├── Visibility/ the menu bar colour band, and dimming when another app is in front
@@ -160,3 +168,6 @@ What follows from that:
Contributions are made under the repository's MIT licence, the same one Nifro
inherited. There is no CLA and no bot to sign.
+
+The share extension lives in `Sources/ShareExtension/`. The Xcode project and build
+configuration live under `Tools/`; tests remain in `Tests/`.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 8e638be..aee2887 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,7 +5,7 @@
# only so `swift test` can compile the pure-logic files. The app's four real
# dependencies (Defaults, KeyboardShortcuts, LaunchAtLogin-Modern,
# swift-syntax) are resolved by the Xcode project, in
-# Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved,
+# Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved,
# which Dependabot does not parse. Adding a `swift` entry here would open no
# pull requests and quietly imply coverage that does not exist. Those four are
# bumped by hand in Xcode.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a75415c..5ea04d5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,19 +37,13 @@ jobs:
uses: actions/cache@v6
with:
path: .xcode-build/SourcePackages
- key: spm-${{ hashFiles('Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
+ key: spm-${{ hashFiles('Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
# Signing needs a certificate we deliberately do not put in CI. Release signing lives in release.yml.
- name: Build
run: |
- xcodebuild build \
- -project Nifro.xcodeproj \
- -scheme Nifro \
- -configuration Debug \
- -destination 'platform=macOS' \
- -derivedDataPath .xcode-build \
- CODE_SIGNING_ALLOWED=NO \
+ ./build.sh ci \
| xcbeautify --renderer github-actions
env:
NSUnbufferedIO: YES
@@ -102,7 +96,7 @@ jobs:
# The Xcode build runs SwiftLint too, but only after compiling. Running it separately puts style feedback on a PR in under a minute.
- name: Lint
- run: swiftlint lint --strict --reporter github-actions-logging
+ run: swiftlint lint --config Tools/Config/SwiftLint.yml --strict --reporter github-actions-logging
unused:
name: Unused code
@@ -132,11 +126,11 @@ jobs:
env:
PERIPHERY_VERSION: "2.21.2"
- # Config, retentions and the reasoning behind each one live in .periphery.yml.
+ # Config, retentions and the reasoning behind each one live in Tools/Config/Periphery.yml.
# This inherited a 5,700-line utilities file copied wholesale from another
# project; without a gate it grows back.
- name: Scan
- run: periphery scan --strict
+ run: periphery scan --config Tools/Config/Periphery.yml --strict
sites:
name: Site registry
@@ -209,7 +203,7 @@ jobs:
python3 - <<'CHECK'
import json, pathlib, sys
- catalogue = json.loads(pathlib.Path("Nifro/Localizable.xcstrings").read_text())
+ catalogue = json.loads(pathlib.Path("Sources/Nifro/Localizable.xcstrings").read_text())
missing = [
key
for key, entry in catalogue["strings"].items()
@@ -228,8 +222,8 @@ jobs:
- name: Check the generated catalogue is current
run: |
python3 Tools/generate-site-catalog.py
- if ! git diff --quiet -- sites/index.json Nifro/Sites/SiteCatalog.generated.swift; then
+ if ! git diff --quiet -- sites/index.json Sources/Nifro/Sites/SiteCatalog.generated.swift; then
echo "::error::sites/ changed without regenerating. Run Tools/generate-site-catalog.py and commit the result."
- git diff --stat -- sites/index.json Nifro/Sites/SiteCatalog.generated.swift
+ git diff --stat -- sites/index.json Sources/Nifro/Sites/SiteCatalog.generated.swift
exit 1
fi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 25601bc..ddb1671 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -28,8 +28,6 @@ on:
default: false
env:
- XCODE_PROJECT: Nifro.xcodeproj
- XCODE_SCHEME: Nifro
BUILT_APP_NAME: Nifro.app
APP_NAME: Nifro
BUNDLE_ID: com.pathgao.nifro
@@ -61,7 +59,7 @@ jobs:
major="$(xcodebuild -version | sed -n '1s/Xcode \([0-9]*\).*/\1/p')"
[ "$major" -ge 26 ] || { echo "::error::Xcode $major is too old; this project needs 26 or newer"; exit 1; }
- # `Config.xcconfig` is the only place a version is written. The tag is derived from it rather
+ # `Tools/Config/App.xcconfig` is the only place a version is written. The tag is derived from it rather
# than typed alongside it, so the two cannot disagree -- which they used to be able to, and the
# workflow's answer was to fail after being dispatched.
- name: Work out the version, and refuse to reuse one
@@ -69,7 +67,7 @@ jobs:
run: |
set -euo pipefail
- version="$(sed -n 's/^MARKETING_VERSION *= *//p' Config.xcconfig | tr -d ' \r')"
+ version="$(sed -n 's/^MARKETING_VERSION *= *//p' Tools/Config/App.xcconfig | tr -d ' \r')"
tag="v$version"
case "$version" in
@@ -82,7 +80,7 @@ jobs:
# A `v*` tag cannot be moved or deleted, so a second release of one is not something to
# recover from afterwards. Refused here, before anything is built.
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
- echo "::error::$tag already exists. Bump MARKETING_VERSION in Config.xcconfig first."
+ echo "::error::$tag already exists. Bump MARKETING_VERSION in Tools/Config/App.xcconfig first."
exit 1
fi
@@ -186,12 +184,7 @@ jobs:
rm -rf .xcode-build/Build/Products
# ONLY_ACTIVE_ARCH=NO with a single ARCHS: a thin binary holding only this architecture.
- xcodebuild clean build \
- -project "$XCODE_PROJECT" \
- -scheme "$XCODE_SCHEME" \
- -configuration Release \
- -destination "generic/platform=macOS" \
- -derivedDataPath .xcode-build \
+ APPLE_SIGNING_IDENTITY="$identity" APPLE_TEAM_ID="$TEAM_ID" ./build.sh release \
ARCHS="$arch" \
ONLY_ACTIVE_ARCH=NO \
"${signing_args[@]}" \
diff --git a/.periphery.yml b/.periphery.yml
deleted file mode 100644
index 2da3c40..0000000
--- a/.periphery.yml
+++ /dev/null
@@ -1,111 +0,0 @@
-# Unused-code scan. Run it before adding anything to `retain_*` below.
-#
-# periphery scan
-#
-# The scanner is a good signal and a bad judge. It reads the code and nothing
-# else, so anything the runtime reaches without naming — a selector, an App
-# Intent, a property whose whole job is to hold a reference — comes back as
-# unused. Deleting those compiles fine and breaks at runtime, which is the worst
-# way to find out.
-#
-# Every entry below is therefore a claim that has to be argued, not a way to
-# quiet the tool. If the reason stops being true, the entry should fail.
-
-project: Nifro.xcodeproj
-schemes:
- - Nifro
-targets:
- - Nifro
-
-# Both flags below are off, and both were on. A flag that retains a whole category is the one kind of
-# entry this file cannot make argue for itself: it has no name attached, so nothing about it expires.
-#
-# `retain_objc_accessible` was here for selector targets, and it hid far more than it protected. An
-# `NSMenu` subclass is Objective-C accessible by inheritance, so the dead `SSMenu` — thirty-two lines
-# nothing had constructed since the menu was deleted — was invisible to a scan that reported nothing
-# and therefore read as though it had been checked. The selector targets turn out not to need the
-# flag: `#selector(ActionTrampoline.handleAction)` and the others are references the index already
-# follows, and with the flag off the scan is still clean. An `@objc` member reached only from a nib or
-# `Info.plist` would now be reported — that is a finding to argue here, not a reason to switch this
-# back on.
-retain_objc_accessible: false
-
-# `retain_public` was on with nothing to retain. This is an app target and has no public API: the three
-# `public` declarations in `Nifro/` are all witnesses to public protocol requirements (`Identifiable.id`
-# twice, `ExpressibleByStringLiteral.init`) and are retained by the conformance, not by this. It hid a
-# category of finding against a case that does not arise.
-#
-# There were four, and the fourth is why the count is written out. `Error.isCancelled` was `public`
-# witnessing nothing — `Error` has no such requirement — on a helper no test target can even see, and it
-# read as one of the three for as long as nobody counted. It is `internal` now. The sentence above is an
-# argument only while its number is checked against the code.
-retain_public: false
-
-report_exclude:
- # App Intents. The system finds and runs these from bundle metadata; no Swift code names them, so
- # deleting one silently removes a Shortcuts action.
- #
- # Still earning it: with this line removed the scan reports fourteen findings, all fourteen in this
- # file and all fourteen framework surface — eleven `AppIntent` structs, the two `@Property` fields
- # of `WebsiteAppEntity` that only Shortcuts reads, and `findIntentDescription`, which
- # `EnumerableEntityQuery` requires. A whole file rather than fourteen names because the file is
- # nothing but that surface. The cost is real and worth writing down: an ordinary helper added to
- # this file would be excluded too.
- - "**/Intents.swift"
-
-# Named retentions, each with the reason it is not really dead.
-retain_files: []
-
-# Nothing is retained by name right now. The one entry that used to be here was
-# `Zoom.init(center:scale:)`, kept because the SwiftPM test target uses it and this scan — configured
-# against the Xcode project — cannot see that target. It is gone because the initializer is gone: once
-# the rectangle-based one was deleted, the memberwise initializer is synthesized and writing it out
-# was the thing SwiftLint objected to. The reason expired with the code, which is the point of making
-# every entry argue for itself.
-#
-# Everything is reported by nothing, and the scan runs with `--strict`, so anything new fails CI.
-#
-# What this scan cannot see, so a green run is not read as more than it is. An extension with an empty
-# body has no declaration to call unused; `empty_extension` in `.swiftlint.yml` is the check for those.
-# A `Defaults` key that is written and never read is a use as far as any symbol graph is concerned.
-# `Localizable.xcstrings` is not Swift and is outside this tool's world entirely.
-#
-# Four inline `periphery:ignore`s, and all four are recorded here because a retention nobody can find
-# is the same failure as a retention nobody argued.
-#
-# The second is in `App.swift`, on `AppMain.appState`. It is a `@StateObject` nothing reads, and that
-# is the whole of what it does: the three screens were handed it through `environmentObject` and none
-# of them ever asked, so those injections are gone and this is what still names `AppState.shared`
-# inside the SwiftUI graph. `AppState.init` schedules `didLaunch`, so deleting it hands the moment the
-# singleton is first built to whichever app-delegate callback happens to touch it first. The scan is
-# right about what it can see — nothing reads the property — and wrong about what the property is for,
-# which is being the thing that constructs it, at a time the launch sequence depends on.
-#
-# The first is in `Extensions.swift`, on the `KeyedDecodingContainer.decode`
-# overload that `@DecodableDefault` is resolved through. Its only caller is a synthesised
-# `init(from:)`, which no index attributes to it — so the scan is not wrong about what it can see, and
-# the annotation sits at the declaration rather than here because the reason is about that code. That
-# overload was already deleted once for looking unreferenced, and the cost was every user's website
-# list on the next field added to `Website` — which is where the rule above comes from.
-#
-# The third and fourth are in `WebsitesScreen.swift`, on `RowView`'s `disabledDisplays` and `appState`.
-# They are the same case as `AppMain.appState` above, in two property wrappers: `@Default` and
-# `@ObservedObject` are both `DynamicProperty`, so what subscribes the row is SwiftUI calling
-# `update()` on the declaration, not any code reading the value. The row asks
-# `WebsitesController.isShowing(_:)` for the answer — one derivation, which is the rule both exist to
-# keep — and reads neither, so the scan is right that nothing names them and wrong about what they are
-# for.
-#
-# Two rather than three: `currentWebsites` was here and is gone, because the tick stopped reading that
-# key. `AppState` publishes when `rebuildScenes` has moved the scenes, which is the moment the answer
-# changes; the key changes a turn earlier. There was also a `_ = currentWebsites` in the row once, with
-# a comment saying the read was what subscribed. It was not, so the line bought nothing but this
-# entry's absence.
-#
-# The three findings that used to be listed here were parameters that `#selector` signatures require
-# and nothing reads. They are written `_` now, which is what they always were — the scan was right,
-# and the honest fix was smaller than the paragraph explaining why it should be ignored.
-#
-# When something does get reported, argue it here or delete it. An entry for a finding the scan no
-# longer reports is a justification that has outlived its reason, which is the thing this file exists
-# to prevent.
diff --git a/Config.xcconfig b/Config.xcconfig
deleted file mode 100644
index 5e5f01d..0000000
--- a/Config.xcconfig
+++ /dev/null
@@ -1,8 +0,0 @@
-MARKETING_VERSION = 0.9.1
-CURRENT_PROJECT_VERSION = 1
-
-// Xcode adds `get-task-allow` to whatever the .entitlements file says, which lets any
-// debugger attach to the running app. That is what you want while developing and a hole
-// in anything shipped, so it stops at Release. Apple's notary service rejects a build
-// that carries it, which is the check that would otherwise find this at release time.
-CODE_SIGN_INJECT_BASE_ENTITLEMENTS[config=Release] = NO
diff --git a/Nifro/Nifro.icon/Assets/Rectangle 2 2.svg b/Nifro/Nifro.icon/Assets/Rectangle 2 2.svg
deleted file mode 100644
index a98d8ed..0000000
--- a/Nifro/Nifro.icon/Assets/Rectangle 2 2.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/Nifro/Nifro.icon/icon.json b/Nifro/Nifro.icon/icon.json
deleted file mode 100644
index 7264cf3..0000000
--- a/Nifro/Nifro.icon/icon.json
+++ /dev/null
@@ -1,56 +0,0 @@
-{
- "fill" : {
- "solid" : "display-p3:0.86961,0.89959,0.83962,1.00000"
- },
- "groups" : [
- {
- "layers" : [
- {
- "blend-mode" : "normal",
- "fill" : {
- "automatic-gradient" : "display-p3:0.92551,0.70193,0.24484,1.00000"
- },
- "glass" : true,
- "hidden" : false,
- "image-name" : "Rectangle 2 2.svg",
- "name" : "Rectangle 2 2",
- "position" : {
- "scale" : 14,
- "translation-in-points" : [
- -154,
- -60
- ]
- }
- },
- {
- "fill" : {
- "automatic-gradient" : "display-p3:0.43164,0.73389,0.95361,1.00000"
- },
- "image-name" : "Rectangle 2 2.svg",
- "name" : "Rectangle 2 2",
- "position" : {
- "scale" : 14,
- "translation-in-points" : [
- 168,
- 66
- ]
- }
- }
- ],
- "shadow" : {
- "kind" : "neutral",
- "opacity" : 0.5
- },
- "translucency" : {
- "enabled" : true,
- "value" : 0.3
- }
- }
- ],
- "supported-platforms" : {
- "circles" : [
- "watchOS"
- ],
- "squares" : "shared"
- }
-}
diff --git a/Package.swift b/Package.swift
index d87efaf..682413f 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,23 +1,6 @@
// swift-tools-version: 6.0
-// This is not how the app is built — the app is the Xcode project next to this file.
-//
-// It exists so the pure logic can be exercised by `swift test` without an Xcode test target, an app
-// bundle or a window server: the crop and zoom geometry, the menu bar strip, schedule windows, which
-// website is current on which display and how often it changes, video embedding, URL commands, the
-// disk budget, the update check and the decoding of the site catalogue. It reads the same source files the app
-// compiles, so there is one implementation and not a copy.
-//
-// Which is the whole point of the `Sites` files below. `sites/index.json` is published and fetched at
-// runtime, and it had never once decoded, because nothing in the repository read it through the type
-// that reads it in the app. A test can only catch that by running the app's own `SiteCatalog.Entry`
-// against the committed file, so `SiteCatalog` is kept free of `Website`, `Defaults` and SwiftUI in
-// order to be listed here.
-//
-// `PlaylistCopying` is listed for a different reason. Duplicating a playlist has to hand back new
-// members rather than the ones it was given, and a copy that gets that wrong compiles, draws the
-// right rows, and then edits both lists at once. Keeping `withFreshIDs` free of `Website` and
-// `Defaults` is what lets that be run here at all.
+// Tests compile the app’s pure logic directly, without an app bundle or window server.
import PackageDescription
@@ -27,7 +10,7 @@ let package = Package(
targets: [
.target(
name: "NifroLogic",
- path: "Nifro",
+ path: "Sources/Nifro",
sources: ["Sites/SiteCatalog.swift", "Sites/SiteCatalog.generated.swift", "Sites/PlaylistCopying.swift", "Support/ImageSampling.swift", "Support/DiskBudget.swift", "Support/Geometry.swift", "Support/UpdateCheck.swift", "Support/Rotation.swift", "Support/RotationInterval.swift", "Support/Schedule.swift", "Support/URLCommand.swift", "Support/VideoEmbed.swift"]
),
.testTarget(
diff --git a/README.md b/README.md
index 3ba1f67..bf35020 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@
·
Roadmap
·
- Contributing
+ Contributing
·
简体中文
@@ -155,26 +155,25 @@ at them.
- macOS 15 or later
- Xcode 26 or later
-### Open and run in Xcode
+### Build a local development app
```sh
git clone https://github.com/PathGao/Nifro.git
cd Nifro
-open Nifro.xcodeproj
+./build.sh
```
-In Xcode, select the `Nifro` scheme and **My Mac**, then press **Run** (⌘R).
+Full Xcode is required for icon compilation and Shortcuts metadata, but the IDE
+can stay closed. The app is written to
+`.xcode-build/Build/Products/Release/Nifro.app` with an ad-hoc signature and sandbox
+entitlements. Its `com.pathgao.nifro.dev` identity and `nifro-dev:` URL scheme keep
+development data separate from the installed release. The script does not install,
+launch, or modify keychains.
-### Build a local test copy
-
-```sh
-./Tools/build-local.sh
-```
-
-The script creates the local signing identity when needed, builds with the app's sandbox entitlement,
-and installs `Nifro-test.app` on the Desktop. Use it instead of re-signing an Xcode build by hand:
-re-signing an already signed bundle can remove its entitlements and make it use a different preferences
-container from the released app.
+`./build.sh ci` builds unsigned Debug code. `./build.sh release` builds with the
+production identity and an existing Developer ID certificate. See
+[the release guide](docs/RELEASE.md) for signing and notarization.
+For interactive debugging, open `Tools/Nifro.xcodeproj` and use the `Nifro` scheme.
### Run tests
@@ -186,9 +185,16 @@ The suite covers both app behaviour and project guardrails: display-specific sta
crop and zoom behaviour, URL handling, settings compatibility, and source-level rules that types alone
cannot enforce.
+## Repository layout
+
+`Sources/` contains the app and share extension, `Tests/` contains tests, and
+`Tools/` contains the Xcode project, build configuration, and maintenance scripts.
+`assets/` holds design assets, `docs/` holds design and release notes, `sites/`
+holds the published site catalog, and `Casks/` is the Homebrew entry point.
+
## Contributing
-Start with [CONTRIBUTING.md](CONTRIBUTING.md). The lowest-effort useful contribution is a site
+Start with [CONTRIBUTING.md](.github/CONTRIBUTING.md). The lowest-effort useful contribution is a site
entry. If you have a page that works well as a wallpaper, that is worth more to this project than
most code.
diff --git a/README.zh-Hans.md b/README.zh-Hans.md
index 233f070..878f4ef 100644
--- a/README.zh-Hans.md
+++ b/README.zh-Hans.md
@@ -31,7 +31,7 @@
·
路线图
·
- 参与贡献
+ 参与贡献
·
English
@@ -147,25 +147,22 @@ Nifro 最适合那些可以一直留在屏幕上、偶尔瞥一眼又确实有
- macOS 15 或更新版本
- Xcode 26 或更高版本
-### 在 Xcode 中运行
+### 构建本地开发版
```sh
git clone https://github.com/PathGao/Nifro.git
cd Nifro
-open Nifro.xcodeproj
+./build.sh
```
-在 Xcode 中选择 `Nifro` scheme 和**我的 Mac**,然后按 **Run**(⌘R)。
+图标编译和快捷指令元数据生成需要完整 Xcode,但构建时不用打开 Xcode 界面。
+产物位于 `.xcode-build/Build/Products/Release/Nifro.app`,使用临时签名并保留沙盒权限。
+开发版的应用标识为 `com.pathgao.nifro.dev`,URL 协议为 `nifro-dev:`,数据与已安装的正式版隔离。
+脚本不会安装、启动应用或修改钥匙串。
-### 构建本地测试包
-
-```sh
-./Tools/build-local.sh
-```
-
-脚本会在需要时创建本地签名身份,带着 App 的沙盒 entitlement 构建,并把 `Nifro-test.app` 安装到桌面。
-请用它,不要手动对 Xcode 构建产物重新签名:重新签名可能会删掉 entitlement,让它使用与正式版不同的
-偏好设置容器。
+`./build.sh ci` 构建不签名的 Debug 版本。`./build.sh release` 使用正式版标识和已有的
+Developer ID 证书构建,签名及公证步骤见[发布说明](docs/RELEASE.md)。
+需要交互调试时,仍可打开 `Tools/Nifro.xcodeproj`,使用 `Nifro` scheme。
### 运行测试
@@ -176,9 +173,14 @@ swift test
测试既覆盖 App 行为,也覆盖项目护栏:每显示器的状态、播放列表迁移、裁切和缩放、URL 处理、设置兼容性,
以及类型系统本身守不住的源码级规则。
+## 仓库目录
+
+`Sources/` 放主应用和分享扩展,`Tests/` 放测试,`Tools/` 放 Xcode 工程、构建配置和维护脚本。
+`assets/` 放设计资源,`docs/` 放设计与发布文档,`sites/` 放线上站点目录,`Casks/` 是 Homebrew 入口。
+
## 参与贡献
-先看 [CONTRIBUTING.md](CONTRIBUTING.md)。最省力又最有用的贡献是加一条站点。如果你有一个当壁纸
+先看 [CONTRIBUTING.md](.github/CONTRIBUTING.md)。最省力又最有用的贡献是加一条站点。如果你有一个当壁纸
很好看的页面,那对这个项目的价值超过大部分代码。
提交信息、PR 和 issue 请用英文——不是因为英文更好,而是因为它是所有读这个仓库的人唯一共有的
diff --git a/Nifro/App/Actions.swift b/Sources/Nifro/App/Actions.swift
similarity index 100%
rename from Nifro/App/Actions.swift
rename to Sources/Nifro/App/Actions.swift
diff --git a/Nifro/App/App.swift b/Sources/Nifro/App/App.swift
similarity index 100%
rename from Nifro/App/App.swift
rename to Sources/Nifro/App/App.swift
diff --git a/Nifro/App/AppState.swift b/Sources/Nifro/App/AppState.swift
similarity index 100%
rename from Nifro/App/AppState.swift
rename to Sources/Nifro/App/AppState.swift
diff --git a/Nifro/App/BrowsingModeShortcut.swift b/Sources/Nifro/App/BrowsingModeShortcut.swift
similarity index 100%
rename from Nifro/App/BrowsingModeShortcut.swift
rename to Sources/Nifro/App/BrowsingModeShortcut.swift
diff --git a/Nifro/App/Constants.swift b/Sources/Nifro/App/Constants.swift
similarity index 100%
rename from Nifro/App/Constants.swift
rename to Sources/Nifro/App/Constants.swift
diff --git a/Nifro/App/Events.swift b/Sources/Nifro/App/Events.swift
similarity index 100%
rename from Nifro/App/Events.swift
rename to Sources/Nifro/App/Events.swift
diff --git a/Nifro/App/Intents.swift b/Sources/Nifro/App/Intents.swift
similarity index 100%
rename from Nifro/App/Intents.swift
rename to Sources/Nifro/App/Intents.swift
diff --git a/Nifro/App/RestoreDefaults.swift b/Sources/Nifro/App/RestoreDefaults.swift
similarity index 100%
rename from Nifro/App/RestoreDefaults.swift
rename to Sources/Nifro/App/RestoreDefaults.swift
diff --git a/Nifro/App/Shortcuts.swift b/Sources/Nifro/App/Shortcuts.swift
similarity index 100%
rename from Nifro/App/Shortcuts.swift
rename to Sources/Nifro/App/Shortcuts.swift
diff --git a/Nifro/App/URLCommands.swift b/Sources/Nifro/App/URLCommands.swift
similarity index 100%
rename from Nifro/App/URLCommands.swift
rename to Sources/Nifro/App/URLCommands.swift
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/Contents.json
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/Contents.json
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/Contents.json
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
diff --git a/Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
similarity index 100%
rename from Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
rename to Sources/Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
diff --git a/Nifro/Assets.xcassets/Contents.json b/Sources/Nifro/Assets.xcassets/Contents.json
similarity index 100%
rename from Nifro/Assets.xcassets/Contents.json
rename to Sources/Nifro/Assets.xcassets/Contents.json
diff --git a/Nifro/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/Sources/Nifro/Assets.xcassets/MenuBarIcon.imageset/Contents.json
similarity index 100%
rename from Nifro/Assets.xcassets/MenuBarIcon.imageset/Contents.json
rename to Sources/Nifro/Assets.xcassets/MenuBarIcon.imageset/Contents.json
diff --git a/Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg b/Sources/Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg
similarity index 100%
rename from Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg
rename to Sources/Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg
diff --git a/Nifro/Info.plist b/Sources/Nifro/Info.plist
similarity index 88%
rename from Nifro/Info.plist
rename to Sources/Nifro/Info.plist
index 890776f..e11fe48 100644
--- a/Nifro/Info.plist
+++ b/Sources/Nifro/Info.plist
@@ -8,10 +8,10 @@
CFBundleTypeRoleEditorCFBundleURLName
- com.pathgao.nifro
+ $(NIFRO_BUNDLE_ID)CFBundleURLSchemes
- nifro
+ $(NIFRO_URL_SCHEME)
diff --git a/Nifro/InternetAccessPolicy.json b/Sources/Nifro/InternetAccessPolicy.json
similarity index 100%
rename from Nifro/InternetAccessPolicy.json
rename to Sources/Nifro/InternetAccessPolicy.json
diff --git a/Nifro/Localizable.xcstrings b/Sources/Nifro/Localizable.xcstrings
similarity index 100%
rename from Nifro/Localizable.xcstrings
rename to Sources/Nifro/Localizable.xcstrings
diff --git a/Nifro/Nifro.entitlements b/Sources/Nifro/Nifro.entitlements
similarity index 100%
rename from Nifro/Nifro.entitlements
rename to Sources/Nifro/Nifro.entitlements
diff --git a/Nifro/Preview Content/Preview Assets.xcassets/Contents.json b/Sources/Nifro/Preview Content/Preview Assets.xcassets/Contents.json
similarity index 100%
rename from Nifro/Preview Content/Preview Assets.xcassets/Contents.json
rename to Sources/Nifro/Preview Content/Preview Assets.xcassets/Contents.json
diff --git a/Nifro/Screens/AboutSettings.swift b/Sources/Nifro/Screens/AboutSettings.swift
similarity index 100%
rename from Nifro/Screens/AboutSettings.swift
rename to Sources/Nifro/Screens/AboutSettings.swift
diff --git a/Nifro/Screens/AddWebsiteScreen.swift b/Sources/Nifro/Screens/AddWebsiteScreen.swift
similarity index 100%
rename from Nifro/Screens/AddWebsiteScreen.swift
rename to Sources/Nifro/Screens/AddWebsiteScreen.swift
diff --git a/Nifro/Screens/DisplayPanel.swift b/Sources/Nifro/Screens/DisplayPanel.swift
similarity index 100%
rename from Nifro/Screens/DisplayPanel.swift
rename to Sources/Nifro/Screens/DisplayPanel.swift
diff --git a/Nifro/Screens/DisplayPanelController.swift b/Sources/Nifro/Screens/DisplayPanelController.swift
similarity index 100%
rename from Nifro/Screens/DisplayPanelController.swift
rename to Sources/Nifro/Screens/DisplayPanelController.swift
diff --git a/Nifro/Screens/DisplayPanelModel.swift b/Sources/Nifro/Screens/DisplayPanelModel.swift
similarity index 100%
rename from Nifro/Screens/DisplayPanelModel.swift
rename to Sources/Nifro/Screens/DisplayPanelModel.swift
diff --git a/Nifro/Screens/IntervalField.swift b/Sources/Nifro/Screens/IntervalField.swift
similarity index 100%
rename from Nifro/Screens/IntervalField.swift
rename to Sources/Nifro/Screens/IntervalField.swift
diff --git a/Nifro/Screens/PanelControls.swift b/Sources/Nifro/Screens/PanelControls.swift
similarity index 100%
rename from Nifro/Screens/PanelControls.swift
rename to Sources/Nifro/Screens/PanelControls.swift
diff --git a/Nifro/Screens/SettingHelp.swift b/Sources/Nifro/Screens/SettingHelp.swift
similarity index 100%
rename from Nifro/Screens/SettingHelp.swift
rename to Sources/Nifro/Screens/SettingHelp.swift
diff --git a/Nifro/Screens/SettingsScreen.swift b/Sources/Nifro/Screens/SettingsScreen.swift
similarity index 100%
rename from Nifro/Screens/SettingsScreen.swift
rename to Sources/Nifro/Screens/SettingsScreen.swift
diff --git a/Nifro/Screens/SiteGalleryScreen.swift b/Sources/Nifro/Screens/SiteGalleryScreen.swift
similarity index 100%
rename from Nifro/Screens/SiteGalleryScreen.swift
rename to Sources/Nifro/Screens/SiteGalleryScreen.swift
diff --git a/Nifro/Screens/WebsitesScreen.swift b/Sources/Nifro/Screens/WebsitesScreen.swift
similarity index 100%
rename from Nifro/Screens/WebsitesScreen.swift
rename to Sources/Nifro/Screens/WebsitesScreen.swift
diff --git a/Nifro/Screens/WelcomeScreen.swift b/Sources/Nifro/Screens/WelcomeScreen.swift
similarity index 100%
rename from Nifro/Screens/WelcomeScreen.swift
rename to Sources/Nifro/Screens/WelcomeScreen.swift
diff --git a/Nifro/Sites/Playlist.swift b/Sources/Nifro/Sites/Playlist.swift
similarity index 100%
rename from Nifro/Sites/Playlist.swift
rename to Sources/Nifro/Sites/Playlist.swift
diff --git a/Nifro/Sites/PlaylistCopying.swift b/Sources/Nifro/Sites/PlaylistCopying.swift
similarity index 100%
rename from Nifro/Sites/PlaylistCopying.swift
rename to Sources/Nifro/Sites/PlaylistCopying.swift
diff --git a/Nifro/Sites/RotationBehaviour.swift b/Sources/Nifro/Sites/RotationBehaviour.swift
similarity index 100%
rename from Nifro/Sites/RotationBehaviour.swift
rename to Sources/Nifro/Sites/RotationBehaviour.swift
diff --git a/Nifro/Sites/SiteCatalog.generated.swift b/Sources/Nifro/Sites/SiteCatalog.generated.swift
similarity index 100%
rename from Nifro/Sites/SiteCatalog.generated.swift
rename to Sources/Nifro/Sites/SiteCatalog.generated.swift
diff --git a/Nifro/Sites/SiteCatalog.swift b/Sources/Nifro/Sites/SiteCatalog.swift
similarity index 100%
rename from Nifro/Sites/SiteCatalog.swift
rename to Sources/Nifro/Sites/SiteCatalog.swift
diff --git a/Nifro/Sites/Website.swift b/Sources/Nifro/Sites/Website.swift
similarity index 100%
rename from Nifro/Sites/Website.swift
rename to Sources/Nifro/Sites/Website.swift
diff --git a/Nifro/Sites/WebsiteIconFetcher.swift b/Sources/Nifro/Sites/WebsiteIconFetcher.swift
similarity index 100%
rename from Nifro/Sites/WebsiteIconFetcher.swift
rename to Sources/Nifro/Sites/WebsiteIconFetcher.swift
diff --git a/Nifro/Sites/WebsiteReport.swift b/Sources/Nifro/Sites/WebsiteReport.swift
similarity index 100%
rename from Nifro/Sites/WebsiteReport.swift
rename to Sources/Nifro/Sites/WebsiteReport.swift
diff --git a/Nifro/Sites/WebsitesController.swift b/Sources/Nifro/Sites/WebsitesController.swift
similarity index 100%
rename from Nifro/Sites/WebsitesController.swift
rename to Sources/Nifro/Sites/WebsitesController.swift
diff --git a/Nifro/Support/AppInfo.swift b/Sources/Nifro/Support/AppInfo.swift
similarity index 100%
rename from Nifro/Support/AppInfo.swift
rename to Sources/Nifro/Support/AppInfo.swift
diff --git a/Nifro/Support/DiskBudget.swift b/Sources/Nifro/Support/DiskBudget.swift
similarity index 100%
rename from Nifro/Support/DiskBudget.swift
rename to Sources/Nifro/Support/DiskBudget.swift
diff --git a/Nifro/Support/Display.swift b/Sources/Nifro/Support/Display.swift
similarity index 100%
rename from Nifro/Support/Display.swift
rename to Sources/Nifro/Support/Display.swift
diff --git a/Nifro/Support/Extensions.swift b/Sources/Nifro/Support/Extensions.swift
similarity index 100%
rename from Nifro/Support/Extensions.swift
rename to Sources/Nifro/Support/Extensions.swift
diff --git a/Nifro/Support/Geometry.swift b/Sources/Nifro/Support/Geometry.swift
similarity index 100%
rename from Nifro/Support/Geometry.swift
rename to Sources/Nifro/Support/Geometry.swift
diff --git a/Nifro/Support/ImageCache.swift b/Sources/Nifro/Support/ImageCache.swift
similarity index 100%
rename from Nifro/Support/ImageCache.swift
rename to Sources/Nifro/Support/ImageCache.swift
diff --git a/Nifro/Support/ImageSampling.swift b/Sources/Nifro/Support/ImageSampling.swift
similarity index 100%
rename from Nifro/Support/ImageSampling.swift
rename to Sources/Nifro/Support/ImageSampling.swift
diff --git a/Nifro/Support/Language.swift b/Sources/Nifro/Support/Language.swift
similarity index 100%
rename from Nifro/Support/Language.swift
rename to Sources/Nifro/Support/Language.swift
diff --git a/Nifro/Support/MenuSupport.swift b/Sources/Nifro/Support/MenuSupport.swift
similarity index 100%
rename from Nifro/Support/MenuSupport.swift
rename to Sources/Nifro/Support/MenuSupport.swift
diff --git a/Nifro/Support/Rotation.swift b/Sources/Nifro/Support/Rotation.swift
similarity index 100%
rename from Nifro/Support/Rotation.swift
rename to Sources/Nifro/Support/Rotation.swift
diff --git a/Nifro/Support/RotationInterval.swift b/Sources/Nifro/Support/RotationInterval.swift
similarity index 100%
rename from Nifro/Support/RotationInterval.swift
rename to Sources/Nifro/Support/RotationInterval.swift
diff --git a/Nifro/Support/RotationMode.swift b/Sources/Nifro/Support/RotationMode.swift
similarity index 100%
rename from Nifro/Support/RotationMode.swift
rename to Sources/Nifro/Support/RotationMode.swift
diff --git a/Nifro/Support/Schedule.swift b/Sources/Nifro/Support/Schedule.swift
similarity index 100%
rename from Nifro/Support/Schedule.swift
rename to Sources/Nifro/Support/Schedule.swift
diff --git a/Nifro/Support/ScrollableTextView.swift b/Sources/Nifro/Support/ScrollableTextView.swift
similarity index 100%
rename from Nifro/Support/ScrollableTextView.swift
rename to Sources/Nifro/Support/ScrollableTextView.swift
diff --git a/Nifro/Support/SecurityScopedBookmarks.swift b/Sources/Nifro/Support/SecurityScopedBookmarks.swift
similarity index 100%
rename from Nifro/Support/SecurityScopedBookmarks.swift
rename to Sources/Nifro/Support/SecurityScopedBookmarks.swift
diff --git a/Nifro/Support/SystemEvents.swift b/Sources/Nifro/Support/SystemEvents.swift
similarity index 100%
rename from Nifro/Support/SystemEvents.swift
rename to Sources/Nifro/Support/SystemEvents.swift
diff --git a/Nifro/Support/URLCommand.swift b/Sources/Nifro/Support/URLCommand.swift
similarity index 100%
rename from Nifro/Support/URLCommand.swift
rename to Sources/Nifro/Support/URLCommand.swift
diff --git a/Nifro/Support/UpdateCheck.swift b/Sources/Nifro/Support/UpdateCheck.swift
similarity index 100%
rename from Nifro/Support/UpdateCheck.swift
rename to Sources/Nifro/Support/UpdateCheck.swift
diff --git a/Nifro/Support/VideoEmbed.swift b/Sources/Nifro/Support/VideoEmbed.swift
similarity index 100%
rename from Nifro/Support/VideoEmbed.swift
rename to Sources/Nifro/Support/VideoEmbed.swift
diff --git a/Nifro/Visibility/DimWhenUnfocused.swift b/Sources/Nifro/Visibility/DimWhenUnfocused.swift
similarity index 100%
rename from Nifro/Visibility/DimWhenUnfocused.swift
rename to Sources/Nifro/Visibility/DimWhenUnfocused.swift
diff --git a/Nifro/Visibility/MenuBarBand.swift b/Sources/Nifro/Visibility/MenuBarBand.swift
similarity index 100%
rename from Nifro/Visibility/MenuBarBand.swift
rename to Sources/Nifro/Visibility/MenuBarBand.swift
diff --git a/Nifro/Wallpaper/ContentRules.swift b/Sources/Nifro/Wallpaper/ContentRules.swift
similarity index 100%
rename from Nifro/Wallpaper/ContentRules.swift
rename to Sources/Nifro/Wallpaper/ContentRules.swift
diff --git a/Nifro/Wallpaper/DesktopWindow.swift b/Sources/Nifro/Wallpaper/DesktopWindow.swift
similarity index 100%
rename from Nifro/Wallpaper/DesktopWindow.swift
rename to Sources/Nifro/Wallpaper/DesktopWindow.swift
diff --git a/Nifro/Wallpaper/SSWebView.swift b/Sources/Nifro/Wallpaper/SSWebView.swift
similarity index 100%
rename from Nifro/Wallpaper/SSWebView.swift
rename to Sources/Nifro/Wallpaper/SSWebView.swift
diff --git a/Nifro/Wallpaper/ScrollRestoration.swift b/Sources/Nifro/Wallpaper/ScrollRestoration.swift
similarity index 100%
rename from Nifro/Wallpaper/ScrollRestoration.swift
rename to Sources/Nifro/Wallpaper/ScrollRestoration.swift
diff --git a/Nifro/Wallpaper/SwapLoading.swift b/Sources/Nifro/Wallpaper/SwapLoading.swift
similarity index 100%
rename from Nifro/Wallpaper/SwapLoading.swift
rename to Sources/Nifro/Wallpaper/SwapLoading.swift
diff --git a/Nifro/Wallpaper/WallpaperScene.swift b/Sources/Nifro/Wallpaper/WallpaperScene.swift
similarity index 100%
rename from Nifro/Wallpaper/WallpaperScene.swift
rename to Sources/Nifro/Wallpaper/WallpaperScene.swift
diff --git a/Nifro/Wallpaper/WebViewController.swift b/Sources/Nifro/Wallpaper/WebViewController.swift
similarity index 100%
rename from Nifro/Wallpaper/WebViewController.swift
rename to Sources/Nifro/Wallpaper/WebViewController.swift
diff --git a/Nifro/Zoom/CropSelection.swift b/Sources/Nifro/Zoom/CropSelection.swift
similarity index 100%
rename from Nifro/Zoom/CropSelection.swift
rename to Sources/Nifro/Zoom/CropSelection.swift
diff --git a/Nifro/Zoom/CropSelectionView.swift b/Sources/Nifro/Zoom/CropSelectionView.swift
similarity index 100%
rename from Nifro/Zoom/CropSelectionView.swift
rename to Sources/Nifro/Zoom/CropSelectionView.swift
diff --git a/Nifro/Zoom/PageView.swift b/Sources/Nifro/Zoom/PageView.swift
similarity index 100%
rename from Nifro/Zoom/PageView.swift
rename to Sources/Nifro/Zoom/PageView.swift
diff --git a/Nifro/Zoom/WebsiteSettings.swift b/Sources/Nifro/Zoom/WebsiteSettings.swift
similarity index 100%
rename from Nifro/Zoom/WebsiteSettings.swift
rename to Sources/Nifro/Zoom/WebsiteSettings.swift
diff --git a/ShareExtension/Info.plist b/Sources/ShareExtension/Info.plist
similarity index 91%
rename from ShareExtension/Info.plist
rename to Sources/ShareExtension/Info.plist
index c7496b0..ff72237 100644
--- a/ShareExtension/Info.plist
+++ b/Sources/ShareExtension/Info.plist
@@ -2,6 +2,8 @@
+ NifroURLScheme
+ $(NIFRO_URL_SCHEME)NSExtensionNSExtensionAttributes
diff --git a/ShareExtension/ShareController.swift b/Sources/ShareExtension/ShareController.swift
similarity index 89%
rename from ShareExtension/ShareController.swift
rename to Sources/ShareExtension/ShareController.swift
index 0aa52a9..9377789 100644
--- a/ShareExtension/ShareController.swift
+++ b/Sources/ShareExtension/ShareController.swift
@@ -12,7 +12,7 @@ final class ShareController: ExtensionController {
var components = URLComponents()
// This target cannot read the app's Info.plist, so the scheme is repeated here. It is the one
// copy, and Tools/check-url-scheme.py fails the build if it drifts from the declaration.
- components.scheme = "nifro"
+ components.scheme = Bundle.main.object(forInfoDictionaryKey: "NifroURLScheme") as? String
components.path = "add"
components.queryItems = [
diff --git a/ShareExtension/ShareExtension.entitlements b/Sources/ShareExtension/ShareExtension.entitlements
similarity index 100%
rename from ShareExtension/ShareExtension.entitlements
rename to Sources/ShareExtension/ShareExtension.entitlements
diff --git a/ShareExtension/Utilities.swift b/Sources/ShareExtension/Utilities.swift
similarity index 100%
rename from ShareExtension/Utilities.swift
rename to Sources/ShareExtension/Utilities.swift
diff --git a/Tests/AudioControlTests.swift b/Tests/AudioControlTests.swift
index 8d19a4f..7b6af94 100644
--- a/Tests/AudioControlTests.swift
+++ b/Tests/AudioControlTests.swift
@@ -39,7 +39,7 @@ struct AudioControlTests {
let line = try Regex("//[^\\n]*")
return try String(
- contentsOf: root.appending(path: "Nifro/Support/Extensions.swift"),
+ contentsOf: root.appending(path: "Sources/Nifro/Support/Extensions.swift"),
encoding: .utf8
)
.replacing(block, with: "")
diff --git a/Tests/CSSInjectionTests.swift b/Tests/CSSInjectionTests.swift
index a02b9c0..4b1b23b 100644
--- a/Tests/CSSInjectionTests.swift
+++ b/Tests/CSSInjectionTests.swift
@@ -26,7 +26,7 @@ struct CSSInjectionTests {
.deletingLastPathComponent()
private static func extensions() throws -> String {
- try String(contentsOf: root.appending(path: "Nifro/Support/Extensions.swift"), encoding: .utf8)
+ try String(contentsOf: root.appending(path: "Sources/Nifro/Support/Extensions.swift"), encoding: .utf8)
}
/**
diff --git a/Tests/ClearWebsiteDataTests.swift b/Tests/ClearWebsiteDataTests.swift
index 8582c81..a824c7f 100644
--- a/Tests/ClearWebsiteDataTests.swift
+++ b/Tests/ClearWebsiteDataTests.swift
@@ -82,7 +82,7 @@ struct ClearWebsiteDataTests {
private static func keysNamingContent() throws -> [String] {
let pattern = try Regex("Key<\\[String: (?:Website|Playlist)\\.ID\\]>\\(\"(\\w+)\"")
- return try source("Nifro/App/Constants.swift")
+ return try source("Sources/Nifro/App/Constants.swift")
.matches(of: pattern)
.map { String($0[1].substring ?? "") }
}
@@ -110,7 +110,7 @@ struct ClearWebsiteDataTests {
*/
@Test("Clearing empties every key that names a website or a playlist, in the function the button calls")
func clearingEmptiesEveryKeyNamingContent() throws {
- let controller = try Self.source("Nifro/Sites/WebsitesController.swift")
+ let controller = try Self.source("Sources/Nifro/Sites/WebsitesController.swift")
let clearing = try Self.body(of: "func removeEverything()", in: controller)
#expect(
@@ -129,7 +129,7 @@ struct ClearWebsiteDataTests {
)
}
- let pane = try Self.source("Nifro/Screens/SettingsScreen.swift")
+ let pane = try Self.source("Sources/Nifro/Screens/SettingsScreen.swift")
#expect(
try Self.body(of: "private func clear()", in: pane).contains("removeEverything()"),
diff --git a/Tests/CropLifetimeTests.swift b/Tests/CropLifetimeTests.swift
index 8f9125f..06124e7 100644
--- a/Tests/CropLifetimeTests.swift
+++ b/Tests/CropLifetimeTests.swift
@@ -29,7 +29,7 @@ struct CropLifetimeTests {
private static let overlay = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
- .appending(path: "Nifro/Zoom/CropSelectionView.swift")
+ .appending(path: "Sources/Nifro/Zoom/CropSelectionView.swift")
/**
The file with its prose taken out. It argues for itself at length and the argument names the very
diff --git a/Tests/DefaultsFallbackTests.swift b/Tests/DefaultsFallbackTests.swift
index b15a19a..ce27343 100644
--- a/Tests/DefaultsFallbackTests.swift
+++ b/Tests/DefaultsFallbackTests.swift
@@ -38,7 +38,7 @@ struct DefaultsFallbackTests {
let line = try Regex("//[^\\n]*")
return try FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" }
.sorted { $0.path < $1.path }
@@ -61,7 +61,7 @@ struct DefaultsFallbackTests {
*/
private static func optionalKeyNames() throws -> [String] {
let declaration = try Regex("static let (\\w+) = Key<(.+)>\\(\"", as: AnyRegexOutput.self)
- let text = try String(contentsOf: root.appending(path: "Nifro/App/Constants.swift"), encoding: .utf8)
+ let text = try String(contentsOf: root.appending(path: "Sources/Nifro/App/Constants.swift"), encoding: .utf8)
return text.matches(of: declaration).compactMap { match in
guard
diff --git a/Tests/DisableSurvivesAQuitTests.swift b/Tests/DisableSurvivesAQuitTests.swift
index 3fd71d7..8cb7291 100644
--- a/Tests/DisableSurvivesAQuitTests.swift
+++ b/Tests/DisableSurvivesAQuitTests.swift
@@ -26,7 +26,7 @@ struct DisableSurvivesAQuitTests {
let file = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
- .appending(path: "Nifro/App/\(name)")
+ .appending(path: "Sources/Nifro/App/\(name)")
let block = try Regex("/\\*.*?\\*/", as: AnyRegexOutput.self).dotMatchesNewlines()
let line = try Regex("//[^\\n]*")
diff --git a/Tests/EditingDoesNotSwitchTests.swift b/Tests/EditingDoesNotSwitchTests.swift
index 657652e..e8d58a3 100644
--- a/Tests/EditingDoesNotSwitchTests.swift
+++ b/Tests/EditingDoesNotSwitchTests.swift
@@ -29,7 +29,7 @@ struct EditingDoesNotSwitchTests {
private static let sources = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
- .appending(path: "Nifro")
+ .appending(path: "Sources/Nifro")
/**
One of the app's Swift files with its prose taken out.
diff --git a/Tests/FirstPaintRevealTests.swift b/Tests/FirstPaintRevealTests.swift
index f127a3f..29d6719 100644
--- a/Tests/FirstPaintRevealTests.swift
+++ b/Tests/FirstPaintRevealTests.swift
@@ -25,7 +25,7 @@ struct FirstPaintRevealTests {
let file = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
- .appending(path: "Nifro/Wallpaper/\(name)")
+ .appending(path: "Sources/Nifro/Wallpaper/\(name)")
let block = try Regex("/\\*.*?\\*/", as: AnyRegexOutput.self).dotMatchesNewlines()
let line = try Regex("//[^\\n]*")
diff --git a/Tests/GalleryInstalledStateTests.swift b/Tests/GalleryInstalledStateTests.swift
index cef5a47..9fa4c42 100644
--- a/Tests/GalleryInstalledStateTests.swift
+++ b/Tests/GalleryInstalledStateTests.swift
@@ -24,7 +24,7 @@ struct GalleryInstalledStateTests {
private static let screen = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
- .appending(path: "Nifro/Screens/SiteGalleryScreen.swift")
+ .appending(path: "Sources/Nifro/Screens/SiteGalleryScreen.swift")
/**
The file with its prose taken out, which here argues for the very thing being looked for.
diff --git a/Tests/IconComposerTests.swift b/Tests/IconComposerTests.swift
index 6e37811..4230684 100644
--- a/Tests/IconComposerTests.swift
+++ b/Tests/IconComposerTests.swift
@@ -3,7 +3,7 @@ import Testing
/**
The app icon is a compiled product, not a source image loaded by Swift. This guardrail holds the
-two project facts together: the Icon Composer source exists in the app target's folder, and both
+two project facts together: the Icon Composer source exists in the assets folder, and both
build configurations select that source by its filename. Without either half, Xcode silently falls
back to the old asset-catalog icon.
*/
@@ -15,8 +15,8 @@ struct IconComposerTests {
@Test("Both build configurations use the checked-in Nifro icon")
func usesNifroIconComposerSource() throws {
- let source = Self.repository.appending(path: "Nifro/Nifro.icon/icon.json")
- let project = Self.repository.appending(path: "Nifro.xcodeproj/project.pbxproj")
+ let source = Self.repository.appending(path: "assets/Nifro.icon/icon.json")
+ let project = Self.repository.appending(path: "Tools/Nifro.xcodeproj/project.pbxproj")
let projectSource = try String(contentsOf: project, encoding: .utf8)
#expect(FileManager.default.fileExists(atPath: source.path(percentEncoded: false)))
diff --git a/Tests/IconFetchCostTests.swift b/Tests/IconFetchCostTests.swift
index 3b8a21c..c7b0a7c 100644
--- a/Tests/IconFetchCostTests.swift
+++ b/Tests/IconFetchCostTests.swift
@@ -22,7 +22,7 @@ struct IconFetchCostTests {
private static let sources = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
- .appending(path: "Nifro")
+ .appending(path: "Sources/Nifro")
/**
One of the app's Swift files with its prose taken out.
diff --git a/Tests/IconFetchLifetimeTests.swift b/Tests/IconFetchLifetimeTests.swift
index 5d91a9b..ba1a611 100644
--- a/Tests/IconFetchLifetimeTests.swift
+++ b/Tests/IconFetchLifetimeTests.swift
@@ -23,7 +23,7 @@ struct IconFetchLifetimeTests {
private static let fetcher = URL(filePath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
- .appending(path: "Nifro/Sites/WebsiteIconFetcher.swift")
+ .appending(path: "Sources/Nifro/Sites/WebsiteIconFetcher.swift")
/**
The file with its prose taken out. It argues for itself at length and the argument names the very
diff --git a/Tests/InflectionTests.swift b/Tests/InflectionTests.swift
index 350fae2..11cbbf1 100644
--- a/Tests/InflectionTests.swift
+++ b/Tests/InflectionTests.swift
@@ -33,7 +33,7 @@ struct InflectionTests {
let call = try Regex("String\\(localized: \"((?:[^\"\\\\]|\\\\.)*)\"")
let sources = FileManager.default
- .enumerator(at: Self.root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: Self.root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" } ?? []
diff --git a/Tests/LanguageDefaultTests.swift b/Tests/LanguageDefaultTests.swift
index d6fb84d..72f1702 100644
--- a/Tests/LanguageDefaultTests.swift
+++ b/Tests/LanguageDefaultTests.swift
@@ -54,7 +54,7 @@ struct LanguageDefaultTests {
@Test("The chosen language is read from Nifro's own domain, never through the search list")
func theLanguageIsReadFromTheAppsOwnDomain() throws {
- let code = try Self.stripComments(Self.source("Nifro/Support/Language.swift"))
+ let code = try Self.stripComments(Self.source("Sources/Nifro/Support/Language.swift"))
#expect(
code.contains("UserDefaults.standard.persistentDomain(forName: SSApp.idString)?[key]"),
@@ -74,8 +74,8 @@ struct LanguageDefaultTests {
@Test("There is no way back to following the Mac")
func nothingClearsTheChoice() throws {
- let language = try Self.stripComments(Self.source("Nifro/Support/Language.swift"))
- let settings = try Self.stripComments(Self.source("Nifro/Screens/SettingsScreen.swift"))
+ let language = try Self.stripComments(Self.source("Sources/Nifro/Support/Language.swift"))
+ let settings = try Self.stripComments(Self.source("Sources/Nifro/Screens/SettingsScreen.swift"))
#expect(
language.contains("static let fallback = Self.english"),
@@ -98,7 +98,7 @@ struct LanguageDefaultTests {
@Test("The default is written before anything in the app can ask for a string")
func theDefaultIsAppliedFirst() throws {
- let code = try Self.stripComments(Self.source("Nifro/App/App.swift"))
+ let code = try Self.stripComments(Self.source("Sources/Nifro/App/App.swift"))
// Inside `init()`, by way of the one thing `init()` calls. Anything later — the delegate's
// `applicationWillFinishLaunching`, `applicationDidFinishLaunching` — is after windows, menus
diff --git a/Tests/LoadingIndicatorTests.swift b/Tests/LoadingIndicatorTests.swift
index 46a5743..ff0352b 100644
--- a/Tests/LoadingIndicatorTests.swift
+++ b/Tests/LoadingIndicatorTests.swift
@@ -47,7 +47,7 @@ struct LoadingIndicatorTests {
}
private static func scene() throws -> String {
- try stripComments(source("Nifro/Wallpaper/WallpaperScene.swift"))
+ try stripComments(source("Sources/Nifro/Wallpaper/WallpaperScene.swift"))
}
/**
@@ -125,7 +125,7 @@ struct LoadingIndicatorTests {
@Test("The counter that pulsed forever has not come back")
func noTally() throws {
- let source = try Self.stripComments(Self.source("Nifro/App/AppState.swift"))
+ let source = try Self.stripComments(Self.source("Sources/Nifro/App/AppState.swift"))
#expect(!source.contains("loadingScenes"))
#expect(!source.contains("beginLoadingIndicator"))
@@ -149,8 +149,8 @@ struct LoadingIndicatorTests {
*/
@Test("The menu bar paces its own pulse, and the panel paces nothing")
func oneCadence() throws {
- let menuBar = try Self.stripComments(Self.source("Nifro/Support/MenuSupport.swift"))
- let panel = try Self.stripComments(Self.source("Nifro/Screens/DisplayPanel.swift"))
+ let menuBar = try Self.stripComments(Self.source("Sources/Nifro/Support/MenuSupport.swift"))
+ let panel = try Self.stripComments(Self.source("Sources/Nifro/Screens/DisplayPanel.swift"))
#expect(menuBar.contains("pulse.duration = WallpaperScene.loadingPulseDuration"))
@@ -174,7 +174,7 @@ struct LoadingIndicatorTests {
*/
@Test("A swapped-in page is revealed by the swap")
func adoptingRevealsThePage() throws {
- let source = try Self.source("Nifro/Wallpaper/SwapLoading.swift")
+ let source = try Self.source("Sources/Nifro/Wallpaper/SwapLoading.swift")
// Sliced rather than brace-matched: `adopt` is the last method in its extension, so the next
// declaration is the end of it, and a slice that is too long can only make this pass by
@@ -219,7 +219,7 @@ struct LoadingIndicatorTests {
*/
@Test("A load is reported to the panel and does not disable it")
func aLoadDoesNotTrapTheColumn() throws {
- let source = try Self.stripComments(Self.source("Nifro/Screens/DisplayPanel.swift"))
+ let source = try Self.stripComments(Self.source("Sources/Nifro/Screens/DisplayPanel.swift"))
#expect(
source.components(separatedBy: "column.isLoading").count == 2,
diff --git a/Tests/NavigationStateLifetimeTests.swift b/Tests/NavigationStateLifetimeTests.swift
index 0553f18..4a28811 100644
--- a/Tests/NavigationStateLifetimeTests.swift
+++ b/Tests/NavigationStateLifetimeTests.swift
@@ -93,7 +93,7 @@ struct NavigationStateLifetimeTests {
@Test("The page's MIME type is kept on the web view that was told it")
func theMIMETypeBelongsToOneWebView() throws {
#expect(
- try Self.source("Nifro/Wallpaper/SSWebView.swift").contains("var responseMIMEType"),
+ try Self.source("Sources/Nifro/Wallpaper/SSWebView.swift").contains("var responseMIMEType"),
"The response's MIME type is not stored on the web view any more, so the two views a swap keeps alive share one answer and whichever finishes last decides it for both."
)
@@ -101,14 +101,14 @@ struct NavigationStateLifetimeTests {
// it, and neither would a differently named slot — which is the point: what has to stay true
// is that this controller holds no response of its own, whatever it would be called.
#expect(
- try !Self.source("Nifro/Wallpaper/WebViewController.swift").contains("HTTPURLResponse?"),
+ try !Self.source("Sources/Nifro/Wallpaper/WebViewController.swift").contains("HTTPURLResponse?"),
"`WebViewController` holds a response again. It is one per display and outlives every page it shows, so a slot on it belongs to no navigation in particular."
)
}
@Test("A navigation does not inherit the last one's MIME type")
func eachNavigationStartsWithNoAnswer() throws {
- let source = try Self.source("Nifro/Wallpaper/WebViewController.swift")
+ let source = try Self.source("Sources/Nifro/Wallpaper/WebViewController.swift")
// Anchored on WebKit's selector rather than on a helper this repository could rename, and on
// this one rather than on the policy callback: it is the main frame only, and it runs for the
@@ -123,7 +123,7 @@ struct NavigationStateLifetimeTests {
@Test("Every download's destination is filed and taken back under its own download")
func eachDownloadKeepsItsOwnDestination() throws {
- let source = try Self.source("Nifro/Wallpaper/WebViewController.swift")
+ let source = try Self.source("Sources/Nifro/Wallpaper/WebViewController.swift")
#expect(
try Self.body(of: "decideDestinationUsing response: URLResponse, suggestedFilename: String", in: source)
@@ -164,7 +164,7 @@ struct NavigationStateLifetimeTests {
*/
@Test("A stored failure does not outlive the load it describes")
func theFailureEndsWithItsLoad() throws {
- let scene = try Self.source("Nifro/Wallpaper/WallpaperScene.swift")
+ let scene = try Self.source("Sources/Nifro/Wallpaper/WallpaperScene.swift")
for (declaration, when) in [
("func load(_ url: URL?)", "A load starting"),
@@ -180,7 +180,7 @@ struct NavigationStateLifetimeTests {
// callers wrote it: a reload finishing on one display replaced another display's failure with
// its own page title, and the failure was simply gone.
#expect(
- try Self.source("Nifro/App/AppState.swift").contains("storedWebViewErrors: [String: Error]"),
+ try Self.source("Sources/Nifro/App/AppState.swift").contains("storedWebViewErrors: [String: Error]"),
"The failure store is not keyed per display any more, so clearing one display's entry clears or misses every other display's."
)
}
diff --git a/Tests/NifroTests.swift b/Tests/NifroTests.swift
index 232ca8d..9bd9e05 100644
--- a/Tests/NifroTests.swift
+++ b/Tests/NifroTests.swift
@@ -941,7 +941,7 @@ The file is served from `main` and fetched at runtime, so it ships without a bui
anything on the way out that compiles it. `SiteCatalog` used to need SwiftUI, `Defaults` and the
websites controller to compile, which kept it out of this target and left the published file with no
reader in the repository at all; the parts of it that turn an entry into a website now live in
-`Nifro/Sites/WebsitesController.swift` so that the decoding can be listed in `Package.swift` and
+`Sources/Nifro/Sites/WebsitesController.swift` so that the decoding can be listed in `Package.swift` and
tested here. That is the whole point of the split — see the note on `SiteCatalog`.
*/
@Suite("Published site catalogue")
diff --git a/Tests/OrphanStringsTests.swift b/Tests/OrphanStringsTests.swift
index 1d9e9f2..7ed10fc 100644
--- a/Tests/OrphanStringsTests.swift
+++ b/Tests/OrphanStringsTests.swift
@@ -48,11 +48,11 @@ struct OrphanStringsTests {
let readable: Set = ["swift", "plist", "strings", "json"]
let sources = FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL } ?? []
let extensionSources = FileManager.default
- .enumerator(at: root.appending(path: "ShareExtension"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/ShareExtension"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL } ?? []
let block = try Regex("/\\*.*?\\*/", as: AnyRegexOutput.self).dotMatchesNewlines()
@@ -75,7 +75,7 @@ struct OrphanStringsTests {
}
private static func catalogueKeys() throws -> [String] {
- let data = try Data(contentsOf: root.appending(path: "Nifro/Localizable.xcstrings"))
+ let data = try Data(contentsOf: root.appending(path: "Sources/Nifro/Localizable.xcstrings"))
// Only the keys are wanted; the values are three levels of nesting this test has no use for.
let raw = try JSONSerialization.jsonObject(with: data) as? [String: Any]
diff --git a/Tests/PanelControlStateTests.swift b/Tests/PanelControlStateTests.swift
index 2360f46..cc5412a 100644
--- a/Tests/PanelControlStateTests.swift
+++ b/Tests/PanelControlStateTests.swift
@@ -42,7 +42,7 @@ struct PanelControlStateTests {
private static func controls(between start: String, and end: String) throws -> Substring {
let source = try String(
- contentsOf: repository.appending(path: "Nifro/Screens/DisplayPanel.swift"),
+ contentsOf: repository.appending(path: "Sources/Nifro/Screens/DisplayPanel.swift"),
encoding: .utf8
)
diff --git a/Tests/PerPageKeyTests.swift b/Tests/PerPageKeyTests.swift
index 57cd57c..e99a172 100644
--- a/Tests/PerPageKeyTests.swift
+++ b/Tests/PerPageKeyTests.swift
@@ -38,7 +38,7 @@ struct PerPageKeyTests {
let line = try Regex("//[^\\n]*")
return try FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" }
.sorted { $0.path < $1.path }
diff --git a/Tests/PlaceholderTests.swift b/Tests/PlaceholderTests.swift
index 85f2f6f..3828e29 100644
--- a/Tests/PlaceholderTests.swift
+++ b/Tests/PlaceholderTests.swift
@@ -44,7 +44,7 @@ struct PlaceholderTests {
let token = try Regex("\\[\\[[A-Za-z][A-Za-z0-9]*\\]\\]")
let sources = FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" } ?? []
@@ -75,7 +75,7 @@ struct PlaceholderTests {
)
let catalogue = try String(
- contentsOf: Self.root.appending(path: "Nifro/Localizable.xcstrings"),
+ contentsOf: Self.root.appending(path: "Sources/Nifro/Localizable.xcstrings"),
encoding: .utf8
)
diff --git a/Tests/PlaylistRulesTests.swift b/Tests/PlaylistRulesTests.swift
index 5f94f85..d613849 100644
--- a/Tests/PlaylistRulesTests.swift
+++ b/Tests/PlaylistRulesTests.swift
@@ -42,7 +42,7 @@ struct PlaylistRulesTests {
guard
let url = FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap({ $0 as? URL })
.first(where: { $0.lastPathComponent == name })
else {
diff --git a/Tests/PlaylistStorageTests.swift b/Tests/PlaylistStorageTests.swift
index 85b8f7f..84f27da 100644
--- a/Tests/PlaylistStorageTests.swift
+++ b/Tests/PlaylistStorageTests.swift
@@ -82,7 +82,7 @@ struct PlaylistStorageTests {
*/
@Test("The shared ordering does not carry the flag reset")
func theFlagResetStaysWithItsOwnCallers() throws {
- let controller = try Self.source("Nifro/Sites/WebsitesController.swift")
+ let controller = try Self.source("Sources/Nifro/Sites/WebsitesController.swift")
let shared = try Self.body(of: "func prepareWebsiteStorage()", in: controller)
let install = try Self.body(of: "func installDefaultPlaylist()", in: controller)
@@ -111,7 +111,7 @@ struct PlaylistStorageTests {
*/
@Test("A binding cannot be written onto the default playlist")
func theDefaultPlaylistIsUnbindable() throws {
- let playlist = try Self.source("Nifro/Sites/Playlist.swift")
+ let playlist = try Self.source("Sources/Nifro/Sites/Playlist.swift")
#expect(
playlist.contains("private(set) var boundDisplay"),
diff --git a/Tests/PrivateWebKitKeysTests.swift b/Tests/PrivateWebKitKeysTests.swift
index 6f6b82d..5654fea 100644
--- a/Tests/PrivateWebKitKeysTests.swift
+++ b/Tests/PrivateWebKitKeysTests.swift
@@ -40,7 +40,7 @@ struct PrivateWebKitKeysTests {
let setter = try Regex("\\bsetValue\\([^\n]*?,\\s*forKey:\\s*\"([^\"]+)\"")
return try FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" }
.sorted { $0.path < $1.path }
diff --git a/Tests/RestoreDefaultsTests.swift b/Tests/RestoreDefaultsTests.swift
index b7fe730..75874e5 100644
--- a/Tests/RestoreDefaultsTests.swift
+++ b/Tests/RestoreDefaultsTests.swift
@@ -82,7 +82,7 @@ struct RestoreDefaultsTests {
private static func declaredKeyNames() throws -> [String] {
let pattern = try Regex("\\bKey<.+\\(\"(\\w+)\"")
- return try source("Nifro/App/Constants.swift")
+ return try source("Sources/Nifro/App/Constants.swift")
.matches(of: pattern)
.map { String($0[1].substring ?? "") }
}
@@ -108,7 +108,7 @@ struct RestoreDefaultsTests {
@Test("The reset wipes the whole domain rather than a list of keys")
func theResetIsAWipe() throws {
- let code = try Self.stripComments(Self.source("Nifro/App/RestoreDefaults.swift"))
+ let code = try Self.stripComments(Self.source("Sources/Nifro/App/RestoreDefaults.swift"))
#expect(code.contains("Defaults.removeAll()"), "Restore no longer wipes the domain")
#expect(!code.contains("Defaults.reset"), "Restore now resets named keys, which is a list that will rot")
@@ -127,7 +127,7 @@ struct RestoreDefaultsTests {
*/
@Test("No preference is reached through its declaration, so none can be left out")
func noKeyIsNamed() throws {
- let code = try Self.stripComments(Self.source("Nifro/App/RestoreDefaults.swift"))
+ let code = try Self.stripComments(Self.source("Sources/Nifro/App/RestoreDefaults.swift"))
for name in try Self.declaredKeyNames() {
#expect(!code.contains(".\(name)"), "Restore reaches the “\(name)” preference through `Defaults.Keys`. Reaching one that way means reaching all of them, and the next one added will be missed.")
@@ -136,11 +136,11 @@ struct RestoreDefaultsTests {
@Test("Every keyboard shortcut is reset through the table rather than one at a time")
func shortcutsResetThroughTheTable() throws {
- let code = try Self.stripComments(Self.source("Nifro/App/RestoreDefaults.swift"))
+ let code = try Self.stripComments(Self.source("Sources/Nifro/App/RestoreDefaults.swift"))
#expect(code.contains("KeyboardShortcuts.reset(Shortcut.allNames)"), "Shortcuts are no longer reset through the CaseIterable table")
- let cases = try Self.stripComments(Self.source("Nifro/App/Shortcuts.swift"))
+ let cases = try Self.stripComments(Self.source("Sources/Nifro/App/Shortcuts.swift"))
// The optional tail is an explicit raw value. `browsingMode` carries one — its stored key is
// the older `toggleBrowsingMode` — and without this the parser silently skipped it, which is
// the shortcut this test would then let Restore name one at a time.
@@ -183,7 +183,7 @@ struct RestoreDefaultsTests {
*/
@Test("The wipe has exactly six exceptions, put back after it and argued for")
func onlyTheseSurviveTheWipe() throws {
- let source = try Self.source("Nifro/App/RestoreDefaults.swift")
+ let source = try Self.source("Sources/Nifro/App/RestoreDefaults.swift")
let code = try Self.stripComments(source)
#expect(
@@ -248,7 +248,7 @@ struct RestoreDefaultsTests {
*/
@Test("Restoring never touches website data")
func websiteDataIsLeftAlone() throws {
- let code = try Self.stripComments(Self.source("Nifro/App/RestoreDefaults.swift"))
+ let code = try Self.stripComments(Self.source("Sources/Nifro/App/RestoreDefaults.swift"))
#expect(!code.contains("WKWebsiteDataStore"))
#expect(!code.contains("DiskBudget"))
diff --git a/Tests/SceneTimerLifetimeTests.swift b/Tests/SceneTimerLifetimeTests.swift
index 871feed..b69959d 100644
--- a/Tests/SceneTimerLifetimeTests.swift
+++ b/Tests/SceneTimerLifetimeTests.swift
@@ -53,7 +53,7 @@ struct SceneTimerLifetimeTests {
let line = try Regex("//[^\\n]*")
return try String(
- contentsOf: root.appending(path: "Nifro/Wallpaper/WallpaperScene.swift"),
+ contentsOf: root.appending(path: "Sources/Nifro/Wallpaper/WallpaperScene.swift"),
encoding: .utf8
)
.replacing(block, with: "")
diff --git a/Tests/ScopeTests.swift b/Tests/ScopeTests.swift
index 207795b..2cfee5a 100644
--- a/Tests/ScopeTests.swift
+++ b/Tests/ScopeTests.swift
@@ -47,7 +47,7 @@ struct ScopeTests {
let line = try Regex("//[^\\n]*")
return try FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" }
.sorted { $0.path < $1.path }
diff --git a/Tests/ShuffleTests.swift b/Tests/ShuffleTests.swift
index e86dc11..ca37936 100644
--- a/Tests/ShuffleTests.swift
+++ b/Tests/ShuffleTests.swift
@@ -171,7 +171,7 @@ struct ShuffleShapeTests {
guard
let url = FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap({ $0 as? URL })
.first(where: { $0.lastPathComponent == name })
else {
diff --git a/Tests/SwitchedOffTests.swift b/Tests/SwitchedOffTests.swift
index 5b80095..6f64abe 100644
--- a/Tests/SwitchedOffTests.swift
+++ b/Tests/SwitchedOffTests.swift
@@ -45,7 +45,7 @@ struct SwitchedOffTests {
let line = try Regex("//[^\\n]*")
return try FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" }
.sorted { $0.path < $1.path }
diff --git a/Tests/TimerToleranceTests.swift b/Tests/TimerToleranceTests.swift
index 1872f36..c014d55 100644
--- a/Tests/TimerToleranceTests.swift
+++ b/Tests/TimerToleranceTests.swift
@@ -42,7 +42,7 @@ struct TimerToleranceTests {
let line = try Regex("//[^\\n]*")
return try FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" }
.sorted { $0.path < $1.path }
diff --git a/Tests/UnreadSettingsTests.swift b/Tests/UnreadSettingsTests.swift
index 25e95f0..922d4e5 100644
--- a/Tests/UnreadSettingsTests.swift
+++ b/Tests/UnreadSettingsTests.swift
@@ -41,7 +41,7 @@ struct UnreadSettingsTests {
let line = try Regex("//[^\\n]*")
return try FileManager.default
- .enumerator(at: root.appending(path: "Nifro"), includingPropertiesForKeys: nil)?
+ .enumerator(at: root.appending(path: "Sources/Nifro"), includingPropertiesForKeys: nil)?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" }
.map {
@@ -58,7 +58,7 @@ struct UnreadSettingsTests {
typed into this file would be the same kind of list that lost the update check its surface.
*/
private static func declaredKeys() throws -> [String] {
- let text = try String(contentsOf: root.appending(path: "Nifro/App/Constants.swift"), encoding: .utf8)
+ let text = try String(contentsOf: root.appending(path: "Sources/Nifro/App/Constants.swift"), encoding: .utf8)
let declaration = try Regex("static let (\\w+) = Key<")
return text.matches(of: declaration).map { String($0.output[1].substring!) }
@@ -149,7 +149,7 @@ struct UnreadSettingsTests {
*/
@Test("The panel's footer is what reads the update check's answer")
func theUpdateAnswerIsDrawn() throws {
- let panel = try String(contentsOf: Self.root.appending(path: "Nifro/Screens/DisplayPanel.swift"), encoding: .utf8)
+ let panel = try String(contentsOf: Self.root.appending(path: "Sources/Nifro/Screens/DisplayPanel.swift"), encoding: .utf8)
#expect(
panel.contains("@Default(.latestKnownVersion)"),
diff --git a/Tests/WebsiteDecodingTests.swift b/Tests/WebsiteDecodingTests.swift
index 2ff0e8f..1138ac3 100644
--- a/Tests/WebsiteDecodingTests.swift
+++ b/Tests/WebsiteDecodingTests.swift
@@ -83,7 +83,7 @@ struct WebsiteDecodingTests {
Computed properties are skipped by the brace on their own line, which is what makes them computed.
*/
private static func storedProperties() throws -> [(name: String, declaration: String)] {
- let text = try source("Nifro/Sites/Website.swift")
+ let text = try source("Sources/Nifro/Sites/Website.swift")
guard
let start = text.range(of: "struct Website"),
@@ -215,7 +215,7 @@ struct WebsiteDecodingTests {
*/
@Test("A website with no answer of its own follows Settings")
func theGlobalSwitchIsStillTheDefault() throws {
- let website = try Self.source("Nifro/Sites/Website.swift")
+ let website = try Self.source("Sources/Nifro/Sites/Website.swift")
#expect(
website.contains("static let defaultValue = followSettings"),
@@ -227,7 +227,7 @@ struct WebsiteDecodingTests {
"Nothing falls back to the app-wide switch any more, so everybody who set it loses it."
)
- let webView = try Self.source("Nifro/Wallpaper/WebViewController.swift")
+ let webView = try Self.source("Sources/Nifro/Wallpaper/WebViewController.swift")
#expect(
webView.contains("opensExternalLinksInBrowser"),
@@ -245,7 +245,7 @@ struct WebsiteDecodingTests {
*/
@Test("A @DecodableDefault field survives a payload written before it existed")
func decodableDefaultToleratesAnAbsentKey() throws {
- let source = try String(contentsOf: Self.root.appending(path: "Nifro/Support/Extensions.swift"), encoding: .utf8)
+ let source = try String(contentsOf: Self.root.appending(path: "Sources/Nifro/Support/Extensions.swift"), encoding: .utf8)
#expect(
source.contains("DecodableDefault.Wrapper.Type"),
diff --git a/Tests/ZoomSummaryTests.swift b/Tests/ZoomSummaryTests.swift
index 96c8c38..ef6aef0 100644
--- a/Tests/ZoomSummaryTests.swift
+++ b/Tests/ZoomSummaryTests.swift
@@ -46,7 +46,7 @@ struct ZoomSummaryTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
- for path in ["Nifro/Sites/WebsiteReport.swift", "Nifro/Zoom/WebsiteSettings.swift"] {
+ for path in ["Sources/Nifro/Sites/WebsiteReport.swift", "Sources/Nifro/Zoom/WebsiteSettings.swift"] {
let source = try String(contentsOf: root.appending(path: path), encoding: .utf8)
#expect(!source.contains("fractionLength"), "\(path) rounds a scale of its own")
diff --git a/Tools/Config/App.xcconfig b/Tools/Config/App.xcconfig
new file mode 100644
index 0000000..514b755
--- /dev/null
+++ b/Tools/Config/App.xcconfig
@@ -0,0 +1,8 @@
+MARKETING_VERSION = 0.9.1
+CURRENT_PROJECT_VERSION = 1
+
+// Release builds must omit get-task-allow for notarization.
+CODE_SIGN_INJECT_BASE_ENTITLEMENTS[config=Release] = NO
+
+NIFRO_BUNDLE_ID = com.pathgao.nifro
+NIFRO_URL_SCHEME = nifro
diff --git a/Tools/Config/Periphery.yml b/Tools/Config/Periphery.yml
new file mode 100644
index 0000000..1c0ccc8
--- /dev/null
+++ b/Tools/Config/Periphery.yml
@@ -0,0 +1,20 @@
+# Run periphery scan --config Tools/Config/Periphery.yml --strict before adding a retention.
+project: Tools/Nifro.xcodeproj
+schemes:
+ - Nifro
+targets:
+ - Nifro
+retain_objc_accessible: false
+retain_public: false
+
+report_exclude:
+ # Shortcuts discovers these declarations through bundle metadata.
+ # Keep ordinary helpers elsewhere: this exclusion covers the whole file.
+ - "**/Intents.swift"
+retain_files: []
+
+# Inline retentions:
+# - KeyedDecodingContainer.decode: called by synthesized Decodable implementations.
+# - AppMain.appState: StateObject controls initialization timing.
+# - RowView.disabledDisplays and appState: SwiftUI DynamicProperty subscriptions.
+# This scan does not detect unused localization keys or writes with no reads.
diff --git a/.swiftlint.yml b/Tools/Config/SwiftLint.yml
similarity index 72%
rename from .swiftlint.yml
rename to Tools/Config/SwiftLint.yml
index 51ecf66..52d1b33 100644
--- a/.swiftlint.yml
+++ b/Tools/Config/SwiftLint.yml
@@ -1,12 +1,9 @@
excluded:
- # Build output. SwiftLint has no business policing the style of other people's packages.
- - .xcode-build
- - .build
- - .venv
- # Generated. `--fix` rewrote 21600 as 21_600 here, the generator wrote it back without the
- # separator on the next run, and CI's "the catalogue is stale" check caught the two tools
- # disagreeing forever. Nothing in a generated file is anyone's style to correct.
- - Nifro/Sites/SiteCatalog.generated.swift
+ # Paths are relative to this config. Skip dependencies and generated source.
+ - ../../.xcode-build
+ - ../../.build
+ - ../../.venv
+ - ../../Sources/Nifro/Sites/SiteCatalog.generated.swift
only_rules:
- accessibility_trait_for_button
@@ -248,48 +245,28 @@ custom_rules:
regex: '\b\(alignment: .center\b'
message: 'This alignment is the default.'
- # What a deleted member leaves behind. Nothing else reports it: `periphery` needs a declaration to
- # call unused and an extension with an empty body has none, so nine of these had accumulated in
- # `Extensions.swift`, `AppInfo.swift` and `MenuSupport.swift`, three still wearing the `// MARK:`
- # header of the type whose last member was removed — which is the jump bar the file header says the
- # MARKs are there for.
- #
- # Only the multi-line form. A conformance-only `extension NSImage: @retroactive @unchecked Sendable {}`
- # declares the conformance and is content; it is written on one line and is not matched.
+ # Multiline empty bodies only; one-line conformance declarations remain valid.
empty_extension:
regex: 'extension [^{\n]*\{\n[ \t]*\}'
message: 'An extension with nothing in it. Delete it, and any `// MARK:` header that only pointed at it.'
- # The three below guard the app's own drawn chrome. Each matches a SwiftUI or AppKit parameter
- # label — nothing this repo named — so renaming `PanelMetrics`, `PanelButton` or anything else here
- # cannot turn one red. `included:` is the three folders that draw; `Wallpaper`, `Support` and `App`
- # are left out because none of them paints chrome.
- #
- # `Nifro/Zoom/CropSelectionView.swift` is exempt as a whole surface rather than line by line. It
- # draws over an arbitrary web page: its black scrim, white outline and 22-point readout are
- # contrast decisions against unknown content, which is the one place in the app where a colour that
- # follows the theme is the wrong answer. Nine identical per-line reasons would say less than this
- # one. The cost is that a genuinely shared value hardcoded in that file goes unnoticed.
- #
- # Everything else takes `// swiftlint:disable:next ` with its reason above it.
- # `superfluous_disable_command`, enabled above, turns each of those red once the line under it
- # stops violating, so the allowlist expires on its own.
+ # CropSelectionView overlays arbitrary web content and needs fixed contrast.
+ # Other exceptions require a local disable with its reason.
hardcoded_corner_radius:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
- excluded: 'Nifro/Zoom/CropSelectionView\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ excluded: 'Sources/Nifro/Zoom/CropSelectionView\.swift'
regex: '(cornerRadius|xRadius|yRadius): *[0-9]'
message: 'Corner radii belong in PanelMetrics. Name it there, or disable this line with the reason it is not shared.'
+ # Size zero selects the platform default and is intentionally allowed.
hardcoded_font_size:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
- excluded: 'Nifro/Zoom/CropSelectionView\.swift'
- # `[1-9]`, not `[0-9]`: `NSFont.controlContentFont(ofSize: 0)` is AppKit's idiom for "the system
- # default size", which is the opposite of a hardcoded size.
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ excluded: 'Sources/Nifro/Zoom/CropSelectionView\.swift'
regex: '\.(system|systemFont|monospacedSystemFont|monospacedDigitSystemFont)\((of)?[Ss]ize: *[1-9]'
message: 'Point sizes belong in PanelMetrics.'
hardcoded_ui_colour:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
- excluded: 'Nifro/Zoom/CropSelectionView\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ excluded: 'Sources/Nifro/Zoom/CropSelectionView\.swift'
regex: 'Color\(red:|(NS)?Color\.(white|black)\b|AnyShapeStyle\(\.white\)'
message: 'A literal colour follows neither the theme nor the user accent. Use PanelMetrics, or a semantic style.'
diff --git a/Nifro.xcodeproj/project.pbxproj b/Tools/Nifro.xcodeproj/project.pbxproj
similarity index 97%
rename from Nifro.xcodeproj/project.pbxproj
rename to Tools/Nifro.xcodeproj/project.pbxproj
index 5a0d35b..ee6e4dd 100644
--- a/Nifro.xcodeproj/project.pbxproj
+++ b/Tools/Nifro.xcodeproj/project.pbxproj
@@ -113,7 +113,7 @@
E32421332384E9D700D28A91 /* AppState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AppState.swift; sourceTree = ""; usesTabs = 1; };
E32421352384E9D700D28A91 /* AddWebsiteScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AddWebsiteScreen.swift; sourceTree = ""; usesTabs = 1; };
E32421372384E9D900D28A91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
- A1C0DE01B0074E01A00000F2 /* Nifro.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.icon; path = Nifro.icon; sourceTree = ""; };
+ A1C0DE01B0074E01A00000F2 /* Nifro.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.icon; path = assets/Nifro.icon; sourceTree = SOURCE_ROOT; };
E324213A2384E9D900D28A91 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
E324213F2384E9D900D28A91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
2BA31C0FAF40DE6D9B93FFA1 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
@@ -166,7 +166,7 @@
E32AD6BC26D275070008D900 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; };
E32AD6B226D2718B0008D900 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
E32AD6B326D2718B0008D900 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; };
- E32AD6BB26D271C00008D900 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; };
+ E32AD6BB26D271C00008D900 /* Tools/Config/App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tools/Config/App.xcconfig; sourceTree = ""; };
E333EB1A2385244E005FCE44 /* SettingsScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = SettingsScreen.swift; sourceTree = ""; usesTabs = 1; };
A1C0DE01B0074E01A0000076 /* DisplayPanelController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DisplayPanelController.swift; sourceTree = ""; usesTabs = 1; };
A1C0DE01B0074E01A0000074 /* DisplayPanelModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DisplayPanelModel.swift; sourceTree = ""; usesTabs = 1; };
@@ -211,7 +211,7 @@
E32421272384E9D700D28A91 = {
isa = PBXGroup;
children = (
- E32AD6BB26D271C00008D900 /* Config.xcconfig */,
+ E32AD6BB26D271C00008D900 /* Tools/Config/App.xcconfig */,
E32421322384E9D700D28A91 /* Nifro */,
E32AD6AA26D2718B0008D900 /* ShareExtension */,
E32421312384E9D700D28A91 /* Products */,
@@ -244,7 +244,7 @@
E3EF205F24C0D1A100A5F802 /* Other */,
E32421392384E9D900D28A91 /* Preview Content */,
);
- path = Nifro;
+ path = Sources/Nifro;
sourceTree = "";
};
E32421392384E9D900D28A91 /* Preview Content */ = {
@@ -263,7 +263,7 @@
E32AD6B226D2718B0008D900 /* Info.plist */,
E32AD6B326D2718B0008D900 /* ShareExtension.entitlements */,
);
- path = ShareExtension;
+ path = Sources/ShareExtension;
sourceTree = "";
};
E3EF205F24C0D1A100A5F802 /* Other */ = {
@@ -470,7 +470,7 @@
);
preferredProjectObjectVersion = 77;
productRefGroup = E32421312384E9D700D28A91 /* Products */;
- projectDirPath = "";
+ projectDirPath = "..";
projectRoot = "";
targets = (
E324212F2384E9D700D28A91 /* Nifro */,
@@ -519,7 +519,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "PATH=\"/opt/homebrew/bin/:${PATH}\"\n# Skipped rather than failed when it is not installed. Style is checked on CI, and a\n# contributor who has not installed SwiftLint should still be able to build and run the app.\nif command -v swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint is not installed, skipping. brew install swiftlint\"\nfi\n";
+ shellScript = "PATH=\"/opt/homebrew/bin/:${PATH}\"\n# Skipped rather than failed when it is not installed. Style is checked on CI, and a\n# contributor who has not installed SwiftLint should still be able to build and run the app.\nif command -v swiftlint >/dev/null; then\n swiftlint lint --config Tools/Config/SwiftLint.yml\nelse\n echo \"warning: SwiftLint is not installed, skipping. brew install swiftlint\"\nfi\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
@@ -617,7 +617,7 @@
/* Begin XCBuildConfiguration section */
E32421412384E9D900D28A91 /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = E32AD6BB26D271C00008D900 /* Config.xcconfig */;
+ baseConfigurationReference = E32AD6BB26D271C00008D900 /* Tools/Config/App.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -683,7 +683,7 @@
};
E32421422384E9D900D28A91 /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = E32AD6BB26D271C00008D900 /* Config.xcconfig */;
+ baseConfigurationReference = E32AD6BB26D271C00008D900 /* Tools/Config/App.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -744,16 +744,16 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Nifro;
- CODE_SIGN_ENTITLEMENTS = Nifro/Nifro.entitlements;
+ CODE_SIGN_ENTITLEMENTS = Sources/Nifro/Nifro.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_ASSET_PATHS = "\"Nifro/Preview Content\"";
+ DEVELOPMENT_ASSET_PATHS = "\"Sources/Nifro/Preview Content\"";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Nifro/Info.plist;
+ INFOPLIST_FILE = Sources/Nifro/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "MIT License. See the license file in the source repository.";
@@ -761,7 +761,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.pathgao.nifro;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(NIFRO_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
};
@@ -771,16 +771,16 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Nifro;
- CODE_SIGN_ENTITLEMENTS = Nifro/Nifro.entitlements;
+ CODE_SIGN_ENTITLEMENTS = Sources/Nifro/Nifro.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_ASSET_PATHS = "\"Nifro/Preview Content\"";
+ DEVELOPMENT_ASSET_PATHS = "\"Sources/Nifro/Preview Content\"";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Nifro/Info.plist;
+ INFOPLIST_FILE = Sources/Nifro/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "MIT License. See the license file in the source repository.";
@@ -788,7 +788,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.pathgao.nifro;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(NIFRO_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
};
@@ -797,21 +797,21 @@
E32AD6B826D2718B0008D900 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
+ CODE_SIGN_ENTITLEMENTS = Sources/ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = ShareExtension/Info.plist;
+ INFOPLIST_FILE = Sources/ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Nifro;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.pathgao.nifro.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(NIFRO_BUNDLE_ID).ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
};
@@ -820,21 +820,21 @@
E32AD6B926D2718B0008D900 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
+ CODE_SIGN_ENTITLEMENTS = Sources/ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = ShareExtension/Info.plist;
+ INFOPLIST_FILE = Sources/ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Nifro;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.pathgao.nifro.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(NIFRO_BUNDLE_ID).ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
};
diff --git a/Nifro.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Tools/Nifro.xcodeproj/project.xcworkspace/contents.xcworkspacedata
similarity index 100%
rename from Nifro.xcodeproj/project.xcworkspace/contents.xcworkspacedata
rename to Tools/Nifro.xcodeproj/project.xcworkspace/contents.xcworkspacedata
diff --git a/Nifro.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
similarity index 100%
rename from Nifro.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
rename to Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
diff --git a/Nifro.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
similarity index 100%
rename from Nifro.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
rename to Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
diff --git a/Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
similarity index 100%
rename from Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
rename to Tools/Nifro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
diff --git a/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
similarity index 92%
rename from Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
rename to Tools/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
index 1ea609a..7f9f228 100644
--- a/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
+++ b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
@@ -17,7 +17,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Nifro.xcodeproj">
+ ReferencedContainer = "container:Tools/Nifro.xcodeproj">
@@ -47,7 +47,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Nifro.xcodeproj">
+ ReferencedContainer = "container:Tools/Nifro.xcodeproj">
@@ -64,7 +64,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Nifro.xcodeproj">
+ ReferencedContainer = "container:Tools/Nifro.xcodeproj">
diff --git a/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
similarity index 92%
rename from Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
rename to Tools/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
index df3f8f1..d38dbd1 100644
--- a/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
+++ b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
@@ -18,7 +18,7 @@
BlueprintIdentifier = "E32AD6A826D2718B0008D900"
BuildableName = "Share Extension.appex"
BlueprintName = "Share Extension"
- ReferencedContainer = "container:Nifro.xcodeproj">
+ ReferencedContainer = "container:Tools/Nifro.xcodeproj">
+ ReferencedContainer = "container:Tools/Nifro.xcodeproj">
@@ -64,7 +64,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Nifro.xcodeproj">
+ ReferencedContainer = "container:Tools/Nifro.xcodeproj">
@@ -83,7 +83,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Nifro.xcodeproj">
+ ReferencedContainer = "container:Tools/Nifro.xcodeproj">
diff --git a/Tools/README.md b/Tools/README.md
index d0099bf..6649a94 100644
--- a/Tools/README.md
+++ b/Tools/README.md
@@ -1,11 +1,11 @@
# Tools
-Everything the repository is maintained with. Nothing here ships in the app.
+Development and maintenance helpers. The main build entry point is [`../build.sh`](../build.sh).
| | What it does |
| --- | --- |
| `setup-signing.sh` | Creates a stable self-signed identity for local development only |
-| `build-local.sh` | Builds a sandboxed test copy, optionally using an explicit Developer ID SHA-1 |
+| `build-local.sh` | Calls `../build.sh dev`; optionally copies to a new destination without overwriting |
| `sync-labels.sh` | Makes the repository's labels match `.github/labels.yml` |
| `validate-sites.py` | Checks every `sites/*.yml` against `sites/schema.json`. CI runs this |
| `generate-site-catalog.py` | Writes `sites/index.json` and the bundled Swift copy from the YAML. CI fails if they disagree |
@@ -21,3 +21,12 @@ python3 -m venv .venv
.venv/bin/pip install jsonschema pyyaml
.venv/bin/python Tools/validate-sites.py
```
+
+Build configuration lives in `Tools/Config/`: `App.xcconfig` holds versions and
+application identifiers, `SwiftLint.yml` controls lint rules, and `Periphery.yml`
+controls unused-code scanning. Run these from the repository root:
+
+```sh
+swiftlint lint --config Tools/Config/SwiftLint.yml --strict
+periphery scan --config Tools/Config/Periphery.yml --strict
+```
diff --git a/Tools/build-local.sh b/Tools/build-local.sh
index e9c008c..2a06e1e 100755
--- a/Tools/build-local.sh
+++ b/Tools/build-local.sh
@@ -1,74 +1,13 @@
-#!/bin/zsh
-# Builds a sandboxed local test copy. Set APPLE_SIGNING_IDENTITY (SHA-1) and
-# APPLE_TEAM_ID to use Developer ID; otherwise use the local self-signed identity.
+#!/bin/bash
+# Compatibility entry point. Existing destinations are never overwritten.
set -euo pipefail
-
-IDENTITY="${APPLE_SIGNING_IDENTITY:-Nifro Signing}"
-TEAM_ID="${APPLE_TEAM_ID:-}"
-DESTINATION="${1:-$HOME/Desktop/Nifro-test.app}"
-DERIVED_DATA=".xcode-build"
-
cd "$(dirname "$0")/.."
-
-signing_args=()
-if [[ -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then
- [[ "$IDENTITY" =~ ^[0-9A-F]{40}$ && "$TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || {
- echo "APPLE_SIGNING_IDENTITY must be a SHA-1 and APPLE_TEAM_ID must be set." >&2
- exit 1
- }
- security find-identity -v -p codesigning | grep -E "^[[:space:]]*[0-9]+\) $IDENTITY \"Developer ID Application: .* \($TEAM_ID\)\"$" >/dev/null || {
- echo "The specified Developer ID identity is unavailable." >&2
- exit 1
- }
- signing_args+=(ENABLE_HARDENED_RUNTIME=YES OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime")
-else
- TEAM_ID=""
- if ! security find-identity -p codesigning 2>/dev/null | grep -q "$IDENTITY"; then
- echo "→ Signing identity missing, creating it."
- ./Tools/setup-signing.sh
- fi
-
- # A locked signing keychain still lists its identity, so the check above passes and codesign then
- # fails halfway through with `errSecInternalComponent` after putting a keychain prompt on screen.
- # Unlocking here is what keeps a build from ever asking for a password. If the passphrase no longer
- # matches — an older script wrote a different one — the keychain is rebuilt, since all it holds is a
- # self-signed local certificate that setup-signing.sh makes from scratch anyway.
- KEYCHAIN="$HOME/Library/Keychains/nifro-signing.keychain-db"
- if [[ -f "$KEYCHAIN" ]] && ! security unlock-keychain -p "nifro-signing" "$KEYCHAIN" 2>/dev/null; then
- echo "→ Signing keychain will not unlock, rebuilding it."
- security delete-keychain "$KEYCHAIN"
- ./Tools/setup-signing.sh
- fi
- security set-keychain-settings "$KEYCHAIN" # No auto-lock, so the next build does not prompt either.
+destination="${1:-}"
+if [[ -n "$destination" && ( -e "$destination" || -L "$destination" ) ]]; then
+ echo "Destination already exists: $destination" >&2
+ exit 1
fi
-
-xcodebuild \
- -project Nifro.xcodeproj \
- -scheme Nifro \
- -configuration Release \
- -derivedDataPath "$DERIVED_DATA" \
- CODE_SIGN_STYLE=Manual \
- CODE_SIGN_IDENTITY="$IDENTITY" \
- DEVELOPMENT_TEAM="$TEAM_ID" \
- PROVISIONING_PROFILE_SPECIFIER="" \
- "${signing_args[@]}" \
- build | awk '/error:|warning: .*(deprecat|unused)|BUILD/'
-
-BUILT="$DERIVED_DATA/Build/Products/Release/Nifro.app"
-
-codesign --verify --deep --strict "$BUILT"
-
-# The sandbox is the whole reason this script exists. A build that lost it looks
-# fine and behaves differently, so fail here rather than let it get tested.
-if ! codesign -d --entitlements - --xml "$BUILT" 2>/dev/null | plutil -p - | grep -q "app-sandbox"; then
- echo "✗ Built app has no sandbox entitlement. Not installing." >&2
- exit 1
+./build.sh dev
+if [[ -n "$destination" ]]; then
+ ditto .xcode-build/Build/Products/Release/Nifro.app "$destination"
fi
-
-osascript -e 'quit app "Nifro"' 2>/dev/null || true
-sleep 1
-rm -rf "$DESTINATION"
-cp -R "$BUILT" "$DESTINATION"
-
-echo "✓ $DESTINATION"
-codesign -dv "$DESTINATION" 2>&1 | grep -E "^(Identifier|Authority|TeamIdentifier)"
diff --git a/Tools/check-strings.py b/Tools/check-strings.py
index 8650cf4..35dc0c3 100755
--- a/Tools/check-strings.py
+++ b/Tools/check-strings.py
@@ -32,7 +32,7 @@
import subprocess
import sys
-CATALOGUE = pathlib.Path("Nifro/Localizable.xcstrings")
+CATALOGUE = pathlib.Path("Sources/Nifro/Localizable.xcstrings")
# A view with `.labelsHidden()` still writes an empty label, and an App Intents parameter summary is
# stored as a bare specifier. Neither is text anybody reads.
diff --git a/Tools/check-url-scheme.py b/Tools/check-url-scheme.py
index accd297..78764f2 100755
--- a/Tools/check-url-scheme.py
+++ b/Tools/check-url-scheme.py
@@ -1,44 +1,23 @@
#!/usr/bin/env python3
-"""Fail if the share extension's URL scheme has drifted from the app's declaration.
-
-`Info.plist` is what makes the system route a URL to the app, so it is the authority. The app reads
-it at runtime. The share extension is a separate target and cannot, so it carries a literal, and a
-mismatch shows up as the extension doing nothing at all with nothing going red. Hence this check.
-"""
-
+"""Check both targets use the shared, overridable URL scheme build setting."""
import pathlib
import plistlib
-import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
def main() -> int:
- plist = plistlib.loads((ROOT / "Nifro/Info.plist").read_bytes())
-
- try:
- declared = plist["CFBundleURLTypes"][0]["CFBundleURLSchemes"][0]
- except (KeyError, IndexError):
- print("Info.plist declares no URL scheme", file=sys.stderr)
+ app = plistlib.loads((ROOT / "Sources/Nifro/Info.plist").read_bytes())
+ extension = plistlib.loads((ROOT / "Sources/ShareExtension/Info.plist").read_bytes())
+ declared = app["CFBundleURLTypes"][0]["CFBundleURLSchemes"][0]
+ source = (ROOT / "Sources/ShareExtension/ShareController.swift").read_text()
+ if (declared != "$(NIFRO_URL_SCHEME)"
+ or extension.get("NifroURLScheme") != declared
+ or 'Bundle.main.object(forInfoDictionaryKey: "NifroURLScheme")' not in source):
+ print("Both targets must read NIFRO_URL_SCHEME through their bundle metadata", file=sys.stderr)
return 1
-
- source = (ROOT / "ShareExtension/ShareController.swift").read_text()
- match = re.search(r'components\.scheme = "([^"]+)"', source)
-
- if not match:
- print("ShareController.swift no longer sets components.scheme as a literal; update this check", file=sys.stderr)
- return 1
-
- if match.group(1) != declared:
- print(
- f"URL scheme mismatch: Info.plist says {declared!r}, "
- f"ShareController.swift says {match.group(1)!r}",
- file=sys.stderr,
- )
- return 1
-
- print(f"URL scheme {declared!r} matches in both places")
+ print("Both targets use NIFRO_URL_SCHEME")
return 0
diff --git a/Tools/generate-site-catalog.py b/Tools/generate-site-catalog.py
index a59e67f..496609b 100755
--- a/Tools/generate-site-catalog.py
+++ b/Tools/generate-site-catalog.py
@@ -17,7 +17,7 @@
ROOT = pathlib.Path(__file__).resolve().parent.parent
SITES = ROOT / "sites"
-OUTPUT = ROOT / "Nifro" / "Sites" / "SiteCatalog.generated.swift"
+OUTPUT = ROOT / "Sources" / "Nifro" / "Sites" / "SiteCatalog.generated.swift"
INDEX = SITES / "index.json"
HEADER = """// Generated by Tools/generate-site-catalog.py from the YAML files under sites. Do not edit.
diff --git a/assets/README.md b/assets/README.md
index 6c99a28..67a008c 100644
--- a/assets/README.md
+++ b/assets/README.md
@@ -5,10 +5,10 @@ Images and editable design sources for the repository. Nothing here ships inside
| File | Where it is used | How it gets there |
| --- | --- | --- |
-| `Nifro.icon/` | Preserved editable icon design; not wired into the app target | Imported from the local design package with its original SVG asset |
+| `Nifro.icon/` | Editable icon source used by the app target | Imported from the local design package with its original SVG asset |
| `icon.png` | Nothing references it. Kept as the full-size original, for anywhere 256px is too small | Exported from `Nifro/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png` |
| `icon-256.png` | The heading of both READMEs | Downscaled from `icon.png` |
-| `nifro-icon-256.png` | The heading of both READMEs | Rendered from the checked-in `Nifro/Nifro.icon` source |
+| `nifro-icon-256.png` | The heading of both READMEs | Rendered from the checked-in `assets/Nifro.icon` source |
| `menu-bar-icon-legacy-2026-08-30.svg` | Nothing references it. Kept as Nifro's original menu bar acorn icon | Restored from the revision immediately before the menu bar icon was first redrawn for Nifro |
There is no social preview here any more. Several uploads through Settings → General were accepted
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..1ccc28e
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+# Build locally without opening Xcode. Full Xcode 26+ is required.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+mode="${1:-dev}"
+if [[ $# -gt 0 ]]; then shift; fi
+case "$mode" in
+ -h|--help)
+ cat <<'HELP'
+Usage: ./build.sh [dev|ci|release] [xcodebuild arguments...]
+ dev Release-optimized, ad-hoc signed, isolated com.pathgao.nifro.dev (default)
+ ci Debug, unsigned, isolated development identity
+ release Production identity; requires APPLE_SIGNING_IDENTITY and APPLE_TEAM_ID
+
+Output: .xcode-build/Build/Products/{Debug,Release}/Nifro.app
+No installation, launch, keychain changes, or notarization is performed.
+For a selected Xcode, set DEVELOPER_DIR=/path/to/Xcode.app/Contents/Developer.
+HELP
+ exit 0 ;;
+ dev|ci|release) ;;
+ *) echo "Unknown mode: $mode. See ./build.sh --help." >&2; exit 2 ;;
+esac
+
+version="$(xcodebuild -version)" || {
+ echo "Full Xcode 26+ is required, including the icon and App Intents tools." >&2
+ exit 1
+}
+major="$(sed -n '1s/Xcode \([0-9]*\).*/\1/p' <<< "$version")"
+[[ "$major" =~ ^[0-9]+$ && "$major" -ge 26 ]] || {
+ echo "Xcode 26+ is required. Found: $version" >&2; exit 1;
+}
+
+configuration=Release
+signing=(CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= PROVISIONING_PROFILE_SPECIFIER=)
+identity=(NIFRO_BUNDLE_ID=com.pathgao.nifro.dev NIFRO_URL_SCHEME=nifro-dev)
+case "$mode" in
+ ci) configuration=Debug; signing=(CODE_SIGNING_ALLOWED=NO) ;;
+ release)
+ : "${APPLE_SIGNING_IDENTITY:?Set the Developer ID certificate SHA-1}"
+ : "${APPLE_TEAM_ID:?Set the Developer ID team}"
+ [[ "$APPLE_SIGNING_IDENTITY" =~ ^[0-9A-F]{40}$ && "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || {
+ echo "Invalid signing SHA-1 or team ID." >&2; exit 1;
+ }
+ identity=(NIFRO_BUNDLE_ID=com.pathgao.nifro NIFRO_URL_SCHEME=nifro)
+ signing=(CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="$APPLE_SIGNING_IDENTITY"
+ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" PROVISIONING_PROFILE_SPECIFIER=
+ ENABLE_HARDENED_RUNTIME=YES OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime") ;;
+esac
+
+xcodebuild build -project Tools/Nifro.xcodeproj -scheme Nifro \
+ -configuration "$configuration" -destination 'generic/platform=macOS' \
+ -derivedDataPath .xcode-build "${identity[@]}" "${signing[@]}" "$@"
+
+app=".xcode-build/Build/Products/$configuration/Nifro.app"
+if [[ "$mode" != ci ]]; then
+ codesign --verify --deep --strict "$app"
+ for bundle in "$app" "$app/Contents/PlugIns/Share Extension.appex"; do
+ codesign -d --entitlements - --xml "$bundle" 2>/dev/null \
+ | plutil -extract 'com\.apple\.security\.app-sandbox' raw -o - - | grep -qx true
+ done
+fi
+printf '\nBuilt: %s/%s\n' "$PWD" "$app"
diff --git a/CHANGELOG.md b/docs/CHANGELOG.md
similarity index 100%
rename from CHANGELOG.md
rename to docs/CHANGELOG.md
diff --git a/docs/PLAYLIST-REFACTOR.md b/docs/PLAYLIST-REFACTOR.md
index 1e753ee..f72e612 100644
--- a/docs/PLAYLIST-REFACTOR.md
+++ b/docs/PLAYLIST-REFACTOR.md
@@ -1,276 +1,53 @@
-# Planned: websites become playlists
+# Playlist design
-**Status: not built. This is the design, argued before the code, so the decisions in it can be
-disagreed with while that is still cheap.**
+Implemented in the current baseline (0.9.1). This describes the stored model and
+runtime behavior, not a pending refactor.
-## 1. The one change
+## Ownership and display state
-A website stops belonging to a display. A display picks what to show.
+A `Playlist` owns its `Website` values. Each display selects a playlist and keeps
+its own website cursor. Rotation mode, interval, power and browsing state also
+belong to the display.
+```text
+Playlist → Website values (settings and independent IDs)
+Display → selected Playlist.ID + current Website.ID
```
-today after
-┌─────────────────────────┐ ┌──────────────────────────────┐
-│ Website │ │ Playlist │
-│ display: Display? ───┼── ownership │ websites: [Website] │
-│ isCurrent: Bool │ lives on │ boundDisplay: Binding? │
-└─────────────────────────┘ the site └──────────────────────────────┘
- ▲
-content claims the screen Display ──┘ picks a playlist
- the screen claims the content
-```
-
-Everything below follows from that one inversion, and every risk in it is a place where the old
-direction is still assumed.
-
-The inversion is not cosmetic. `WebsitesController.displaysInUse` — the list `rebuildScenes` reads to
-decide which displays get a scene at all — is derived from the websites' `display` fields. Its own
-comment says so:
-
-> `displaysInUse` is read off the websites, so a display nothing names has no wallpaper.
-
-That is also why `firstLaunchPlacements` pins the Nth featured site to the Nth display: not as
-curation, but because otherwise the second screen is named by nothing and gets no wallpaper. The
-symptom users see — a second display whose website chooser has exactly one item — is a consequence of
-that mechanism, not of a filter written wrong.
-
-## 2. What it dissolves, and what it does not
-
-Counted against the K series rather than asserted.
-
-| | Entry | After |
-|---|---|---|
-| **K17** | The chooser on a display lists only that display's own websites | Gone. The chooser lists the selected playlist's members; `effectiveDisplay == scene.display` is deleted |
-| **K24** | Rotation arrows can be lit and inert | Gone **only if** `canRotate` and `eligible(for:)` are made one expression. They are two derivations of one question today, which is the defect; the refactor is the chance to collapse them, not a cure on its own |
-| **W7** | Move a website to another display | Gone. A website moves between playlists |
-| **K23** | The chooser does not wake a switched-off display; the arrows do | Not dissolved by the model. `show()` and `step()` disagree today and both are rewritten here, so it is cheap to fix in passing — but it is adjacent work, not a consequence |
-
-Fourteen entries are untouched, and it is worth writing down that they are: K1, K6, K7, K8, K12,
-K18, K20, K21, K26, K28, K30, K33, K35, K38. All of them are web view, window ordering, rendering or
-cache problems. None of them has anything to do with which website is on which screen. A refactor
-that dissolves three structural defects and leaves fourteen local ones is a good trade, but not
-because of the count.
-
-**K16 and K31 are stale, in different ways.** Both are written against `mirrorAcrossSyncGroup`, and
-sync groups were pulled — see `docs/shelved/MULTI-DISPLAY-SYNC.md` and V3. K16 cannot happen at all
-today. K31's premise ("sync groups make this the normal configuration") is gone with them, which
-demoted it to a corner case — and then § 6 below promotes it back, for a new reason.
-
-## 3. The model
-
-```swift
-struct Playlist: Codable, Identifiable, Hashable {
- let id: UUID
- var name: String
- var websites: [Website] // bodies, not references — see D1
- var boundDisplay: DisplayBinding? // nil is "none", the default — see D3
-}
-
-/// A binding has to be readable while the display is not attached, so the name is a snapshot.
-struct DisplayBinding: Codable, Hashable {
- let id: UUID
- let nameWhenBound: String
-}
-```
-
-Three pieces of per-display state, each answering one question:
-
-```
-[displayKey: Playlist.ID] which playlist this display is showing (chosen in the panel)
-[displayKey: Website.ID] where in that playlist it is (replaces Website.isCurrent)
-Playlist.boundDisplay which displays may offer this playlist (filters the panel's picker)
-```
-
-`Website` loses two fields: `display` and `isCurrent`.
-
-### D1 — a playlist holds websites, not references to them
-
-The use case that decides this: duplicate the default playlist twice, bind one copy to each display,
-and give the same site a different crop on each screen. A crop is `Website.zoom`, stored on the
-website itself, along with `css`, `javaScript`, `invertColors2`, `usePrintStyles`, `audio` and
-`reloadInterval`. Under a reference model every copy shares all of them, which is the opposite of
-what the feature is for.
-
-So duplication is a deep copy: new `Website` values with new ids. That also settles what "handle the
-rename properly" means — nothing special is needed, because the copy's members are independent
-objects from the moment they exist.
-
-### D2 — the cursor is keyed by display
-
-`Website.isCurrent` is a `Bool` on the website, and `currentFlags(displays:wasCurrent:makingCurrent:)`
-keeps exactly one of them true per display. Its first argument is `all.map(\.effectiveDisplay)` — the
-field being deleted. The mechanism does not survive the change and is not worth patching: "which
-website is up on which display" becomes a dictionary keyed by `Display.settingsKey`, where uniqueness
-is a property of the dictionary rather than of a sweep over a list.
-
-This is the one change in the set that can compile, pass every test, and still be wrong. A
-per-display fact kept in a slot with room for one answer is the shape behind six K entries already;
-see `ScopeTests` for the two guardrails that exist because of it.
-
-### D3 — a binding filters the picker and does nothing else
-
-A playlist bound to a display appears only in that display's picker. It is not applied, not
-preferred, and not consulted at runtime. Two playlists bound to the same display do not conflict —
-both appear in that picker and the user chooses. Dragging the playlist list to reorder it is
-therefore ordering, not precedence.
-
-This is what makes the unplugged case answer itself: a picker exists only for an attached display, so
-a playlist bound to a display that is gone appears in no picker. There is nothing to fall back to and
-no new default to choose. The binding remains visible and removable in the management page, which is
-why `DisplayBinding` carries `nameWhenBound` — `Display.localizedName` resolves through
-`NSScreen.screens` and returns the hardcoded `` for a display that is not attached.
-
-### D4 — a copy starts unbound
-
-Duplicating a bound playlist would otherwise put a second, identical entry in that display's picker
-without the user having asked for one. The copy's binding starts at "none", which is also the shape
-of the use case: duplicate the default twice, then bind each.
-
-### D5 — the panel gains a picker rather than repurposing one
-
-The existing chooser stays, listing websites; a playlist picker is added above it. The column grows
-by 37pt — one 28pt control and one 9pt gap — which two side-by-side columns pay once between them,
-not each. There is room: the popover sits well under the height of the display it opens on.
-
-`DisplayPanel` already argues for the grouping this pressures. Mute and power sit *on* the picture
-deliberately, so that what is under the picture reads as one thing:
-
-> these two say what this display is *doing*, and the row below says what to show on it. Keeping
-> them apart stops the column reading as five controls in a stack with no grouping.
-
-Under the picture there will now be four rows, three of which say "what to show" at different
-grains. That is a layout problem to solve, not a reason to merge the two pickers back into one.
-
-## 4. The six sites
-
-| | Today | After |
-|---|---|---|
-| `displaysInUse` | Distinct `effectiveDisplay` over the websites; a display nothing names gets no scene | Every attached display gets a scene. The direction inverts here and nowhere else |
-| `currentFlags(displays:…)` | Keeps one `isCurrent` true per display | Deleted. Write `[displayKey: Website.ID]` |
-| `firstLaunchPlacements` | Nth featured site pinned to Nth display, to make the second screen exist | Deleted. Everything installs into one default playlist |
-| `eligible(for:)` / `canRotate` | Two derivations: `onDisplay.count > 1` against the set intersected with the schedule | One expression: the selected playlist's members intersected with the schedule |
-| `DisplayPanelModel.column(for:)` | `websites.filter { $0.effectiveDisplay == scene.display }` | The selected playlist's members |
-| `WebsiteDisplaySetting` | A display picker in the website editor | Deleted |
-
-## 5. Invariants, and what holds them
-
-| | Invariant | Symptom when broken | Guardrail |
-|---|---|---|---|
-| **I1** | "What is showing where" has one home | Two answers disagree, silently | `ScopeTests`: no `isCurrent`-shaped field on `Website`; one writer for the cursor |
-| **I2** | "Can it rotate" and "rotate to what" come from one expression | K24 returns: arrows lit, pressing does nothing | Source shape: `canRotate`'s implementation must name `eligible` |
-| **I3** | A binding's display name is a snapshot | `` reaches the UI once a display is unplugged | `Display.localizedName` must not appear on the playlist drawing path |
-
-I1 and I2 are the two that make the refactor worth doing rather than merely different. Both are
-cheap to pin and expensive to discover.
-
-## 6. Order
-
-```
-PR1 Rename today's Playlist.swift independent
- It is rotation and scheduling, not a user-facing list. The word has to be free
- before anything else can use it. Also renames playlistTimer, playlistMinutes,
- resetPlaylistTimer — 33 identifier uses.
-
-PR2 Introduce Playlist, migrate the existing list independent
- Everything into one default playlist. The stored display beside each website is
- left on disk and read by nothing — see the third correction below.
-
-PR2.5 Re-key PerPageDefaults from URL to website.id before PR6
- Scroll position, last address and page zoom are keyed by URL. Two copies of one
- website share a URL, so duplication makes K31 the normal configuration — the exact
- thing sync groups used to do. Costs the saved positions once.
-
-PR3 Move the cursor off Website.isCurrent needs PR2
- This is I1. The only step that can be wrong while green.
-
-PR4 Invert displaysInUse needs PR2
- Attached displays get scenes. firstLaunchPlacements deleted.
-
-PR5 Panel: add the playlist picker needs PR3, PR4
- Website chooser reads the selected playlist. canRotate reads eligible (I2).
- K17 and K24 close here.
-
-PR6 Management page: list, drag, ⋮ menu, bind, duplicate needs PR2.5
- Can run beside PR5.
-
-PR7 Delete Website.display, WebsiteDisplaySetting,
- keepWallpaperWhenDisplayUnplugged, Website.shouldBeOnScreen
- and Display.withFallbackToMain last
-```
-
-PR1 and PR2 are the first two links of a chain; PR5 and PR6 can be opened in parallel once their
-dependencies land.
-
-**Three corrections, from building it rather than planning it.**
-
-PR2 is not independently landable, and the word "independent" above was wrong about it. `periphery
-scan --strict` runs in CI, and a model whose fields are written by the migration and read by nothing
-is five findings. The readers arrive in PR5 and PR6. Silencing it in `.periphery.yml` is the move
-that file exists to refuse — "the readers arrive later" is a justification with nothing about it that
-expires. So PR2 lands with the PRs that give it readers, and PR1 and PR2.5 land on their own.
-
-PR3 and PR4 are not parallel either. They share six files, `WebsitesController.swift` and
-`Website.swift` among them. PR5 and PR6 still are.
-
-"`Website.display` still readable" was a step nothing ever took. PR2 shipped a `PinnedWebsite`
-wrapper that decoded the stored `display` beside each website, and the migration's very next line was
-`stored.map(\.website)` — so the field was read and dropped without anything looking at it, which is
-what "readable" was meant to buy. It is deleted now and `Defaults[.websites]` is a plain
-`Key<[Website]>`. Nothing about the data on disk changes: a synthesised `Codable` asks for the keys it
-declares and skips the rest, so the records that carry a `display` decode exactly as they already did,
-and the field stays on disk as the record of what the list was before the conversion.
-`WebsiteDecodingTests.aRecordWithADisplayStillDecodes` runs one of them.
-
-## 7. Decided here, because the design forced the question
-
-### D6 — the default playlist cannot be bound
-
-If every playlist were bound to some display, another display's picker would be empty and that screen
-would show nothing — a state the user can reach by ordinary use and cannot get out of from the panel.
-Refusing a binding on the default playlist keeps at least one entry in every picker, which is the
-cheapest guard available and needs no runtime check anywhere else. The ⋮ menu disables the display
-option for that one playlist.
-
-### D7 — `keepWallpaperWhenDisplayUnplugged` is deleted, and its default behaviour becomes the only
-behaviour
-The setting exists because a website is pinned to a display: off, the wallpaper goes away with that
-display and comes back when it is plugged in; on, it moves to the main display and takes over there.
-Nothing is pinned after this, so the "on" half has nothing to describe — and the "off" half stops
-needing code at all. An unplugged display has no scene, so nothing shows; plug it back in and the
-display gets a scene and its stored playlist selection with it. What the default did, the model does.
+`WebsitesController.playlist(for:in:)` falls back to the default playlist when a
+selection is missing or refers to a deleted playlist. An empty library can still
+have no playlist to show.
-`Website.shouldBeOnScreen` and `Display.withFallbackToMain` exist only to serve the "on" half and go
-with it.
+The stored cursor records what was requested. `WallpaperScene.website` resolves
+that request against available, scheduled websites. `loadedWebsiteID` records
+what the web view has actually loaded. These can differ during a page change.
-### D8 — a display with no selection shows the default playlist
+## Binding and duplication
-PR4 surfaced the gap: once every attached display gets a scene, a display the user has never picked
-for has to show *something*, and `firstLaunchPlacements` — the mechanism that used to answer this by
-pinning the Nth site to the Nth screen — is deleted by the same PR. Between the two, a second monitor
-on a fresh install draws "No Website" and waits to be told.
+A playlist's `boundDisplay` filters the picker. It does not move content or force
+a runtime selection. The saved display name remains readable while unplugged.
+The default playlist refuses a binding so every display can select it.
-So an empty entry in `[displayKey: Playlist.ID]` means the default playlist, and the picker shows it
-selected rather than showing nothing. That is what makes D6 load-bearing rather than merely tidy: the
-default playlist is the one every display falls back to, which is the second reason it cannot be
-bound to one of them.
+Duplication creates a new playlist and new website IDs, starts unbound, and clears
+the default flag. Settings are copied, but login sessions, page storage and saved
+positions are independent because they are keyed by website ID.
-It also replaces what first-run curation was for. The old rule put a different site on each screen;
-this puts the same list on each screen and lets each display walk it independently, which is what the
-per-display cursor already does.
+## Startup and rotation
-## 8. Open
+Attached displays own wallpaper scenes. On first installation, featured websites
+are added to the default playlist and assigned to displays in order, main display
+first. This sets the cursor without binding a website to a display.
-**A duplicate is logged out.** Data stores are keyed by `website.id`
-(`WKWebsiteDataStore(forIdentifier:)`), so a deep copy gets a new, empty store. For the stated use
-case that costs nothing, but it is what "duplicate" means here: the configuration is copied, the
-session is not. Worth a sentence in the confirmation rather than a mechanism.
+`RotationBehaviour` resolves eligible websites from the selected playlist and its
+schedule. Per-display cursors and shuffled orders keep navigation independent.
+Unplugging a display leaves its stored selection available for reconnection.
-## 9. Migration
+## Storage boundary
-There is no upgrade mechanism at all — E23 says so, and says the shape of one is still free to
-choose because nobody is upgrading from anything yet. This is the change that needs it.
+`Defaults.playlists` is the source of website storage. The earlier flat-list
+conversion and display-pinning migration have been removed from this baseline.
+The historical migration proposal is not an implemented upgrade guarantee.
-The existing list is the first case to carry: websites with `display` set become members of a
-playlist bound to that display; the rest become the default playlist. Per-display settings
-(`rotationModes`, `rotationIntervals`, `disabledDisplays`) are keyed by display and stay that way —
-they describe the screen, not the content.
+See `Sources/Nifro/Sites/Playlist.swift`, `WebsitesController.swift`, and
+`RotationBehaviour.swift` for the implementation. `Tests/PlaylistRulesTests.swift`
+checks that copied members receive fresh identities.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 1ef5486..3ef1e0d 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -3,7 +3,7 @@
Nifro distributes through GitHub Releases and the Homebrew cask in this repository.
Official releases now require Developer ID signing, Apple notarization, ticket stapling,
and Gatekeeper verification. Missing credentials or a failed build stop the workflow
-before it creates a tag. Self-signed development builds remain available locally.
+before it creates a tag. Ad-hoc signed development builds remain available locally.
## Signing environment
@@ -38,23 +38,26 @@ certificate and stapled-ticket verification; Gatekeeper accepted both apps as `N
## Local builds
-`./Tools/build-local.sh` keeps the stable self-signed development identity and installs a test
-copy on the Desktop. To build with Nifro's exact Developer ID certificate already in the local
-keychain, without modifying the default development identity:
+`./build.sh` builds an isolated, ad-hoc signed development app. It does not create
+certificates, change keychains, install the app, or quit a running copy.
+
+For a production-identity build with an existing Developer ID certificate:
```sh
APPLE_SIGNING_IDENTITY=57E90D936910FD25342D56B3D8E35E1CC6DF201C \
APPLE_TEAM_ID=GN56VLVTJ6 \
-./Tools/build-local.sh .release/Nifro-test.app
+./build.sh release
```
-Build products live in `.xcode-build`. The script preserves sandbox entitlements and verifies
-the signature before copying the app. A local Developer ID build is **not notarized** by this
-script. Use the release workflow's dry run to exercise notarization and packaging.
+The app is `.xcode-build/Build/Products/Release/Nifro.app`. The script verifies the
+signature and sandbox entitlements of both the app and share extension. A local
+Developer ID build is **not notarized**. Use the release workflow's dry run to
+exercise notarization and packaging. Production-identity builds use the installed
+release's data, so use the default development mode for isolated testing.
## Release sequence
-1. Update `MARKETING_VERSION` in `Config.xcconfig` and land the reviewed changes on `main`.
+1. Update `MARKETING_VERSION` in `Tools/Config/App.xcconfig` and land the reviewed changes on `main`.
2. Dispatch `gh workflow run release.yml --ref main -f dry_run=true` and approve the environment.
Both architectures must build, sign, notarize, staple and pass Gatekeeper. Disk images are
retained as the `dry-run-disk-images` artifact; no tag or release is created.
diff --git a/docs/VISUAL-SYSTEM.md b/docs/VISUAL-SYSTEM.md
index 2a11c88..976abc5 100644
--- a/docs/VISUAL-SYSTEM.md
+++ b/docs/VISUAL-SYSTEM.md
@@ -55,7 +55,7 @@ could give it. It is exempt on the strongest ground available, not by concession
just a number.
```sh
-grep -rhoE --include='*.swift' "(cornerRadius|xRadius|yRadius): *[0-9]+" Nifro/Screens Nifro/Zoom |
+grep -rhoE --include='*.swift' "(cornerRadius|xRadius|yRadius): *[0-9]+" Sources/Nifro/Screens Sources/Nifro/Zoom |
grep -oE "[0-9]+$" | sort -n | uniq -c
```
@@ -95,7 +95,7 @@ Against `WORKSPACE_GUIDE.md` § "缺陷的三个形状".
panel files:
```sh
- grep -rc "PanelMetrics\." Nifro/Screens/*.swift # DisplayPanel 8, PanelControls 8
+ grep -rc "PanelMetrics\." Sources/Nifro/Screens/*.swift # DisplayPanel 8, PanelControls 8
```
Against 39 visual literals in those same two files. The mechanism covers roughly a fifth of what
@@ -192,7 +192,7 @@ missing but that it is named for one screen, lives beside the views it serves, a
them. So this is a rename, a handful of additions each of which has at least two existing users, and
one shared modifier — not a new framework.
-### 5.1 One file: `Nifro/Screens/Appearance.swift`
+### 5.1 One file: `Sources/Nifro/Screens/Appearance.swift`
`PanelMetrics` moves here and becomes `Appearance`, keeping its documentation. It gets its own file
so that the lint rule in § 5.3 can exempt one stable path; leaving it in `PanelControls.swift` would
@@ -246,24 +246,24 @@ No protocol, no style registry, no `ButtonStyle` hierarchy. There is one panel.
### 5.3 The guardrail: three SwiftLint custom rules
SwiftLint is already pinned at 0.65.1 and already runs `--strict` in CI (`ci.yml:99`), so no new tool
-and no new job. Added to `.swiftlint.yml`:
+and no new job. Added to `Tools/Config/SwiftLint.yml`:
```yaml
custom_rules:
hardcoded_corner_radius:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
excluded: 'Nifro/(Screens/Appearance|Zoom/CropSelectionView)\.swift'
regex: '(cornerRadius|xRadius|yRadius): *[0-9]'
message: 'Corner radii belong in Appearance. Name it there, or disable this line with the reason it is not shared.'
hardcoded_font_size:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
excluded: 'Nifro/(Screens/Appearance|Zoom/CropSelectionView)\.swift'
regex: '\.(system|systemFont|monospacedSystemFont|monospacedDigitSystemFont)\((of)?[Ss]ize: *[1-9]'
message: 'Point sizes belong in Appearance.'
hardcoded_ui_colour:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
excluded: 'Nifro/(Screens/Appearance|Zoom/CropSelectionView)\.swift'
regex: 'Color\(red:|(NS)?Color\.(white|black)\b|AnyShapeStyle\(\.white\)'
message: 'A literal colour follows neither the theme nor the user accent. Use Appearance, or a semantic style.'
@@ -287,7 +287,7 @@ swiftlint lint --quiet --config /path/to/probe.yml | grep -c 'warning:'
- Per-rule `excluded:` works: pointing it at `PanelControls.swift` drops the count 32 → 24, exactly
that file's eight.
- `// swiftlint:disable:next hardcoded_corner_radius - ` silences one line, and
- `superfluous_disable_command` — already enabled in `.swiftlint.yml` — reports the disable as a
+ `superfluous_disable_command` — already enabled in `Tools/Config/SwiftLint.yml` — reports the disable as a
violation once the line under it stops violating. So an allowlist entry whose reason has expired
fails, which is the property `WORKSPACE_GUIDE.md` asks allowlists to have and the reason this is a
lint rule rather than a source-shape test.
@@ -313,7 +313,7 @@ number has a name to be given, and today it does not.
## 6. Migration, and the 32 violations
-**There is no "warn first" phase.** CI already runs `swiftlint lint --strict`, so a custom rule at
+**There is no "warn first" phase.** CI already runs `swiftlint lint --config Tools/Config/SwiftLint.yml --strict`, so a custom rule at
default severity is red the moment it lands. The order below exists to bring the count to zero before
the rule is added, not to ease it in.
@@ -322,7 +322,7 @@ the rule is added, not to ease it in.
| 1 | `Appearance.swift`: move `PanelMetrics`, rename, add the eight names from § 5.1 | `git grep -c PanelMetrics` → 0; the app builds; `swift test` unchanged (the package target does not compile these files, so this step cannot break it) |
| 2 | `panelChrome`, and the sync-link button adopts it | The literal count in `DisplayPanel.swift` falls from 31 to ~20. Visible change: the sync button becomes 26×22 and gains a hover state |
| 3 | Q1 and Q2 in § 7 — decisions, not renames | Nothing to measure. Needs you |
-| 4 | Add the three rules. Three violations remain and each takes a `disable:next` with its reason | `swiftlint lint --strict` exits 0. From here, a hardcoded literal is red on the pull request that writes it |
+| 4 | Add the three rules. Three violations remain and each takes a `disable:next` with its reason | `swiftlint lint --config Tools/Config/SwiftLint.yml --strict` exits 0. From here, a hardcoded literal is red on the pull request that writes it |
### The 32, each with its disposition
diff --git a/docs/VISUAL-SYSTEM.zh-Hans.md b/docs/VISUAL-SYSTEM.zh-Hans.md
index 5563f63..e3a3569 100644
--- a/docs/VISUAL-SYSTEM.zh-Hans.md
+++ b/docs/VISUAL-SYSTEM.zh-Hans.md
@@ -50,7 +50,7 @@ done
**哪些值是重复的。** 让字面量变成缺陷的是重数;只用一次的数字就只是个数字。
```sh
-grep -rhoE --include='*.swift' "(cornerRadius|xRadius|yRadius): *[0-9]+" Nifro/Screens Nifro/Zoom |
+grep -rhoE --include='*.swift' "(cornerRadius|xRadius|yRadius): *[0-9]+" Sources/Nifro/Screens Sources/Nifro/Zoom |
grep -oE "[0-9]+$" | sort -n | uniq -c
```
@@ -85,7 +85,7 @@ grep -rhoE --include='*.swift' "(cornerRadius|xRadius|yRadius): *[0-9]+" Nifro/S
- **`PanelMetrics` 有六个成员,`PanelMetrics.` 出现 16 次**,两个面板文件各 8 次:
```sh
- grep -rc "PanelMetrics\." Nifro/Screens/*.swift # DisplayPanel 8, PanelControls 8
+ grep -rc "PanelMetrics\." Sources/Nifro/Screens/*.swift # DisplayPanel 8, PanelControls 8
```
而这两个文件里有 39 个视觉字面量。这套机制覆盖了它存在理由的大约五分之一。
@@ -165,7 +165,7 @@ grep -rhoE --include='*.swift' "(cornerRadius|xRadius|yRadius): *[0-9]+" Nifro/S
视图放在一起、只覆盖了其中五分之一。所以这是一次重命名、几个新增(每个都有至少两个既有使用者),加一个共享
modifier——不是一套新框架。
-### 5.1 一个文件:`Nifro/Screens/Appearance.swift`
+### 5.1 一个文件:`Sources/Nifro/Screens/Appearance.swift`
`PanelMetrics` 搬过来,改名 `Appearance`,保留它的文档。它要有自己的文件,是为了让 5.3 的 lint 规则能豁免
一条稳定路径;留在 `PanelControls.swift` 里会连带豁免 `PanelButton` 和 `PanelWideButton`——而那正是这件事
@@ -218,24 +218,24 @@ padding,它们留在调用点,因为那才是真正的差别。
### 5.3 护栏:三条 SwiftLint 自定义规则
SwiftLint 已经 pin 在 0.65.1,CI 里已经跑 `--strict`(`ci.yml:99`),所以不引入新工具、不新增 job。
-加进 `.swiftlint.yml`:
+加进 `Tools/Config/SwiftLint.yml`:
```yaml
custom_rules:
hardcoded_corner_radius:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
excluded: 'Nifro/(Screens/Appearance|Zoom/CropSelectionView)\.swift'
regex: '(cornerRadius|xRadius|yRadius): *[0-9]'
message: '圆角属于 Appearance。在那里给它一个名字,或者用 disable 注明这一次为什么不是共享的。'
hardcoded_font_size:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
excluded: 'Nifro/(Screens/Appearance|Zoom/CropSelectionView)\.swift'
regex: '\.(system|systemFont|monospacedSystemFont|monospacedDigitSystemFont)\((of)?[Ss]ize: *[1-9]'
message: '字号属于 Appearance。'
hardcoded_ui_colour:
- included: 'Nifro/(Screens|Zoom|Visibility)/.*\.swift'
+ included: 'Sources/Nifro/(Screens|Zoom|Visibility)/.*\.swift'
excluded: 'Nifro/(Screens/Appearance|Zoom/CropSelectionView)\.swift'
regex: 'Color\(red:|(NS)?Color\.(white|black)\b|AnyShapeStyle\(\.white\)'
message: '字面颜色既不跟随主题也不跟随用户 accent。用 Appearance,或者用语义样式。'
@@ -256,7 +256,7 @@ swiftlint lint --quiet --config /path/to/probe.yml | grep -c 'warning:'
- 三条规则在 `dead3a6` 上触发 **32 次**。第 6 节逐条列出。
- 按规则的 `excluded:` 生效:把它指向 `PanelControls.swift`,计数 32 → 24,正好是那个文件的 8 条。
- `// swiftlint:disable:next hardcoded_corner_radius - <理由>` 能消掉一行;而
- `superfluous_disable_command`——`.swiftlint.yml` 里**已经**开着——会在下面那行不再违规时把这条 disable
+ `superfluous_disable_command`——`Tools/Config/SwiftLint.yml` 里**已经**开着——会在下面那行不再违规时把这条 disable
本身报成违规。也就是说,**理由过期的允许清单条目会失败**,这正是 `WORKSPACE_GUIDE.md` 要求允许清单具备
的性质,也是这件事做成 lint 规则而不是源码形状测试的原因。
@@ -277,7 +277,7 @@ diff 里指到出问题的那一列。测试要把这些全部重写一遍,而
## 6. 迁移顺序,以及那 32 条
-**没有「先只告警」这个阶段。** CI 已经跑 `swiftlint lint --strict`,所以一条默认严重度的自定义规则落地
+**没有「先只告警」这个阶段。** CI 已经跑 `swiftlint lint --config Tools/Config/SwiftLint.yml --strict`,所以一条默认严重度的自定义规则落地
当天就是红的。下面这个顺序的作用是在规则加进去之前把计数降到零,不是让它慢慢过渡。
| 步骤 | 做什么 | 验证判据 |
@@ -285,7 +285,7 @@ diff 里指到出问题的那一列。测试要把这些全部重写一遍,而
| 1 | `Appearance.swift`:搬 `PanelMetrics`、改名、加 5.1 里那八个名字 | `git grep -c PanelMetrics` → 0;app 能构建;`swift test` 不变(package target 不编译这些文件,所以这一步不可能弄坏它) |
| 2 | `panelChrome`,同步链接按钮采用它 | `DisplayPanel.swift` 的字面量数从 31 降到约 20。可见变化:同步按钮变成 26×22 并获得 hover 状态 |
| 3 | 第 7 节的 Q1 和 Q2 —— 是决定,不是改名 | 没有可量的东西。需要你 |
-| 4 | 加那三条规则。剩三条违规,每条一行 `disable:next` 附理由 | `swiftlint lint --strict` 退出码 0。从此之后,写下一个硬编码字面量的那个 PR 当场变红 |
+| 4 | 加那三条规则。剩三条违规,每条一行 `disable:next` 附理由 | `swiftlint lint --config Tools/Config/SwiftLint.yml --strict` 退出码 0。从此之后,写下一个硬编码字面量的那个 PR 当场变红 |
### 那 32 条,逐条给出处置
diff --git a/docs/shelved/MULTI-DISPLAY-SYNC.md b/docs/shelved/MULTI-DISPLAY-SYNC.md
index f3fa353..8b8a45e 100644
--- a/docs/shelved/MULTI-DISPLAY-SYNC.md
+++ b/docs/shelved/MULTI-DISPLAY-SYNC.md
@@ -14,7 +14,7 @@ Why the file is here and not in `docs/` proper: `docs/shelved/` participates in
target lists it, `Package.swift` does not name it, `swiftlint` and `periphery` never read a markdown
file, and the release disk image contains the built `.app` and an Applications symlink and nothing
else from the repository. It is markdown rather than an unbuilt `.swift` file for one reason:
-`.swiftlint.yml` has no `included:` key, so SwiftLint walks the whole working tree and a stray
+`Tools/Config/SwiftLint.yml` has no `included:` key, so SwiftLint walks the whole working tree and a stray
`.swift` file anywhere in it would be linted — the feature would still be in the build, just quietly.
Removed on the maintainer's decision after three audits on real two-display hardware found the
@@ -55,7 +55,7 @@ The arrangement survived quitting and relaunching the app.
## 2. The UI, preserved
-Verbatim from `Nifro/Screens/DisplayPanel.swift` and `Nifro/Screens/DisplayPanelModel.swift` as they
+Verbatim from `Sources/Nifro/Screens/DisplayPanel.swift` and `Sources/Nifro/Screens/DisplayPanelModel.swift` as they
stood at removal. Nothing here compiles any more — `SyncGroup` and `Display.settingsKey`'s sync use
are gone — and it is not meant to. Read the doc comments; they are the design.
@@ -280,7 +280,7 @@ follower is offered one entry and a leader is offered the rest.
### 2.6 The strings
-Four entries in `Nifro/Localizable.xcstrings`, with their translations.
+Four entries in `Sources/Nifro/Localizable.xcstrings`, with their translations.
| Key | zh-Hans |
|---|---|
From 2154e27d01dfb7add91e411e971ce73cf05981f3 Mon Sep 17 00:00:00 2001
From: PathGao
Date: Wed, 16 Sep 2026 20:27:15 +0800
Subject: [PATCH 2/2] Resolve project paths consistently for Xcode and
Periphery
---
Tools/Nifro.xcodeproj/project.pbxproj | 32 +++++++++----------
.../xcshareddata/xcschemes/Nifro.xcscheme | 6 ++--
.../xcschemes/ShareExtension.xcscheme | 8 ++---
3 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/Tools/Nifro.xcodeproj/project.pbxproj b/Tools/Nifro.xcodeproj/project.pbxproj
index ee6e4dd..799d5e9 100644
--- a/Tools/Nifro.xcodeproj/project.pbxproj
+++ b/Tools/Nifro.xcodeproj/project.pbxproj
@@ -113,7 +113,7 @@
E32421332384E9D700D28A91 /* AppState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AppState.swift; sourceTree = ""; usesTabs = 1; };
E32421352384E9D700D28A91 /* AddWebsiteScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AddWebsiteScreen.swift; sourceTree = ""; usesTabs = 1; };
E32421372384E9D900D28A91 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
- A1C0DE01B0074E01A00000F2 /* Nifro.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.icon; path = assets/Nifro.icon; sourceTree = SOURCE_ROOT; };
+ A1C0DE01B0074E01A00000F2 /* Nifro.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.icon; path = ../assets/Nifro.icon; sourceTree = SOURCE_ROOT; };
E324213A2384E9D900D28A91 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
E324213F2384E9D900D28A91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
2BA31C0FAF40DE6D9B93FFA1 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
@@ -166,7 +166,7 @@
E32AD6BC26D275070008D900 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; };
E32AD6B226D2718B0008D900 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
E32AD6B326D2718B0008D900 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; };
- E32AD6BB26D271C00008D900 /* Tools/Config/App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tools/Config/App.xcconfig; sourceTree = ""; };
+ E32AD6BB26D271C00008D900 /* Tools/Config/App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = ""; };
E333EB1A2385244E005FCE44 /* SettingsScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = SettingsScreen.swift; sourceTree = ""; usesTabs = 1; };
A1C0DE01B0074E01A0000076 /* DisplayPanelController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DisplayPanelController.swift; sourceTree = ""; usesTabs = 1; };
A1C0DE01B0074E01A0000074 /* DisplayPanelModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DisplayPanelModel.swift; sourceTree = ""; usesTabs = 1; };
@@ -244,7 +244,7 @@
E3EF205F24C0D1A100A5F802 /* Other */,
E32421392384E9D900D28A91 /* Preview Content */,
);
- path = Sources/Nifro;
+ path = ../Sources/Nifro;
sourceTree = "";
};
E32421392384E9D900D28A91 /* Preview Content */ = {
@@ -263,7 +263,7 @@
E32AD6B226D2718B0008D900 /* Info.plist */,
E32AD6B326D2718B0008D900 /* ShareExtension.entitlements */,
);
- path = Sources/ShareExtension;
+ path = ../Sources/ShareExtension;
sourceTree = "";
};
E3EF205F24C0D1A100A5F802 /* Other */ = {
@@ -470,7 +470,7 @@
);
preferredProjectObjectVersion = 77;
productRefGroup = E32421312384E9D700D28A91 /* Products */;
- projectDirPath = "..";
+ projectDirPath = "";
projectRoot = "";
targets = (
E324212F2384E9D700D28A91 /* Nifro */,
@@ -519,7 +519,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "PATH=\"/opt/homebrew/bin/:${PATH}\"\n# Skipped rather than failed when it is not installed. Style is checked on CI, and a\n# contributor who has not installed SwiftLint should still be able to build and run the app.\nif command -v swiftlint >/dev/null; then\n swiftlint lint --config Tools/Config/SwiftLint.yml\nelse\n echo \"warning: SwiftLint is not installed, skipping. brew install swiftlint\"\nfi\n";
+ shellScript = "cd \"$SRCROOT/..\"\nPATH=\"/opt/homebrew/bin/:${PATH}\"\n# Skipped rather than failed when it is not installed. Style is checked on CI, and a\n# contributor who has not installed SwiftLint should still be able to build and run the app.\nif command -v swiftlint >/dev/null; then\n swiftlint lint --config Tools/Config/SwiftLint.yml\nelse\n echo \"warning: SwiftLint is not installed, skipping. brew install swiftlint\"\nfi\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
@@ -744,16 +744,16 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Nifro;
- CODE_SIGN_ENTITLEMENTS = Sources/Nifro/Nifro.entitlements;
+ CODE_SIGN_ENTITLEMENTS = ../Sources/Nifro/Nifro.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_ASSET_PATHS = "\"Sources/Nifro/Preview Content\"";
+ DEVELOPMENT_ASSET_PATHS = "\"../Sources/Nifro/Preview Content\"";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Sources/Nifro/Info.plist;
+ INFOPLIST_FILE = ../Sources/Nifro/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "MIT License. See the license file in the source repository.";
@@ -771,16 +771,16 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Nifro;
- CODE_SIGN_ENTITLEMENTS = Sources/Nifro/Nifro.entitlements;
+ CODE_SIGN_ENTITLEMENTS = ../Sources/Nifro/Nifro.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_ASSET_PATHS = "\"Sources/Nifro/Preview Content\"";
+ DEVELOPMENT_ASSET_PATHS = "\"../Sources/Nifro/Preview Content\"";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Sources/Nifro/Info.plist;
+ INFOPLIST_FILE = ../Sources/Nifro/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "MIT License. See the license file in the source repository.";
@@ -797,14 +797,14 @@
E32AD6B826D2718B0008D900 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = Sources/ShareExtension/ShareExtension.entitlements;
+ CODE_SIGN_ENTITLEMENTS = ../Sources/ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Sources/ShareExtension/Info.plist;
+ INFOPLIST_FILE = ../Sources/ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Nifro;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -820,14 +820,14 @@
E32AD6B926D2718B0008D900 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGN_ENTITLEMENTS = Sources/ShareExtension/ShareExtension.entitlements;
+ CODE_SIGN_ENTITLEMENTS = ../Sources/ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
- INFOPLIST_FILE = Sources/ShareExtension/Info.plist;
+ INFOPLIST_FILE = ../Sources/ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Nifro;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
diff --git a/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
index 7f9f228..1ea609a 100644
--- a/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
+++ b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/Nifro.xcscheme
@@ -17,7 +17,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Tools/Nifro.xcodeproj">
+ ReferencedContainer = "container:Nifro.xcodeproj">
@@ -47,7 +47,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Tools/Nifro.xcodeproj">
+ ReferencedContainer = "container:Nifro.xcodeproj">
@@ -64,7 +64,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Tools/Nifro.xcodeproj">
+ ReferencedContainer = "container:Nifro.xcodeproj">
diff --git a/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
index d38dbd1..df3f8f1 100644
--- a/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
+++ b/Tools/Nifro.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
@@ -18,7 +18,7 @@
BlueprintIdentifier = "E32AD6A826D2718B0008D900"
BuildableName = "Share Extension.appex"
BlueprintName = "Share Extension"
- ReferencedContainer = "container:Tools/Nifro.xcodeproj">
+ ReferencedContainer = "container:Nifro.xcodeproj">
+ ReferencedContainer = "container:Nifro.xcodeproj">
@@ -64,7 +64,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Tools/Nifro.xcodeproj">
+ ReferencedContainer = "container:Nifro.xcodeproj">
@@ -83,7 +83,7 @@
BlueprintIdentifier = "E324212F2384E9D700D28A91"
BuildableName = "Nifro.app"
BlueprintName = "Nifro"
- ReferencedContainer = "container:Tools/Nifro.xcodeproj">
+ ReferencedContainer = "container:Nifro.xcodeproj">