<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Niko's Blog</title><link>https://nikolan.net/blog/</link><description>Full posts from Niko's Blog.</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Fri, 24 Jul 2026 14:47:02 +0300</lastBuildDate><atom:link href="https://nikolan.net/blog/index.xml" rel="self" type="application/rss+xml"/><item><title>Reviving Valve's 15-year-old e-book</title><link>https://nikolan.net/posts/portal2/</link><pubDate>Fri, 24 Jul 2026 14:47:02 +0300</pubDate><guid isPermaLink="true">https://nikolan.net/posts/portal2/</guid><description>You can now experience the 15-year-old Portal 2 e-book like it&amp;rsquo;s 2011 again. And it&amp;rsquo;s all open source.</description><content:encoded><![CDATA[<p><strong>RSS note:</strong> You're reading the static RSS version of this post. Interactive elements and embedded media are shown as previews. <a href="https://nikolan.net/posts/portal2/">View the full interactive post on the website.</a></p><blockquote>
<p><strong>If you just want to enjoy TFHoP2 and DO NOT CARE about the technical details of how I got it working, head to the Steam guide on how to patch it below.</strong></p>
</blockquote>
<p><strong>Steam guide:</strong> <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3766654296">Fix Broken Web Content [Windows] [2026]</a></p>

<h2 id="intro">Intro</h2>
<p>Picture this: there&rsquo;s a puzzle game you really like. You have over 100 hours of playtime, even though the story takes only 10.5 hours to beat and the whole game takes about 22.5 hours to complete. You like it so much that you spend hours playing co-op with friends and competing for the fastest times in Challenge Mode.</p>
<p>That&rsquo;s me with Portal 2.</p>
<div class="portal2-collage">
  <div class="portal2-collage-top">
    <div class="portal2-collage-left">
      <img src="https://nikolan.net/posts/portal2/images/achievements.webp" alt="All 51 Portal 2 achievements unlocked">
      <img src="https://nikolan.net/posts/portal2/images/playtime.webp" alt="101.7 hours played in Portal 2">
    </div>
    <img src="https://nikolan.net/posts/portal2/images/trading-cards.webp" alt="Portal 2 Steam trading cards and maximum-level badge">
  </div>
  <img src="https://nikolan.net/posts/portal2/images/hltb.webp" alt="Portal 2 completion times from HowLongToBeat">
</div>
<p>Then you find out there&rsquo;s an interactive e-book covering the entire development process, with all kinds of interesting stuff inside. It costs only €0.63. Of course you buy it.</p>
When you launch it, an outdated Adobe AIR installer appears:
<figure class="interactive-fallback"><img src="https://nikolan.net/posts/portal2/images/3-airsetup.webp" alt="An outdated Adobe AIR installer appearing after Portal 2: The Final Hours launches"><figcaption>Static preview of the outdated Adobe AIR installer that appears after launching the e-book.</figcaption></figure>

<p>&hellip;and quickly realize that, unlike the main game, which still receives security patches and bug fixes, the e-book isn&rsquo;t really maintained. Though Adobe AIR is still kinda maintained by <a href="https://www.wipro.com/newsroom/press-releases/2025/wipro-completes-acquisition-of-harmans-digital-transformation-solutions-dts-business-unit/">Wipro</a>, the e-book installs a version from 2011.</p>
<p>And that wouldn&rsquo;t even be a huge problem if the book worked properly. Let&rsquo;s take a look at the various broken things I found.</p>
<div class="portal2-broken-compilation">
  <div class="portal2-broken-pair">
    <figure class="portal2-broken-group">
      <img src="https://nikolan.net/posts/portal2/images/brokenstuff/badlobby.webp" alt="The Valve lobby panorama viewer displaying a blank white frame">
      <figcaption>A bunch of broken panorama viewers</figcaption>
    </figure>
    <figure class="portal2-broken-group">
      <img src="https://nikolan.net/posts/portal2/images/brokenstuff/brokenvideo.webp" alt="An embedded video displaying a black screen and stuck at 0:00">
      <figcaption>A bunch of broken videos</figcaption>
    </figure>
  </div>
  <div class="portal2-broken-polls-audio">
    <figure class="portal2-broken-group">
      <div class="portal2-broken-polls">
        <img src="https://nikolan.net/posts/portal2/images/brokenstuff/bokenpoll2.webp" alt="A poll displaying a notice that the site is down for maintenance">
        <img src="https://nikolan.net/posts/portal2/images/brokenstuff/brokenpoll.webp" alt="An empty poll frame displaying Internet connection required">
      </div>
      <figcaption>A bunch of broken polls</figcaption>
    </figure>
    <figure class="portal2-broken-group portal2-broken-audio">
      <img src="https://nikolan.net/posts/portal2/images/brokenstuff/songstuckloading.webp" alt="The East Bound and Down audio player stuck loading">
      <figcaption>A bunch of broken audio</figcaption>
    </figure>
  </div>
  <figure class="portal2-broken-group">
    <div class="portal2-broken-domains">
      <img src="https://nikolan.net/posts/portal2/images/brokenstuff/brokeniframe.webp" alt="A Portal interactive page embedding an unrelated World of Warcraft guide">
      <img src="https://nikolan.net/posts/portal2/images/brokenstuff/domainsn1.webp" alt="The Odd Couple page displaying an unrelated World of Warcraft guide">
      <img src="https://nikolan.net/posts/portal2/images/brokenstuff/domainsn2.webp" alt="The Odd Couple page displaying a 404 error">
    </div>
    <figcaption>Sniped domains filled with slop, appearing in several places</figcaption>
  </figure>
  <figure class="portal2-broken-group">
    <img src="https://nikolan.net/posts/portal2/images/brokenstuff/oldcomment.webp" alt="The feedback page annotated to highlight that its latest comment is from 2012">
    <figcaption>Abandoned comment system</figcaption>
  </figure>
</div>
<p>And I don&rsquo;t want to read a book with the constant fear that the next page will be broken :(</p>
<h2 id="why-broken">Why broken?</h2>
<p>Most of the e-book&rsquo;s broken features depended on external web services.</p>
<p>For music, bundling the songs with the e-book would have made licensing more difficult.</p>
<p>And for polls, using the web was basically the only option.</p>
<p>Unfortunately, services get refactored over time, breaking legacy endpoints that nobody cares about. Sometimes an owner forgets to renew a domain, so someone else buys it and fills it with something completely unrelated. Other times, the service simply breaks through lack of maintenance. This e-book contains examples of all three.</p>
<p>The panorama viewers are the exception. Their assets are stored locally, but they fail on newer versions of Windows despite using the same version of Adobe AIR. I&rsquo;ll come back to those later.</p>
<p>Now let&rsquo;s figure out exactly what&rsquo;s broken, starting by inspecting the e-book&rsquo;s files:</p>
<img src="https://nikolan.net/posts/portal2/images/direcotry.webp" alt="The e-book's installation folder, including TheFinalHoursOfPortal2.swf">
<p>SWF files. As established earlier, the e-book uses Adobe Flash, so I needed to decompile them somehow. Having no experience with this at all, I turned to Google:</p>
<img src="https://nikolan.net/posts/portal2/images/gugulserch.webp" alt="A Google search for how to decompile SWF files returning JPEXS Free Flash Decompiler">
<p>It sent me to the finest of software, <a href="https://github.com/jindrapetrik/jpexs-decompiler">JPEXS Free Flash Decompiler</a>:</p>
<img src="https://nikolan.net/posts/portal2/images/decompiler.webp" alt="The JPEXS Free Flash Decompiler welcome screen">
<p>I searched the main SWF file and the five additional SWFs to compile a list of every online dependency. Here&rsquo;s how I recovered each one:</p>
<h3 id="videos">Videos</h3>
<p>The application uses YouTube’s ancient ActionScript 3 Player API, which was discontinued more than a decade ago.</p>
<p>The original API worked by loading a player SWF executable from YouTube. The e-book then controlled that SWF using methods such as <code>loadVideoById()</code>, <code>playVideo()</code>, and <code>pauseVideo()</code> while listening for playback events.</p>
<p>Since I can&rsquo;t write Flash stuff, I used GPT-5.6-Sol to help write a replacement ActionScript player and compiled it into a SWF. It recreates the part of YouTube’s API that the e-book expects, but instead of using YouTube, it translates those calls into playback of local MP4 files.</p>
<p>But YouTube does not directly serve MP4 files, and some of the videos are no longer accessible. The e-book contains nine YouTube videos:</p>
<ul>
<li>Chapter 3, page 10: <a href="https://www.youtube.com/watch?v=j7LFbmTHY78">https://www.youtube.com/watch?v=j7LFbmTHY78</a></li>
<li>Chapter 3, page 10: <a href="https://www.youtube.com/watch?v=CkoMbxHQmuM">https://www.youtube.com/watch?v=CkoMbxHQmuM</a></li>
<li>Chapter 6, page 4: <a href="https://www.youtube.com/watch?v=DnMdBnLWvpg">https://www.youtube.com/watch?v=DnMdBnLWvpg</a></li>
<li>Chapter 7, page 3: <a href="https://www.youtube.com/watch?v=0Zzfdlxjx4Y">https://www.youtube.com/watch?v=0Zzfdlxjx4Y</a></li>
<li>Chapter 7, page 5: <a href="https://www.youtube.com/watch?v=JpFNKyeckZc">https://www.youtube.com/watch?v=JpFNKyeckZc</a></li>
<li>Chapter 11, page 2: <a href="https://www.youtube.com/watch?v=VLqw5JW8OLY">https://www.youtube.com/watch?v=VLqw5JW8OLY</a></li>
<li>Chapter 12, page 2: <a href="https://www.youtube.com/watch?v=GOMI0BxB0yA">https://www.youtube.com/watch?v=GOMI0BxB0yA</a></li>
<li>Chapter 12, page 3: <a href="https://www.youtube.com/watch?v=Im6yRpQrsaY">https://www.youtube.com/watch?v=Im6yRpQrsaY</a></li>
<li>Chapter 12, page 3: <a href="https://www.youtube.com/watch?v=pZaY1jV96jI">https://www.youtube.com/watch?v=pZaY1jV96jI</a></li>
</ul>
<p>I used <a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a> to download each one. Only two needed extra work:</p>
<ul>
<li>One was age-restricted, but I was able to download it by passing my cookies to yt-dlp.</li>
<li>The other was private. After pasting its video ID into Google and doing some digging, I found its title: &ldquo;She&rsquo;s Too Good For Everyone.&rdquo; Searching YouTube for that title led me to <a href="https://www.youtube.com/watch?v=K2FP77IIdE8">a reupload</a>.</li>
</ul>
<h3 id="3d-and-games">3D and games</h3>
<p>The e-book contains three 3D visualizations and one interactive minigame, all four of which relied on dead URLs:</p>
<ul>
<li>Chapter 3, page 7 — Portal minigame: <a href="http://www.thefinalhoursofportal2.com/TFHoP2_assets/howto/diagram.html">http://www.thefinalhoursofportal2.com/TFHoP2_assets/howto/diagram.html</a></li>
<li>Chapter 7, page 8 — Wheatley 3D: <a href="http://www.thefinalhoursofportal2.com/TFHoP2_assets/interactive/index.html">http://www.thefinalhoursofportal2.com/TFHoP2_assets/interactive/index.html</a></li>
<li>Chapter 9, page 1 — The Odd Couple 3D: <a href="http://www.thefinalhoursofportal2.com/TFHoP2_assets/oddcouple/">http://www.thefinalhoursofportal2.com/TFHoP2_assets/oddcouple/</a></li>
<li>Chapter 13, page 1 — Companion Cube 3D: <a href="http://www.thefinalhoursofportal2.com/TFHoP2_assets/companioncube/">http://www.thefinalhoursofportal2.com/TFHoP2_assets/companioncube/</a></li>
</ul>
<p>At first I was sad. None of those URLs were archived, and the domain had been registered by someone else hosting slop :(</p>
<p>After about an hour of searching, I still couldn&rsquo;t find archived copies. Recreating them seemed like my best option, so I started with the Portal minigame and got pretty close:</p>
<figure class="rss-media-preview">
  <a href="https://nikolan.net/posts/portal2/images/comparvid.mp4"><img src="https://nikolan.net/posts/portal2/images/comparvid-poster.jpg" alt="The original Portal minigame beside its recreation"></a>
  <figcaption>Video comparing the original Portal minigame with the recreation. <a href="https://nikolan.net/posts/portal2/images/comparvid.mp4">Watch or download the video.</a></figcaption>
</figure>

<p>I think it&rsquo;s pretty good, especially since I was working from only a 10-second video. At that point, I was satisfied.</p>
<p>At one point I discovered that there was a native iOS version of the e-book. Not expecting anything in particular, I decided to try it out.</p>
<p>After digging around, I found its IPA file inside a 173.7 GB <a href="https://archive.org/details/missing-ipas/">Archive.org collection</a> titled &ldquo;Missing IPAs.&rdquo; Its description was simply &ldquo;Some stuff,&rdquo; and it contained more than 10,000 IPAs.</p>
<p>Here is a direct download link for anyone who might need it:</p>
<p><a href="https://archive.org/download/missing-ipas/com.joezeffdesign.finalhoursofportaltwo%20-%20Geoff.Keighley.Presents.The.Final.Hours.of.Portal.2.v1.7.iPad-Lz0PDA.ipa">https://archive.org/download/missing-ipas/com.joezeffdesign.finalhoursofportaltwo%20-%20Geoff.Keighley.Presents.The.Final.Hours.of.Portal.2.v1.7.iPad-Lz0PDA.ipa</a></p>
<p>Since the App Store launched with iPhone OS 2, downloaded apps have been distributed in encrypted form. An authorized device decrypts an app&rsquo;s executable pages as they are loaded into memory. Because those pages must exist in decrypted form for the processor to execute them, they can be extracted from memory on a jailbroken device.</p>
<p>Luckily, someone had done exactly that with the IPA I found, meaning I could install it on my iPad without owning the original app.</p>
<p>And I discovered something interesting:</p>
<div class="portal2-media-pair">
  <figure class="rss-media-preview">
    <a href="https://nikolan.net/posts/portal2/images/why-do-u-work.webm"><img src="https://nikolan.net/posts/portal2/images/why-do-u-work-poster.jpg" alt="The interactive highlights screen in the Portal 2 e-book&#39;s iPad version"></a>
    <figcaption>Animation from the Portal 2 e-book&#39;s iPad version. <a href="https://nikolan.net/posts/portal2/images/why-do-u-work.webm">Watch or download the video.</a></figcaption>
  </figure>

  <img src="https://nikolan.net/posts/portal2/images/spinnyqube.webp" alt="A rotating Companion Cube visualization from the iPad version">
</div>
<p>All four visualizations worked!</p>
<p>That is because unlike the PC version, the mobile app bundles all of the visualizations&rsquo; code and assets locally.</p>
<p>I renamed the <code>.ipa</code> file to <code>.zip</code> and found the assets inside. Because they were standard HTML files, I could simply copy them to my PC!</p>
Below is a static preview of the restored Portal minigame. Open the linked interactive version to play it.
<figure class="interactive-fallback"><a href="https://tfhop2.nikolan.net/TFHoP2_assets/howto/diagram.html"><img src="https://nikolan.net/posts/portal2/images/workingstuff/workyinteractive.webp" alt="Static preview of the Portal minigame with a character and two portals"></a><figcaption>Static preview of the Portal minigame.</figcaption></figure>

<h3 id="music">Music</h3>
<p>The e-book loads eight songs from <code>mzstatic.com</code> domains.</p>
<p>Seven come from a playlist in chapter 5:</p>
<ul>
<li>East Bound and Down: <a href="http://a5.mzstatic.com/us/r1000/050/Music/89/1f/6d/mzm.hmsvmlsc.aac.p.m4a">http://a5.mzstatic.com/us/r1000/050/Music/89/1f/6d/mzm.hmsvmlsc.aac.p.m4a</a></li>
<li>Queen of Denmark: <a href="http://a4.mzstatic.com/us/r1000/013/Music/9f/da/c0/mzi.dviddoso.aac.p.m4a">http://a4.mzstatic.com/us/r1000/013/Music/9f/da/c0/mzi.dviddoso.aac.p.m4a</a></li>
<li>Polaroids and Red Wine: <a href="http://a1.mzstatic.com/us/r30/Music/2c/81/ca/mzi.wxxicnqc.aac.p.m4a">http://a1.mzstatic.com/us/r30/Music/2c/81/ca/mzi.wxxicnqc.aac.p.m4a</a></li>
<li>My Violent Heart (Pirate Robot Midget): <a href="http://a1.mzstatic.com/us/r1000/013/Music/90/2a/b7/mzm.ojhmmnlb.aac.p.m4a">http://a1.mzstatic.com/us/r1000/013/Music/90/2a/b7/mzm.ojhmmnlb.aac.p.m4a</a></li>
<li>Bang Bang Bang: <a href="http://a5.mzstatic.com/us/r1000/022/Music/24/12/e8/mzi.bukbidcp.aac.p.m4a">http://a5.mzstatic.com/us/r1000/022/Music/24/12/e8/mzi.bukbidcp.aac.p.m4a</a></li>
<li>Bridges and Balloons: <a href="http://a4.mzstatic.com/us/r1000/026/Music/e4/6f/19/mzm.hcizchvw.aac.p.m4a">http://a4.mzstatic.com/us/r1000/026/Music/e4/6f/19/mzm.hcizchvw.aac.p.m4a</a></li>
<li>Bilar: <a href="http://a2.mzstatic.com/us/r1000/036/Music/d7/5e/9b/mzi.eyucogyw.aac.p.m4a">http://a2.mzstatic.com/us/r1000/036/Music/d7/5e/9b/mzi.eyucogyw.aac.p.m4a</a></li>
</ul>
<p>The eighth appears in chapter 3, page 10 — Still Alive: <a href="http://a5.mzstatic.com/us/r30/Music/3e/c7/48/mzm.cxiybvxa.aac.p.m4a">http://a5.mzstatic.com/us/r30/Music/3e/c7/48/mzm.cxiybvxa.aac.p.m4a</a></p>
<p>The base domain, <code>mzstatic.com</code>, redirects to <code>apple.com</code>, and the e-book notes that the audio samples are provided by iTunes.</p>
<p>I found all eight songs on <a href="https://music.apple.com/">music.apple.com</a>. The web version of Apple Music also serves previews without requiring authentication.</p>
<p>I found each song&rsquo;s ID by copying its Apple Music link and taking the final number:</p>
<p><code>https://music.apple.com/us/song/still-alive/<span class="portal2-song-id">270749989</span></code></p>
<p>I then used the API to retrieve its 30-second preview:</p>
<img src="https://nikolan.net/posts/portal2/images/howtofindsongid.webp" alt="A Python script using an Apple Music song ID to retrieve its audio preview URL">
<p>I repeated the process to download a preview of every song.</p>
<h3 id="polls">Polls</h3>
<p>These are the polls&rsquo; original URLs:</p>
<ul>
<li>Chapter 1, page 5 — Poll 1: <a href="http://www.gameslice.com/p2polls/poll1.php5">http://www.gameslice.com/p2polls/poll1.php5</a></li>
<li>Chapter 5, page 6 — Poll 6: <a href="http://www.gameslice.com/p2polls/poll6.php5">http://www.gameslice.com/p2polls/poll6.php5</a></li>
<li>Chapter 9, page 6 — Poll 3: <a href="http://www.gameslice.com/p2polls/poll3.php5">http://www.gameslice.com/p2polls/poll3.php5</a></li>
<li>Chapter 12, page 6 — Poll 4: <a href="http://ww.gameslice.com/p2polls/poll4.php5">http://ww.gameslice.com/p2polls/poll4.php5</a></li>
<li>Chapter 13, page 3 — Poll 5: <a href="http://www.gameslice.com/p2polls/poll5.php5">http://www.gameslice.com/p2polls/poll5.php5</a></li>
<li>Chapter 14, page 5 — Poll 20: <a href="http://geoffkeighley.com/p2polls/poll.php5?id=20">http://geoffkeighley.com/p2polls/poll.php5?id=20</a></li>
<li>Chapter 14, page 8 — Poll 21: <a href="http://geoffkeighley.com/p2polls/poll.php5?id=21">http://geoffkeighley.com/p2polls/poll.php5?id=21</a></li>
</ul>
<p>Every link is dead.</p>
<p>Funnily enough, I don&rsquo;t think they intentionally took the polls down. My guess is that a backend update broke them without anyone noticing. That is because most of the URLs return <code>500 Internal Server Error</code> rather than <code>404 Not Found</code>:</p>
<img src="https://nikolan.net/posts/portal2/images/500img.webp" alt="Browser console reporting a 500 Internal Server Error">
<p>After entering <code>http://www.gameslice.com/p2</code> into <a href="https://web.archive.org/">the Wayback Machine</a> and going to its URLs section, I found that one poll had been fully archived, including its font and style:</p>
<div class="portal2-polls-pair">
  <img src="https://nikolan.net/posts/portal2/images/pollsarchivbed.webp" alt="The Wayback Machine listing an archived Portal 2 poll and its supporting files">
  <img src="https://nikolan.net/posts/portal2/images/polls.webp" alt="The archived poll asking players what they think of Portal 2">
</div>
<p>That was great because even one surviving poll gave me enough to reproduce the original style and layout.</p>
<img src="https://nikolan.net/posts/portal2/images/poll4.webp" alt="The recreated Portal 2 poll beside its source code in Visual Studio Code">
<p>I then needed the rest of the contents. I found a <a href="https://valvearchive.com/Games/Portal%202/Documents/The_Final_Hours_of_Portal_2_ENG.pdf">PDF version</a> containing the rest of the questions through chapter 13, so I noted them all down.</p>
<p>And here&rsquo;s the problem: chapter 14, which was added in a later update, contains two polls. As far as I know, neither question has been archived anywhere. I spent about two hours searching and found nothing. Every PDF, review, and recording I found, including the iPad version, only covered content through chapter 13.</p>
<p>I even found an edition of the book distributed through Amazon. Surprisingly, it included chapter 14, but because it was a text-only offline e-book, it had no polls.</p>
<p><strong>If you have any old recordings, screenshots, or anything else that could help me recover those two polls, please, please, please contact me.</strong></p>
<p>While I&rsquo;m at it, there&rsquo;s one more thing from chapter 14 that I couldn&rsquo;t recover, which appears to be a subscription form:</p>
<ul>
<li>Chapter 14, page 10 — Subscription form: <a href="http://geoffkeighley.com/p2polls/subscribe/index.php5">http://geoffkeighley.com/p2polls/subscribe/index.php5</a></li>
</ul>
<h3 id="feedback-form">Feedback form</h3>
<p>At the end of the book is a feedback form. It&rsquo;s the only online feature that still loads as intended:</p>
<img src="https://nikolan.net/posts/portal2/images/finalfeedbackform.webp" alt="The e-book's feedback form and its original reader comments">
<p>But submitting a new comment leaves it stuck in a review queue.</p>
<p>As with the polls, I could copy the original HTML and CSS. But then what about all the old comments? What would happen to them after this last working part inevitably shuts down?</p>
<p>I decided to take care of that too. A <a href="https://github.com/nikolan123/TFHoP2-server/blob/main/comments_archive/export.py">Python script</a> fetched every paginated feedback page from the old Gameslice server, parsed each comment&rsquo;s author, text, and timestamp, and saved the results to a CSV for import into the replacement server. In total, I backed up 2,662 comments.</p>
<h3 id="a-local-exception-panoramas">A local exception: panoramas</h3>
<p>The panoramas are different from the other broken features because their assets are local, so nothing needs to be rehosted.</p>
<p>Despite that, all three panorama viewers have stopped working on current versions of Windows 11. Interestingly, they still work on an older Windows 10 build, even when both installs are running the same version of Adobe AIR.</p>
<p>I could consistently reproduce two errors on the newer, broken system:</p>
<ul>
<li>Error #3207 appeared around the existing <code>Security.allowDomain(&quot;*&quot;)</code> calls.</li>
<li>Error #3226 appeared while loading compiled code from a nested SWF without <code>allowCodeImport</code>.</li>
</ul>
<p>I don&rsquo;t know exactly what changed between those Windows builds to make the same AIR code hit these errors. My best guess is that newer sandbox behavior interferes with the way <code>HTMLLoader</code> embeds the nested SWF.</p>
<p>What I do know is the fix, which is replacing <code>HTMLLoader</code> with a native <code>Loader</code> and explicitly allowing code imports. That makes all three panoramas work again. I again used GPT-5.6-Sol to figure that out since I have no knowledge about Flash.</p>
<h2 id="what-now">What now?</h2>
<h3 id="my-initial-server">My initial server</h3>
<p>I had recovered the external content and figured out how to repair the local panoramas. But how could I actually put everything back?</p>
<p>My first goal was to stitch together a server that rehosted everything, just to see whether the concept would work.</p>
<p>I chose Python and FastAPI for the stack because that&rsquo;s what I&rsquo;m familiar with.</p>
<p>To redirect the original requests to my computer, I used the hosts file, which tells the system where to find each domain. I modified it so every domain contacted by the e-book pointed to my own computer. That included <code>youtube.com</code>, <code>thefinalhoursofportal2.com</code>, and more.</p>
<p>I then mounted all the static assets at their original paths, including the custom YouTube recreation, and started the server.</p>
<p>I also needed somewhere to store comments and poll data, so I used a basic SQLite database to make everything persistent.</p>
<p>After some troubleshooting, I got everything working!</p>
<p>And this was the result:</p>
<div class="portal2-working-compilation">
  <figure class="portal2-working-group">
    <img src="https://nikolan.net/posts/portal2/images/workingstuff/working3d.webp" alt="The restored interactive 3D Companion Cube visualization">
    <figcaption>Working 3D visualizations</figcaption>
  </figure>
  <div class="portal2-working-interactive-video">
    <figure class="portal2-working-group">
      <img src="https://nikolan.net/posts/portal2/images/workingstuff/workyinteractive.webp" alt="The restored Portal minigame with a stick figure, cubes, and portals">
      <figcaption>Working interactive content</figcaption>
    </figure>
    <figure class="portal2-working-group">
      <img src="https://nikolan.net/posts/portal2/images/workingstuff/workyvideo.webp" alt="A restored video of Gabe Newell speaking to a high school class">
      <figcaption>Working videos</figcaption>
    </figure>
  </div>
  <div class="portal2-working-polls-comments">
    <figure class="portal2-working-group">
      <div class="portal2-working-poll-audio">
        <img src="https://nikolan.net/posts/portal2/images/workingstuff/wkringpoll.webp" alt="A restored poll asking which game Valve should make next">
        <img src="https://nikolan.net/posts/portal2/images/workingstuff/workyaudio.webp" alt="The restored Polaroids and Red Wine audio player">
      </div>
      <figcaption>Working polls and audio</figcaption>
    </figure>
    <figure class="portal2-working-group">
      <img src="https://nikolan.net/posts/portal2/images/workingstuff/workingcomment.webp" alt="The restored feedback form confirming that a new comment was posted">
      <figcaption>Working comment system</figcaption>
    </figure>
  </div>
</div>
<h3 id="the-public-server">The public server</h3>
<p>The finished server is available at <a href="https://github.com/nikolan123/TFHoP2-server">github.com/nikolan123/TFHoP2-server</a> for anyone to download and self-host.</p>
<p>The next problem was distribution. I couldn&rsquo;t bundle 600 MB of video and a collection of copyrighted songs. Doing so would make the open-source server much harder to distribute and create obvious legal problems.</p>
<p>The fix was simple enough. Instead of serving the audio myself, I redirected the client to the original preview URLs.</p>
<p>For the YouTube videos, I uploaded them to Archive.org and redirected the client to the reuploaded MP4 files rather than serving them myself.</p>
<p>The server is available for anyone to self-host, including as a Docker image.</p>
<h3 id="streamlining-the-patching-process">Streamlining the patching process</h3>
<p>Redirecting requests through the hosts file is a messy solution. Among other problems, it also breaks YouTube.</p>
<p>A better approach was to change the SWF files themselves to use the custom URL.</p>
<p>The patcher locates the three SWF files that need modification, backs them up, decompresses them, and reads the URL strings stored in their compiled ActionScript. It changes only a specific list of retired services, then rebuilds and verifies the SWFs before replacing the originals with the patched versions.</p>
<p>Patching the panoramas was more complicated because the fix required actual ActionScript changes, not just replacing URL strings. To keep the process simple and fast, I generated a diff between the original SWF and my recompiled, patched version. The patcher applies that diff to the user&rsquo;s copy.</p>
<img src="https://nikolan.net/posts/portal2/images/patcher.webp" alt="The Portal 2: The Final Hours Patcher, with options to fix online services and panoramas">
<p>It&rsquo;s open source at <a href="https://github.com/nikolan123/TFHoP2-patcher">github.com/nikolan123/TFHoP2-patcher</a>, with builds available <a href="https://builds.nikolan.net/app/nikolan123_s_TFHoP2-patcher">here</a>.</p>
<p>It consists of two Python files and a few assets. The Python files have no external dependencies, so you can use the patcher by simply running <code>python patcher.py</code>. No extra installation is required.</p>
<p>That&rsquo;s basically it. You can try it all for yourself with the Steam guide below:</p>
<p><strong>Steam guide:</strong> <a href="https://steamcommunity.com/sharedfiles/filedetails/?id=3766654296">Fix Broken Web Content [Windows] [2026]</a></p>

<p>Now I can finally go read the book!</p>
<p>This is only my fourth blog post, and I still feel like I&rsquo;m bad at this. If you have any feedback, questions, or <strong>know anything about the two polls or subscription form from chapter 14</strong>, please, please, please get in touch through one of the links below.</p>
<p>Also feel free to leave a comment or reaction using the new comment system I just added :3</p>
]]></content:encoded></item><item><title>Adding a Now Page to My Blog</title><link>https://nikolan.net/posts/now-page/</link><pubDate>Sun, 28 Jun 2026 01:32:32 +0300</pubDate><guid isPermaLink="true">https://nikolan.net/posts/now-page/</guid><description>Adding a small but cool &amp;ldquo;now page&amp;rdquo; to my Hugo blog that I can update with Apple Shortcuts.</description><content:encoded><![CDATA[<p><strong>RSS note:</strong> You're reading the static RSS version of this post. Interactive elements and embedded media are shown as previews. <a href="https://nikolan.net/posts/now-page/">View the full interactive post on the website.</a></p><h2 id="what-is-a-now-page">What is a now page?</h2>
<p>While I was researching various options for blog software, I stumbled upon <a href="https://herman.bearblog.dev/">Herman&rsquo;s Blog</a>, from the creator of <a href="https://bearblog.dev">Bear</a>. What immediately caught my attention was a button in the header that I hadn&rsquo;t seen before: <em>now</em>.</p>
<img src="https://nikolan.net/posts/now-page/images/hermansnowpage.webp" alt="Herman's Now Page">
<p>It&rsquo;s a page that is actively updated and shows what someone is currently into or doing. The first thing I asked myself was &ldquo;why don&rsquo;t I have this yet?&rdquo;. In the past, I&rsquo;ve thought about how the information I put on my website and blog gets outdated too quickly because what I do changes so often. I&rsquo;ve even thought of a now page as a solution, but always figured the idea was too dumb to actually do.</p>
<p>I was surprised to see that it&rsquo;s actually a pretty popular thing. There is even a website just for it: <a href="https://nownownow.com">nownownow.com</a>, which also has a <a href="https://nownownow.com/about">FAQ page explaining the idea in detail</a>. It has a big listing of now pages, including 8 from Bulgaria. I will soon be the 9th!</p>
<h2 id="my-implementation">My Implementation</h2>
<p>After seeing that, I knew I had to make one for myself.</p>
<p>My blog is based on Hugo and automatically builds on every commit using workflows.</p>
<p>Making a new page on my blog is very easy. The problem is the update flow. When I&rsquo;m writing a blog post, I usually start in Apple Notes, then when it&rsquo;s a bit more mature I transfer it to a draft version of my blog and eventually publish it. Even though I won&rsquo;t have to write a big blog post every time I update my now page, it would still take time, effort, and a computer to open VSCode, find the file, update it, and commit.</p>
<p>I decided to use Apple Shortcuts to make a shortcut to quickly update my now page. It&rsquo;s an app that lets you make small scripts using blocks, and those blocks are surprisingly capable. Shortcuts supports variables, HTTP requests, working with JSON, and App Intents, which are actions that installed apps can expose to Siri/Shortcuts. It also integrates well with the Apple ecosystem and is already installed on both my phone and laptop. If you&rsquo;re an Apple user and haven&rsquo;t tried it yet, you definitely should.</p>
<p>In about 15 minutes, I managed to make a simple shortcut that pushes a file called now.txt to my blog&rsquo;s GitHub repo.</p>
<img src="https://nikolan.net/posts/now-page/images/shortcut.webp" alt="Apple Shortcut for updating my Now page">
<p>I can run that from a Home Screen shortcut on my phone or Spotlight on my Mac. With this setup it takes about 3-5 seconds to start it.</p>
<p>But pushing a single text file doesn&rsquo;t automatically update my blog. To fix that, I updated my existing build script to read the text file and generate a JSON file with the status itself, when it was last updated, and how many times it changed in the last month.</p>
<p>By placing generated JSON like this in Hugo&rsquo;s <code>data/</code> directory:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;content&#34;</span>: <span style="color:#e6db74">&#34;new page testing&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;updatedAt&#34;</span>: <span style="color:#e6db74">&#34;2026-06-27T20:54:16.000Z&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;updatedDate&#34;</span>: <span style="color:#e6db74">&#34;Saturday 27th, June 2026&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;updateCount&#34;</span>: <span style="color:#ae81ff">2</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>I can access that data from any template like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go-html-template" data-lang="go-html-template"><span style="display:flex;"><span><span style="color:#75715e">{{</span> <span style="color:#a6e22e">hugo</span><span style="color:#a6e22e">.Data.now.content</span> <span style="color:#75715e">}}</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">{{</span> <span style="color:#a6e22e">hugo</span><span style="color:#a6e22e">.Data.now.updatedAt</span> <span style="color:#75715e">}}</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">{{</span> <span style="color:#a6e22e">hugo</span><span style="color:#a6e22e">.Data.now.updateCount</span> <span style="color:#75715e">}}</span>
</span></span></code></pre></div><p>And that&rsquo;s basically all. I have a now page now. You can find it at <a href="https://nikolan.net/now">/now</a> and <a href="https://nikolan.net/">the blog&rsquo;s homepage</a>.</p>
<p>This post is on the shorter side, but I&rsquo;ll hopefully have some longer and more interesting ones up soon.</p>
]]></content:encoded></item><item><title>Porting 1000+ Legacy Widgets to Modern macOS</title><link>https://nikolan.net/posts/dashboard-widgets/</link><pubDate>Fri, 06 Mar 2026 14:55:17 +0200</pubDate><guid isPermaLink="true">https://nikolan.net/posts/dashboard-widgets/</guid><description>How I got 2005-era OS X Dashboard widgets running natively on modern macOS.</description><content:encoded><![CDATA[<p><strong>RSS note:</strong> You're reading the static RSS version of this post. Interactive elements and embedded media are shown as previews. <a href="https://nikolan.net/posts/dashboard-widgets/">View the full interactive post on the website.</a></p><blockquote>
<p><strong>If you just want to use widgets on modern macOS and don&rsquo;t care about the rest, the Widget Porting app is available to download at <a href="https://widgets.nikolan.net">widgets.nikolan.net</a>. This blog post goes over how they work and how I ported them.</strong></p>
</blockquote>
<h2 id="introduction">Introduction</h2>
<h3 id="what-are-dashboard-widgets">What are Dashboard Widgets?</h3>
<!-- [Steve Jobs introducing Dashboard in OS X Tiger](https://youtu.be/XQQPTtdzBig?t=4837) -->
<p>I think the original author can describe them better than anyone else. Here&rsquo;s a video of Steve Jobs introducing Dashboard in OS X Tiger at WWDC 2004:</p>
<figure class="rss-media-preview">
  <a href="https://nikolan.net/dashboard-widgets-assets/introvideo.mp4"><img src="https://nikolan.net/dashboard-widgets-assets/introvideo-poster.jpg" alt="Steve Jobs demonstrating Dashboard widgets at WWDC 2004"></a>
  <figcaption>Steve Jobs introducing Dashboard at WWDC 2004. <a href="https://nikolan.net/dashboard-widgets-assets/introvideo.mp4">Watch or download the video.</a></figcaption>
</figure>

<p>Now onto my part:</p>
<div id="intro-website"></div>
<div id="intro-text">
<p><img src="https://nikolan.net/dashboard-widgets-assets/andwidgets/lotswidgets.webp" alt="A Few Widgets"></p>

<p><a href="https://nikolan.net/dashboard-widgets-assets/tiger_dashboard2.mov">Apple&rsquo;s Demo Video</a></p>
<p>Dashboard widgets were introduced in Mac OS X 10.4 Tiger - they were these silly little skeuomorphic apps that ran on a separate desktop which you could access with the press of a key. Most had 2 sides - front side for most general interactions and back side for settings.</p>
<p>While there were a lot of silly and useless ones like some of the ones I showed in the picture above, there were actually many actual uses for them - it&rsquo;s very sad most users never noticed they even existed.</p>
<p>Unfortunately, 14 years after their introduction, they were silently removed from macOS Mojave. While they existed, they were mostly neglected, and most of them looked the same throughout those years.</p>
<p>But it&rsquo;s not all bad news - after a few days of tinkering around, I got old widgets to work on modern macOS! :D
And it really wasn&rsquo;t that hard.</p>
</div>
<h2 id="porting">Porting</h2>
<h3 id="file-structure">File Structure</h3>
<p>Before explaining how I got these to work, let&rsquo;s first look at their file structure. At the base, they&rsquo;re just web apps with some extras.</p>
<p>Just like macOS apps, widgets are packaged in what is called a bundle - essentially a folder meant to look like a file. Apps use the <code>.app</code> extension, while widgets use <code>.wdgt</code>. The widgets&rsquo; structure is actually pretty simple.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>niko@Nikos-MacBook-Air:~$ tree
</span></span><span style="display:flex;"><span>.
</span></span><span style="display:flex;"><span>├── Default.png
</span></span><span style="display:flex;"><span>├── Info.plist
</span></span><span style="display:flex;"><span>├── version.plist
</span></span><span style="display:flex;"><span>├── English.lproj
</span></span><span style="display:flex;"><span>│   ├── InfoPlist.strings
</span></span><span style="display:flex;"><span>│   └── localizedStrings.js
</span></span><span style="display:flex;"><span>├── Images
</span></span><span style="display:flex;"><span>│   ├── backside.png
</span></span><span style="display:flex;"><span>│   ├── blue.png
</span></span><span style="display:flex;"><span>│   ├── pink.png
</span></span><span style="display:flex;"><span>│   ├── ...
</span></span><span style="display:flex;"><span>│   └── yellow.png
</span></span><span style="display:flex;"><span>├── Icon.png
</span></span><span style="display:flex;"><span>├── Stickies.html
</span></span><span style="display:flex;"><span>├── Stickies.css
</span></span><span style="display:flex;"><span>└── Stickies.js
</span></span></code></pre></div><p>This is the structure of the Stickies widget. Now let&rsquo;s go over all of the files and what they do:</p>
<h4 id="defaultpng">Default.png</h4>
<p>This image is shown while the widget is loading. After that, the widget overlays HTML elements on top of the image. Yes, most widgets overlay HTML elements on top of images instead of building from the ground up with HTML.</p>
<details>
  <summary>niko@Nikos-MacBook-Air:~$ open Default.png</summary>
<figure class="dashboard-widget-preview">
<img src="https://nikolan.net/dashboard-widgets-assets/Stickies-Default.png" alt="Stickies.wdgt's Default.png">
<figcaption>Stickies</figcaption>
</figure>
<figure class="dashboard-widget-preview">
<img src="https://nikolan.net/dashboard-widgets-assets/Stocks-Default.png" alt="Stocks.wdgt's Default.png">
<figcaption>Stocks</figcaption>
</figure>
<figure class="dashboard-widget-preview">
<img src="https://nikolan.net/dashboard-widgets-assets/Google-Default.png" alt="Google.wdgt's Default.png">
<figcaption>Google</figcaption>
</figure>
</details>
<h4 id="infoplist">Info.plist</h4>
<p>A plist (short for property list) is basically the file format that Apple uses to store metadata and configuration for apps, widgets, system stuff, and others.</p>
<p>Unlike generic XML, plists have a standardized schema. They also are easier for the system to parse and can be read natively by APIs in Swift and Objective-C, which is why Apple uses them instead of XML.</p>
<p>This specific plist has the widget&rsquo;s metadata. It can contain information like the widget&rsquo;s name, author, identifier, window size, permissions, addons, entry HTML file and more.</p>
<details>
  <summary>niko@Nikos-MacBook-Air:~$ cat Info.plist</summary>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#75715e">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;!DOCTYPE plist PUBLIC &#34;-//Apple Computer//DTD PLIST 1.0//EN&#34; &#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd&#34;&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;1.0&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;dict&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleIdentifier<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>com.apple.widget.stickies<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleName<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>Stickies<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleDisplayName<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>Stickies<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleDevelopmentRegion<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>English<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleShortVersionString<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>2.0.0<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleVersion<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>2.0.0<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>Height<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;integer&gt;</span>206<span style="color:#f92672">&lt;/integer&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>MainHTML<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>Stickies.html<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>Width<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;integer&gt;</span>225<span style="color:#f92672">&lt;/integer&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CloseBoxInsetX<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;integer&gt;</span>11<span style="color:#f92672">&lt;/integer&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CloseBoxInsetY<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;integer&gt;</span>6<span style="color:#f92672">&lt;/integer&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/dict&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/plist&gt;</span>
</span></span></code></pre></div></details>
<h4 id="versionplist">version.plist</h4>
<p>I couldn&rsquo;t find much about this file, but it seems to be information about the widget&rsquo;s version.</p>
<details>
  <summary>niko@Nikos-MacBook-Air:~$ cat version.plist</summary>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#75715e">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;!DOCTYPE plist PUBLIC &#34;-//Apple//DTD PLIST 1.0//EN&#34; &#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd&#34;&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;1.0&#34;</span><span style="color:#f92672">&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;dict&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>BuildVersion<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>5<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleShortVersionString<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>2.0.0<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>CFBundleVersion<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>2.0.0<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>ProjectName<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>StickiesWidget<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;key&gt;</span>SourceVersion<span style="color:#f92672">&lt;/key&gt;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;string&gt;</span>180100<span style="color:#f92672">&lt;/string&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/dict&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/plist&gt;</span>
</span></span></code></pre></div></details>
<h4 id="lproj">*.lproj</h4>
<p>These are folders which contain a bunch of localized strings in the language that is the folder&rsquo;s name.</p>
<details>
  <summary>niko@Nikos-MacBook-Air:~$ cat English.lproj/localizedStrings.js</summary>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">localizedStrings</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Array();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Paper Color:&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Paper Color:&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Font:&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Font:&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Done&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Done&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Auto&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Auto&#39;</span>;
</span></span></code></pre></div></details>
<details>
  <summary>niko@Nikos-MacBook-Air:~$ cat French.lproj/localizedStrings.js</summary>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">var</span> <span style="color:#a6e22e">localizedStrings</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Array();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;WidgetDisplayName:&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;adhésif :&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Paper Color:&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Couleur du papier :&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Font:&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Police :&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Done&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Terminé&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;Auto&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Auto&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">localizedStrings</span>[<span style="color:#e6db74">&#39;FontSize&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;Taille de la police&#39;</span>; <span style="color:#75715e">// for aria-label of secondary select
</span></span></span></code></pre></div></details>
<h4 id="iconpng">Icon.png</h4>
<p>The icon of the widget - as far as I know this is only shown in the pane where you add/remove widgets to your dashboard.</p>
<details>
  <summary>Icons of all Widgets bundled with OS X 10.5</summary>
  <div class="dashboard-widget-icons">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Address%20Book.wdgt.png" alt="Address Book">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Google.wdgt.png" alt="Google">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Ski%20Report.wdgt.png" alt="Ski Report">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Weather.wdgt.png" alt="Weather">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Business.wdgt.png" alt="Business">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/iCal.wdgt.png" alt="iCal">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Stickies.wdgt.png" alt="Stickies">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Web%20Clip.wdgt.png" alt="Web Clip">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Calculator.wdgt.png" alt="Calculator">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Images.png" alt="Images">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Stocks.wdgt.png" alt="Stocks">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/World%20Clock.wdgt.png" alt="World Clock">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Dictionary.wdgt.png" alt="Dictionary">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/iTunes.wdgt.png" alt="iTunes">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Tile%20Game.wdgt.png" alt="Tile Game">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/ESPN.wdgt.png" alt="ESPN">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Movies.wdgt.png" alt="Movies">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Translation.wdgt.png" alt="Translation">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Flight%20Tracker.wdgt.png" alt="Flight Tracker">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/People.wdgt.png" alt="People">
    <img src="https://nikolan.net/dashboard-widgets-assets/cons/Unit%20Converter.wdgt.png" alt="Unit Converter">
  </div>
</details>
<p>The Images folder contains images for use by the widget, and the rest of the files in the root directory are generally the widget&rsquo;s code.</p>
<h3 id="trying-to-load-them-in-a-browser">Trying to load them in a browser</h3>
<p>If widgets are just webapps, then you should be able to just open them in a web browser, right? Let&rsquo;s try it with the Calculator widget.</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/idfk/calcwork.webp" alt="Trying to load Calculator in Safari"></p>
<p>The calculator widget works perfectly! Out of all of the built-in widgets, only the calculator works without any modifications. Now let&rsquo;s try Stickies.</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/idfk/stickiesinbrowser.webp" alt="Trying to load Stickies in Safari"></p>
<p><code>Not allowed to load local resource: file:///System/Library/WidgetResources/AppleClasses/AppleAnimator.js</code> ?</p>
<p>These widgets use some shared resources, which are stored at <code>/System/Library/WidgetResources.</code> Modern macOS versions have strict sandboxing, so the web browser isn&rsquo;t able to access that directory. Even then, those files don&rsquo;t exist anymore in modern versions of macOS.</p>
<!-- They mostly contain buttons for switching to the widget's back side (widgets have 2 sides - one which the user interacts with and sees most of the time and the back side where they configure the widget), but also contain some parsers which I'll get into later. -->
<p>Even with all those errors, the base functionality of the widget is still barely there! I can still type normally, although refreshing the webpage resets all contents and none of the other functions work.</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/idfk/osx104wrs.webp" alt="OS X 10.5 WidgetResources Folder"></p>
<p>I tried to make the widget a little more functional by copying over a real WidgetResources folder from an OS X 10.5 installation.</p>
<p>That WidgetResources folder includes shared resources for things like:</p>
<ul>
<li>Logic for switching to the back side (widget settings and stuff)</li>
<li>UI for buttons, scrollbars, and other elements</li>
<li>Parsers for some specific widgets (will go into detail later)</li>
</ul>
<p>After setting up an OS X 10.5 VM in UTM, I copied the WidgetResources folder to my host.</p>
<p>Inside the MainHTML file, the widget tries to load several system scripts:</p>
<pre>
&lt;script type=&quot;text/javascript&quot; src=&quot;file:///System/Library/WidgetResources/AppleClasses/AppleAnimator.js&quot; charset=&quot;utf-8&quot;&gt;&lt;/script&gt;
&lt;script type=&quot;text/javascript&quot; src=&quot;file:///System/Library/WidgetResources/AppleClasses/AppleButton.js&quot; charset=&quot;utf-8&quot;&gt;&lt;/script&gt;
&lt;script type=&quot;text/javascript&quot; src=&quot;file:///System/Library/WidgetResources/AppleClasses/AppleInfoButton.js&quot; charset=&quot;utf-8&quot;&gt;&lt;/script&gt;
</pre>
<p>I&rsquo;m going to remove the <code>file:///System/Library/</code> part from each line and move the WidgetResources into the .wdgt folder.</p>
<div class="dashboard-side-comparison">
  <img src="https://nikolan.net/dashboard-widgets-assets/idfk/sfss.webp" alt="Front side">
  <img src="https://nikolan.net/dashboard-widgets-assets/idfk/sbss.webp" alt="Back side">
</div>
<p>It looks about the same, but if you look closely there’s a little square that appears when you hover over the widget. Following the Width and Height elements from Info.plist, that button would be in the bottom right corner of the widget.</p>
<p>Clicking it flips the widget to its backside. The backside lets you change the color of the paper, font family, and size. Some functionality has been successfully restored :D</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/idfk/fancy.webp" alt="Pink Font Thing"></p>
<h3 id="windowing-them">Windowing Them</h3>
<p>Now let&rsquo;s make this system a bit better and try some more widgets.</p>
<p>First off, let&rsquo;s fix the square button and make it an &ldquo;i&rdquo; like it&rsquo;s supposed to be. The console shows: <code>Not allowed to load local resource: file:///System/Library/WidgetResources/ibutton/black_i.png</code>.</p>
<p>Even though I modified the code to point to the local WidgetResources folder, this error was actually coming from a file within the folder itself. Those files also use absolute paths to refer to their own resources. Using find and replace to remove all instances of <code>file:///System/Library/</code> fixed it, and the i button appears instead of a square now!</p>
<p>Now let&rsquo;s address the window size. Unfortunately, Safari doesn&rsquo;t let you make the window as small as it should be, so I decided to try a basic WebKit wrapper with custom size controls.</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/idfk/cumbersome.webp" alt="Basic WebKit Wrapper"></p>
<details>
  <summary>WidgetTestApp.swift</summary>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">SwiftUI</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">WebKit</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">WebView</span>: NSViewRepresentable {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> url: URL
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">func</span> <span style="color:#a6e22e">makeNSView</span>(context: Context) -&gt; WKWebView {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">let</span> config = WKWebViewConfiguration()
</span></span><span style="display:flex;"><span>        config.preferences.setValue(<span style="color:#66d9ef">true</span>, forKey: <span style="color:#e6db74">&#34;developerExtrasEnabled&#34;</span>) <span style="color:#75715e">// enable devtools</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">let</span> webView = WKWebView(frame: .zero, configuration: config)
</span></span><span style="display:flex;"><span>        webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> webView
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">func</span> <span style="color:#a6e22e">updateNSView</span>(<span style="color:#66d9ef">_</span> nsView: WKWebView, context: Context) {}
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">WebWindow</span>: View {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> url: URL
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> body: some View {
</span></span><span style="display:flex;"><span>        WebView(url: url)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>@main
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">struct</span> <span style="color:#a6e22e">WidgetTestApp</span>: App {
</span></span><span style="display:flex;"><span>    @State <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">var</span> path: String = <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    @State <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">var</span> width: String = <span style="color:#e6db74">&#34;800&#34;</span>
</span></span><span style="display:flex;"><span>    @State <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">var</span> height: String = <span style="color:#e6db74">&#34;600&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> body: some Scene {
</span></span><span style="display:flex;"><span>        WindowGroup {
</span></span><span style="display:flex;"><span>            VStack(spacing: <span style="color:#ae81ff">12</span>) {
</span></span><span style="display:flex;"><span>                TextField(<span style="color:#e6db74">&#34;Path to .html file&#34;</span>, text: <span style="color:#960050;background-color:#1e0010">$</span>path)
</span></span><span style="display:flex;"><span>                    .textFieldStyle(RoundedBorderTextFieldStyle())
</span></span><span style="display:flex;"><span>                    .padding(.horizontal)
</span></span><span style="display:flex;"><span>                HStack {
</span></span><span style="display:flex;"><span>                    TextField(<span style="color:#e6db74">&#34;Width&#34;</span>, text: <span style="color:#960050;background-color:#1e0010">$</span>width)
</span></span><span style="display:flex;"><span>                        .frame(width: <span style="color:#ae81ff">80</span>)
</span></span><span style="display:flex;"><span>                    TextField(<span style="color:#e6db74">&#34;Height&#34;</span>, text: <span style="color:#960050;background-color:#1e0010">$</span>height)
</span></span><span style="display:flex;"><span>                        .frame(width: <span style="color:#ae81ff">80</span>)
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>                Button(<span style="color:#e6db74">&#34;Open Window&#34;</span>) {
</span></span><span style="display:flex;"><span>                    openWindow()
</span></span><span style="display:flex;"><span>                }
</span></span><span style="display:flex;"><span>            }
</span></span><span style="display:flex;"><span>            .padding()
</span></span><span style="display:flex;"><span>            .frame(width: <span style="color:#ae81ff">400</span>, height: <span style="color:#ae81ff">200</span>)
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">func</span> <span style="color:#a6e22e">openWindow</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">guard</span> <span style="color:#66d9ef">let</span> url = URL(string: <span style="color:#e6db74">&#34;file://</span><span style="color:#e6db74">\(</span>path<span style="color:#e6db74">)</span><span style="color:#e6db74">&#34;</span>) <span style="color:#66d9ef">else</span> { <span style="color:#66d9ef">return</span> }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">let</span> w = Int(width) ?? <span style="color:#ae81ff">800</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">let</span> h = Int(height) ?? <span style="color:#ae81ff">600</span>
</span></span><span style="display:flex;"><span>        
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">let</span> window = NSWindow(
</span></span><span style="display:flex;"><span>            contentRect: NSRect(x: <span style="color:#ae81ff">0</span>, y: <span style="color:#ae81ff">0</span>, width: w, height: h),
</span></span><span style="display:flex;"><span>            styleMask: [.titled, .resizable, .miniaturizable],
</span></span><span style="display:flex;"><span>            backing: .buffered, <span style="color:#66d9ef">defer</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>        window.title = url.lastPathComponent
</span></span><span style="display:flex;"><span>        window.center()
</span></span><span style="display:flex;"><span>        window.contentView = NSHostingView(rootView: WebWindow(url: url))
</span></span><span style="display:flex;"><span>        window.makeKeyAndOrderFront(<span style="color:#66d9ef">nil</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div></details>
<p>After pointing the wrapper to the MainHTML file and entering the correct window dimensions, the Stickies widget is fully functional too!</p>
<h3 id="restoring-dashboard-apis">Restoring Dashboard APIs</h3>
<img src="https://nikolan.net/dashboard-widgets-assets/Google-Default.png" alt="Screenshot of Google widget">
<p>There is also the Google widget - it originally redirected you to Google search in your default browser. It displays correctly with the same modifications as the previous widget, but does not actually work. Let&rsquo;s see its code.</p>
<p>In Google.js:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">search</span> (<span style="color:#a6e22e">input</span>)
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">var</span> <span style="color:#a6e22e">value</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">input</span>.<span style="color:#a6e22e">value</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">value</span>.<span style="color:#a6e22e">length</span> <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">value</span> <span style="color:#f92672">=</span> encodeURIComponent (<span style="color:#a6e22e">value</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> <span style="color:#a6e22e">url</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;http://www.google.com/search?q=&#34;</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">value</span> <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;&amp;ie=UTF-8&amp;oe=UTF-8&#34;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (window.<span style="color:#a6e22e">widget</span>)
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">widget</span>.<span style="color:#a6e22e">openURL</span> (<span style="color:#a6e22e">url</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The key line here is <code>widget.openURL(url);</code>. That method isn&rsquo;t a standard JavaScript thing - it is one of the Dashboard&rsquo;s API functions that help widgets interact with the system.</p>
<p>While it is pretty self-explanatory, its documentation can also be found in Apple&rsquo;s <a href="https://adc09.nikolan.net/documentation/AppleApplications/Reference/Dashboard_Ref/GadgetObj/GadgetObj.html#//apple_ref/doc/uid/TP40001339-CH203-SW10">Dashboard Reference</a>:</p>
<blockquote>
<p>Opens the specified URL in the user’s preferred browser.</p>
<p><code>widget.openURL(url)</code></p>
<p>This method opens the specified URL and dismisses the Dashboard layer. This method does not permit the opening of URLs that use the file:scheme unless the AllowFileAccessOutsideOfWidgetkey is set in the widget’s information property list file.</p>
</blockquote>
<p>However, since we&rsquo;re not in the original Dashboard environment, it needs to be rewritten to work again. Luckily, <code>WKWebView</code>, which is what I used in the windowed example above, lets me inject my own JavaScript code into the widget.</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/wkwebviewinject.webp" alt="WKWebView JS Inject"></p>
<p>We can inject our own function that sends the URL back to the host using this code:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> userContentController = WKUserContentController()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> scriptSource = <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">window.widget = window.widget || {};
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">window.widget.openURL = function(url) {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    window.webkit.messageHandlers.openInBrowser.postMessage(url);
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">};
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> userScript = WKUserScript(
</span></span><span style="display:flex;"><span>    source: scriptSource,
</span></span><span style="display:flex;"><span>    injectionTime: .atDocumentStart,
</span></span><span style="display:flex;"><span>    forMainFrameOnly: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>userContentController.addUserScript(userScript)
</span></span><span style="display:flex;"><span>config.userContentController = userContentController
</span></span></code></pre></div><p>However, that is pointless if we don&rsquo;t have a way to actually receive what was sent from the widget. Swift&rsquo;s <code>WKScriptMessageHandler</code> comes in clutch here. It allows receiving messages from JavaScript code running in a webpage.</p>
<p>However, <code>WKScriptMessageHandler</code> by itself isn&rsquo;t enough - it defines what method must exist, not where that logic should live. In setups such as our own, that logic is typically placed inside a coordinator.</p>
<p>A coordinator is a separate object that acts as a bridge between the WebKit view and the rest of the Swift code. It exists because SwiftUI views are structs and can’t easily conform to Objective-C-based delegate protocols like WKScriptMessageHandler. The coordinator, being a class, can.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Coordinator</span>: NSObject, WKScriptMessageHandler {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">func</span> <span style="color:#a6e22e">userContentController</span>(<span style="color:#66d9ef">_</span> userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> message.name == <span style="color:#e6db74">&#34;openInBrowser&#34;</span>, <span style="color:#66d9ef">let</span> urlString = message.body <span style="color:#66d9ef">as</span>? String,
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">let</span> url = URL(string: urlString) {
</span></span><span style="display:flex;"><span>            NSWorkspace.shared.open(url) <span style="color:#75715e">// open in browser</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Instead of creating an instance of the coordinator ourselves, Swift can automatically manage it. We just need to tell it how to make one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">makeCoordinator</span>() -&gt; Coordinator {
</span></span><span style="display:flex;"><span>    Coordinator()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>With that, it automatically creates an instance when the custom WebView is initiated. We can use it as <code>context.coordinator</code>.</p>
<p>Then, right under <code>userContentController.addUserScript(userScript)</code> from our previous code, we register the coordinator as the message handler for <code>openInBrowser</code>:
<code>userContentController.add(context.coordinator, name: &quot;openInBrowser&quot;)</code>.</p>
<p>So the full flow is:</p>
<ol>
<li>SwiftUI starts the custom WebView.</li>
<li>The custom JavaScript is injected, including the widget.openURL function.</li>
<li>SwiftUI calls makeCoordinator() automatically and creates the coordinator instance.</li>
<li>The coordinator is registered with the WebView via <code>userContentController.add(context.coordinator, name: &quot;openInBrowser&quot;)</code>.</li>
<li>The user types a search or triggers an action in the widget.</li>
<li>The widget calls window.widget.openURL(url), which posts a message to the <code>openInBrowser</code> handler.</li>
<li>The WebView forwards the message to the coordinator.</li>
<li>The coordinator receives the message in <code>userContentController(_:didReceive:)</code>.</li>
<li>The coordinator opens the URL in the default browser using <code>NSWorkspace.shared.open(url)</code>.</li>
</ol>
<p>Now the Google widget is fully functional!</p>
<figure class="rss-media-preview">
  <a href="https://nikolan.net/dashboard-widgets-assets/googlework.mp4"><img src="https://nikolan.net/dashboard-widgets-assets/googlework-poster.jpg" alt="The restored Google Dashboard widget opening a search in Safari"></a>
  <figcaption>The restored Google widget opening a search in the default browser. <a href="https://nikolan.net/dashboard-widgets-assets/googlework.mp4">Watch or download the video.</a></figcaption>
</figure>

<p>Going back to the Calculator widget, it is now broken. Why did emulating the openURL function break a widget that used to work perfectly?</p>
<p>Looking at the JS console shows: <code>TypeError: undefined is not an object (evaluating 'calc.evaluateExpression')</code>. Looking closer at the code reveals:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (window.<span style="color:#a6e22e">widget</span>)
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">result</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">widget</span>.<span style="color:#a6e22e">calculator</span>.<span style="color:#a6e22e">evaluateExpression</span> (<span style="color:#a6e22e">str</span>, (<span style="color:#a6e22e">directInput</span> <span style="color:#f92672">?</span> <span style="color:#ae81ff">16</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">8</span>));
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">result</span> <span style="color:#f92672">=</span> eval(<span style="color:#a6e22e">str</span>);
</span></span></code></pre></div><p>That snippet checks if the widget is running in Dashboard. Since we recreated the widget object, it thinks it is, so it tries to use another function to calculate the expression. Why did Apple do this? Idk.</p>
<p>The fix is pretty easy:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#a6e22e">widget</span>.<span style="color:#a6e22e">calculator</span>.<span style="color:#a6e22e">evaluateExpression</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">function</span>(<span style="color:#a6e22e">expr</span>, <span style="color:#a6e22e">mode</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">expr</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;decimal_string&#34;</span>) <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;.&#34;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">expr</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;thousands_separator&#34;</span>) <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;,&#34;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> eval(<span style="color:#a6e22e">expr</span>);
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">e</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;ERROR&#34;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>There&rsquo;s a few more similar undocumented functions. <a href="https://github.com/icedman/kludgets/blob/master/resources/scripts/macoswidgets.js">Kludgets has them all</a>, even though I implemented most of them before I discovered that.</p>
<p>My final app has all functions, both documented and undocumented, recreated to the best of my abilities.
Here are some notable ones:</p>
<ul>
<li><strong>widget.system</strong> - Allows the widget to run shell commands. I added a basic permissions system for that too.</li>
<li><strong>widget.preferenceForKey</strong> - Preference system that allows widgets to store persistent data.</li>
<li><strong>widget.performTransition</strong> - Fancy animation for flipping to the back side.</li>
<li><strong>widget.resizeTo</strong> - Resizes the widget as sometimes the back side is a different size.</li>
</ul>
<p>Here&rsquo;s 3 resources that also really helped me with that:</p>
<ol>
<li>Apple&rsquo;s <a href="https://adc09.nikolan.net/documentation/AppleApplications/Reference/Dashboard_Ref/GadgetObj/GadgetObj.html">Dashboard Reference</a> - this contains documentation about most of the widget.* functions</li>
<li><a href="https://code.google.com/archive/p/kludgets/">kludgets</a>&rsquo; <a href="https://github.com/icedman/kludgets/tree/master/resources/scripts">/resources/scripts/ directory</a> - I discovered this way too late but it was still very helpful, it contained most of the stuff that wasn&rsquo;t in Apple&rsquo;s documentation</li>
<li>&ldquo;Find&rdquo; function in a folder with a bunch of widgets to see what other functions there are and how they&rsquo;re called</li>
</ol>
<h2 id="my-app">My App</h2>
<p>With all of that knowledge, I made an app with a simple interface to help you run widgets on macOS 12.0 or later.</p>
<p>The app automatically handles everything I talked about above - it recreates the Dashboard API, fixes all the path issues, windows the widgets with the correct size, and a bunch more.</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/ss/mainapp.webp" alt="Main app interface"></p>
<p>Using it is pretty simple - just drag a <code>.wdgt</code> bundle into the app and it&rsquo;ll handle the rest.</p>
<p>When you drop a widget in, the app copies it to a temp folder and does all the preprocessing automatically - replacing hardcoded system paths, fixing broken script tags, and injecting the JavaScript that makes the Dashboard API work again. Each widget gets its own settings too, so you can tweak things like window size or whether to use native shadows.</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/ss/tweakslist.webp" alt="Tweaks list"></p>
<p>Oh, and I recreated the old fullscreen Dashboard UI too!</p>
<p><img src="https://nikolan.net/dashboard-widgets-assets/ss/fullscreen.webp" alt="Fullscreen app interface">
<img src="https://nikolan.net/dashboard-widgets-assets/ss/fullscreen2.webp" alt="Fullscreen app list"></p>
<p>The whole thing is built with SwiftUI and WebKit, so it&rsquo;s native and pretty lightweight. Works on macOS 12.0 or later, both Intel and Apple Silicon.</p>
<p><strong>Links:</strong></p>
<ul>
<li><a href="https://github.com/nikolan123/WidgetPortingToolkit/releases/download/nightly/Widget.Porting.Toolkit.dmg">Download</a></li>
<li><a href="https://widgets.nikolan.net">Project Website</a></li>
<li><a href="https://github.com/nikolan123/WidgetPortingToolkit">Source Code</a></li>
</ul>
]]></content:encoded></item><item><title>Playing Half-Life on a Takeaway Payment Terminal</title><link>https://nikolan.net/posts/takeaway-terminal/</link><pubDate>Sat, 02 Nov 2024 23:13:18 +0200</pubDate><guid isPermaLink="true">https://nikolan.net/posts/takeaway-terminal/</guid><description>Breaking out of a second-hand Takeaway T-Connect payment terminal.</description><content:encoded><![CDATA[<p><strong>RSS note:</strong> You're reading the static RSS version of this post. Interactive elements and embedded media are shown as previews. <a href="https://nikolan.net/posts/takeaway-terminal/">View the full interactive post on the website.</a></p><h2 id="getting-the-device">Getting the device</h2>
<p>Yesterday when I was at my local flea market looking for fun stuff to mess with, this thing caught my eye. It seems to be a Takeaway/Just Eat/Grub Hub/Lieferando payment terminal. More specifically, T-Connect Terminal V2.</p>
<p>This was definitely not the first time I&rsquo;ve seen one of those, both here and in restaurants, but I remember recently seeing <a href="https://blog.mgdproductions.com/justeat-takeaway-terminal/">a blog post by Marcel</a> on it, so I decided to get it.</p>
<p>The device has a 7&quot; screen on the front and 2x USB ports, Ethernet port, antenna screw and a DC jack on the back.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/back_io.webp" alt="Device Back IO" width="300">
<p>Once I got home, I first tried to power it with a 5v TP-Link power adapter. When I plugged it in, the device showed the Takeaway logo, but the backlight started glitching and it shut off.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/boot_screen.webp" alt="Takeaway Boot Screen" width="300">
<p>I suspected this was a power issue, and I was right. Unfortunately, that 5v power adapter was the only one I had that fits. I did not know how many volts the thing needs, it wasn&rsquo;t written on the label and I could not find that info with a simple Google search, so I asked ChatGPT. It told me it needs 12v, so as my only source I decided to trust that.</p>
<p>I cut the cable of another 12v power adapter I had for an STB and connected those together, resulting in a janky 12v power adapter that fits in the device. To my amazement, it somehow worked.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/very_good_cable.webp" alt="My Great Power Adapter" width="300">
<h2 id="hacking-the-device">Hacking the device</h2>
<p><em>Disclaimer: I am dumb. I did not discover most of my stuff myself.</em></p>
<p>The device booted into some app and seemed to be registered to some doner place. Not wanting to mess with the previous owner&rsquo;s stuff and potentially get in trouble, I decided to reset it. I couldn&rsquo;t find a clear way to reset it, so I decided to do some googling. I found a discussion at <a href="https://xdaforums.com/t/how-to-root-a-takeaway-com-t-connect-terminal-v-2.4586039/">XDA</a> about it, people found that pressing the bottom left corner 5 times asks for a pin code. 59074 opens an app launcher and 14611 opens a diagnostic menu. Another way to get into another app is via an NFC tag, but this way is easier.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/pin_screen.webp" alt="PIN Screen" width="300">
<img src="https://nikolan.net/posts/takeaway-terminal/images/app_thing.webp" alt="App Menu Thing" width="300">
<p>From there I opened settings and did a hard reset.
After a few minutes, I was greeted by a welcome screen asking me to activate my device.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/bad_thing.webp" alt="Bad Thing. AKA the device activation" width="300">
<p>I couldn&rsquo;t do anything from there other than open the Wi-Fi selector, so I decided to use the same menu again. I then tried to do what Marcel did on his device and used a USB to install a few apps, including a new launcher. The Cyanogen file browser could not see the external storage, and I could not open the USB drive through the notifications panel because mine didn&rsquo;t work for some reason. I thought I was stuck, but it turned out I could browse the drive from the settings app and copy the APKs to the internal storage, then use the Cyanogen file browser to install them.</p>
<p>I then installed some APKs, more specifically AIDA64, Firefox, Niagara launcher, Power Menu (since there are no physical buttons), RD Client, Minecraft PE and Xash3D.</p>
<p>I set the default launcher to Niagara and uninstalled the terminal client app. Now it&rsquo;s basically just a cut-down android tablet.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/launcher.webp" alt="Niagara Launcher running on The Thing:tm:" width="300">
<h2 id="misusing-the-device">Misusing the device</h2>
<p>AIDA64 reported the device having a Snapdragon 210, 1GB RAM and 4GB of usable space. It runs Android 6.
My next goal was to get Minecraft and Half-Life running.</p>
<p>Minecraft was easy. I just copied over an old APK from archive.org, and installed it. It worked great, ran at about 40 FPS and I was able to control it with my USB keyboard and mouse.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/minecraft.webp" alt="Minecraft running on The Thing:tm:" width="300">
<p><a href="https://www.youtube.com/watch?v=E73MC8yizjQ">YouTube demonstration</a></p>
<p>(the screen recorder puts additional load on the poor thing, the game performs worse than usual in the recording)</p>
<p>For Half-Life, I decided to use <a href="https://github.com/FWGS/xash3d/">Xash3D</a>. Even though that version is deprecated, I still decided to use it. I installed Half-Life on my laptop, then copied the <code>valve</code> folder from the installation directory onto my USB stick as well as the <a href="https://github.com/FWGS/xash3d/releases/download/v0.19.2/xash3d_fwgs_android_0.19.2.apk">Xash3D APK File</a>.</p>
<p>I then made a folder called .halflife in the internal storage and copied the <code>valve</code> folder from the USB stick there. After opening Xash3D, I pressed start without touching a single setting. To my surprise, it worked flawlessly, and even ran at above 60 FPS most of the times.</p>
<img src="https://nikolan.net/posts/takeaway-terminal/images/hl.webp" alt="Half-Life running on The Thing:tm:" width="300">
<p><a href="https://www.youtube.com/watch?v=wCc88hDV5J0">YouTube demonstration</a></p>
<p>(the screen recorder puts additional load on the poor thing, the game performs worse than usual in the recording)</p>
<p>Thank you for reading this to the end. This is my first ever blog post, if you have any questions or feedback, feel free to contact me.</p>
]]></content:encoded></item></channel></rss>