Tuesday, February 24, 2026

Trails in the Sky 1st Chapter

I've been playing JRPGs and CRPGs since the original NES releases of "Dragon Warrior", "Final Fantasy", and "Ultima: Exodus", and the original IBM PC release of "Wizardry". 

"Trails in the Sky 1st Chapter" is perhaps the finest video game RPG I've ever played. 

The above is not to imply I've comprehensively played every major RPG release -- far from it. Indeed, "Trails in the Sky 1st Chapter" is the first game I've played in the Trails series, even though that series has been around since 2004 (albeit with no Western releases until the 2010s). 

I went into the game completely cold, knowing only that this was a remake of an older game in the Trails series, and based only on a hankering to play a longer-form JPG, combined with the highly positive Steam reviews

I downloaded the Steam demo, and was quickly hooked by the top-tier worldbuilding, the enjoyable battle system, and the likeable characters. I bought the game (at full price! For me, a very rare US $60 PC game purchase!) -- and ended up loving the game.

(I did almost bail at the game's very first fight, which is an entirely real-time combat battle, with the turn-based combat system not being introduced quite yet -- as what I had been wanting to play was a turn-based RPG. If this is you, too, then stick with the game for 5 more minutes: The bulk of the game's combat is indeed turn-based. I actually ended up really liking how the real-time battle system acts as a brief setup for the beginning of the ensuing turn-based battle -- or against trivially easy enemies, they can be finished off in real time without having to go into turn-based combat at all.) 

They say that good storytelling in video games is rare -- and that's because it is -- but "Trails in the Sky 1st Chapter" is an exception. Like a good novel, it features foreshadowing and mysteries: Some that I was pretty readily able to figure out in advance; others... not so much! 

The gameplay is really nicely balanced, too. As a pretty experienced RPG gamer, I was just barely able to "sight read" (win on the first attempt, going in cold) a late-game battle against a certain silver-haired adversary; and the same, just last night, for the final boss. 

"Trails in the Sky 1st Chapter" ran great on both my fairly low-end desktop PC, and on my OG Steam Deck (with no tweaks needed). 

For turn-based RPG fans, I highly recommend at least trying out the free demo, to see if "Trails in the Sky 1st Chapter" is a game that you'll really like, too.

I'm now very much looking forward to the next entry, coming later in 2026.

Monday, February 16, 2026

AI-Driven Development: Insights from Day One

Hello from February 2026, where the way we as software developers get our jobs done on a daily basis is in the process of changing rapidly, with the advent of AI code-writing tools! 

Today being a company holiday (Presidents' Day here in the U.S.), I decided to spend the day getting in some self-guided hands-on practice with using some of this new AI tooling. Specifically, I decided to implement a user-requested feature for TimeSnapper for MacOS: Zoom-in / zoom-out functionality on images displayed in the playback view. I'd do this not by coding the feature myself -- as I'd always have needed to do in the past -- but by having AI write the code for me. 

For context: TimeSnapper is a utility app that runs in the background on your Mac or Windows computer, and captures screenshots every few seconds, making it possible for you to go back and look at what you were seeing earlier in the day (or even on previous days). Here's what the playback window looks like in the Mac version:

TimeSnapper for MacOS playback window

I didn't spec out a whole lot of requirements up front. I generally just wanted the zoom functionality to work "as a user would expect" -- i.e., having more or less the same familiar user experience as when using the Preview (image viewing) or Apple Maps apps on a Mac.

For the AI tooling, I used Claude Code v2.1.42 with the Claude Sonnet 4.5 model, running in a terminal window in my repository's directory. For the IDE, I used the standard XCode 16.4 atop my MacOS Sequoia.

Hey little sister, what have you done? 

Here's what I went with for my initial Claude prompt:

TimeSnapper (this repository) is an application that runs in the background and periodically takes screenshots of what the user is working on, for the user to review later. The PlayYourDay* project files implement a window where the user can review screenshots. The PlayYourDayImageView is where the screenshots are actually displayed to the user. Change or replace that view to allow the user to pan and zoom the image being displayed. Some specific requirements:

  • The user should be able to zoom in with the keyboard shortcut cmd+=, zoom out with cmd+-, and restore the zoom to the default level with cmd+0
  • The user should be able to zoom in and out by holding the cmd key and scrolling the mouse wheel up and down.
  • When the image is larger than the window, the window should render scroll bars to allow the user to pan the visible portion of the image.
  • When the image is larger than the window, the user should be able to pan the image by holding down the left mouse button and moving the mouse. 

The model "thought" for several minutes, and then made some changes to my project's files.  

So, I went ahead and shipped the changes to the App Store!

...In actually, no. I did not do that. (Although I imagine that there now are folks out there shipping apps this way: What's stopping them? Read on for why it's probably a good thing that I didn't just vibe-and-ship!)

Instead, I took at pass at reviewing what the AI had done, by:

  1. Reading through the output in the Claude window;
  2. Reading through the changes to the source files (using GitHub desktop);
  3. Testing out the changes in my local running TimeSnapper app. 

It turns out that, for me, today, only that 3rd item had much value. Claude's explanation of what it was doing showed that it seemed to be on the right track, and didn't include anything that looked "off." Likewise, the code changes all seemed plausible; I didn't spot anything egregious. 

So, I proceeded with testing the changes. In retrospect, I basically acted as a QA engineer counterpart to Claude as the software developer. (On top of the "product manager" and "UX designer" hats I was already wearing to tell Claude what to build in the first place.)

Initially, I was impressed by the changes! I could indeed zoom the image in and out by pressing cmd+-  / cmd+=. I could also zoom the image using cmd+mousewheel. I could pan the image with a left-click drag. This was a pretty striking improvement over "no zoom or pan functionality at all", as was previously the case!

Once I got past the surface level of the new functionality, however, I pretty quickly noticed some issues with the behavior. I decided to go ahead and have Claude iterate on what it had built. So, without clearing Claude's context, I told it:

It works pretty well, but there are some bugs:

  1. When panning the image by holding down the left mouse button and moving the mouse, moving the image left/right works correctly, but moving the mouse up/down moves the image in the OPPOSITE direction that the mouse is moving. The image should move in the same direction.
  2. When the image is zoomed in, after pressing Cmd+0 to restore the default zoom level, the image is still partially vertically panned out of the viewable area of the window. The image should automatically be moved to be within the viewable area of the window, if possible.
  3. Trying to zoom in the image past 100% doesn't work. Upon a Cmd+= key press, the image moves up and to the right, but it doesn't "zoom in" (visibly become any larger). The "canvas" (the scrollable area of the window) _does_ become larger, but the image itself doesn't correspondingly change size to fit.

Claude again "thought" for a couple of minutes, then made another round of code changes. Ok! I jumped in to QA the changes again -- and found that the drag-to-pan functionality was no longer working. I also noticed an additional bug: The right-click context menu for the displayed image no longer appeared adjacent to the mouse cursor, but (for some reason) below the playback window. 

Once again, I told Claude the problems; a couple of minutes elapsed; Claude made some code changes; I QA'ed; and I noticed additional bugs/problems and/or regressions. A sampling of these additional problems, each encountered after a round of having Claude make changes, and me manually testing the results:

  • When pressing Cmd+0 to restore the default zoom level, the image is vertically scrolled down past the bottom of the viewable area of the window.
  • When beginning to pan the image using left-click-drag, as soon as the mouse is dragged a little bit, the image "snaps down" and partially moves below the bottom of the viewable area of the window.  
  • When panning around the image, during the mouse drag, the position of the image "stutters" a bit: It abruptly jumps back and forth by about 20 pixels. 
  • When zooming the image using cmd + mouse wheel (in either direction -- either zoom in or zoom out), the vertical position of the image changes, such that there is now empty space between the top edge of the image, and the top edge of the window.
  • Regression: The mouse wheel zoom should be "centered" on the mouse cursor position, but it is instead centered on the lower-left corner of the image. 

During this whole process, I was jotting down my observations and insights (during those 2-3 minute periods while Claude was working on each set of changes). A couple of insights I had after the first ~75 minutes or so of this:

  • From my notes: "I feel like a QA engineer who is dealing with a kind of careless developer who repeatedly keeps 'throwing changes over the wall to QA' without testing them." 
  • As Claude repeatedly struggled to successfully fix a couple of specific bugs: "At this point I'm getting concerned that these rounds of changes could be making the codebase worse instead of better. Should I be setting 'save points,' e.g. via git branches? Or should I be doing a commit after each incremental change, and squashing them all when things look ready to publish?"

As I played with the work-in-progress changes, I also realized that there were a few behaviors that I hadn't specified up front to Claude, but that became apparent through the process of playing with the app. These included both minor nuances of behavior, and more significant features. An example of each:

  • The zoom "increment" when using the mouse wheel feels best when it is small. When using the keyboard, it feels better to have a bigger increment. 
  • Additional feature: Cmd+9 should perform a "Zoom to fit." That is, to set the zoom level as large as possible, with the constraint that the image still fits within the bounds of the window. (Same functionality and keyboard shortcut as the MacOS Preview app.)  

This aspect of software development is nothing new. AI or not, it has always been that case that certain behaviors and requirements are "discovered" during the process of the software being built, rather than having been specified up front. (Y'all have heard of agile, right?) A takeaway that I have from this:

  • Having AI write code doesn't solve the bottleneck that occurs when coordination is needed between roles (technical, UX designer, product) as questions arise. The more that the software developer (or whomever is "piloting" the AI) themselves understands the multiple considerations/aspects of the software being written, the lower the cost will be in time and context switching. 

At about the 3-hour mark, I had reached a point where there were still a couple of non-trivial bugs that Claude was having trouble solving. Despite a few rounds of me describing the problematic behavior, and Claude making a set of changes and cheerfully reporting that the problem should now be solved, the bugs persisted. Insight I had at this point, from my notes:

  • "I feel like I'm at a point of diminishing returns of the AI. A skilled developer would have initially built the implementation such that all of these remaining persistent issues would not be happening in the first place." 

In other words, I had seemed to have reached a point where the AI's attempts to fix problems by tweaking its implementation just wasn't working. The underlying structure/framework that the AI had built initially at the beginning of the day atop my human-authored master branch was such that it just didn't seem to be able to fix certain behaviors -- sort of akin to a house that had been built with a creaky frame. 

It's a nice day to start again 

With this in mind -- and remembering how quickly Claude had built its initial implementation (a few minutes) compared to how long I'd gone back and forth with Claude to get the software to be high enough quality that I wouldn't be embarrassed to ship it (multiple hours at this point), I decided to try "starting fresh": I saved what I had so far to a git branch; I went back to the master branch; I cleared Claude's context; and I gave Claude a fresh set of instructions, with more complete requirements, incorporating what I'd learned over the past few hours:

TimeSnapper (this repository) is an application that runs in the background and periodically takes screenshots of what the user is working on, for the user to review later. The PlayYourDay* project files implement a window where the user can review screenshots. The PlayYourDayImageView is where the screenshots are actually displayed to the user. Change or replace that view to allow the user to pan and zoom the image being displayed. Some specific requirements:

  • The user should be able to zoom in with the keyboard shortcut cmd+=, zoom out with cmd+-, and restore the zoom to the default level with cmd+0. 
  • Upon a cmd+9 press, a "Zoom to fit" operation should happen, with the zoom level being set to a multiple of 0.1 such that the image fits entirely within the window. 
  • The default zoom level when the "Play Your Day" window is opened should be 1.0. 
  • The zoom increment when zooming with the keyboard should be 0.1.  
  • The minimum zoom level should be 0.1. The maximum zoom level should be 5.0. 
  • The user should be able to zoom in and out by holding the cmd key and scrolling the mouse wheel up and down. The zoom increment for mouse wheel zooming should be 0.05. 
  • When the image is larger than the window, the window should render scroll bars to allow the user to pan the visible portion of the image.
  • When the image is larger than the window, the user should be able to pan the image by holding down the left mouse button and moving the mouse. 
  • The "View Image File in Finder" context menu should no longer display on left-click -- it should display only on right-click. (Left-click will now be used for the image panning functionality.) 
  • The "View Image File in Finder" context menu should always display adjacent to the mouse cursor position, even when the image zoom level is something other than 1.0.
  • Whenever the image fits within the window horizontally, the image should be anchored to the left edge of the window.
  • Whenever the image fits within the window vertically, the image should be anchored to the top edge of the window (NOT to the bottom edge).

This approach ended up working well (for me, today, for this particular project!). While the resulting implementation still had some problems, Claude seemed to build a more solid "foundation," given the fresh context, and the more complete set of requirements up front. 

Within about an hour of additional rounds of QA and back-and-forth with Claude, I had the zoom feature working to a point where I was happy enough to be able to ship it to the App Store.

Insights from that last hour of work:

  • Human attention to detail still matters. I was having Claude fix such minor/nuanced behaviors that I noticed as: After zooming one (small) increment via the mouse wheel, it was not possible to get back to the "default" (100%) zoom level, or to the minimum or maximum zoom levels, via the keyboard (large increments) -- because Claude had implemented keyboard zoom to just add/subtract 0.1 from the zoom factor, rather than having the zoom factor "snap" to the nearest multiple of 0.1. 
  • AI does not produce perfect results. The build I ended up shipping still had a minor bug where sometimes, after zooming in or doing a "restore to 100% zoom", the image would be vertically scrolled down slightly (instead of the image being "anchored" to the top-left corner). Despite a couple of attempted rounds of having Claude try to fix this, Claude was unable to do so. I decided that it wasn't (for now) worth my time to manually track this bug down in the Claude-authored code -- which I don't have a deep understanding of, because I didn't write it! -- and fix it.

Take me back home, yeah 

Ultimately, the day was a success! I was able to ship a new feature for TimeSnapper; and I got in some valuable early reps in using AI tooling to write code. 

Wrapping up: A few big obvious caveats apply to this entire post!

  • As stated up front, today was the first day where I was able to devote an entire day to playing with AI-driven development. Ask me again how things are going after I've had more chances to do this in a few weeks; or at this time next year. Not to mention the fact that the available tools themselves are undergoing such rapid changes. 
  • This project was building on a standalone Objective-C Mac desktop app, with changes focused around new GUI behaviors. Would things have gone differently (better; worse) in some ways if I were instead working on an app with, say, a Python back-end, a React front-end, B2B API integrations, and changes focused around CRUD forms? I bet they would have!

Finally: For what it's worth, this post (as with all posts on this blog) is 100% human-authored, by me. (Although there's currently little stopping this same claim from being made on prose that is AI-generated! Interesting times!)

Sunday, December 28, 2025

The Jon Schneider Game of the Year awards: 2025

As has become my annual tradition, here's the list of my favorite computer and console video games that I played for the first time during 2025 (along with the platform(s) that I played them on)!

1. Hades 2 (Windows)

I really don't know how Supergiant Games does it. The original Hades was my personal 2020 Game of the Year, with its combination of tight top-down-view action combat, great voice-acted characters and character development, and excellent music. Hades 2 delivers more of the same, except with even more interesting combat and varied gameplay. I put in the time to 100% the Hades 2 Steam achievements, and enjoyed every run. 

There's certainly something special about a game where -- despite there being no gameplay benefit or purpose to it -- you can opt to spend some time having your character join in and beautifully harmonize with a song that another character visiting your home base is singing.

2. StarVaders (Windows, Steam Deck)

Like it says on the tin, StarVaders is a roguelike deckbuilder that takes place on a Space-Invaders-like battle map. Cards are used to make your upward-facing spaceship move and fire up at the alien invaders moving down to attack you. Game balance is solid; and lots of different character classes, character variants, difficulty levels, and missions provide lots of replayability. Recommended for fans of Slay the Spire and similar games! 

3. Metaphor ReFantazio (Windows, Steam Deck)

Although I'm not a big "AAA" game player, Metaphor ReFantazio hit a sweet spot for me with its "Persona, but in a unique swords and sorcery fantasy setting" gameplay. I played all the way through the ending during the first few months of 2025.

4. Mario Kart World (Switch 2)

I particularly like the new Knockout Tour mode (in single-player) -- it really takes advantage of Mario Kart World's interconnected world to delivery a fun continuous 15 minutes or so of racing. Mario Kart World is also 2025's Schneider Family Game of the Year, managing the rare feat of getting us playing a video game together in 4-player split-screen. 

5. Monster Train 2 (Windows, Steam Deck)

Just as with Hades 2: Monster Train 2 is like the original, but more and better! I had a good time discovering Monster Train 2's various synergies, both within the various factions, and in the intra-faction combination pairings. And I particularly enjoyed the major content unlock that occurs midway through the game. 

6. 8-Bit Adventures 2 (Steam Deck, Windows)

My favorite classic-style turn-based RPG of 2025! A wholesome save-the-world adventure with likeable characters. I haven't played the original (but plan to circle back to it soon, as the developer, Critical Games, added Steam Cloud saving to the Steam version, at my request!!) I played all the way through to the end. Recommended for fans of classic-style JRPG-like games

7. Donkey Kong Bananza (Switch 2)

Here begins the portion of this year's list of games that I was impressed by and enjoyed, but didn't yet make it all the way through to completion. Bananza certainly meets the "impressed me" criterion, with its preponderance of destructible terrain combined with solid 3D platforming level design. I like how powerful DK feels in this game: In Mario games, Mario generally needs to be pretty cautious of enemies; by contrast, DK can readily clobber just about anything he comes across! 

8. Tactical Breach Wizards (Windows)

First off: Tactical Breach Wizards features the wittiest writing in a video game that I've come across in my long history as a gamer, so it's got that going for it! Beyond that, the world imagined by Tactical Breach Wizards -- a modern world, but where magic is real, and thus is the primary tool employed by special-forces agents -- is a great playground for the turn-based tactical combat gameplay. I'm currently enjoying making my way through this game in short sessions of a mission or three each morning before work, mouse in my right hand, coffee in left.

9. Clair Obscur: Expedition 33 (Windows)

I probably needn't explain much about the game that has already featured so prominently on so many 2025 awards and Game of the Year lists. I feel like I should have liked the game's turn-based combat (but with dodges requiring real-time reflexes) more than I did -- Paper Mario (N64) and Thousand-Year Door are both on my all-time favorites list -- but I never was quite able to really get the hang of dodging terribly reliably, and 33 seems to want you to be pretty good at dodging in able to be successful in combat.  I made it a fair ways into the game, but didn't end up completing it. 

10. Rift of the Necrodancer (Windows, Steam Deck) 

I'm a bit of a fan of Cadence (including her Hyrule appearance), and so was looking forward to Rift! I spent a fair bit of time with it when it came out, and did play all the way through the story. I'm (evidently) not super talented at this game, though, and topped out at Medium difficulty on most songs, and Hard on the easier ones. 

(However -- and since this is my blog, I'll just go right ahead and dad-brag a bit -- my 20-year-old son is the current #1 world record holder of multiple Rift songs in "Impossible" difficulty on the Switch platform, and also has a bunch of top-percentile scores on the Steam platform -- despite carrying an otherwise-full load as a Chemical Engineering major and marching band member at university!) 

(Aside: New game developers, consider resisting the temptation to include the word "Rift" in the name of your new game. There are quite a few such games out there already! 😅) 

Appendix: NOT-new-in-2025 Multiplayer GOTY

My continuing-favorite game of 2025 was once again Super Smash Bros. Ultimate on the Switch! The aforementioned 20-year-old son and I enjoy playing the online 2v2 mode and taking on all comers -- and, per the in-game stats, managing to win matches at a ~85% clip! I'm decent at Smash and generally hold up my end of the team with my Bowser Jr. and Samus; but "20" (who can skillfully play the entire roster(!), but probably likes best Lucina, Mii Brawler, and Pyra/Mythra) definitely does most of the heavy lifting! 

Appendix: Games in Progress

Fields of Mistria (Windows, Steam Deck)

Field of Mistria is essentially Stardew Valley, but different! I spent a solid few weeks with this game midway through the year, and enjoyed some of the changes and improvements it makes on Stardew Valley. I decided to put the game back on the shelf for now, though, pending the rest of the planned content (including the full implementation of PC-NPC relationships) being added to the game. Hopefully that'll be later on in 2026!

CrossCode (Windows, Steam Deck)

Although CrossCode is a somewhat older game -- having come out in 2018 -- I just discovered it in December 2025, it having been one of a few inexpensive games that I picked up in the Steam Winter Sale. (I think I may have tried but bounced off of a CrossCode demo back around the original release date.) Wow, there's a lot I'm liking here so far. A twin-stick action-adventure game (like my 2024 GOTY, Minishoot' Adventures)! A simulated MMORPG game, bringing back some fond memories of when I casually played World of Warcraft in its early days? Pixel art? Firm but fair puzzles? An intriguing story? There's a lot here so far that falls pretty squarely in my personal wheelhouse!

At times, CrossCode has felt like it may wind up earning a place among my favorite games of all time! At other times, it has been a little frustrating or obscure. I've managed to play through to what feels like it might be about the halfway mark without resorting to any hint guides, though, so far. I'm looking forward to continuing to play in 2026, forming a fuller opinion --  and maybe featuring CrossCode in my 2026 GOTY list post!

Tuesday, September 23, 2025

N+1 query problem? More like 1+N query problem!

Some years back, when I first learned about the N+1 query problem, I had a little bit of difficulty wrapping my mind around it -- or at least the naming of it. Inadvertently making lots of round trips to the database is bad, sure. And the problem gets a lot worse when you have record counts in the thousands (or more) instead of just a handful, sure. But why are we calling this "N+1"?

I eventually realized that the problem is that the nomenclature is backwards, chronologically! The order of operations happening when this problem gets triggered is:

  1. The database is queried for a list of records. (1 query)
  2. For each of those records, a follow-up query is made. (N queries)

I started thinking of the problem instead as the "1+N query problem." And for me, at least, the naming immediately became much more intuitive to grasp!

 


A quick perusal of search results makes it clear that this is a pretty #unpopularopinion. Almost all of the discussion of this problem out there exclusively uses the "N+1" terminology. 

I'm going to keep saying "1+N" in conversations with peers, though. I don't mind being one man standing against the tide on this specific issue! 🌊👨‍💻

Thursday, July 10, 2025

TimeSnapper for Mac: Custom Directory Selection Now Available!

TimeSnapper for Mac has been updated to allow a custom directory to be selected for your captured screen images!

If you're not already familiar with TimeSnapper, it's a utility that runs in the background on your Mac or PC and saves an image of your screen(s) (or just the active application window) every few seconds. This can be highly useful when, to take a few examples: 

  • A piece of text you were composing got lost because your browser crashed, or because a web form submit failed. (Retrieve the text out of your screenshot history!) 
  • You want to search up information about a specific error message that you encountered a few minutes ago, for which you don't remember the exact wording. (Again, it'll be waiting for you in your screenshot history!) 
  • You want to remember what all you were working on the afternoon of two days ago. (Click and drag across the TimeSnapper timeline UI to pan through your screenshot history!)  

Previously, the Mac version of TimeSnapper saved your screen images to subfolders of a particular "sandboxed" directory that the TimeSnapper app was by default permitted to access. This worked, but the target folder was in a fairly deep location on the MacOS filesystem, and thus was inconvenient to access by any means other than the "View in Finder" button in TimeSnapper's Preferences window.

Now, starting in version 1.1.0, TimeSnapper for Mac has been enhanced to use security-scoped bookmarks to allow you to choose any folder on your local computer for TimeSnapper to use to save its screen images. 

To use the new feature, after updating your copy of TimeSnapper to the latest version via the Mac App Store, click on the TimeSnapper icon in your Mac's top menu bar, then select Preferences, then the Auto Cleanup tab in the Preferences window. You'll see a "Change folder..." button in that window. 

TimeSnapper Preferences window > Auto Cleanup tab 
If you aren't already using TimeSnapper, take a look at the reviews and ratings (4.8 out of 5 stars as of today!) of TimeSnapper on the Mac App Store, and treat your personal productivity to a lifetime license for a quite reasonably low price!

Tuesday, May 27, 2025

Fix: Large file upload tests in a Rails app start failing after bumping rack to 3.0.16

Problem: After bumping the Rack gem in a Rails app from 3.0.14 to 3.0.16, a couple of tests which tested an HTTP POST of a large-ish (~30 MB) PDF file to a controller started failing. Other similar tests, which did not upload a large file, continued passing.

The failure symptom was the POST returning an HTTP 400 Bad Request error. The error was returned before the request made it to any of our application code (as evidenced by none of our breakpoints being hit, or debug statements appearing in the output log).  

For our particular test, the specific error message was: Minitest::Assertion: Expected response to be a <413: payload_too_large>, but was a <400: Bad Request>

Fortunately, there were no other gem dependencies that came with the bump of Rack to 3.0.16. So we knew the problem must be caused by some change in Rack 3.0.15 or 3.0.16. 

Solution: The single item in the rack 3.0.16 release notes did turn out to be the pertinent change:

Security: CVE-2025-46727 Unbounded parameter parsing in Rack::QueryParser can lead to memory exhaustion.

As that linked advisory mentions, a new check is added in rack 3.0.16 that turns away "extremely large" application/x-www-form-urlencoded bodies. From this nist.gov page on CVE-2025-46727, links were available to the fix's specific code changes. From the first linked change, we can see that a "BYTESIZE_LIMIT" of 4194304 (a little over 4 MB) is now imposed on application/x-www-form-urlencoded request bodies. 

Per the mozilla.org page on HTTP POSTapplication/x-www-form-urlencoded is only one possible form Content-Type HTTP header value -- and not the one that is supposed to be used with binary data, such as PDF file uploads! Instead, multipart/form-data should be used.

Our original unit test code was actually not specifying any Content-Type HTTP header value in our test POST request. Changing the test's POST request to additionally send the HTTP header Content-Type: multipart/form-data fixed the problem, and got our test passing once again.

Aside: When I spent a minute during the investigation to ask ChatGPT about this problem -- a POST request failure introduced as of Rack 3.0.16, possibly due to a large request payload -- ChatGPT obligingly hallucinated up a plausible-sounding (but 100% fictional) response about Rack introducing a default limit in POST request payload size, and a corresponding plausible-sounding (but 100% fictional) response about a configuration line to be added to config/applicaiton.rb to override the supposed new behavior. 😂🙄

Friday, May 23, 2025

Yet another use case for LIFO stacks (humor)

This is like what happens while I'm working on a coding project at my job on an unfortunately-often basis. Anyone else? 😂

Sunday, January 26, 2025

The Jon Schneider Game of the Year awards: 2024

There were my favorite games that I played for the first time during 2024 (along with the platforms that I played them on)!

1. Minishoot' Adventures (Steam Deck / Windows) 

I picked up Minishoot' Adventures on Steam when looking for a twin-stick shooter game to play without knowing anything about it beforehand, and am so glad I did! This game is an exploration / combat game a little bit reminiscent of the original Legend of Zelda; except that it's a twin-stick shooter, and more of a sci-fi theme. The exploration, combat, and upgrade systems are all really nicely designed. I immediately started a second playthrough on the "Hard" difficulty after completing my "Normal" playthrough, which for me is a rarity, and indicative of a really good game. Highly recommended!

2. Slice & Dice (iPhone)

This is the best new strategy game I've played on my phone in years. (Also available on other mobile and desktop platforms.) Slice & Dice is a really tight turn-based combat-only game where your party of 5 heroes takes on a series of 20 increasingly difficult battles. There's a free demo with the first 12 fights so you can try before you buy. The base game is plenty replayable, but Slice & Dice also comes with a plethora of different game modes if you want to change things up. Stands out even further from the crowd by being seamlessly playable in both Portrait and Landscape modes!

3. dotAGE (Mac / Windows)

dotAGE is a turn-based village builder where disasters periodically befall your town -- but you are able to see them coming some time in advance! If you successfully forestall a disaster -- for example, having enough heat sources to counteract a freezing weather event -- then the event becomes a blessing instead. Features quirky humor and pixel art, both of which are right up my alley. 

4. Unicorn Overlord (Switch)

Unicorn Overlord is a very polished small-squad battlefield combat game. A bit like the Fire Emblem games, except that instead of controlling individual heroes, you're controlling several squads of 3-5 characters, with movement across the battlefield happening in pausable real-time. Individual battles play out automatically according to if-then rules that you assign to each unit in advance. A simple set of tactics for a healer hero might be "If any ally is at less than 75% HP, heal them; otherwise, attack the lowest % HP enemy with holy magic." As a programmer, I had a lot of fun setting up those rulesets and then watching them play out!

5. Star Trek Resurgence (Windows) 

Although I'm a big fan of the TNG-era Star Trek shows, I haven't played many Star Trek computer games. Resurgence, though, did come pretty close to making me feel like I was playing through a short series of TNG-era Star Trek episodes! Although some of the action/combat sequences in the game were a little rough, the interesting nature of the "command decisions" in the game more than made up for that.

6. Sea of Stars (Steam Deck / Windows)

Sea of Stars in a fun turn-based, small-party RPG that is a bit reminiscent of classics from that era like Chrono Trigger. The combat system features the heroes having small mana pools -- but mana can be restored via "normal attacks," a little like in Diablo 3. Although the story was a bit uneven in places, I enjoyed the combat, graphics, and music enough to play Sea of Stars through to completion!

7. Lil' Guardsman (Steam Deck / Windows)

It's Papers, Please, except that rather than dystopian, it's fantasy-cartoony; and the protagonist is a 12-year-old girl! Despite the silly premise, there are some serious-ish enough plot elements that I (as an adult guy gamer) found Lil' Guardsman engaging enough to play through to completion. It was also fun to pair-play a bit with my own real-life 12-year-old daughter!

8. Dragon Quest 3 HD-2D Remake (Steam Deck / Windows) 

Ask a kid, I had somehow missed out on all of the NES Dragon Quest (née Dragon Warrior) games beyond the first. Nevertheless, I had a good time checking out this game for the first time, with its early implementation of a job system, and anachronisms such as individual non-combatant NPC characters hanging out by themselves deep in monster-filled dungeons. I progressed fairly deep into the game, but the combat system was a little too old-school for me with regard to the lack of interesting decision-making to be had in most combats, and I didn't quite make it through to completion.

9. Spider-Man Remastered (Windows)

I've never owned nor played a Playstation 4, but I'd heard a lot of positive buzz around the Spider-Man games, so when I finally hooked up a decent gaming PC to my family's main big screen TV, this was one of the first games I fired up. My favorite part was the amazing feeling of just web-swinging around New York City -- what a rush! As a (mostly) old-school gamer, I never quite felt like I had mastery of Spider-Man's abilities in combat, resulting in Spider-Man getting beat up by non-super-powered goons a little more than it felt reasonable than he should; perhaps at some point I'll put in the effort to come back to this game and "get good" at it.

10. Balatro (iPhone, Steam Deck)

I actually originally bounced off this game on its original release. My two main criticisms at that time were that (1) it seemed too easy (just a matter of getting some "good" jokers, after which it hardly mattered which cards you even played; and  (2) it seemed unbalanced (certain jokers just seemed terrible, e.g. increasing the score multiplier in tiny increments of 0.1). I gave Balatro another try when it came out later in the year on iOS at which time I figured out that my two criticisms were met by (1) the built-in higher difficulty levels, and (2) the jokers that had seemed terrible generally actually were quite good because they multiplied the score multiplier, rather than just adding to it. Balatro hasn't dominated my gaming time as it has for some, but it's been an occasional fun diversion!

Other games I played for the first time, and enjoyed, in 2024 (in descending order of where they would have been appended onto the top 10) included: Shogun Showdown; Ara Fell; Dungeons of Aether; Paperback Adventures; No Man’s Sky; Tangledeep.

Sunday, January 12, 2025

Bypassing Google "URL Swapping" in Firefox on Mac

Problem: Users on my wifi network can't click on ad links in Google Search results because those links redirect to the ad.doubleclick.net domain, which is blocked by my Pi-hole. The user ends up on a page with this error message:

Unable to connect. An error occurred during a connection to ad.doubleclick.net. 

Unable to connect. An error occurred during a connection to ad.doubleclick.net.

This happens on Firefox (as of the time of this post, January 2025) because Google is doing something a little bit sneaky: If you mouse over a hyperlink in a sponsored ad in a set of Google search results, it appears to be a normal hyperlink. However, as soon as you click on that link, Google swaps out the target of that link to be a different one than the one that was displayed -- and that's where the link ends up taking you.

(In more technical terms: Google appears to be using JavaScript's onClick event to cancel the navigation to the original href; then replaces the original href with the new href; and then proceeds to navigate to that new destination.)

For example, one of the results when I searched Google just now for "new wireless service" was https://www.verizon.com/plans/unlimited. However, clicking on that link actually navigated me to an URL starting with https://ad.doubleclick.net/searchads/link/click?lid=... . 

You can actually see the swapped-in hyperlink by using the browser's back button to return to the Google search results page, and mousing over the clicked hyperlink again. You'll see that preview of the destination URL for that same link has now changed! (The same happens if you right-click on the target link, then cancel the right-click context menu, and hover over the link again.)

My family members from time to time actually want to click on a sponsored Google search result, and have the navigation work properly. However, I don't want to whitelist the entire "ad.doubleclick.net" domain at my Pi-hole, since that would likely result in a lot more distracting ads being rendered as we use the web. 

I searched for a Firefox add-on which would prevent hyperlink URL destinations being changed from the preview URL that was shown prior to clicking on them, but couldn't find one that seemed trustworthy. 

(I do already run EFF's Privacy Badger add-on, which purports to include this particular functionality; but for whatever reason, it isn't working for me in Firefox as of the time of this post. Possibly Google has recently updated the mechanism that they use to perform their URL-swapping in Firefox; and Privacy Badger hasn't yet been correspondingly updated.)

Workaround / Solution

To my surprise, Google doesn't seem to perform their URL replacement when the search link is activated by the Return key, instead of by a click! Therefore, a crude-but-effective workaround is as follows, when looking at a page of Google search results:

  1. Hit the Tab key until the desired link has the focus. 
    • (Alternatively, double-click a word of text near the target link to select it; then press Tab and/or Shift-Tab from there until the desired link has the focus.)
  2. Press Return to activate the link.

This workaround does have the advantage of not needing any browser add-on to work!

When doing this, Google (apparently) doesn't have any JavaScript in place to hijack the link destination; the client browser navigates directly to the actual URL shown in the browser's preview, without being redirected through ad.doubleclick.net (or any other domain).

Friday, December 20, 2024

Getting deep recursion to work in a simple Ruby program on Mac

As part of one of my Advent of Code 2024 solutions, I wrote a small program that included a fairly deep level of recursion. 

When run on my Mac, using Ruby 2.7.2, the program failed to run to completion, generating a stack trace like this (shown in part for brevity):

Traceback (most recent call last):
    3304: from day-16/day-16-part-1-dfs.rb:125:in `<main>'
    3303: from day-16/day-16-part-1-dfs.rb:86:in `explore_map'
    3302: from day-16/day-16-part-1-dfs.rb:86:in `each'
    3301: from day-16/day-16-part-1-dfs.rb:96:in `block in explore_map'
    3300: from day-16/day-16-part-1-dfs.rb:86:in `explore_map'
     ... 3292 levels...
day-16/day-16-part-1-dfs.rb:87:in `key?': stack level too deep (SystemStackError)

I could have fixed this by rewriting my recursive function to use a queue instead, but I wanted to see if I could change my runtime configuration to get my program to run as-is; and I was able to do so! I ended up needing to change TWO things to get the program to run successfully.

First, I needed to increase the system stack size limit for my Mac, for the current terminal session. For my particular program, I needed to set it near my machine's max:

ulimit -s 65520

(The default stack size limit had been 8176.)

Making just that change allowed my program (when run from that terminal session -- not from my IDE) to reach a deeper count of recursions; but it still failed with a similar error:

Traceback (most recent call last):
    10920: from day-16/day-16-part-1-dfs.rb:125:in `<main>'
    10919: from day-16/day-16-part-1-dfs.rb:86:in `explore_map'
    10918: from day-16/day-16-part-1-dfs.rb:86:in `each'
    10917: from day-16/day-16-part-1-dfs.rb:96:in `block in explore_map'
    10916: from day-16/day-16-part-1-dfs.rb:86:in `explore_map'
     ... 10908 levels...

day-16/day-16-part-1-dfs.rb:32:in `hash': stack level too deep (SystemStackError)

Second, I needed to set an environment variable to increase the Ruby VM's maximum allowed stack size. For this "toy" program, I just picked an arbitrary very large value:

export RUBY_THREAD_VM_STACK_SIZE=50000000

With that change also in place, my program was able to successfully run to completion!

I did end up refactoring my solution from using a depth-first search to a breadth-first search, which, in addition to not needing to deal with Ruby's limit on recursion stack depth, was a lot more efficient for solving that particular problem.

I wanted to make a note on what I did, though, so that if I ever face a situation again where I'm bumping up against a Ruby stack depth limit (and a solution other than increasing the limit isn't called for), then I'll be able to remember what I did this last time!

Sunday, October 06, 2024

ClipEmoji.com Updated With New Unicode v16.0 Emoji

Despite the fact that emoji pickers are built into major desktop operating systems these days -- as well as into some specific apps like WhatsApp and Slack -- I still find myself using my own site ClipEmoji.com for quickly getting a copy of particular emoji characters to paste into prose I'm writing.

As such, I took some time this afternoon to make a small update to ClipEmoji.com to add the few recently-added new emoji characters in the Unicode v16.0 Emoji set, such as "🫩" ("face with bags under eyes").

Additionally -- and probably more notably! -- I noticed that the Unicode Consortium added in this version a bunch of new "synonyms" (descriptor words) for existing emoji. For example, the words associated with the simple "👍" were previously:

  • thumbs up +1 hand

Now, in v16.0, the set of synonyms for 👍 is:

  • thumbs up +1 good hand like yes

Since the search on ClipEmoji.com is powered by these synonym words (plus a few custom additions of my own), you can now search for "like" and 👍 will be included in the filtered search results, for example.

Enjoy!

Friday, July 26, 2024

Computer eye strain sufferers: Try "Eyezen" glasses

First off: I'm not an eye doctor; I'm just a guy who spends a lot of time in front of a computer! Double-check anything I'm telling you here with your optometrist or ophthalmologist before you do anything else with this information.

Secondly: This isn't a paid post; I'm not receiving any kind of compensation from it. I'm just a fan!

The last several years, I had been suffering from moderately severe eye strain while working in front of my computer all day. Even though I have a nice setup where my monitors are set up a a little over two feet away from me, after  working for a solid hour two, my eyes would kind of "cramp up," feeling fatigued. I'd need to either close them for a couple of minutes, or at least spend time intentionally looking at a wall or object a longer distance away (e.g. across the room), in order to let my eyes recover enough to resume looking at my monitors and continue working.

At my most recent optometrist visit several months ago, my optometrist -- shout out to Dr. Staci E.! -- prescribed "Eyezen" glasses. The glasses that I got are designed specifically for "near-distance" vision: Things that I look at far away through them are somewhat blurry; but the glasses produce really excellent clarity when looking at my two-feet-away computer monitors!

glasses

These glasses have made a very positive difference in my day-to-day job. When I sit down at my computer, I swap out my "everyday wear" glasses for the Eyezen glasses, which I just keep at my desk. Wearing the Eyezen glasses while working, my eye strain symptoms have mostly disappeared! I no longer get eye "cramps" or fatigue that forces me to stop working to "un-strain" my eyes on a frequent basis. 

When I get up from my desk, I swap my Eyezen "computer glasses" (as I think of them) back out for my daily wear glasses. This is obviously an extra step, but I haven't found it to be at all onerous. The cost has definitely been worth the benefit.

As I hadn't previously even been aware of the existence of specialized "near-distance vision" glasses, or Eyezen glasses, I figured I'd share my experience to spread the knowledge! My new glasses have really helped me; if you suffer from eye strain while working on a computer all day, consider asking your eye doctor if they might be a good option for you, too.

Monday, June 03, 2024

Vigil RPG 3.0 is now available!

Vigil RPG, my classic-style turn-based role-playing game for iPhone, has been updated to a new major version, 3.0! 

Vigil RPG logo

For the first time in 10 years on the App Store: Vigil RPG is now free to download! Vigil RPG has always been a "premium" title, where paying once got you the full game, with no ads, premium currencies, stamina timers, or other such impediments. Vigil RPG still has none of that, but now you can download Vigil RPG from the App Store and play the first portion of the game for free! After that, the game offers a single, one-time in-app purchase, which permanently unlocks the full game for you across all of your devices.

(And of course -- as is only right!-- I added code such that everyone who bought Vigil RPG prior to this v3.0.0 update will continue to have access to the full game (including the other major updates in the new version) without needing to buy the IAP.)

The other major feature in the Vigil RPG 3.0 update is Honor Mode! Inspired by a certain awesome hit 2023 AAA RPG -- (okay, fine, I'll just come right out and say that it's obviously Baldur's Gate 3) -- players considering themselves Vigil RPG veterans (or just hungry for maximum challenge right out of the gate?) can now optionally take on this challenging new difficulty. It features enhanced boss enemies with new move sets, and like Hardcore mode in the Diablo series before it, PERMADEATH upon defeat!

You can see all of this for yourself via a free download of Vigil RPG on the App Store.

Need an expert opinion first? Read a review of Vigil RPG at TouchArcade.com.

Learn more about Vigil RPG on the Vigil RPG website.

Saturday, December 16, 2023

The Jon Schneider Game of the Year awards: 2023

 

Starting with my favorite video game that I played for the first time this year, and continuing on with the rest of the top 10, these were my personal top games of 2023 (along with the platform(s) on which I played them)!

1. Baldur’s Gate 3 (Windows / Mac)

There are so many facets to this game that, on their own, are exceedingly well done, and joined together, make for an incredible game. The adaptation of D&D 5E combat and mechanics. The overall story. The individual party members, and their stories. The smart way in which the game’s world and characters realistically respond to your actions. It speaks to BG3’s quality that it is the first long-form game in a very long time that I played through from start to finish -- and then immediately made a new character and started a second play-through.

2. Zelda: Tears of the Kingdom (Switch)

It ALSO speaks to Baldur’s Gate 3's quality that when I played Tears of the Kingdom earlier in 2023, I felt that it was the best game I’d played in several years, and a lock for game of the year! A more-than-worthy sequel to Breath of the Wild (my personal 2017 Game of the Year). Hyrule is once again super fun to explore -- this time with the additions of caves, the sky, the underground, and more things to do. The construction system managed to successfully achieve making me feel like “I’m not just a fantasy warrior, I'm a fantasy warrior engineer!”

3. Super Mario Bros. Wonder (Switch)

In my opinion, the best new 2D Super Mario game in 32 years(!!) (Super Mario World, SNES, 1991). The combination of more traditional-style Mario 2D gameplay with the “wonder” sequences in the various levels made for a great combined experience. The pseudo-multiplayer real-time “ghosts” of other players that you can see -- and to a limited extent, interact with, but very importantly, not in any toxic way -- was also a neat (optional) addition which I ended up being quite glad that I had decided to leave enabled.

4. Cobalt Core (Steam Deck / Windows)

This recently-released, spaceship-battle-themed roguelike deckbuilder came out of nowhere for me to claim this high spot on my crowded GOTY list this year. It adds to the traditional Slay-the-Spire-like ability to block incoming damage an ability to move laterally to dodge incoming fire (or to take it on more-armored parts of your ship). Battles are quick and punchy. Even though Cobalt Core is mostly not mechanically similar to FTL, it did give me some of the smartly-designed vibes of that game.

5. Infernax (Steam Deck)

A solid, 8-bit-style platforming action game in the tradition of Castlevania 2 (NES). The tight gameplay and fun progression were enough to keep me engaged straight through from start to credits.

6. Dave the Diver (Steam Deck)

A scuba-speargun-fishing main game mashed together with restaurant management sections, complete with a perfect-beer-pouring minigame? Why does this work so well? Once again, it's because all of the individual pieces are so solid! The charming character design and pixel art certainly don’t hurt, either. The first game in this year’s list that I haven’t actually finished yet: I played mostly in Early Access, decided to set it aside until the full 1.0 release -- and haven’t picked it back up quite yet due to the strength of all of the other new games on offer this year.

7. Octopath Traveler 2 (Switch)

This year’s turn-based JRPG comfort food. An interesting battle system, combined with a story that was decent enough to keep me playing straight through to the end -- albeit for only some of the characters, not all 8. As with the first series entry, my wish would be for better interaction between the 8 protagonist characters.

8. Outer Wilds (Steam Deck)

After having this game on my to-play for a few years, I finally had a good way to play it when I acquired a Steam Deck in the early part of 2023. As I had heard about Outer Wilds prior to playing, this is a game worth playing without being spoiled on what it is all about! I will say to give it a try if a first-person, solo exploration game where you board and fly your own spaceship (while still in the first-person perspective) and explore a small solar system sounds appealing. I did admittedly eventually get stuck on how to progress further (and didn’t want to consult a guide) -- but not until after quite a few hours of really interesting exploration and discovery.

9. Tunic (Steam Deck)

For me, Tunic created a vibe of being a kid back in the pre-Internet days, and having a new Nintendo cartridge to play -- but there's no real in-game help, and the instruction booklet that came with the cartridge (which in Tunic's case is discoverable in pieces within the game itself) is mostly in Japanese (a language I can't read) -- but the booklet's bits of English text, plus illustrations, allow for puzzling out the less obvious parts of game's mechanics and objectives. Add that to some overhead-view Zelda-like combat -- except tricky, with some Souls-like aspects -- and Tunic ends up being pretty cool and unique experience.

10. Alina of the Arena (Mac / Windows)

A last-minute list addition! Another roguelike deckbuilder hybrid -- this time with Into-The-Breach-like tactical positioning, with a solo character you control facing off with one or more enemies on a small hex grid.

Honorable Mentions

A few of the other games I played which were good, but didn’t make this year’s Top 10, in approximate descending order of where they would have appeared on the list, had there been room: Retro Bowl College (iPhone), Diablo 4 (Windows), Pinball FX (Windows), 30XX (Steam Deck), Tinykin (Steam Deck).

Sunday, July 23, 2023

How to fix timestamps on Mac Photos exported files

This is a "Remind my future self how to do this, but hopefully it'll be helpful for the rest of y'all too" post!

To change the timestamps on files exported from the Mac's Photos app to match the dates that the photos and/or videos were actually taken:

1. Install exiftool if it isn't already installed:

brew install exiftool

2. One a a time, run these two commands from the terminal, from the directory where the files are located:

for file in *.jpeg; do touch -t "$(exiftool -p '$CreateDate' -d '%Y%m%d%H%M' "$file")" "$file"; done

for file in *.mov; do touch -t "$(exiftool -p '$CreationDate' -d '%Y%m%d%H%M' "$file")" "$file"; done

When those are done, each file's timestamp should match the actual date that the photo or video was taken.

Any The ExtractEmbedded option may find more tags in the media data warnings can be ignored.

Background

When copying photos and/or videos from an iPhone to a Mac, the copied photos don't end up as individual files in the Mac's filesystem. Instead, they become part of the "Photos Library" on the Mac, in which all photos and movies are stored in a single "blob" file.

Fortunately -- for the purpose of copying and/or backing up photos elsewhere, on non-Apple computers or cloud storage -- the Mac's Photos app provides a capability to "export" photos and videos from the library as individual files. (This is accessed via File menu > Export.)

Two export options are provided: "Unmodified Originals" (which tend to have large file sizes); or as JPG, TIFF, or PNG files (for photos), and .mov files for videos (which produces smaller file sizes).

Unfortunately, the exported photo and image files have a timestamp (shown as "Date Modified" in Finder) of the time the export was performed -- not the time that each individual photo or video was actually taken.

For me, having the date shown for each file in Finder match the date that the photo/video was originally taken is a lot more useful. Hence, the procedure described earlier in this post to make that change.

"CreateDate" versus "CreationDate"

You may have noticed that in the two terminal commands above, the former uses the EXIF tag "CreateDate", and the latter, "CreationDate".

For some reason -- for photos and videos exported using the Photos app on macOS Ventura 13.4, and originally taken on an iPhone running iOS 16.5 -- exported .jpeg and .mov files, respectively, have inconsistent sets of EXIF tags.

The EXIF tags on a paritcular file can be inspected using exiftool via a terminal command like:

exiftool -s my_photo.jpeg

For my exported .jpeg files, this produces output like (with irrelevant tags excluded):

CreateDate: 2023:07:04 09:51:12

There's no "CreationDate" tag present.

For my exported .mov files,  the output is like:

CreateDate: 2023:07:22 14:04:56
CreationDate: 2023:07:04 13:39:20+02:00

So both CreateDate and CreationDate values are present; however, here, "CreateDate" is the timestamp of the Mac Photos app export, and CreationDate is the actual time the video was recorded.

I'm sure there are excellent reasons behind this seemingly-inconsistent state of affairs; I am not aware of what those might be. 😅 In any event, it was easy enough, one I investigated and figured out what was going on, to split the exiftool command into two separate parts, for the EXIF tags that are actually present and correct in the .jpeg and .mov files, respectively.

Credit for the original exiftool command that I adapted here goes to Daniel Schofield on the Ask Different Stack Exchange site.

Friday, July 21, 2023

Recommended for international travel in Switzerland: "Traverlers Wifi"

(Note: I have no affiliation with "Travelers Wifi," nor is this a paid post. I'm just a fan!)

My family and I were fortunate enough to be able to spent the past sixteen days traveling abroad in Switzerland. To cover us while there, our current cell provider, AT&T, wanted US $10 per person per day. For just one person, that of course would work out to $160; for the four of us with cell phones, we'd have gotten hit for $640. Eek!

My wife Melissa, an excellent hobbyist travel planner, researched, and landed on Travelers Wifi as the solution. It's a cell-phone-sized device that provides cellular connectivity in Switzerland (specifically that one country), and allows up to 5 client devices at a time to connect via Wi-Fi to access the Internet.

Travelers Wifi device

The device cost €5.90 (a little over US $6) per day. Quite the cost savings over the up-to-$40-daily that we would have been spending!

Bottom line: It worked great! 

The device connected with no issues and had good Internet speeds everywhere we went (Zurich, Lucerne, Lauterbrunnen and the Berner Oberland region, Vevey and environs, and Geneva). 

Our device was good for about eight hours of battery life on a full (overnight) charge. That's not quite a full day of adventures, but it was not to worry: We had come prepared with a few external battery packs with USB-A outputs; connecting one of those to the Travelers Wifi device was more than enough to get it through an entire day. I'd recommend being similarly prepared to back up the Travelers Wifi device with an external battery.

Pick-up of the device in Zurich was trivial; Travelers Wifi has a dedicated storefront in the Zurich airport in the public (outside of security) area, and they were able to look up our online reservation (made in advance, prior to our trip) and hand over the device with no issues.

Return of the device was similarly easy; although the Geneva airport (from which we were departing) has no Travelers Wifi storefront, it does have several of the yellow "Die Post" mailboxes. The Travelers Wifi comes with a "self-addressed stamped envelope"; we just put the device and it's included cable and wall charger into that envelope, and dropped the resultant thin package into a mailbox at the airport.

I'm happy to be able to cheerfully recommend Travelers Wifi to anyone traveling abroad in Switzerland!

Thursday, June 22, 2023

The mystery of the broken JWT magic link login URLs on iPhone

My team at work was recently facing a problem where "magic link" login URLs being sent out via SMS (text message) were "broken" when received by iPhone users. Only part of the URL's query string portion was properly rendering as part of the link; the remainder -- despite not being separated by a space, or any URL-invalid characters -- was showing up as plain text:


A magic link in this context is an URL that includes a secure, tamper-evident key which identifies the user, and allows them to log in to the application that sent the link, in lieu of having to enter a password. (This has security pros and cons; that linked article provides a nice summary.)

My team is using JWT as the magic link key. JWTs are encoded into three portions, separated by period characters (remember that, it's important later!): A header; the message payload (including things like the user's ID, and the key's expiration time); and the signature verification.

In our case, only the first portion of the JWT value, the header, was being rendered by iPhone recipients of our SMS message as a part of the clickable hyperlink. The remaining two portions were showing up as plain text. This broke the magic link! While it still directed users to our site, it was unable to log them in.

I spent the day yesterday performing an investigation into why this was happening.

For starters, asking Google about the maximum length of an SMS message yields the answer "160 characters." Here in 2023, as far as regular users are concerned, this is no longer really true. (When's the last time you were composing a text message, and your phone stopped you from sending your message because it was longer than about half a Tweet?) All modern providers use "SMS concatenation" to, behind the scenes, break a long SMS message into multiple parts, and then seamlessly stitch those parts back together into a single message for the recipient.

I hypothesized: Perhaps Apple's implementation of SMS concatenation doesn't work when the URL itself is longer than 160 characters (as our magic link login URLs including a JWT token are)? No; I was able to disprove this by sending myself a text message with such an URL; it arrived in one piece, no problems. 

(As an aside, I started out doing these tests by using the web UI of my work's existing Twilio account to send messages to my personal iPhone's number. This worked fine; I pretty quickly determined, though, that I could more expeditiously test by just using my Mac's Messages app to send messages to my own phone number. This produced the same results, as far as the received message ending up broken or not.)

Perhaps SMS concatenation doesn't work when the query string portion of the URL is longer than 160 characters? No; disproved by sending myself such a link, which once again was delivered in one piece, as expected.

Perhaps the problem is when a single query string key-value pair -- or just a query string value -- is longer than 160 characters? No; I was able to successfully send myself messages (using the string "1234567890" repeatedly as the query string value to achieve the target length) in with such query string values excess of 500 characters in length, no problems.

My testing went on like this. I was consistently able to reproduce the broken link behavior using an actual (Dev environment) magic login link; the behavior of any particular URL being broken or not appeared to be consistent/deterministic, at least. Further, by trimming down certain portions of that URL, the link would be correctly delivered in one piece. 

By testing many message and URL variants, and recording for each one whether it succeeded or failed to deliver properly, along with the lengths of the various portions of the message text and the URL, I was finally able to pin down the problematic behavior. Here it is, in plain English:

For a given query string value: If that value contains any URL-valid punctuation characters (i.e. non-alphanumeric characters): If any portion (or "slice") of that query string value beyond the first portion, when separated/sliced by punctuation characters, is 302 characters or longer, the URL will break (on Apple devices). If all such portions are 301 characters or shorter, the URL will render correctly. 

Recall that JWT values consist of 3 portions -- separated by period characters? This meant that if the token's 2nd (payload) or 3rd (signature) encoded portions were in excess of 301 characters, the resulting link would be broken when delivered to an iPhone. 

(Notably: It's only Apple's handling of SMS messages, in their Messages / iMessage app on iPhone and on Mac, where links render as broken in this particular way. In my testing with Android clients, and with Google Voice, all links that I tested with were delivered correctly, regardless of length!)

Here are a few examples of working and broken URLs (when received by an Apple client). To save space (and to make this post less ugly!), instead of actually spelling out URL portions of 300+ characters, I'll represent such portions with the number of characters in that portion. The following links, when delivered to and viewed on an iPhone, or in Apple's Messages app on a Mac:

https://example.com?key=400 (OK; there are no punctuation characters in the query param value)

https://example.com?key=10.302 (BROKEN; the second portion of the query param value is longer than 301 characters)

https://example.com?key=301.301_301 (OK; no portion of the query param value is longer than 301 characters)

https://example.com?key=200~200.400 (BROKEN; the 2nd portion is ok, but the 3rd potion is longer than 301 characters)

https://example.com?key=400-50 (OK; only the first portion of the query param value is longer than 301 characters, and that doesn't manifest the problem)

https://example.com?key1=400&key2=400 (OK; the both query param values here consist only of "first portions", which don't manifest the problem)

To work around this problem -- and to produce links that are some what less nasty-looking on clients that render the entire URL -- I'm planning on making a pair of changes to our magic login tokens:

1. Reducing the payload content to "essential" values only. Namely, the user's email address, and an expiration date/time value. This will cut down on the middle "payload" portion of the JWT.

2. Using HS256 instead of RS256 as the signing algorithm. For our specific application and usage scenario, HS256 will provide sufficient security; but HS256 signature values are significantly shorter in length. 

All of the aforementioned testing was done in June 2023 using an iPhone 12 running iOS 16.5.1; and a MacBook Pro running MacOS Ventura 13.4.  Perhaps Apple will address this issue in future software versions? (But if this particular bug isn't at the top of their priority list, I certainly can understand why not. ☺)

Hopefully this post may be helpful to any of y'all out there who are researching why your SMS messages that include JWT magic link login URLs (or other long URLs including long query string values) being delivered to iPhone clients aren't rendering properly!

Thursday, April 06, 2023

The Mystery of the NULL Values in a NOT NULL DATETIME MySQL Database Column

My development team at work yesterday picked up a task to investigate a error that was periodically showing up in our production logs:

NoMethodError: undefined method `>' for nil:NilClass

This is a common Ruby error that occurs when you try to call a method on a variable with a value of nil. In this case, the "method" in question was the greater-than operator, ">". 

The line of code associated with the error in question was actually a conditional evaluation that involved 3 separate ">" evaluations, which obscured the exact variable that was the source of the error a bit. 

Two of the three variables in question were attributes of an ActiveRecord model object whose associated database column was defined as NOT NULL. Those attributes were not being re-assigned after being read from the database, so we initially ruled them out as being the possible cause of the error.

However, further investigation revealed that none of the three variables on the problematic line of code could -- in theory -- possibly ever be null. Faced with this, I decided to take a closer look at the actual data in the database.

Our production environment is split into multiple MySQL databases. For purposes of conveniently being able to query customer data across databases all at once, we have an ETL process which extracts (non-sensitive) customer data from the source databases, and populates it all into a single central Snowflake SQL database. 

The database structure of the relevant table (Users) was similar to this (simplified for brevity):

  • id: INT, primary key
  • first_name: VARCHAR(255)
  • last_name: VARCHAR(255)
  • updated_at: DATETIME, NOT NULL

Temporarily putting common sense aside, I queried the snowflake database to see if any of the NOT NULL updated_at values were nevertheless null:

SELECT * FROM Users WHERE updated_at IS null

This returned 0 results, as expected. 

Acting on a hunch, I tried searching for unexpectedly old records; this database has been in service since about 2009:

SELECT * FROM Users WHERE updated_at < '2005-01-01'

This query did produce some results! Out of the tens of thousands of records in the Users table, a few hundred records were returned whose updated_at date was '1970-01-01 00:00' -- a value equal to the start of the epoch in Unix time

At this point I questioned: Does Ruby on Rails and/or ActiveRecord somehow treat start-of-epoch date values of '1970-01-01 00:00' at nil? This seemed unlikely, but I tested it anyway, setting the updated_at value for an existing record in my local machine's copy of the database to that start-of-epoch value; and then reading the record into the corresponding ActiveRecord model object. Not unexpectedly, the updated_at attribute on the model did not end up with a nil value; it had the expected value of midnight on January 1, 1970.

I still felt like I might be onto something here, though. At this point, I wanted to inspect the data in the actual MySQL customer database. I don't have access to the live database, but I was able to access a read-only replica of the database.

Having connected to that MySQL database, I verified that the updated_at column was still defined at NOT NULL; it was. I then ran the same query to look for old values:

SELECT * FROM Users WHERE updated_at < '2005-01-01'

As before, this returned a few records -- about 300. However, I noticed that the actual updated_at values were different. This time, they were returned as '0000-00-00 00:00'. 

Year "0", month "0", day "0" -- all nonexistent values. Feeling a bit of a chill, I re-ran my earlier query to look for records with null values:

SELECT * FROM Users WHERE updated_at IS null

MySQL returned the same 300 records

Cool, cool, coolcoolcool. The evidence indicated that (1) MySQL was allowing values of '0000-00-00 00:00' to be set on a DATETIME, NOT NULL column; and (2) despite the aforementioned NOT NULL restriction on the column, was evaluating such values as being null.

To close the loop, back on my local development machine's database, I set an existing record to the '0000-00-00 00:00' value -- which MySQL happily allowed -- and then, in the Rails console, populated a model object from the record. Sure enough: The updated_at attribute on the record had been assigned a value of nil.

Summary of findings

(1) In a Rails application (and possibly in other languages / frameworks as well), it may not be safe to assume that a value read from a DATETIME, NOT NULL column on a MySQL database is actually guaranteed not to be null. MySQL allows a value of '0000-00-00 00:00' to be set in such columns (despite the NOT NULL restriction); such values are treated as null both my MySQL itself, and by Rails / ActiveRecord.

(2) Although I'm not familiar with its inner workings, that the ETL process from the multiple production MySQL databases to consolidate data into a single Snowflake database couldn't 100.0% be trusted not to change data values. In this case, it silently converted MySQL DATETIME values of '0000-00-00 00:00' to values of '1970-01-01 00:00' 

Mitigations

Doing a bit of additional reading after the fact, I found a StackOverflow post that references a MySQL configuration mode of "NO_ZERO_DATE," which when set, prevents such "zero date values" from being set. 

According to the MySQL documentation, not setting that option may be "more convenient" and/or consume less space. Based on my team's experience here, though, I'd certainly be inclined to take advantage of that NO_ZERO_DATE setting whenever feasible!


Wednesday, December 28, 2022

The Jon Schneider Game of the Year awards: 2022

Starting with my personal Game of the Year for 2022, and continuing on with the rest of the top 10, these were my favorite games that I played for the first time this year (along with the platform(s) on which I played them)!

1. Persona 5 Royal (Switch)

Probably the most expansive single-player RPG I've ever played, I was hesitant to dive into the this game given the expected time commitment, but I'm certainly glad I did! Engaging turn-based combat system, check. Interesting story, check. Realistic and sympathetic characters, check. Getting to explore (in a limited way) the districts of real-life Tokyo, a fun bonus. All wrapped up in a package that tries hard to be super stylish and cool -- and actually manages to pull it off!

2. Xenoblade Chronicles 3 (Switch) 

I enjoyed seeing the lore of the first two main games in the Xenoblade series woven together here into something completely different, yet still congruent with the original pairs of source material. A key innovation here was an RPG with a large party of player characters where some PCs aren't present during combat; all 6 (or 7) of them are present, contributing, and playable. The way the game handles, in consistent fashion with the game's world, a mixed-gender party of (essentially) late-teens / early-20s characters with romance between characters (mostly) not being a thing was also an enjoyable novelty. 

3. Crystal Project (Steam / Mac / Windows)

This game was a real treat for me, a fan of old-school Final Fantasy games and similar JRPGs. Layering challenging turn-based on top of exploration of a blocky Minecraft-like world worked remarkably well. Like Stardew Valley a few years back, Crystal Project is a game that managed to pleasingly surprise me on a number of occasions after I thought that I had what the game had to offer all figured out.

4. Knotwords (iPhone)

The best word game app I've ever played. It's Sudoku crossed with crossword puzzles in a really smart way. Wonderful UX design (which inspired a Vigil RPG haptics update!). More-than-fair premium pricing model with no ads, premium currencies, or other typical mobile-game dark patterns. Multiple game modes, including daily and monthly puzzles. Knotwords' ability for the player to "recover" lapsed daily streaks is another innovation that should be universally copied. Highly recommended to fans of word games!

5. Isle of Arrows (iPhone) 

Another premium iOS game done right. Combines tower defense, a light puzzle/card game, and a cool aesthetic. I 100%'ed the campaign.

6. Phoenotopia Awakening (Steam / Mac) 

A smartly-designed, difficult-but-fair, cute pixel-art hybrid of Metroidvania and Zelda 2: The Adventure of Link gameplay styles, with great settings/locations and story.

7. Kirby Forgotten Lands (Switch) 

I played through this cute first 3D foray of Kirby through start-to-finish with my 10-year-old, with her taking on the role of Kirby, and me, Bandana Waddle Dee. I appreciate how this game caters to a variety of skill levels, with the the main story being well suited for the pair of us to complete, and also including a post-game suitable for experts.

8. Chasm (Steam / Mac)

Like Phoenotopia, a pixel art melee Metroidvania game, whose weapon/combat system seems to have been particularly inspired by the more recent 2D games in the Castlevania series (see: next entry below!). Slightly thin on story, but solid enough in terms of level design and gameplay that I did complete the main story.

9. Castlevania Aria of Sorrow (Switch port of Game Boy Advance game) 

Played through this game which I'd never played in its original form as part of the Castlevania Advance Collection. I'd previously been most familiar with the more traditional series entries including the original Castlevania (NES) and Super Castlevania (SNES); hat tip to TouchArcade and freelance game writer Shaun Musgrave, whose recent Patreon ranking of all(!) of the Castlevania series games inspired me to give this a play-through. 

10. Super Auto Pets (Web / iPhone)

This is the type of multiplayer-game that I tend to prefer these days: Quick and asynchronous, meaning low-stress! You start each game from scratch to build a team of up to 5 of animals, each with their own unique combat ability, and watch them engage in a series of brief auto-battles against teams built by other players who have done the same.