diff --git a/Nifro.xcodeproj/project.pbxproj b/Nifro.xcodeproj/project.pbxproj index bc5a383..5a0d35b 100644 --- a/Nifro.xcodeproj/project.pbxproj +++ b/Nifro.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ E32421342384E9D700D28A91 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32421332384E9D700D28A91 /* AppState.swift */; }; E32421362384E9D700D28A91 /* AddWebsiteScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32421352384E9D700D28A91 /* AddWebsiteScreen.swift */; }; E32421382384E9D900D28A91 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E32421372384E9D900D28A91 /* Assets.xcassets */; }; + A1C0DE01B0074E01A00000F1 /* Nifro.icon in Resources */ = {isa = PBXBuildFile; fileRef = A1C0DE01B0074E01A00000F2 /* Nifro.icon */; }; E324213B2384E9D900D28A91 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E324213A2384E9D900D28A91 /* Preview Assets.xcassets */; }; E32421472384EB4E00D28A91 /* DesktopWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32421462384EB4E00D28A91 /* DesktopWindow.swift */; }; 66A8D44DC83A0DDE8B0E8EE2 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BA31C0FAF40DE6E9B93FFE2 /* Actions.swift */; }; @@ -112,6 +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 = ""; }; 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 = ""; }; @@ -238,6 +240,7 @@ 56260C7010D9CCF5536E7C56 /* Screens */, 7964C184AA1CBE4499C98410 /* Support */, E32421372384E9D900D28A91 /* Assets.xcassets */, + A1C0DE01B0074E01A00000F2 /* Nifro.icon */, E3EF205F24C0D1A100A5F802 /* Other */, E32421392384E9D900D28A91 /* Preview Content */, ); @@ -485,6 +488,7 @@ 66A8D44DC83A0DDD8B0E8AA1 /* Localizable.xcstrings in Resources */, E324213B2384E9D900D28A91 /* Preview Assets.xcassets in Resources */, E32421382384E9D900D28A91 /* Assets.xcassets in Resources */, + A1C0DE01B0074E01A00000F1 /* Nifro.icon in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -739,7 +743,7 @@ E32421442384E9D900D28A91 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Nifro; CODE_SIGN_ENTITLEMENTS = Nifro/Nifro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; @@ -766,7 +770,7 @@ E32421452384E9D900D28A91 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = Nifro; CODE_SIGN_ENTITLEMENTS = Nifro/Nifro.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; diff --git a/Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg b/Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg index d159815..cf78e7d 100644 --- a/Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg +++ b/Nifro/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.svg @@ -1,7 +1,6 @@ - + - - - + + diff --git a/Nifro/Nifro.icon/Assets/Rectangle 2 2.svg b/Nifro/Nifro.icon/Assets/Rectangle 2 2.svg new file mode 100644 index 0000000..a98d8ed --- /dev/null +++ b/Nifro/Nifro.icon/Assets/Rectangle 2 2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Nifro/Nifro.icon/icon.json b/Nifro/Nifro.icon/icon.json new file mode 100644 index 0000000..7264cf3 --- /dev/null +++ b/Nifro/Nifro.icon/icon.json @@ -0,0 +1,56 @@ +{ + "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/README.md b/README.md index 8028e8b..83acc2d 100644 --- a/README.md +++ b/README.md @@ -111,95 +111,94 @@ empties it on demand. Inspired by [Plash](https://github.com/sindresorhus/Plash) by Sindre Sorhus. -Nifro exists because a website-as-wallpaper app, as the idea is usually built, rests on two -assumptions worth revisiting: +Nifro starts with the same idea: use a web page as your desktop wallpaper. Everything around that idea +has been rebuilt. -- It keeps a browser rendering continuously to display content that changes once a minute. -- It is not really a wallpaper. It is a transparent window sitting just above one. +The interface makes the relationship between websites, playlists and displays easy to see and control. +Each display has its own wallpaper, controls and state. You can crop a page to the part that belongs on +your desktop, and manage sites as playlists instead of a flat list. -Undoing those is what this fork is about, and the first one has been harder than it looks: two -rendering backends, occlusion measurement and automatic still detection were all built, and every one -of them turned out to own an answer that Browsing Mode also owns. They came back out — 811 lines — and -go back in one piece at a time, each with a measurement first. [docs/ROADMAP.md](docs/ROADMAP.md) §2 -has what that cost and what has to be true before any of it returns. +Performance and day-to-day use are part of that redesign too. The idea remains; nearly every part of +the app that delivers it has been rebuilt. ## What it does -**Frame part of a page by moving it.** Drag or two-finger scroll the wallpaper, pinch to zoom, and -what you leave on screen is the region. You aim at the result rather than at the thing you are -framing, and it starts from the region a website already has, so it adjusts as well as creates. The -page still lays out at full size, so the site does not reflow into something you did not frame, and -the region is re-rendered rather than scaled up, so text stays sharp. +**Put a web page on your desktop.** Use a live camera, an art site, a map, a dashboard or any other +page that works better in the background than in another browser tab. -**A region survives moving to another display.** It is stored as a place and a magnification rather -than a rectangle, so a screen of a different shape works out its own rectangle around the same part -of the page. +**Give every display its own page.** On a multi-display Mac, each screen can show something different. +The menu-bar panel puts every display side by side, with its current page and only its own controls. -**One page per display, and a panel that shows them side by side.** Assign a website to a screen and -each screen gets its own. The menu bar icon opens one column per display: a live preview of what is on -it, its name, the controls that belong to that screen alone — mute, off, previous and next, pin or -rotate, Crop, Browsing Mode — and nothing that would apply to a screen you were not looking at. +**Show only the useful part of a page.** Drag, scroll or zoom to frame the region you want. Nifro +remembers that region and keeps the same place in view when you move it to a display of another size. + +**Organize sites as playlists.** Make playlists for work, scenery or anything else, then let a display +stay on one site, move through them in order or at random, or follow each site's schedule.

The display panel with one column per screen

-**Rotation with hours.** Rotate through the websites on a display, and let a website say when it -is allowed to be up. A schedule never leaves a display empty. +**Use a page when you need to.** Hold a key to click, scroll and zoom it; let go and it becomes a +wallpaper again. Sound and link behaviour are remembered for each website. -**Hold a key to use the page.** Press and hold to click, scroll and zoom; let go and it is a -wallpaper again. +**Start from a gallery or make your own.** The built-in gallery has sites selected for desktop use, +and you can add any page yourself. Nifro is available in English and Simplified Chinese. -**Audio per website.** A clock should never make a sound, a live stream is pointless without one. +## Best uses -**A curated site list.** Pages that work well as wallpapers, each carrying the settings that make it -work. The in-app gallery reads it straight from this branch, so a merged entry appears without waiting -for a release. Suggesting one takes a form and the app's Copy Settings button. +Nifro works best with pages that are pleasant to leave on screen and still useful when you only glance +at them. -**Links decide per website where they open.** A site you sign in to has to keep its links in Nifro, -because signing in navigates away from it; a dashboard's links belong in your browser. Each website -answers for itself, or follows the app-wide default. +- **Generative art and ambient animation.** [Floor796](https://floor796.com/) and similar art sites + turn a static desktop into something that keeps changing without demanding attention. +- **Music and long-form video.** A YouTube lofi stream, ambient music or HDR landscape video can sit + behind your work, while sound stays under per-website control. +- **Live scenery.** Window views, nature cameras and [WindowSwap](https://www.window-swap.com/) make + a calm background that keeps changing throughout the day. +- **Work and world dashboards.** OpenAI or Claude usage pages, and live dashboards such as + [World Monitor](https://www.worldmonitor.app/), make information you check repeatedly available at + a glance. +- **Live maps.** [Windy](https://www.windy.com/) and [Flightradar24](https://www.flightradar24.com/) + are useful when weather or flight activity is worth keeping in view. -**Placeholders for the screen it is on.** `[[screenWidth]]` and `[[screenHeight]]` in an address are -replaced, on every load, with the size of the wallpaper on that display — so one entry is right on -every machine instead of on the one it was typed on. +## Build from source -**Content blocking says what became of the address.** Paste a rule list and the setting answers: -blocking, could not download, or not a rule list. +### Requirements -**English and Simplified Chinese**, throughout the app. +- macOS 15 or later +- Xcode 26 or later -## Build from source +### Open and run in Xcode ```sh -git clone https://github.com/PathGao/Nifro +git clone https://github.com/PathGao/Nifro.git cd Nifro open Nifro.xcodeproj ``` -Needs Xcode 26 or later. Swift 6 language mode, deployment target macOS 15. +In Xcode, select the `Nifro` scheme and **My Mac**, then press **Run** (⌘R). + +### 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. -`./Tools/build-local.sh` builds and installs a test copy signed the way releases are. Do that -rather than signing a build by hand: re-signing an app after Xcode has already signed it replaces -the signature and drops the sandbox entitlement with it, and an un-sandboxed Nifro reads a -different preferences file than a real install. +### Run tests ```sh swift test ``` -153 tests. Two kinds, and the split is deliberate. The first exercises pure logic that needs no app -bundle and no window server — crop and zoom geometry, the menu bar strip and what the colour band -samples from it, schedule windows, which website is current on which display, video embedding, URL -commands, the disk budget, the update check. - -The second is guardrails: assertions about the source itself, for rules a type cannot carry. Every -`Timer` sets a tolerance. No `Defaults` key is written and never read. No string is translated and -never shown. Every `[[placeholder]]` the code substitutes is named in the help text. No KVC key -reaches into a WebKit class. A field added to `Website` decodes from a payload written before it -existed. Nothing keeps a per-display fact — a load failure, which display is being browsed, whether -a display is switched off — in a slot with room for one answer. They exist because each of those rules had already been broken once, silently, and nothing -went red. +The suite covers both app behaviour and project guardrails: display-specific state, playlist migration, +crop and zoom behaviour, URL handling, settings compatibility, and source-level rules that types alone +cannot enforce. ## Contributing diff --git a/README.zh-Hans.md b/README.zh-Hans.md index f1fc313..09e28fe 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -108,80 +108,87 @@ brew uninstall --zap --cask nifro 灵感来自 Sindre Sorhus 的 [Plash](https://github.com/sindresorhus/Plash)。 -「把网页当壁纸」这个想法,照通常的做法实现出来,会建立在两个值得重新考虑的前提上: +Nifro 保留了「把网页当作桌面壁纸」这个想法,但围绕这个想法的 App 基本被重新做了一遍。 -- 为了显示一分钟才变一次的内容,它让浏览器一直在渲染。 -- 它其实不是壁纸,而是压在壁纸上方的一个透明窗口。 +它把界面设计成一眼能看懂网站、播放列表和显示器之间关系的样子。每块屏幕都有自己的壁纸、控制和 +状态;网页可以裁切到你真正想放在桌面上的那一块;网站不再只是平铺列表,而是按播放列表来管理。 -这个分支要做的就是拆掉这两条,而第一条比看上去难:两套渲染后端、遮挡测量、静止画面自动识别 -都做过,结果每一样都在替「此刻到底在渲染什么」下判断,而浏览模式也在下同一个判断。它们又被拿了 -出来,一共 811 行,之后一次放回一件,每件都要先有测量。这笔账和「什么条件成立了才放回去」写在 -[docs/ROADMAP.zh-Hans.md](docs/ROADMAP.zh-Hans.md) 第 2 节。 +性能和日常使用体验也在这次重构范围内。保留下来的是这个 Idea,重构的是实现它的整个 App。 ## 它做了什么 -**用移动的方式框出网页的一部分。** 拖动或双指滚动壁纸,捏合缩放,最后留在屏幕上的就是那一块。 -你瞄的是结果而不是被框的东西,而且它从这个网站已有的区域开始,所以既能新建也能调整。页面仍然按 -整屏排版,所以站点不会重排成你没框过的样子;那一块是重新渲染的而不是拉大的,字仍然清楚。 +**把网页放到桌面上。** 实时摄像头、艺术网站、地图、仪表盘,或任何放在背景里比占着浏览器标签更合适 +的网页。 -**换一块屏幕,框好的区域依然成立。** 它存的是位置和放大倍数而不是一个矩形,所以形状不同的屏幕会 -各自算出自己的矩形,围着页面的同一处。 +**每块屏幕各放各的。** 多显示器时,每块屏都能显示不同内容。菜单栏面板会把各块屏并排展示,只显示 +这块屏当前的网页和它自己的控制项。 -**一块屏一个页面,面板把它们并排摆出来。** 把网站指派给某块屏幕,每块屏各显示各的。点菜单栏图标, -每块屏一列:这块屏上正在放什么的实时预览、它的名字、只属于这块屏的那些控件——静音、关闭、上一个下一个、 -钉住或轮换、Crop、浏览模式——不会有任何会作用到你没在看的那块屏上的东西。 +**只显示网页中有用的部分。** 拖动、滚动或缩放,框出想留在桌面上的区域。Nifro 会记住它,换到尺寸 +不同的显示器时也尽量保持在网页的同一个位置。 + +**用播放列表整理网站。** 可以按工作、风景或任意用途建播放列表,让一块屏固定显示一个网站、按顺序或 +随机轮换,也可以遵守每个网站自己的时间安排。

显示器面板,每块屏一列

-**带时段的轮播。** 让一块屏在几个网站之间轮换,也可以让某个网站声明自己什么时间段才允许出现。 -排班永远不会把一块屏清空。 +**需要时再操作网页。** 按住快捷键即可点击、滚动和缩放,松开就回到壁纸。声音和链接打开方式都按网站 +分别记住。 -**按住某个键就能操作页面。** 按住时可以点击、滚动、缩放,松开就变回壁纸。 +**从图库开始,或自己添加。** 内置图库收录了适合放到桌面的网页,也可以加入任意网站。Nifro 提供中英文 +界面。 -**声音按网站分别记住。** 时钟永远不该出声,而直播没有声音就没意义。 +## 适合拿来做什么 -**一份经过挑选的站点清单。** 适合当壁纸的网页,每条都带着让它好用的那些设置。App 内的图库直接 -读这个分支,所以合并进来的条目不用等发版。推荐一个站点只需要填一张表,加上 app 里的「复制设置」。 +Nifro 最适合那些可以一直留在屏幕上、偶尔瞥一眼又确实有内容的网页。 -**外部链接按网站决定在哪里打开。** 一个你要登录的站点必须把链接留在 Nifro 里,因为登录本身就是一次 -离开该站点的跳转;而一个 dashboard 的链接该去你的浏览器。每个网站自己回答,或者跟随全局默认。 +- **生成艺术和环境动画。** [Floor796](https://floor796.com/) 这类艺术网站,让原本静止的桌面持续有 + 变化,又不会抢走注意力。 +- **音乐和长视频。** YouTube 的 lofi 直播、环境音乐或 HDR 风景视频,可以放在工作窗口后面;声音是否 + 播放仍按网站单独控制。 +- **实时风景。** 窗景、自然摄像头,以及 [WindowSwap](https://www.window-swap.com/) 这类网站,很适合 + 作为安静、持续变化的桌面背景。 +- **工作和世界动态仪表盘。** OpenAI、Claude 的用量页面,以及 [World Monitor](https://www.worldmonitor.app/) + 这类实时仪表盘,把需要反复查看的信息放在一眼能看到的地方。 +- **实时地图。** [Windy](https://www.windy.com/) 和 [Flightradar24](https://www.flightradar24.com/) 适合在 + 需要持续关注天气或航班动态时常驻桌面。 -**跟着所在屏幕走的占位符。** 地址里的 `[[screenWidth]]` 和 `[[screenHeight]]` 在每次加载时被替换成 -壁纸在那块屏上的尺寸——于是一条记录在每台机器上都是对的,而不只在打字那台上是对的。 +## 从源码构建 -**内容拦截会说出那个地址怎么了。** 粘一个规则表,设置里会回答:正在拦截、无法下载、或者不是规则列表。 +### 环境要求 -**全应用中英双语。** +- macOS 15 或更新版本 +- Xcode 26 或更高版本 -## 从源码构建 +### 在 Xcode 中运行 ```sh -git clone https://github.com/PathGao/Nifro +git clone https://github.com/PathGao/Nifro.git cd Nifro open Nifro.xcodeproj ``` -需要 Xcode 26 或更高版本。Swift 6 语言模式,部署目标 macOS 15。 +在 Xcode 中选择 `Nifro` scheme 和**我的 Mac**,然后按 **Run**(⌘R)。 -`./Tools/build-local.sh` 会构建并安装一份测试包,签名方式和发布版一致。请用它,不要自己手动 -签名:Xcode 已经签过之后再签一次会覆盖掉原签名,连沙盒 entitlement 一起丢掉,而一个没有沙盒的 -Nifro 读的偏好文件和真实安装的不是同一份。 +### 构建本地测试包 ```sh -swift test +./Tools/build-local.sh ``` -153 条测试,两类,分法是刻意的。第一类跑纯逻辑,不需要 app 包也不需要窗口服务器——裁剪与缩放的几何、 -菜单栏那一条的高度以及色带从中取哪一块、排班时段、哪块屏幕上哪个网站是当前的、视频嵌入、URL 命令、 -磁盘预算、更新检查。 +脚本会在需要时创建本地签名身份,带着 App 的沙盒 entitlement 构建,并把 `Nifro-test.app` 安装到桌面。 +请用它,不要手动对 Xcode 构建产物重新签名:重新签名可能会删掉 entitlement,让它使用与正式版不同的 +偏好设置容器。 + +### 运行测试 + +```sh +swift test +``` -第二类是护栏:对源码本身的断言,用来守那些类型扛不住的规则。每一个 `Timer` 都设了容差。没有任何 -`Defaults` 键被写了却没人读。没有任何字符串被翻译了却不会显示。代码替换的每一个 `[[占位符]]` 都在 -帮助文案里出现过。没有任何 KVC 键伸进 WebKit 的类。给 `Website` 新加的字段能从早于它的载荷里解出来。 -没有任何只对一台显示器成立的事实——加载失败、哪台正在被浏览、哪台被关掉了——被放进只装得下一个 -答案的槽里。它们存在的原因是:上面每一条都已经被静默地违反过一次,而没有任何东西变红。 +测试既覆盖 App 行为,也覆盖项目护栏:每显示器的状态、播放列表迁移、裁切和缩放、URL 处理、设置兼容性, +以及类型系统本身守不住的源码级规则。 ## 参与贡献 diff --git a/Tests/IconComposerTests.swift b/Tests/IconComposerTests.swift new file mode 100644 index 0000000..6e37811 --- /dev/null +++ b/Tests/IconComposerTests.swift @@ -0,0 +1,25 @@ +import Foundation +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 +build configurations select that source by its filename. Without either half, Xcode silently falls +back to the old asset-catalog icon. +*/ +@Suite("Nifro Icon Composer source") +struct IconComposerTests { + private static let repository = URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + + @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 projectSource = try String(contentsOf: project, encoding: .utf8) + + #expect(FileManager.default.fileExists(atPath: source.path(percentEncoded: false))) + #expect(projectSource.ranges(of: "ASSETCATALOG_COMPILER_APPICON_NAME = Nifro;").count == 2) + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 20913c3..9cfee9c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,519 +1,53 @@ -# Nifro Roadmap and Working Ledger +# Nifro Roadmap [简体中文](ROADMAP.zh-Hans.md) -> Source of truth for scope. The README is the community-facing write-up; this is the working document. -> Re-checked 2026-08-28 against `4032ff2` — v0.1.3 plus #56 to #65 — and then against #66 to #69 on top -> of it, read in the merged tree rather than off five descriptions. Items that shipped are gone, -> not struck through — the one-line residue that stops a question being re-litigated lives in section 14. -> **Eleven rows had been left struck through in breach of that rule** — ten in section 9 and one in -> section 8 — so the section named "known and not yet fixed" was a third things that were fixed. They -> are in section 14 now, one line each. Five more closed on this pass. Section 9 goes from twenty-eight -> rows to thirteen. -> -> **Closed this round, each read in the code rather than off a commit message.** Sync groups no longer -> exist anywhere in `Nifro/` — no `mirrorAcrossSyncGroup`, no `playbackRate`, no group of any kind — so -> K16, K18 and V3 are dissolved rather than fixed. K28 is fixed: `observeAddressChanges` is re-subscribed -> from `SwapLoading`, which is what it had no way to be. K35 is dissolved: `rebuildScenes` iterates -> `Display.all` and falls back to `[nil]` only when nothing is attached, so what it builds scenes from -> cannot hold `nil` and a real display at once. E24 shipped. -> -> **Closed by #66 to #69**, which were written against this pass and merged after it: K12, K21, K23, K26, -> K33 and W2. Five of the six were the same shape — the panel and the scene keeping separate answers to -> what a display is showing — and were fixed as one change rather than five, because the last five -> entries of that shape were fixed one at a time and each left the others behind. -> -> **Re-confirmed open by reading the code, not carried forward on trust:** W1, W3–W6, W8, W9, E25 and R6. -> K20, K30, K38 and K39 were re-confirmed on that pass too and are closed by #75 to #77, which is the -> whole of what was left of the K series apart from three entries with nothing in common. **K37 was closed the other way:** it claimed there is no way -> at all to forget a display that was sold, and the code says the opposite in as many words — forgetting -> one for good is what Restore Defaults is for, and `browsingDisplays` is pruned per entry on every -> display change rather than only emptied wholesale. The row had also missed `currentPlaylists`, a fifth -> key of the same shape that `ScopeTests` already enumerates. A row wrong on its premise, its mechanism -> and its list. +Nifro makes a web page part of the desktop. This roadmap separates hypotheses needing evidence from work ready to build, records the decisions that shape the current app, and keeps rejected directions from returning as new proposals. Legacy labels such as `P`, `L1`, and `K7` remain as indexes for code comments and older design notes. -**The rule this document keeps failing.** Before writing "nobody has looked", read the callers. Every -claim here that has ever turned out false failed that way, and reading cost less than the hardware it -was waiting for. +## Trying ---- +### Lower idle-wallpaper cost (`P`) -## 1. What this is +Nifro renders until disabled, locked, or configured to pause on battery. The old power subsystem was removed because its rendering backends, occlusion checks, and still-image detection all also answered “what is rendering now”, which Browsing Mode changes too. Any replacement needs a measurement of a covered idle wallpaper, exactly one owner of rendering state, and an explicit, reversible off switch. It must beat the present baseline in its target state. -Nifro puts a website on the desktop wallpaper, one page per display. Distribution is a Homebrew cask -plus a GitHub Release, not the Mac App Store. +### HDR and real multi-display hardware (`K7`, `D4`, `D6`) -**Where this stands after v0.1.3.** Two structural changes have landed, and each closed entries by -removing the question rather than by answering it. +No code or entitlement handles HDR. A real HDR source and end-to-end hardware measurement come before a design. Also validate a display without a menu bar and a pair with different sizes and backing scales; the remaining risk is OS geometry and pixels. -The menu-bar menu became a per-display panel (#21). That half is unchanged: the panel is where a -website is chosen now, and eight things the menu could do still have no entry point at all (section 8). +### Shared rendering across displays (`V3`) -A website then stopped belonging to a display (#62–#64). A display picks a playlist; a playlist holds -websites. That dissolved K16, K17, K18 and K35 outright and closed K24 and W7 — six entries, none of -which was fixed, because the state each described no longer exists. +The old sync feature was removed because independent pages, clocks, and controls drifted into conflicting states. A probe showed `SCShareableContent` can capture Nifro’s own desktop-level windows without Screen Recording permission. This remains an experiment: only one-shot capture was measured; continuous `SCStream`, colour management, independent crops, follower interaction, and differing layouts remain open. See [the shelved design](shelved/MULTI-DISPLAY-SYNC.md). -**And it broke switching website for nine commits without anybody noticing.** #56 replaced a polling -loop with `for await … in publisher(for: \.isLoading).values`, which delivers one element and then -nothing; the load finished, the wait did not, and every switch hung until the next one cancelled it. -#65 measured it against a standalone WebKit harness and fixed it. The first load of a session takes a -different path and always worked, which is why it read as fine. There is no automated check that would -have caught this — see the trap in section 10. +## Planned -``` -Open W1 W3-W5 W9 wiring K1 K6 K40 bugs L1-L4 V1 V2 V4 V5 S1 S2 S4 D4 D6 E21 E23 E25 U2 U3 -Parked K7 HDR (your call), the P series (needs a measurement first) -Blocked nothing -``` +### Desktop blocks (`L1`–`L4`) ---- +Let maps, dashboards, and small live pages occupy part of a desktop. Use free placement plus a snap grid, stored as fractions. A block is the window’s base frame; visibility may shrink within it, never redefine it. Every block needs its own web process and data store, and it is placed through the wallpaper rather than a title-bar window. -## 2. Power (the P series) +### Page state, panel, and media (`M3`, `M5`, `M6`, `W1`–`W5`, `V1`–`V5`) -All of it is out of the code — 811 lines removed, back to what upstream does: the page renders and -stops only when disabled, when the screen is locked, or on battery. Every piece of it owned the answer -to "what is being rendered right now", which is the answer Browsing Mode also changes, and each fix -exposed the next one. +Nifro remembers its own settings; sites remember their own state. URL fragments and scroll can sometimes restore position, while Floor796-like pages may not restore their internal zoom. A per-website in-session `WKWebView` cache is possible if it preserves isolation. -Before any of it returns: +Complete the panel with enable, reload, immediate random selection, safe address saving, and old-menu cleanup. The panel controls a **display**; the Websites window edits a **record**. Media controls require a per-page reporter first. GIFs need a full-resolution capture path, bounded memory, and a hard stop. -1. A measurement of the cost it claims to remove, taken while the machine is idle, on the state it - targets — a covered wallpaper, not a browsing session. -2. Exactly one owner for "what is being rendered". This is what broke every previous version. -3. An off switch that needs no explanation. +### Catalogue, reliability, and release (`S1`, `K1`, `K6`, `E23`, `E25`, `U2`) -Upstream stops for three reasons only and does nothing about occlusion at all. That is the baseline -any of this has to beat. Two ideas from the original design are refused rather than deferred and now -live in section 12 as X10 and X11. +Review featured sites first. Bilibili sign-in and YouTube playback are separate problems; YouTube’s measured `Referer` solution stays held while WebKit element fullscreen can damage the Dock (`K40`). Review help text, create versioned settings migrations, run or remove inactive SwiftLint analyzer rules, enable release immutability, and defer Sparkle until its permanent signing and installer commitments are justified. ---- +## Done -## 3. Blocks: a page as a piece of the desktop (the L series) +- **Playlists and independent displays:** displays select playlists; copied playlists use new IDs, isolating crop and sign-in state. +- **Per-display state:** loading failures, switched-off state, and Browsing Mode are keyed by display; the panel reads its owning scene. +- **Crop:** page position and magnification, rather than fixed pixels, adapt a chosen region across display shapes. +- **Display-first panel:** one column per display shows that display’s page, state, errors, and controls. +- **Reliability:** intended reloads reuse pages; thumbnail cleanup shares the data-store sweep; hidden panels stop snapshotting; older website data still decodes. +- **Build and release:** stable local signing preserves sandbox and bookmarks; releases are per architecture through GitHub Releases and Homebrew. -Zoom answers *which part of a page*. Blocks answer *where on the desktop it goes*, because a zoomed -fragment usually belongs in a corner rather than at full-screen size. The case is a number that exists -only on a web page — a usage counter, a build dashboard, a deploy status — that nobody will ship a -widget for. +## Not Do -Most of the machinery exists: a scene owns a window, `Zoom` picks the region, `DesktopWindow` sets an -arbitrary frame, several scenes already run at once. +- **No engine or framework rewrite (`X1`–`X4`):** the problem is scheduling and AppKit window behaviour, not a missing web engine, generator, DI container, or plugin system. +- **No high-risk or static substitute (`X7`–`X11`):** camera/screen input crosses an unjustified permission boundary; static desktop images remove live interaction; Chrome windows have no public desktop-layer API; opaque pages and configurable reloads lack measured value. +- **No panel duplication (`X12`–`X14`):** the current localisation system is sufficient; website editing and shortcut configuration belong outside a display-control panel. +- **Keep reviewed mechanisms:** transparent `WKWebView` KVC, `NSStatusItem` + `NSPopover`, `ScrollableTextView`, security-scoped bookmarks, image caching, icon fetching, and the 80 ms preview loop all have concrete API or behaviour reasons to remain. +- **Platform boundary (`K40`):** element fullscreen remains disabled because it can destroy the Dock window in this accessory-app configuration, with no app-side mitigation. -| | Item | Notes | -|---|---|---| -| **L1** | A website has a place and a size on its display, not just a display | Stored as fractions, like `Zoom`, so a block survives a change of display. `Website` has no place/size field today; `DesktopWindow.reducedRegion` still exists and is still written by nothing | -| **L2** | A four-way grid to snap to, and free placement for anything else | The grid is the affordance, not the model. Free placement is the model | -| **L3** | Whether the grid uses the whole screen or keeps clear of the Dock | A setting. **Not as cheap as this row used to claim:** `pageFrame` is `frameWithoutStatusBar`, and `visibleFrame` appears in the app in exactly one place — `Display.statusBarThickness`, not `menuBarStripHeight`, which only takes it as a parameter. Nothing computes a Dock-clear rectangle yet | -| **L4** | Which block takes a click | Browsing Mode and hold-to-interact now mean *one display's* wallpaper. With blocks they have to mean one block | - -**Three constraints, named so they are not discovered later.** - -- **Two things sizing one window.** A block has to be the window's *base* frame with occlusion - shrinking inside it, never the reverse. That collision is what made cropping and the visibility - policy fight until cropping stopped moving the window at all. -- **One web process per block**, and since per-website data stores landed, one data store as well. -- **A block is not a window the user can grab.** No title bars down there. Placement happens over the - wallpaper or from a menu of grid positions. - -**Two traps L1–L4 inherit from the shipped region picker.** - -- **The model is *the frame moves, the page stays still*** — not Photos' *content moves*. A web page - can pan and zoom itself, so a moving picture has two readings and the page's own magnification - multiplies with the frame's. Reasoning is in `Zoom/CropSelectionView.swift`. -- **`resizedFrame(byGrowing:)` grows around the frame's own middle on purpose** — a method on `Zoom`, in `Geometry.swift`; there is no `Geometry` type. - Pointer-anchored zoom slides the frame out from under the pointer when the page is still. Do not - re-add it. - -The overlay's job is to swallow scroll and `magnify(with:)` so gestures move the frame and not the -page — `webView.allowsMagnification` is on. `hasPreciseScrollingDeltas` separates trackpad (scroll -pans, pinch zooms) from wheel mouse (wheel zooms). Drag always moves, on every device. - ---- - -## 4. What a page remembers (the M series) - -Sound and the framed region belong to the website. Where the *page* is inside itself belongs to the -page, and there are four ways it can hold that. - -- **M1** `localStorage` / IndexedDB — **survives.** Stores are per website entry (`website.id`), not - per origin, so two entries on one site share nothing and deleting an entry drops its store. - Orphans are reaped at launch. *The consequence, which now has a user-facing name:* duplicating a - playlist mints fresh ids, so the copy is signed out of every site the original was signed into. That - is deliberate and it is said in the confirmation dialog, because it cannot be undone from there. -- **M2** URL fragment — **survives.** *Trap:* the last-loaded address is stored *beside* the - website's own, never over it, and is used only when the two differ in nothing but the fragment. - Overwriting turned a website into a GitHub 404 once. It records across a suspend again since K28. -- **M3** Document scroll — survives a reload, not a quit. Captured only from `reload()`. -- **M4** Memory only — nothing to be done. -- **M5** Half in the address, half in memory (floor796) — *where* comes back, *how close* does not. -- **M6** Keep the page instead of remembering it — **proposed, nothing implements it.** Complete - within one run of the app and nothing beyond it. Per-website switch. `releaseWebView` currently does - the opposite and drops the process on suspend. - -**Two ways past M5, neither urgent, and they do not conflict.** **B** is M6 as a per-website switch: -it helps every site, depends on no site's internals, and makes switching back stop being a page load. -**C** is `pageWorld: true` on one catalogue entry: the site's own magnification then survives a -restart, at the cost of that entry losing its isolation and depending on floor796's private fields. -C must stay a per-entry opt-in, isolated by default — never a global world change. The app's own -scripts (the audio control and the media clock) sit in `.defaultClient` either way. - -**Worth saying out loud in the app:** a website's settings are per website, and where the page is -inside itself is up to the page. Nothing tells anyone this. - ---- - -## 5. Multiple displays (the D series) - -Written on a one-display machine. Everything that could be answered by reading has now been read, and -most of it was wrong or already broken — those fixes are in section 14. Two claims are left that only -hardware can settle. - -| | The claim | What would show it is wrong | -|---|---|---| -| **D4** | The menu bar band, on a display with no menu bar | The gate is `screen.statusBarThickness > 0`, already tested for the secondary-screen case. The unknown is what "Displays have separate Spaces" does to `visibleFrame`, which is an OS behaviour and unreadable from here | -| **D6** | Different scale factors and different sizes side by side | Nothing in the app branches on backing scale; per-screen layout is `pageFrame` → `DesktopWindow.setFrame`. There is no code to read that could be wrong, only pixels | - -D7 was in this section and did not belong here — a defect with a known fix rather than an unchecked -claim. It became K31 and is closed. - ---- - -## 6. The site catalogue (the S series) - -`CANDIDATES.md` (pool) → `sites/*.yml` (schema-checked catalogue) → `featured` (installed on first -launch). **`featured` is an integer rank, not a flag** — `sites/schema.json` types it `integer, minimum 1` -and `sites/README.md` says why: the order is the decision. Eight entries carry ranks 1–8 out of 38 -files, pinned as `Array(1...8)` in `NifroTests`. **Anything written here as `featured: true` is wrong, -and section 14's K19 has said so all along** — two places in one document answering the same question -differently, which is the third shape in `WORKSPACE_GUIDE.md` and the one that hides best. - -Every entry was written by an agent from a link and a guess, and the featured ones install themselves -on a stranger's first launch. - -| | Item | Notes | -|---|---|---| -| **S1** | The maintainer reviews the featured entries | **This one first.** They are what a new user sees before deciding whether the app is any good. An evening's work, and the highest-value hour in this document | -| **S2** | The maintainer reviews the rest of the catalogue | Lower stakes, same claim: that the settings on them are right | -| **S4** | A reader landing on `sites/` meets the contributor guide first | **Less done than it read.** The two root READMEs link `CANDIDATES.md`; `sites/README.md` — the one a reader landing on `sites/` actually opens — only names it in backticks, so the file S4 is about is the file with no link. Do not add a fourth rendering of the same data: the in-app Site Gallery is the readable list. Make the two markdown files say what they are for, near the top | - -**A rule, not an item:** the candidate pool stays larger than the catalogue. That is the normal state, -not a backlog. Never write a count of it in prose — and note that section 6 as it stood broke its own -rule twice, three paragraphs after stating it. - ---- - -## 7. Media controls for the panel (the V series) - -**Read this before the table.** Every row here used to describe `MediaSync`, the multi-display sync -feature, as though it were running. None of it is in the tree: no clock, no epoch, no reading, no -per-page script. `mediaClock()` and `mediaClockCode`, which these rows named as though they were code -somebody could go and read, appear nowhere in `Nifro/`. What survives of the feature is `docs/shelved/MULTI-DISPLAY-SYNC.md`, -whose first line says it is removed and built by nothing. So no row here is "half built" — every one -of them is downstream of rebuilding that, and the rows say which part each needs. - -| | The item | What is known | -|---|---|---| -| **V1** | Pause, play, and step back or forward on a column | Nothing exists, detection included: nothing in `Nifro/` reads a `