James Montemagno - James Montemagno https://montemagno.com/author/james-montemagno/ https://montemagno.com/content/images/2017/08/MonogramIcon_Orange_NoBuffer.png James Montemagno - James Montemagno https://montemagno.com/author/james-montemagno/ Hugo -- gohugo.io Sat, 17 Oct 2026 09:28:06 GMT 60 Rebuilding 13 Years of Blog Posts as a Static Site with GitHub Copilot CLI https://montemagno.com/rebuilding-my-blog-as-a-static-site-with-github-copilot-cli/ https://montemagno.com/rebuilding-my-blog-as-a-static-site-with-github-copilot-cli/ Fri, 16 Oct 2026 16:00:00 GMT

If you are reading this, you are looking at a brand-new montemagno.com. It looks a lot like the old one on purpose, but underneath it is a completely different animal: no database, no server, no admin panel. Just Markdown files in a private GitHub repo, built by Hugo, verified by GitHub Actions, and served from GitHub Pages.

I have been blogging here since 2012 (hi, Tumblr) and on Ghost since 2017. Ghost was great, but over the years my wishlist kept growing: I wanted to write in my editor, keep images next to my posts in git, have dark mode, get rid of jQuery and ad scripts, and most of all own the whole thing as plain files. The thing that always stopped me was the sheer size of the job: 274 posts, a thousand images, nine years of URLs that people and search engines link to, and a theme I really liked.

So this time I did it differently. I opened a terminal, started GitHub Copilot CLI, and said roughly this:

I want to completely remake my site to be a static site with full backwards compat with the format/slugs, images, import of data, easily writing things in markdown and then having it displayed, easy image management. Here is my Ghost export and my existing design system. Do deep research on montemagno.com and let's plan it all out.

About an afternoon later the initial version of the site you are looking at existed, every one of 2,172 legacy URLs was verified to resolve, and a CI pipeline was standing guard. This post is the story of that migration, the follow-up work to get it launch-ready, and what I learned about working with an agent on a project this size.

Timeline of the migration session
The initial migration session, wall-clock. Planning took longer than the migration scripts.

Step 0: Plan first, code second

The best decision I made was starting in plan mode. Instead of diving into code, Copilot CLI spent the first chunk of time doing research:

  • It parsed the Ghost export JSON and reported exactly what was in it: 298 posts (274 published, 13 drafts), 10 pages, 323 tags, ~1,330 image references, which posts were stored as Ghost "mobiledoc" Markdown cards versus the newer Lexical editor, how many used gists, YouTube, Fireside podcast players, and so on.
  • It spun up a research sub-agent that crawled the live site and came back with a precise inventory: URL patterns (/slug/, /tag/slug/, /page/N/, /rss/, /tag/slug/rss/, /author/james-montemagno/), posts per page, every <meta> and JSON-LD tag Ghost emits, the fonts and colors in the Cedar theme, the third-party scripts, and even the fact that /slug/amp/ 301s back to the canonical URL.
  • It dug through my old montemagno.com-assets repo and found the Cedar theme source, the brand guidelines, and a redirects.json with 293 legacy redirects going back to the Tumblr days.
Charts of how Ghost stored content and which embeds were converted
Knowing what was actually in the export made the converter a lot simpler to design.

Then it asked me a handful of structured questions: which static site generator, where to host, how faithful to keep the design, what to do about comments, newsletters, ads, and the little dog that pops up at the bottom of the page. I pushed back on its first suggestion (Astro), it gave me an honest comparison of Hugo, Eleventy, Statiq, Zola, and Jekyll, and we landed on Hugo on GitHub Pages with an "evolve the Cedar look" design direction.

The output of plan mode was a 16-item plan with dependencies. Only after I approved it did anything get written to disk. That alone saved me from a dozen wrong turns.

The architecture

Here is what we ended up with. Every box is files in one repository.

Architecture diagram of the new site
Sources on the left, one-time migration tooling in the middle, the Hugo repo, then Actions and Pages.

A few things worth calling out:

  • Images keep their exact URLs. Ghost served images from /content/images/YYYY/MM/name.png. The new site puts them at static/content/images/YYYY/MM/name.png, so every hotlink, Google Images result, and old tweet still works. New posts just drop images into the current month's folder.
  • Redirects are front matter. GitHub Pages has no server-side redirects, so each legacy URL becomes an aliases: entry on the post it should point to. Hugo emits a tiny HTML stub with <meta http-equiv="refresh"> and a canonical link. Search engines treat it as permanent.
  • Tags are real pages. content/tags/<slug>/_index.md holds the display name (so xamarin-forms renders as "Xamarin.Forms"), and a few near-duplicate tags (vs-code, visual-studio-code) were merged into vscode with aliases for the old URLs and their RSS feeds.
  • RSS at /rss/ is still XML. Feed readers have been hitting montemagno.com/rss/ for years. Hugo now writes the feed to /rss/index.html (what GitHub Pages serves for that directory) with a Ghost-compatible structure: full content:encoded, media:content feature images, categories, and the original Ghost post IDs as GUIDs so nobody's reader shows 274 "new" items.
  • No build-time API calls. The Apps page (more on that below) reads data/github.json, which a scheduled Action refreshes daily. Builds are deterministic and fast.

The migration pipeline

Seven step migration pipeline
Each step is a small, re-runnable Node script in tools/migrate.

Convert: HTML to Markdown, for real

Ghost's export includes rendered HTML for every post, which is what we converted (the mobiledoc/lexical JSON is an implementation detail). The converter uses Turndown with a set of custom rules that Copilot wrote after sampling the awkward cases in my content:

  • Fenced code blocks with the language preserved (and a few typos like language-csharo normalized).
  • Heading IDs preserved as {#id} attributes so in-page anchors keep working.
  • Ghost image cards with captions become {{< figure >}} shortcodes; plain images become plain Markdown.
  • <script src="https://gist.github.com/..."> becomes {{< gist user id >}} (118 of them!), YouTube iframes become {{< youtube id >}}, Fireside players become {{< fireside url >}}.
  • Every ad block (AdSense, Amazon affiliate widgets, DeveloperMedia) and tracking script is stripped.
  • __GHOST_URL__ placeholders and storage.ghost.io CDN URLs are rewritten to root-relative paths.

It also found and fixed things I had never noticed: a page with a literal </3> closing tag, posts where Markdown image syntax had been saved as plain text, and four image URLs that differed from a sibling only by case (github.png vs github.PNG), which is fine on Linux and a collision on Windows. That last one only surfaced when the first CI run on Ubuntu failed the URL check, and the fix was a second static-case/ mount folder.

Verify: 2,172 URLs, every build

This is the part I am most happy about. A script builds a manifest of every URL the old site served: all post slugs, tag pages and their RSS feeds, pages, images, the 250 usable redirect sources, plus whatever the live sitemap-*.xml files listed. A second script checks that every one of them exists in the Hugo output, case-sensitively, and fails the build if not.

Bar chart of verified URL categories
The manifest is committed to the repo, so the check runs on every push forever.

It caught real bugs: the case-collision above, a redirect that pointed at a typo'd /serach/, and a pair of duplicate rules in the original redirects.json that pointed the same URL at two different places.

The design: Cedar, evolved

I told Copilot "evolve the Cedar look" and that is what it did. The full-viewport cover, the big condensed headlines, the sticky header that hides on scroll, the slide-in drawer, the alternating post list: all there. But it is rebuilt with modern CSS custom properties, no jQuery, no Font Awesome (inline SVG icons instead), system-aware dark mode with a toggle, a copy button on code blocks, responsive embeds, and prev/next and related-post cards at the bottom of every article. Fonts still come from my Adobe Fonts kit, but via a single CSS link instead of a JavaScript loader.

Before and after comparison table
Fewer moving parts everywhere.

Search moved from GhostHunter (jQuery plus a Ghost API key) to Pagefind, which indexes the built HTML after Hugo runs and searches entirely in the browser. And yes, Milli still pops up from the bottom of the page when you find her.

The Apps page, powered by GitHub

Once the migration was done I asked for something fun: "For Apps, do you think we could do something really cool with my GitHub profile?"

The result is the new Apps page. A curated list of apps lives in data/apps.yaml (name, tagline, store links, platforms, and an optional repo). Everything else comes from GitHub: a daily Action runs a small Node script that pulls my profile, every repository, pinned items, and contribution stats (REST plus GraphQL, using the workflow's own token), and commits data/github.json only when something changed. Hugo then renders:

  • Featured app cards enriched with live stars, last-commit date, and language.
  • A "by the numbers" strip: 12,000+ stars, 291 original repos, followers, contributions in the past year, years on GitHub.
  • Language bars and fun facts (most starred, most forked, first-ever repo from April 2012, how many repos I created this year).
  • Pinned repos with their social-preview images, the most-starred open source, and "what I'm hacking on right now."

No client-side API calls, no rate limits, and it stays fresh without me touching it.

Fit and finish: from migrated to launch-ready

At that point I had the content, the design, and even a shiny new Apps page. But getting the migration working in an afternoon was not quite the same as being ready to send everyone to it. In a follow-up session, I asked Copilot: "What still needs to happen before I actually publish this?"

That question turned into 19 GitHub issues, which was a lot more useful than a vague list of things to remember. We split independent fixes across Copilot CLI sub-sessions in separate git worktrees: accessibility, feeds, search, content cleanup, and build checks. Each session had its own branch, and the work came back as focused pull requests that we reviewed and merged once their checks passed. More work could happen in parallel, but I still needed to review it and make the decisions.

The interesting bugs were not missing pages. The homepage needed its paginator initialized before Hugo rendered the head metadata. Posts made almost entirely of embeds needed their titles included in search, and related-post recommendations needed the taxonomy index wired up correctly. Even the code-copy button had a surprise: copying rendered text could double the newlines. None of those would have stopped the migration, but they are exactly the things you notice once you start using a site.

We also spent time on the things that make it easier for everyone to use. Text and button contrast improved without losing the orange branding, keyboard focus stayed inside the mobile drawer and image lightbox, and long headings and the Apps profile stopped overflowing narrow screens. The image pass added or improved 130 alt texts and supplied dimensions for 33 raw image tags. That was a targeted pass over recent and important content, not a claim that thirteen years of images had suddenly been fully audited.

The feeds were a good reminder that "the URL works" is only the beginning. An old tag-feed URL returning an HTML redirect is not much help to a feed reader expecting XML. We repaired all eight merged legacy tag feeds and checked their contents, not just whether the files existed. GitHub Pages still serves those directory-style feeds with a text/html content type, so we documented an optional Cloudflare response-header rule. A successful local build cannot fix the hosting platform's response headers.

As we fixed things, we taught CI to catch them next time: redirect destinations, metadata and headings, sitemap consistency, preserved static files, and search assets all joined the legacy URL checks. A page marked noindex should not quietly slip into the sitemap, and an old image URL should still serve the original file. We added 30 offline tests for the checker rules themselves, too. If I am relying on an agent to build my safety net, I want evidence that the net actually catches something.

Not everything belonged in that pipeline. External sites started blocking link checks, so we narrowed that audit to important pages and recent posts, throttled requests, and treated blocked responses as unverified rather than broken. We refreshed evergreen copy and repaired confirmed dead links, but kept the broad external crawl out of CI. Image optimization, older-image alt text, and browser tests in CI stayed on the follow-up list. There is a difference between ready to launch and nothing left to improve.

Then we rehearsed deployment on staging.montemagno.com without touching production. The hosted check passed all 2,172 legacy URLs, confirmed a 274-page search index, and checked HTTPS, redirects, real 404s, robots.txt, and the sitemap. The feed content-type warning stayed visible, and production DNS cutover remained a separate step for me to handle. With that rehearsal behind us, the site is now live at montemagno.com.

That second pass is what gave me the confidence to ship it. The first afternoon showed that we could build the site; the follow-up turned the rough edges into small, reviewed fixes and repeatable checks.

What it's like to work this way

Between the initial migration and that launch pass, a few things stood out about working with an agent in the terminal:

Plan mode is the killer feature. Research first, questions second, code third. The plan had dependencies, the agent tracked its own todo list, and when I changed my mind ("don't publish yet, I unpublished it") it kept deploys on a manual trigger while we were getting ready to launch.

It verifies its own work. It took Playwright screenshots of the home page, a code-heavy post, the tag page, the mobile drawer, and dark mode, looked at them, and fixed what was wrong (a squished avatar, a blurry upscaled dog, trailing whitespace from a render hook). It parsed the RSS output as XML to prove it was valid. It ran the URL verifier after every change.

It hits real-world walls and climbs them. Windows path restrictions on alias names, Hugo's new template layout system, the HTML minifier corrupting the XML feed served from an .html file, GitHub Pages being case-sensitive when my laptop is not. Each one showed up as a failure, got diagnosed, and got fixed with a small, explained change.

You still steer. I made every product decision: Hugo over Astro, no comments, RSS instead of a newsletter, keep the dog, "Follow" instead of "Newsletter & Feed" in the nav, weight the tag cloud toward what I write about now. The agent did the research that made those decisions easy and then executed them.

Writing a post now

After all that migration and launch work, this is the part I actually wanted: writing the next post without thinking about any of it.

hugo new posts/my-next-post.md

Fill in the front matter, write Markdown, drop images into static/content/images/2026/10/, and set draft: false when the post is ready. Push and open a pull request. Once the post is merged to main, CI builds the site, rebuilds the search index, runs the compatibility and launch checks, and deploys to montemagno.com. The Hugo build itself takes about six seconds.

---
title: "My Next Post"
slug: my-next-post
date: 2026-10-16T09:00:00-07:00
draft: false
description: "One or two sentences for search and social."
image: /content/images/2026/10/cover.png
tags: [github-copilot, dotnet]
---

If you have been putting off a migration like this because of the sheer volume of stuff that could break, I would encourage you to try the plan-first approach. Hand Copilot the export, point it at the live site, tell it what must not change, and let it build the safety net before it builds anything else. Then make time for that second question: what still needs to happen before I publish?

Explore GitHub Copilot

I used GitHub Copilot CLI for this project because I wanted to work in the terminal, but that is only one way to get started. You can explore GitHub Copilot in your editor, in the CLI, in the Copilot app, and on GitHub itself. Pick the place you are most comfortable working and give it a real task you have been putting off. It does not have to be thirteen years of blog posts!

If you are new to it, check out my First Steps with GitHub Copilot guide. And if you would rather follow along with a video, here is my new one:

Huge thanks to the Ghost team for nine great years. Here's to the next thirteen as plain markdown files.

]]>
Introducing PDF Diff: Compare PDFs Privately in Your Browser https://montemagno.com/introducing-pdf-diff-compare-pdfs-privately-in-your-browser/ 69540d8693272a0001bc4c55 Wed, 31 Dec 2025 15:35:55 GMT

Hey friends! I'm super excited to share a new tool I've been working on that solves a problem I bet many of you have faced: how do you quickly compare two PDF documents to see what changed? Enter PDF Diff!

PDF Diff home page with a private and secure notice, Original PDF and Modified PDF drop zones, and a Try Demo button
Landing page for PDF Diff allowing you to upload 2 PDFs to compare

Why I Built This

Look, we've all been there. You get two versions of a contract, a proposal, or a license agreement, and you need to figure out what changed between them. Sure, you could upload them to some random website, but do you really want to send your potentially sensitive documents to a server somewhere? I know I don't.

So I built PDF Diff - a completely client-side, privacy-first PDF comparison tool that runs entirely in your browser. No servers. No uploads. No risk.

Privacy First, Always

This is the core principle that drove the entire development: your documents never leave your device. Period.

Everything happens locally in your browser using JavaScript. All the PDF parsing, text extraction, and comparison logic runs on your machine. Once you load the page, you can even disconnect from the internet and keep using it. That's the power of modern web technology, and it's beautiful.

What Can You Do With It?

I packed in a bunch of features to make comparing PDFs as easy and flexible as possible:

Multiple View Modes

Different people need to see changes in different ways, so I built five different view modes:

Side-by-Side View - This is the classic comparison view. Original on the left, modified on the right, with synchronized scrolling. Perfect for getting the full context of both documents.

Side by Side view of two license PDFs: removals struck through in red on the left, additions highlighted green on the right

Unified View - All changes shown inline with color coding. Green for additions, red for deletions. This is great when you want to see everything in one continuous flow.

Unified view of the license changes in one column, each deleted word struck through in red next to its green replacement

Additions Only - Just show me what's new! This view filters out everything except the added content. Super useful when you're reviewing what someone added to a document.

Removals Only - The opposite - show only what was deleted. Great for catching what might have been taken out of a contract or agreement.

Changes Only - This one's clever - it shows additions and removals side-by-side, grouped by where they appear in the document. You get a focused view of just the changes without any of the unchanged content.

Changes Only view listing 15 additions in green beside 8 removals in red, such as March replacing January

Real-Time Statistics

As soon as you upload your PDFs, you get instant statistics:

  • How many words were added
  • How many were removed
  • How many stayed the same
  • Overall change percentage

This gives you a quick sense of how significant the changes are before diving into the details.

Multi-Page Support

Documents aren't always single pages! PDF Diff handles multi-page documents with easy page navigation. You can step through page-by-page, or flip on "Show all pages" to see changes across the entire document at once.

Export to PDF

Found something important? Export your comparison as a formatted PDF report that you can save or share. The export includes your selected view mode, so you can generate exactly the report you need.

Dark Mode

Because of course it has dark mode. Your eyes will thank you. 🌙

The whole PDF Diff page in dark mode, showing the Additions view of the demo documents

Try the Demo

Not ready to upload your own PDFs yet? Hit the "Try Demo" button to see it in action with sample license agreement documents. It's the fastest way to understand how powerful this tool is.

The Technical Bits

For my fellow developers out there, here's what's under the hood:

Modern Stack

I built this with some of my favorite modern web technologies:

  • React 19 - The latest and greatest from the React team. The new concurrent rendering features make the UI incredibly smooth even when processing large documents.
  • TypeScript - Type safety is non-negotiable for me these days. It catches so many bugs before they make it to production.
  • Vite - Lightning-fast build tool that makes development a joy. Hot module replacement is so fast you barely notice it refreshing.
  • PDF.js - Mozilla's incredible PDF rendering engine. This is what powers the PDF parsing and text extraction, and it's rock solid.
  • jsPDF - For generating the export PDFs. Great library, easy to use.
  • diff - The text comparison algorithm at the heart of the whole thing. This library does the heavy lifting of computing what changed.

Architecture Decisions

The entire app is client-side only. No backend, no API, no database. Just static files served from a CDN. This architecture choice has some huge benefits:

  1. Privacy by design - Can't leak data to a server if there is no server
  2. Scales infinitely - Static files are cheap and can handle unlimited traffic
  3. Fast - No network round trips for processing
  4. Simple - No servers to maintain or databases to manage

The comparison logic is surprisingly straightforward - extract text from both PDFs, run a diff algorithm, and render the results. But getting the details right (handling multi-page docs, preserving formatting, making the UI responsive) is where the real work happens.

Built with Modern Tools

I have to give a huge shoutout to the tools that made building this such a smooth experience:

VS Code + GitHub Copilot

I've been using VS Code for years, and GitHub Copilot has become an indispensable part of my workflow. Writing the TypeScript interfaces, React components, and utility functions was so much faster with Copilot suggesting completions and entire functions.

The autocomplete for repetitive patterns (like all those view mode switches) saved me tons of time. And when I was implementing the different view modes, Copilot would often suggest exactly the logic I was thinking about. It's like pair programming with an AI that never gets tired.

GitHub Copilot Cloud Agent

Here's where things get really cool. I used the new GitHub Copilot Cloud agent to help with some of the more complex parts of the build. The agent can understand your entire codebase context and help you make architectural decisions, write tests, and even suggest optimizations.

For example, when I was working on the export functionality, I described what I wanted (a formatted PDF with the comparison results), and the agent helped me figure out the best way to structure the jsPDF calls and format the output. It understood the context of my existing code and suggested changes that fit perfectly with my patterns.

The Cloud agent also helped me optimize the PDF parsing logic for performance. When I noticed larger documents were taking a while to process, I described the problem, and it suggested some clever optimization strategies.

Open Source

The entire project is open source on GitHub. I'm a huge believer in open source, and I wanted anyone to be able to:

  • Verify that the privacy claims are true (inspect the code yourself!)
  • Contribute improvements or bug fixes
  • Learn from the code
  • Fork it for their own needs

Try It Out!

Ready to compare some PDFs? Head over to www.pdf-diff.com and give it a try. Start with the demo if you want to see it in action, or upload your own documents if you're ready to go.

I'd love to hear your feedback! Found a bug? Have a feature request? Want to contribute? Open an issue or submit a PR.

And if you find this useful, give it a ⭐️ on GitHub - it really helps others discover the tool.

What's Next?

I've got a few ideas for future improvements:

  • Support for comparing more than two documents
  • Highlighting specific types of changes (numbers, dates, etc.)
  • Better handling of PDFs with images and complex layouts
  • Browser extensions for quick access

But I want to hear from you - what would make PDF Diff more useful for your workflow?

Wrapping Up

Building PDF Diff has been a fun journey, and I'm proud of how it turned out. It solves a real problem I had, and I hope it helps you too. The combination of modern web technologies, privacy-first design, and powerful developer tools made it possible to build something genuinely useful in a relatively short time.

The web platform has come so far. The fact that we can parse PDFs, compute diffs, and render beautiful UIs entirely in the browser - without any server-side code - is pretty amazing. And tools like GitHub Copilot and the Cloud agent make the development experience better than ever.

Give it a try and let me know what you think!

Cheers, James

]]>
Introducing Oh My Posh Visual Configurator: Finally, a Drag-and-Drop Terminal Theme Builder! ✨ https://montemagno.com/introducing-oh-my-posh-visual-configurator-finally-a-drag-and-drop-terminal-theme-builder/ 6952bf0593272a0001bc4431 Mon, 29 Dec 2025 19:43:35 GMT

If you've ever spent way too much time digging through JSON files trying to get your terminal prompt just right, this post is for you. I'm incredibly excited to share something I've been working on: the Oh My Posh Visual Configurator—a web-based drag-and-drop builder that makes creating beautiful terminal prompts actually fun (yes, really!).

Segments list on the left, a prompt block with Path and Git on the canvas, and a preview of ~/projects/my-app on branch main
The main interface showing the segment picker, configuration canvas, and live preview

The Problem We All Know Too Well

Let me be honest with you—I love Oh My Posh. It's hands down one of the best tools for customizing your terminal prompt across PowerShell, Bash, Zsh, Fish, and basically any shell you can think of. But here's the thing: configuring it has always meant diving into JSON, YAML, or TOML files and doing a lot of trial-and-error to get segments positioned correctly.

You know the drill:

  1. Edit the config file
  2. Save it
  3. Open a new terminal to see if it worked
  4. Realize the colors clash
  5. Go back to step 1
  6. Repeat for the next hour

There had to be a better way.

Enter the Visual Configurator

The Oh My Posh Visual Configurator solves this problem with a modern, intuitive web interface. No installation required—just open your browser and start building!

👉 Launch the Configurator

What Can You Do With It?

🎨 Browse 103+ Segments {#%F0%9F%8E%A8-browse-103-segments}

The left sidebar gives you access to every segment Oh My Posh supports, organized into logical categories:

  • System: Path, OS, Shell, Session, Battery, Time, Execution Time
  • Version Control: Git, Mercurial, SVN, Fossil, and more
  • Languages: Node.js, Python, Go, Rust, Java, .NET, PHP, Ruby... basically everything
  • Cloud & Infrastructure: AWS, Azure, GCP, Kubernetes, Docker, Terraform
  • CLI Tools: NPM, Yarn, Angular, React, Flutter
  • And more: Music players, health trackers, weather widgets
Canvas with three prompt blocks of segments, from Session and Git to Azure Functions and Execution Time, and the preview
The Languages category expanded showing 26+ programming language segments

🖱️ Drag-and-Drop Interface {#%F0%9F%96%B1%EF%B8%8F-drag-and-drop-interface}

Just click a segment to add it to your prompt, or drag it exactly where you want it. Reordering is as simple as dragging segments around. Want to move that Git status before your path? Just drag it there!

Git segment selected on the canvas, with the Properties panel showing its Powerline style and Sharp Right symbol
Click any segment to customize its colors, style, and template

⚡ Live Preview {#%E2%9A%A1-live-preview}

This is where it gets really cool. Every change you make shows up instantly in the preview panel at the bottom. Toggle between dark and light terminal backgrounds to make sure your theme looks great everywhere. The preview even renders powerline arrows and diamond shapes accurately!

🎛️ Full Customization {#%F0%9F%8E%9B%EF%B8%8F-full-customization}

Click any segment and the Properties Panel opens up with everything you need:

  • Style Selection: Powerline, Plain, Diamond, or Accordion
  • Color Pickers: Full hex color support for foreground and background
  • Template Editor: Customize exactly what each segment displays using Go templates
  • Block Settings: Configure prompt alignment, add new blocks, or create right-aligned prompts

📦 Import & Export {#%F0%9F%93%A6-import-export}

Already have an Oh My Posh config? Import it! The configurator supports JSON, YAML, and TOML formats. When you're done, export in whichever format you prefer and drop it right into your shell configuration.

Community Sharing 🤝 {#community-sharing-%F0%9F%A4%9D}

One of my favorite features is community sharing. Found the perfect configuration? Share it with everyone!

Theme Library dialog on Official Samples, with cards for Developer Pro, Streamer Setup, Minimal Dev, and Cloud Engineer
Browse official samples and community-contributed themes
  1. Click the Share button in the header
  2. Fill in your theme details—name, description, tags
  3. Submit your configuration
  4. Your theme appears in the Community collection for others to use
Share Your Configuration dialog with name, description, username, and tags fields and a Copy Configuration button
Easy-to-use sharing dialog with step-by-step instructions

This means you can browse themes created by other developers, try them instantly in the configurator, and tweak them to match your style. The community grows together!

Under the Hood: Some Fun Architecture Decisions 🏗️ {#under-the-hood-some-fun-architecture-decisions-%F0%9F%8F%97%EF%B8%8F}

For my fellow developers who enjoy the technical details, here's what's powering this thing:

React 19 + TypeScript + Vite

I went with the latest React 19 for this project, paired with TypeScript for type safety (trust me, when you're dealing with complex configuration objects, types are your friend). Vite provides blazing-fast builds and hot module replacement that makes development a joy.

@dnd-kit for Drag-and-Drop

After evaluating several drag-and-drop libraries, @dnd-kit won out. It's modern, accessible, performant, and plays nicely with React 18+. The sortable preset made implementing segment reordering straightforward, and it handles edge cases like keyboard navigation out of the box.

Zustand for State Management

I'm a big fan of Zustand for state management. It's lightweight, has zero boilerplate, and the persistence middleware lets me save your configuration to localStorage automatically. Your work is never lost!

// Simplified example of our store structure
const useConfigStore = create(
  persist(
    (set) => ({
      config: defaultConfig,
      addSegment: (blockId, segment) => set((state) => /* ... */),
      updateSegment: (blockId, segmentId, updates) => set((state) => /* ... */),
      // ...
    }),
    { name: 'ohmyposh-config' }
  )
)

Dynamic Segment Loading

With 103+ segments, loading everything upfront would be wasteful. Instead, segment metadata lives in separate JSON files by category (public/segments/*.json), loaded on demand when you expand a category. This keeps the initial bundle small and the UI snappy.

Tailwind CSS 4.1

Styling is handled by Tailwind CSS 4.1, which gives us:

  • Consistent design tokens
  • Dark theme by default (it's a terminal tool after all!)
  • Rapid iteration on UI polish
  • Small production bundles through automatic purging

100% Client-Side

Your configurations never leave your browser. Everything runs locally—no backend servers, no data collection, no privacy concerns. Export your config and it's yours forever.

Built with GitHub Copilot & VS Code

Here is what blows my mind about this app is that I built it in under an hour. I started by jotting down a product requirements doc in the GitHub mobile app using Copilot, based on a simple idea. Then I created an issue, assigned it to Copilot, and went to bed. By the next morning, I had a working prototype! I spent about 50 minutes in VS Code adding features like community feeds and cleaning up the code for future updates. Copilot even set up all the GitHub Actions workflows and deployed everything to GitHub Pages in seconds. Turning an idea into a live app in just hours—something that used to take me weeks—is absolutely incredible.

Getting Started in 30 Seconds

  1. Open the app: https://jamesmontemagno.github.io/ohmyposh-configurator/
  2. Add segments: Click segments from the left sidebar or drag them to the canvas
  3. Customize: Click any segment to adjust colors, templates, and styles
  4. Preview: Watch your prompt update in real-time at the bottom
  5. Export: Choose JSON, YAML, or TOML and download your configuration
  6. Apply it:

# PowerShell oh-my-posh init pwsh --config ~/your-theme.json | Invoke-Expression

# Bash eval "$(oh-my-posh init bash --config ~/your-theme.json)"

# Zsh eval "$(oh-my-posh init zsh --config ~/your-theme.json)"

That's it! You've got a beautiful, custom terminal prompt.

Sample Configurations to Get You Started

Not sure where to begin? We've included 6 professional sample configurations:

  • Minimal: Clean and simple
  • Developer: Language-aware with Git integration
  • DevOps: Cloud and Kubernetes focused
  • Full Featured: All the bells and whistles
  • Powerline Classic: Traditional powerline style
  • Diamond Style: Rounded segment separators
Theme Library Community tab with one theme, .NET and Azure Developer by James Montemagno
Community-contributed themes available in the Theme Library

Load any of these from the Sample Picker and customize from there!

What's Next?

I'm really excited about where this project can go. Some ideas on the roadmap:

  • More starter templates based on community feedback
  • Theme galleries curated by the community
  • Advanced template editor with autocomplete
  • PWA support for offline use
  • Keyboard shortcuts for power users

Try It Out and Let Me Know!

I'd love to hear what you think. Give it a spin at https://jamesmontemagno.github.io/ohmyposh-configurator/ and let me know how it goes!

Found a bug? Have a feature request? Open an issue on GitHub.

Created an awesome theme? Share it with the community using the Share button!

Happy coding, and may your terminal always look beautiful! 🚀


This blog was drafted with GitHub Copilot and Claude Sonnet 4.5 based off previous blogs. Screenshots were taken with the Playwright MCP server using the cloud agent completely autonomously. I reviewed this blog post and tweaked it as necessary.

]]>
Building a Complete FIRE Calculator App with GitHub Copilot in One Chat Session https://montemagno.com/building-a-complete-fire-calculator-app-with-github-copilot-in-one-chat-session/ 694b62804480ae000170634b Wed, 24 Dec 2025 16:13:01 GMT

I've been diving deep into financial independence planning lately, and as a developer, I naturally wanted a tool that does exactly what I need—without the clutter, ads, or privacy concerns of existing calculators out there. So I decided to build one from scratch using GitHub Copilot's agent mode in VS Code. What started as a simple "let's see what happens" experiment turned into a fully-featured, privacy-first PWA with 9 different calculators, all built in a single conversational chat session in about 30 minutes.

The Starting Point

I had a clear vision: a FIRE (Financial Independence, Retire Early) calculator that respects user privacy. No cookies, no analytics, no data leaving the browser. Just pure client-side calculations with the ability to share scenarios via URL parameters.

My initial prompt was straightforward leveraging the plan agent in VS Code:

"I want to build a financial independence calculator app. It should be a React + TypeScript PWA with Tailwind CSS, dark mode support, and work completely offline. Privacy is key—no data storage, no analytics."

The plan agent got to work asking me questions along the way until it had the context it needed and I answered enough questions that I felt good about it getting to work. From there, Copilot scaffolded the entire project structure: Vite config, TypeScript setup, Tailwind with a custom "fire" orange color palette, and even service worker configuration for offline support. Within minutes I had a running dev server.

Standard FIRE calculator: inputs on the left, a $1,200,000 FIRE number reached in 24.4 years, and a projection chart

Iterating Through Features

What I love about working with Copilot in agent mode is the conversational flow. I didn't have to context-switch between planning and implementing. Each feature request built naturally on the previous one.

The Core Calculators

I started with the Standard FIRE calculator—the classic "25x your annual expenses" rule. Copilot generated not just the calculation logic but also:

  • Responsive input components with tooltips explaining each field
  • A beautiful projection chart using Recharts
  • Real-time URL parameter syncing so users can bookmark or share their scenarios
  • Result cards with the key metrics highlighted

Then came the variations:

"Now add a Coast FIRE calculator. Same structure but with the specific Coast FIRE math—how much do you need saved NOW so compound growth does the rest."

"Let's add Lean FIRE (≤$40k expenses) and Fat FIRE ($100k+ expenses) with appropriate warnings and lifestyle tips."

"Add a Barista FIRE calculator that factors in part-time income."

Each calculator maintained consistent styling while having its own personality—different color schemes, contextual explanations, and tailored result displays.

Barista FIRE calculator: $20,000 a year of part-time income cuts the target to $700,000, 42% less than full FIRE

The Fun Additions

Halfway through, I asked for three more calculators that I hadn't seen done well elsewhere:

"Add these: 1) Savings Rate calculator that shows how your savings rate impacts years to FIRE, 2) Reverse FIRE that works backwards from a target retirement age, 3) Healthcare Gap calculator for estimating costs between early retirement and Medicare."

Copilot handled the math, the UI, and even added helpful reference tables and scenario comparisons. The Healthcare Gap calculator includes ACA subsidy considerations and strategies like health sharing ministries and Barista FIRE for benefits.

Healthcare Gap calculator estimating $134,127 to cover 10 years until Medicare, with ACA subsidy estimates by income

Real-Time Bug Fixes and Refinements

This is where the conversation got interesting. I noticed the Coast FIRE progress card was showing "2160%" instead of "21.5%". A quick:

"The Coast FIRE current progress card is showing an extremely high number—double check those calculations."

Copilot traced through the code and found the issue: the percentage was being multiplied by 100 twice—once in the calculation and again in the ResultCard's percent formatter. Fixed in seconds.

Another issue: the age input would snap to 18 whenever you tried to clear it to type a new value. Annoying UX.

"Entering the retirement age is weird because it sets it to 18 when I try to clear it out. Just don't calculate if invalid but allow entry."

The fix added local state management so users can freely type without the input fighting back, with validation only on blur.

Privacy and Polish

I asked for disclaimers on every page—this isn't financial advice, results are hypothetical, consult a professional. Copilot created a reusable Disclaimer component and added it to all 10 pages in one pass.

The app includes:

  • No cookies or localStorage - Your financial data never leaves your browser
  • No analytics - Zero tracking scripts, no Google Analytics
  • URL-based sharing - Save calculations in the URL, bookmark or share as you choose
  • Offline-first PWA - Works without internet after first load
  • Open source - All code available on GitHub for transparency

Quality of Life Features

Throughout the session, I kept adding small enhancements:

"Add a monthly/annual toggle to the contribution and expenses inputs."

"Put a progress bar showing how far along you are toward your FIRE number."

"Add milestone lines at 25%, 50%, 75% on the projection charts."

"Quick preset buttons for common scenarios—conservative, moderate, aggressive."

Each request was implemented cleanly, maintaining the existing code style and patterns.

The Tech Stack

For those interested in the technical details:

  • Vite + React 18 with TypeScript
  • Tailwind CSS with custom fire-orange color palette
  • Recharts for interactive projection graphs
  • React Router for navigation with URL parameter persistence
  • Vite PWA Plugin for service worker and offline support
  • GitHub Pages for hosting

The entire codebase is well-organized:

src/
  components/
    charts/      # Recharts wrappers
    inputs/      # CurrencyInput, PercentageInput, AgeInput
    ui/          # Cards, Buttons, ProgressBar, Disclaimer
  pages/         # Each calculator as a page component
  hooks/         # useCalculatorParams for URL sync
  utils/         # All the FIRE math calculations

Key Takeaways

Conversational development works. Instead of writing detailed specs upfront, I could explore ideas naturally. "What if we also showed..." or "Actually, can you change that to..." kept the momentum going.

Copilot handles the boring stuff. Adding disclaimers to 10 pages? Tedious. Watching Copilot do it correctly in one multi-file edit? Satisfying.

Bugs get fixed in context. When something didn't work right, I could describe the symptom in plain English and Copilot would trace through the logic to find the root cause.

The result is production-ready. This isn't a prototype or demo—it's a real app I'm actually using to plan my own financial future.

Try It Yourself

The app is live at https://myfirenumber.com and the source is on GitHub.

All 9 calculators are there:

Calculator What it does
🎯 Standard FIRE Classic 25x expenses rule
⛵ Coast FIRE How much you need now so growth does the rest
🌿 Lean FIRE FI with minimalist lifestyle (≤$40k/year)
💎 Fat FIRE FI without compromising ($100k+/year)
☕ Barista FIRE Part-time work + portfolio income
📊 Withdrawal Rate Test portfolio longevity at different rates
🧮 Savings Rate How savings rate impacts time to FIRE
🔄 Reverse FIRE Work backwards from target retirement age
🏥 Healthcare Gap Costs between early retirement and Medicare

Play with the parameters, bookmark your scenarios, and let me know what you think. And if you're curious about building your own apps with Copilot agent mode, just start a conversation—you might be surprised how far it takes you.


This blog was written with VS Code and Claude Sonnet 4.5 based on a conversational chat session. I also reviewed and slightly modified the content.

]]>
Building Better Apps with GitHub Copilot Custom Agents https://montemagno.com/building-better-apps-with-github-copilot-custom-agents/ 69078e67ebf2b1000161ff74 Sun, 02 Nov 2025 17:36:04 GMT

When building Hotkey Typer, a Windows Forms app that types predefined snippets via hotkey, I discovered the power of GitHub Copilot's new custom agents feature. What started as a simple refactoring request turned into an eye-opening demonstration of how specialized AI agents can dramatically improve code quality and implementation speed.

What Are Custom Agents?

Custom agents are specialized versions of GitHub Copilot coding agent that you can tailor to your unique workflows, coding conventions, and use cases. Instead of repeatedly providing the same instructions and context, custom agents allow you to define specialized agents that act like tailored teammates—following your standards, using the right tools, and implementing team-specific practices.

Think of custom agents as expert consultants embedded directly in your development workflow. They're defined using simple Markdown files (called agent profiles) that specify:

  • Name and description: What the agent specializes in
  • Custom prompts: Detailed instructions that define the agent's behavior and expertise
  • Tools: Specific tools the agent can access (optional)
  • MCP servers: Model Context Protocol server configurations for enhancing agents

GitHub Agent HQ banner: Mission control, Copilot CLI, 3rd-party agents, Plan mode, AI controls, and integrations

Where You Can Use Custom Agents

Once created, custom agents are available wherever you use Copilot coding agent:

  • GitHub.com: In the agents tab, panels, issue assignments, and pull requests
  • Visual Studio Code: As chat modes in the editor
  • GitHub Copilot CLI: Command-line interactions

Each interaction maintains consistency based on the agent's defined profile, ensuring targeted support tailored to your specific needs.

Assign Copilot to issue dialog for app-hotkeytyper with the WinForms Expert custom agent selected

Where to Configure Custom Agents

You can define agent profiles at different levels:

  • Repository level: Place .md files in .github/agents/ for project-specific agents
  • Organization level: For broader team availability
  • Enterprise level: For company-wide standardization

The agent profile format is straightforward:

---
name: readme-creator
description: Agent specializing in creating and improving README files
---

You are a documentation specialist focused on README files. Your scope is limited to
README files or other related documentation files only - do not modify or analyze code files.

Focus on the following instructions:
- Create and update README.md files with clear project descriptions
- Structure README sections logically: overview, installation, usage, contributing
- Write scannable content with proper headings and formatting
- Add appropriate badges, links, and navigation elements

Discovering the WinForms Expert Agent

The real power of custom agents became apparent when I discovered the awesome-copilot repository, a curated collection of custom agents maintained by GitHub. This repository contains specialized agents for various frameworks and technologies, including C#, WinForms, arm Migration, Dynatrace, LaunchDarkly, Neon, PagerDuty, and more!

Among these gems, I found the WinForms Expert agent—a sophisticated agent specifically designed for .NET Windows Forms development. This agent encodes decades of WinForms best practices into a single, reusable profile.

What Makes the WinForms Expert Special?

The WinForms Expert agent is a comprehensive guide that understands the nuanced challenges of WinForms development. It addresses several critical areas:

Designer Code vs. Regular Code: One of WinForms' unique challenges is managing two distinct code contexts—designer-generated serialization code and modern business logic. The agent knows exactly which C# features are allowed in each context.

Modern .NET Patterns: The agent is fully updated for .NET 8-10, including:

  • MVVM data binding patterns with the Community Toolkit
  • Async/await patterns with proper InvokeAsync overload selection
  • DarkMode support and high-DPI awareness
  • Nullable reference types (NRTs) in the right places

Layout Best Practices: Comprehensive guidance on using TableLayoutPanel and FlowLayoutPanel for responsive, DPI-aware layouts that work across different screen sizes and scaling factors.

CodeDOM Serialization Management: Critical rules for property serialization in the WinForms designer, preventing common pitfalls with [DefaultValue] attributes and ShouldSerialize*() methods.

Exception Handling: Proper patterns for async event handlers and application-level exception handling to prevent process crashes.

The agent's instructions are remarkably detailed, covering everything from control naming conventions to accessibility requirements. It's like having a senior WinForms architect reviewing every line of code.

Adding the WinForms Expert to My Repository

Adding a custom agent from the awesome-copilot repository is remarkably simple:

  1. Create the .github/agents/ directory in your repository
  2. Copy the agent profile (a .md file) into this directory
  3. Commit and push—that's it!

For Hotkey Typer, I copied the WinFormsExpert.agent.md file into .github/agents/, making it immediately available to Copilot throughout my repository.

The Real-World Test: Multiple Snippets Feature

I had opened Issue #3 requesting a new feature: support for managing multiple reusable text snippets instead of just a single predefined block. Users needed to switch between different prompts, code templates, and demo scripts quickly without manual settings editing.

The feature requirements included:

  • Maintain a list of snippets with names and content
  • Select active snippet from a dropdown in the main UI
  • New/Copy/Rename/Delete operations with validation
  • Backward-compatible migration from the old single-text format
  • Persist everything in settings.json

First Attempt: Without the Custom Agent (PR #7)

I first assigned the standard Copilot coding agent to Issue #3, resulting in Pull Request #7. The implementation was functional:

  • ✅ Data model created with Snippet class
  • ✅ CRUD operations implemented
  • ✅ UI with ComboBox and management buttons
  • ✅ Unit tests added
  • ✅ Settings serialization worked

However, there were notable issues:

  • Layout wasn't optimal for WinForms best practices
  • Controls weren't properly anchored/docked for responsive resizing
  • Missing some WinForms-specific patterns for dialog handling
  • Code style didn't fully align with established WinForms conventions
  • No consideration for DPI scaling or accessibility

While functional, it felt like code written by someone proficient in general C# but not deeply experienced with WinForms specifics.

Hotkey Typer after the first PR: the snippet buttons beside the dropdown are cut off, showing only Co, Re, and D

Second Attempt: With the WinForms Expert Agent (PR #8)

Then I closed PR #7 and reassigned Issue #3 to Copilot, but this time explicitly mentioning the WinForms Expert agent. The results in Pull Request #8 were dramatically better:

Improved UI Organization:

Snippet: [────────Dropdown (300px)────────▼]
         [New] [Copy] [Rename] [Delete]

[Text Editor (460x135)]

[Speed slider, checkboxes, file options]

[Save]
[Minimize] [Stop]        Status: Active

The agent reorganized the layout following WinForms best practices:

  • Logical grouping of snippet management operations
  • Proper spacing and margins (3px minimum)
  • Controls properly anchored for responsive behavior
  • Consideration for DPI scaling

Enhanced User Experience:

  • Popup dialogs using InputDialog for New/Copy/Rename operations
  • Proper validation with meaningful error messages
  • Case-insensitive duplicate detection
  • Protection against deleting the last snippet
  • Better visual feedback for operations

Code Quality Improvements:

  • Modern C# 11-14 features in business logic
  • Proper separation between designer code and regular code
  • File-scoped namespaces
  • Target-typed new expressions
  • Nullable reference types in appropriate contexts
  • Exception handling with ArgumentNullException.ThrowIfNull

Hotkey Typer after the WinForms Expert PR: full New, Copy, Rename, and Delete buttons sit neatly under the Snippet dropdown

Even Better Documentation: The agent included detailed diagrams of the UI layout directly in the PR description, making the implementation immediately understandable.

Key Code Differences: Before and After

Let me show you some concrete examples of the improvements the WinForms Expert agent made:

1. InputDialog Helper Class

PR #7 (Without WinForms Expert): Used an inline dialog creation pattern with repetitive code in each button handler.

PR #8 (With WinForms Expert): Created a dedicated, reusable InputDialog class following WinForms best practices:

internal class InputDialog : Form
{
    private readonly TextBox txtInput;
    private readonly Button btnOK;
    private readonly Button btnCancel;

    public string InputText => txtInput.Text;

    public InputDialog(string prompt, string title, string defaultValue = "")
    {
        Text = title;
        FormBorderStyle = FormBorderStyle.FixedDialog;
        StartPosition = FormStartPosition.CenterParent;
        MaximizeBox = false;
        MinimizeBox = false;
        ClientSize = new Size(350, 120);

        // Control setup...

        // Select all text when dialog opens
        Load += (s, e) => txtInput.SelectAll();
    }

    public static string? Show(string prompt, string title, string defaultValue = "")
    {
        using var dialog = new InputDialog(prompt, title, defaultValue);
        return dialog.ShowDialog() == DialogResult.OK ? dialog.InputText : null;
    }
}

This is a textbook WinForms pattern: proper disposal with using, nullable return type, static factory method, and proper dialog configuration.

2. Settings Migration Pattern

PR #7 (Without WinForms Expert): Used a "fresh start" approach that ignored existing settings if loading failed.

PR #8 (With WinForms Expert): Implemented proper backward-compatible migration:

// Migration: Convert old single PredefinedText to snippet if no snippets exist
if (settings.Snippets == null || settings.Snippets.Count == 0)
{
    string contentToMigrate = !string.IsNullOrEmpty(settings.PredefinedText)
        ? settings.PredefinedText
        : DefaultSnippetContent;
    snippets = new List<TextSnippet>
    {
        new TextSnippet
        {
            Id = DefaultSnippetId,
            Name = DefaultSnippetName,
            Content = contentToMigrate,
            LastUsed = DateTime.Now
        }
    };
    activeSnippetId = DefaultSnippetId;
}
else
{
    snippets = settings.Snippets;
    activeSnippetId = settings.ActiveSnippetId;

    // Validate active snippet exists
    if (string.IsNullOrEmpty(activeSnippetId) ||
        !snippets.Any(s => s.Id == activeSnippetId))
    {
        activeSnippetId = snippets.FirstOrDefault()?.Id;
    }
}

This handles the upgrade path gracefully, preserving user data instead of discarding it.

3. UI Layout in Designer Code

PR #7 (Without WinForms Expert): Controls placed with absolute positioning and cramped spacing:

var btnNewSnippet = new Button
{
    Text = "New",
    Location = new Point(335, 82),
    Size = new Size(35, 25),  // Cramped button
    Font = new Font("Segoe UI", 8F)
};

PR #8 (With WinForms Expert): Better organized with logical grouping and proper sizing:

// Snippet selector ComboBox - wider, more prominent
var cmbSnippets = new ComboBox
{
    Name = "cmbSnippets",
    Location = new Point(85, 82),
    Size = new Size(300, 23),  // 300px width for readability
    DropDownStyle = ComboBoxStyle.DropDownList,
    Font = new Font("Segoe UI", 9F)
};

// Snippet management buttons - moved under dropdown for logical grouping
var btnNewSnippet = new Button
{
    Name = "btnNewSnippet",
    Text = "New",
    Location = new Point(85, 110),
    Size = new Size(70, 25),  // Proper button size
    Font = new Font("Segoe UI", 9F)
};

The WinForms Expert agent understood that:

  • ComboBoxes should be wide enough to display meaningful names
  • Related buttons should be grouped together visually
  • Font size 8F is too small for modern displays
  • Controls need descriptive names for maintainability

4. Validation Logic with User Feedback

PR #7: Basic validation without proper user feedback:

if (string.IsNullOrWhiteSpace(name)) return;
if (snippets.Any(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
    MessageBox.Show("A snippet with this name already exists.",
        "Duplicate Name", MessageBoxButtons.OK, MessageBoxIcon.Warning);
    return;
}

PR #8: Enhanced validation with trim and separate empty-string check:

// Validate: trim and check uniqueness (case-insensitive)
name = name.Trim();
if (string.IsNullOrEmpty(name))
{
    MessageBox.Show("Snippet name cannot be empty.",
        "Invalid Name", MessageBoxButtons.OK, MessageBoxIcon.Warning);
    return;
}

if (snippets.Any(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)))
{
    MessageBox.Show("A snippet with this name already exists.",
        "Duplicate Name", MessageBoxButtons.OK, MessageBoxIcon.Warning);
    return;
}

The WinForms Expert understood the importance of:

  • Trimming user input (common WinForms pattern)
  • Providing specific error messages for different failure modes
  • Using string.Equals for clarity over extension method

5. Modern C# Features in Business Logic

PR #8 consistently used modern C# patterns throughout:

// File-scoped namespace
namespace HotkeyTyper;

// Target-typed new
private List<TextSnippet> snippets = new();

// Expression-bodied member
public string InputText => txtInput.Text;

// Nullable reference types
public static string? Show(string prompt, string title, string defaultValue = "")

// Using declaration (proper disposal)
using var dialog = new InputDialog(prompt, title, defaultValue);

While PR #7 had some modern features, PR #8 applied them consistently and appropriately.

6. Comprehensive Unit Tests

PR #8 included thorough migration tests that PR #7 lacked:

[Fact]
public void OldSettings_MigratesToSnippets()
{
    // Arrange - Old settings format with single PredefinedText
    string oldSettingsJson = """
    {
        "PredefinedText": "Hello, World!",
        "TypingSpeed": 7,
        "HasCode": true
    }
    """;

    var oldSettings = JsonSerializer.Deserialize<AppSettings>(oldSettingsJson);

    // Act & Assert - Settings should be migrated
    Assert.NotNull(oldSettings);
    Assert.Null(oldSettings.Snippets);
    Assert.Equal("Hello, World!", oldSettings.PredefinedText);
}

These tests ensure the backward compatibility works correctly—a crucial detail for production applications.

The Visible Difference

The before-and-after images tell the story:

Without WinForms Expert:

  • Basic layout, functional but not polished
  • Controls not optimally positioned
  • Missing WinForms-specific refinements

With WinForms Expert:

  • Clean, logical organization
  • Professional spacing and alignment
  • Proper control grouping
  • Responsive layout that scales correctly

The agent even provided visual diagrams showing the layout structure, demonstrating deep understanding of WinForms design patterns.

Improved Layout section of the PR description with a text diagram placing the snippet buttons under the dropdown

The Impact: Quality and Velocity

The difference between PR #7 and PR #8 wasn't just cosmetic. The WinForms Expert agent delivered:

Better Architecture:

  • Proper use of BindingSource patterns for data binding
  • Correct designer code serialization patterns
  • Appropriate use of modern C# features in the right contexts
  • Following established WinForms conventions consistently

Fewer Iterations:

  • PR #7 required multiple rounds of feedback and corrections
  • PR #8 was nearly perfect on the first attempt
  • Code reviews focused on business logic, not framework usage

Knowledge Transfer:

  • Seeing the agent's implementation taught me WinForms patterns I hadn't fully mastered
  • Comments in the code explained why certain approaches were used
  • The detailed PR descriptions served as documentation

Time Savings:

  • What would have taken days of iteration took hours
  • Less time debugging designer code issues
  • More time focusing on features, not framework quirks

Getting Started with Custom Agents

Ready to try custom agents in your projects? Here's how:

1. Explore the Awesome-Copilot Repository

Visit github.com/github/awesome-copilot and browse the available agents.

2. Add an Agent to Your Repository

  1. Create .github/agents/ in your repository
  2. Copy the agent .md file from awesome-copilot
  3. Customize if needed (optional)
  4. Commit and push

3. Use the Agent

In GitHub issues, pull requests, or VS Code:

  • Mention the agent by name in your request
  • For issues: Assign Copilot and reference the agent
  • For VS Code: Use <agent-name> in chat from agent drop down

VS Code chat agent picker listing Agent, Plan, Ask, Edit, and custom playwright-generator, healer, and planner agents

4. Create Your Own Agents

Have specific team conventions? Create custom agents that encode:

  • Your coding standards
  • Framework-specific patterns
  • Architecture preferences
  • Testing strategies
  • Documentation requirements

Full documentation: GitHub Docs - Creating Custom Agents

Key Takeaways

Working with the WinForms Expert agent taught me several valuable lessons:

Specialization Matters: Generic AI coding assistance is good, but specialized agents that understand framework-specific patterns are transformative. The depth of knowledge in the WinForms Expert agent far exceeded what I could convey in ad-hoc prompts.

Consistency Across the Team: Custom agents ensure everyone on your team follows the same patterns and best practices, even for developers less familiar with specific frameworks.

Living Documentation: Agent profiles serve as executable documentation of your team's coding standards—they don't just describe patterns, they enforce them.

Knowledge Amplification: Using specialized agents is a learning experience. Seeing high-quality, pattern-consistent code generated repeatedly helps developers internalize best practices.

Velocity Without Sacrificing Quality: The right custom agent doesn't just generate code faster—it generates better code faster, reducing technical debt and future maintenance burden.

Wrapping Up

The difference between PR #7 (without custom agent) and PR #8 (with WinForms Expert) perfectly illustrates why custom agents represent a significant leap forward in AI-assisted development. It's not about replacing developer expertise—it's about amplifying it with domain-specific knowledge that would otherwise require years of experience to accumulate.

For Hotkey Typer, the WinForms Expert agent helped deliver a feature that not only works but follows industry best practices, is maintainable, accessible, and properly architected for future enhancements. The implementation handles edge cases, uses appropriate design patterns, and would pass a senior code review with minimal comments.

If you're working with WinForms—or any specialized framework—I highly recommend exploring custom agents. The awesome-copilot repository is an excellent starting point, and creating your own team-specific agents is easier than you might think.

Learn More

Custom agents aren't just the future of AI-assisted development—they're available today, and they're already making a significant difference in how we build software. Give them a try, and see the difference specialized expertise can make in your projects!


Have you tried custom agents in your projects? I'd love to hear about your experiences. Share your thoughts and questions on X!

This blog was written with VS Code and Claude Sonnet 4.5 based on a very lovely prompt with images, MCP server access, and more written by ME! I also reviewed and slightly modified the blog.

]]>
5 Ways I Use Background Coding Agents Every Day https://montemagno.com/5-ways-i-use-background-coding-agents-every-day/ 68d814b64825cf00016b2e62 Wed, 01 Oct 2025 17:46:09 GMT

Coding agents are different from in-editor chat and agent modes for AI assisted programming. They run asynchronously—often in the cloud—tackling tasks while you focus locally. I rely heavily on the GitHub Copilot coding agent and its VS Code integration to spin up multiple parallel workflows. Below are five everyday patterns I use to offload work, speed up development, and keep my repos in top shape.


1. Assign Issues Directly to GitHub Copilot

When a new issue or idea lands, I immediately hand it off to Copilot so it can start coding while I tackle higher-level planning or work on any tasks. I have worked on huge issues, small bugs, and complete re-designs with this flow. What is great is that you can give it a lot of context in the issue to help guide it along. You can see everything directly in VS Code, stop it, ask it for changes, and even get a diff view without leaving the code editor.

GitHub issue #179, Skeleton Loading for Heavy Sections on Admin, with the Assign for Copilot button circled under Assignees

By assigning an issue to the coding agent:

  • Checks out the base branch (usually main) and creates a feature branch named after your issue.
  • It installs dependencies, runs any setup workflows, and begins implementing the requested feature or fix.
  • A pull request pops up automatically, complete with commit history, a description of what was done, and live session logs you can follow.
  • I can review diffs right in VS Code’s Agent sessions view or directly on GitHub before merging.

VS Code Chat Sessions view listing coding agent PRs, with one session's summary and its 11 changed files open

Key benefits:

  • Parallelize work: start multiple issues at once without context-switching overhead.
  • Instant feedback: see what Copilot built, ask follow-up questions in the same session.
  • Consistency: every branch follows your CI/CD and code-style rules from day one.

Easy setup:

Learn more: • GitHub Copilot Coding Assistant overview: https://docs.github.com/en/copilot/concepts/agents/coding-agent • VS Code integration with GitHub Pull Requests & Issues: https://code.visualstudio.com/docs/copilot/copilot-coding-agent (archived)


2. Delegate Ad-Hoc Tasks from VS Code Chats

Spotted an improvement in your code or UI? Instead of stopping to implement it, or open an issues, you can simply delegate from your chat panel or from GitHub.com and get back to flow. This is maybe one of my favorite ways to use coding agents as I can even spin up a

How it works:

  • In VS Code, open Chat Sessions (enable via Settings > Chat: Agent Sessions View Location and set to view).
  • Under
  • Type a request like “When user clicks Hacker News, auto-load articles in content-feed.” or whatever else you want it to do. You can even reference files and documents.
  • Click Delegate to coding agent send button —Copilot spins up a background session, generates code, and opens a pull request.
  • Monitor progress in the Chat Sessions view or jump into the PR on GitHub.

VS Code Chat Sessions view: the + button opens a new coding agent chat with a Delegate to @copilot prompt box

Why I love it:

  • Lightweight delegation and works of a specific branch that you choose.
  • Rich context: chat history and your codebase give Copilot full visibility.
  • Notification-driven: get pinged when the PR is ready, so you only interrupt flow once.

Tip: You can also trigger these tasks from the mobile apps, GitHub.com on your repo, or the Copilot dashboard at https://github.com/copilot/agents without hopping into each repo.

Agent tasks panel on GitHub.com with a prompt to auto-load Hacker News, plus repository and main branch pickers


3. Continue Work Across Branches and Chat Sessions

Sometimes I am working on a bug or new features and I have it half-implemented locally. Or sometimes I ideate in Ask mode and don't really want to open up an issue or work on it right now. Well, you can simply push your local changes and chat context into a new agent session so Copilot can finish while you move on.

VS Code chat after local edits, with the cursor on the Delegate to Coding Agent button beside the send button

Step by step:

  1. On your feature branch (or main), start a Copilot chat to outline next steps (e.g., add docs, refactor helpers).
  2. Make local edits but don’t commit everything.
  3. Click Delegate to coding agent, choose Include changes, and let Copilot fork a sibling branch with your pending code.
  4. Copilot completes the work—documentation, tests, cleanup—and issues a pull request ready for review.

Advantages:

  • Seamless handoff: your uncommitted work becomes the starting point for the agent.
  • Branch-of-branch workflow: keep your main branches tidy while Copilot wraps up.
  • No more “oh right, I forgot to finish that” moments—wake up to a completed PR.

4. Iterate on Pull Requests with Interactive Comments

When I work with the coding agents it isn't always going to get it right the first time. Sometimes it is the coding agent, sometimes it is because I didn't give it enough context, and sometimes I just ended up wanting it a bit different . I like to treat Copilot like a teammate and file a review comment and then watch it push fixes in real time.

Workflow:

  • Open a PR that Copilot has been working on in GitHub or in the chat sessions in VS Code and comment @copilot with error messages or changes you want.
  • Copilot launches a new agent session, applies fixes or enhancements, and updates the same branch.
  • You can reply to the agent’s comments, request tweaks, or approve and merge when satisfied.

PR comment asking @copilot to center a checkbox as a toggle, followed by Copilot's commit and reply saying it's fixed

Perks:

  • Instant turn-around on feedback.
  • Team-like collaboration: follow the same comment-reply flow you’d use with a colleague (or yourself in my case often).
  • Full history: every change is tracked in the PR conversation and commit log.

Docs to explore: • Using Copilot in pull requests: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-on-github#asking-copilot-to-make-changes


5. Keep Dependencies Secure and Up-to-Date with Dependabot

Dependabot isn’t AI-driven, but it embodies the same “background agent” principle. It quietly opens PRs to bump libraries and fix vulnerabilities.

dotnet/aspire pull requests filtered to author:app/dependabot, showing merged Bump tmp and form-data dependency PRs

Setup highlights:

  • In your GitHub repos Settings > Security > Advanced Secuirty, enable Dependency graph, Dependabot alerts, and Dependabot security updates.
  • Create a .github/dependabot.yml to specify package ecosystems (npm, NuGet, etc.), directory paths, and update schedules.
  • Dependabot will open pull requests whenever a new version is available or a security issue is discovered.
  • Review, merge, or auto-merge based on your repo’s policies.

Why it matters:

  • You don’t have to remember every library you depend on—they get updated for you.
  • Security vulnerabilities get flagged and patched without manual intervention.
  • Your CI/CD stays green with minimal overhead.

Get started: https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically


Setting Up Your Environment for Background Coding Agents

Before you reap these benefits, configure both GitHub and your local IDE:

  1. Enable the Copilot Coding Agent
  2. Install VS Code Extensions
  3. Activate Chat & Agent Sessions
    • In VS Code Settings, search “Agent Sessions View Location” and switch from Disabled to View.
    • This adds the Chat Sessions icon for monitoring all local and active background tasks.
  4. Add Copilot Setup Workflows (optional but recommended)
  5. Configure Dependabot
    • Add a .github/dependabot.yml specifying ecosystems, directories, and schedules.
    • Enable security updates and auto-merge rules under Settings > Advanced Security.

With those pieces in place, you’ll have a fleet of coding agents— GitHub Copilot and Dependabot—working around the clock to keep your code moving forward.


How are you leveraging background agents in your workflow? Hit me on X or leave a comment on YouTube and let me know.

]]>
Announcing Hotkey Typer — type long prompts with a hotkey https://montemagno.com/announcing-hotkey-typer/ 68d715b04825cf00016b2592 Fri, 26 Sep 2025 22:57:22 GMT When I'm recording demos or making videos I often need to reproduce long prompts or blocks of text on-screen. Manually retyping them every time is slow and error-prone, and pasting from the clipboard can feel jerky and reveal private content. I wanted a tiny, reliable app that would let me press a global hotkey and have the application type a predefined, human-like block of text into whatever app currently has focus — perfect for demos, tutorials, and repeatable workflows.

Enter Hotkey Typer: a small Windows Forms utility that types back long prompts using a single global hotkey.

What Hotkey Typer does

  • Register a global hotkey (defaults to CTRL+SHIFT+1) that works across applications
  • Type a predefined text snippet into the currently focused window with human-like timing
  • Save your snippet and typing speed to a settings.json file for persistence
  • Minimize to the system tray so it runs unobtrusively in the background

This is intentionally minimal — it solves one problem very well: quickly and reliably retyping long prompts during recordings.

Quick feature tour

  • Global hotkey: Press CTRL+SHIFT+1 from any running application to trigger typing.
  • Human-like typing: Characters are sent one-by-one with randomized delays so the result looks natural on screen.
  • Typing speed slider: Choose from Very Slow through Very Fast (1–10) to simulate different typing paces.
  • Persistent settings: Your text and speed are saved to settings.json in the app directory.
  • System tray: Minimize the UI and keep the app running in the tray with a context menu to show or exit.

How I built it (and how GitHub Copilot + VS Code helped)

This little app was built as a Windows Forms app targeting .NET 10. I used Visual Studio Code as my editor and relied on GitHub Copilot to accelerate routine code — generating method bodies, serialization snippets, and the interop signatures for the Windows API calls.

Why Copilot was helpful here:

  • Saved time writing boilerplate: form initialization, settings load/save, and JSON serialization were scaffolded quickly.
  • Suggested idiomatic C# snippets: small things like the randomized delay logic and the speed-to-label mapping were easier to iterate on.
  • Encouraged small, testable functions: I split concerns into LoadSettings/SaveSettings, InitializeSystemTray, TypePredefinedText, and so on.

I used VS Code for quick editing and testing, and dotnet CLI for building and running the app.

How to build and run

Clone the repo and run it with the .NET SDK installed (Windows 10+):

git clone https://github.com/jamesmontemagno/app-hotkeytyper.git
cd app-hotkeytyper/HotkeyTyper
dotnet build
dotnet run

The app will start and register the default hotkey. Enter your desired text, click Update Text, then switch to any application and press CTRL+SHIFT+1 to have the app type your snippet.

If you want the app to always run in the background, minimize it to the tray.

Implementation notes — how some features work {#implementation-notes-%E2%80%94-how-some-features-work}

Here are a few implementation details for anyone curious or wanting to extend the app.

  • Global hotkey registration

    The app uses the Windows API functions RegisterHotKey and UnregisterHotKey via P/Invoke. The main form registers a hotkey during construction and handles the hotkey event in WndProc by checking for WM_HOTKEY and the registered id.

    Example P/Invoke signatures used in Form1.cs:

    [DllImport("user32.dll")]
    private static extern bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vk);
    
    [DllImport("user32.dll")]
    private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
    
    private const int WM_HOTKEY = 0x0312;
    private const int HOTKEY_ID = 1;
    
  • Typing the text

    When the hotkey fires the app calls an async TypePredefinedText() method. It uses SendKeys.SendWait() to send characters one-by-one to the foreground window and Task.Delay() with a randomized interval to simulate human typing. A short initial delay ensures the hotkey release doesn't interfere with the first character.

    A simplified version of TypePredefinedText() looks like:

    private async void TypePredefinedText()
    {
        SendKeys.Flush();
        await Task.Delay(250); // ensure hotkey is released
    
        int baseDelay = 310 - (typingSpeed * 30);
        int variation = Math.Max(10, baseDelay / 3);
        Random random = new Random();
    
        foreach (char c in predefinedText)
        {
            SendKeys.SendWait(c.ToString());
            SendKeys.Flush();
            await Task.Delay(Math.Max(20, random.Next(Math.Max(10, baseDelay - variation), baseDelay + variation)));
        }
    }
    
  • Timing and realism

    Typing speed is an integer from 1 (slowest) to 10 (fastest). The app converts that value into a base delay and adds a bit of random variation per character. This creates a natural rhythm instead of a machine-gun paste.

  • Settings persistence

    User configuration (the predefined text and the typing speed) are serialized to settings.json in the application directory using System.Text.Json. The app loads settings on startup and writes them whenever the user updates the snippet or changes speed.

    The simple settings DTO used for JSON (and its defaults):

    public class AppSettings
    {
        public string PredefinedText { get; set; } = string.Empty;
        public int TypingSpeed { get; set; } = 5;
    }
    
  • System tray

    A NotifyIcon with a ContextMenuStrip lets the app hide in the tray and provide quick actions (Show / Exit). The form's Dispose method unregisters the hotkey and disposes of the tray icon to keep the system state clean.

Extending Hotkey Typer — ideas {#extending-hotkey-typer-%E2%80%94-ideas}

  • Support multiple named snippets and a configurable hotkey per snippet
  • Add a CLI mode for scripting or remote triggering
  • Use the Windows Input Injection APIs for applications that block SendKeys
  • Add an option to send the entire string as a clipboard paste (with clipboard restoration) for faster input when acceptable

Notes and security

Because Hotkey Typer sends keystrokes to other applications, some security software may flag it as automation. This is expected for tools that simulate input — distribute and run it only in environments you control.

Also, typing into elevated apps may require Hotkey Typer to run with the same elevation level.

Isn't this in ZoomIt?

Why yes it is! I couldn't remember if this was added into some tool that I use, so I vibed coded it in 10 minutes :)

Fun fact, it is in SysInternals/ZoomIt which is also available in PowerToys and works pretty similar.


Thanks for reading — hope Hotkey Typer makes your demos smoother!

]]>
Introducing Bingo-o-matic: Your Ultimate Bingo Management App https://montemagno.com/introducing-bingo-o-matic-your-ultimate-bingo-management-app/ 6834c49620e2c90001f0aa79 Mon, 26 May 2025 19:58:22 GMT

Are you tired of managing bingo games with paper cards and manual tracking? Look no further! I'm thrilled to announce the release of Bingo-o-matic, a comprehensive bingo management application designed to make hosting and playing bingo games easier and more enjoyable than ever before.

What is Bingo-o-matic?

Bingo-o-matic is a modern, user-friendly application that digitizes the entire bingo experience. Whether you're hosting a family game night, running a community fundraiser, or organizing a classroom activity, Bingo-o-matic provides all the tools you need to create, manage, and play bingo games with minimal effort.

Play traditional number-based bingo or unleash your creativity with fully customizable bingo sets! From holiday themes to educational concepts, company events to baby showers - the possibilities are endless. Create your own unique bingo experience tailored to any occasion or audience.

Key Features

  • Standard and Custom Bingo: Play classic numbered bingo or create themed sets with your own content
  • Generate Random Bingo Cards: Instantly create unique bingo cards for all your players
  • Print Custom Cards: Easily print physical cards for your custom bingo sets
  • Real-time Calling Tool: Call out bingo items with an intuitive interface
  • History Tracking: Keep track of called items to verify winning cards
  • Multiple Theme Options: Choose from light and dark themes for comfortable viewing
  • Offline Support: Play anytime, anywhere, even without an internet connection
  • Mobile-Friendly Design: Enjoy a seamless experience on any device

Screenshots

Standard Bingo mode with traditional numbered board Classic B-I-N-G-O with standard numbered calling interface

Custom Bingo interface with themed content Play with customized content perfect for themed events and educational settings

Custom bingo set creation interface Easy-to-use editor for creating and saving your own custom bingo sets

Getting Started

Getting started with Bingo-o-matic is incredibly simple:

  1. Visit https://www.bingo-o-matic.com
  2. Create a new bingo set from scratch or browse through our collection of pre-made templates to save time
  3. Generate as many unique cards as you need for your players - each card is randomized for fair play
  4. Use the intuitive calling interface to manage your game and track progress in real-time

Install as a Progressive Web App

One of the most exciting features of Bingo-o-matic is its Progressive Web App (PWA) capabilities. This modern web technology allows the application to function like a native mobile or desktop app while still being accessible through any web browser.

What does this mean for you?

Installing Bingo-o-matic as a PWA gives you several advantages:

  • Faster loading times - The app caches resources locally for instant access
  • Offline functionality - Continue playing even when your internet connection is spotty
  • App-like experience - Opens in its own window without browser navigation bars
  • Easy access - Pin it to your taskbar, dock, or home screen for quick launching

How to install:

  1. Navigate to https://www.bingo-o-matic.com using any modern web browser
  2. On desktop computers: Look for the install icon (usually a download or plus symbol) in your browser's address bar, or check the browser menu for "Install Bingo-o-matic"
  3. On mobile devices: Tap the share button and select "Add to Home Screen" from the menu options
  4. Once installed, Bingo-o-matic will appear in your applications and can be launched just like any other app

The PWA functionality ensures that Bingo-o-matic works reliably in any environment - from remote locations with poor connectivity to busy venues with crowded networks. Your bingo games can continue uninterrupted! Learn more about Progressive Web Apps on web.dev.


Behind the Scenes: The Technical Side of Bingo-o-matic

Technology Stack and Architecture

Bingo-o-matic leverages cutting-edge web technologies to deliver a seamless, high-performance experience. At its core, the application is built using Blazor WebAssembly, Microsoft's web UI framework that allows developers to build interactive web applications using C# instead of JavaScript.

Why Blazor WebAssembly?

  • Performance: Runs at near-native speed in the browser through WebAssembly
  • Type Safety: C#'s strong typing system reduces runtime errors
  • Ecosystem: Access to the entire .NET ecosystem and NuGet packages
  • Developer Experience: Rich tooling and debugging capabilities

Technical Components:

Development Workflow and Tools

The entire development process was revolutionized by leveraging AI-powered development tools:

Visual Studio Code served as the primary development environment, providing excellent Blazor debugging capabilities and integrated terminal access for .NET CLI commands.

GitHub Copilot Agent Mode wrote pretty much all of the code by providing intelligent code completions and suggestions that understood the context of Blazor components, helping accelerate development while maintaining code quality standards. It created the base app features, added new features, implemented advanced logic and styles, and so much more... including this blog post!

GitHub Copilot Coding Agent assisted with architectural decisions, best practice implementations, and complex problem-solving scenarios specific to Progressive Web App development all directly from GitHub.com.

This AI-assisted development approach reduced development time significantly while ensuring adherence to modern web development patterns and Blazor-specific best practices.

Cloud Infrastructure and Deployment

Azure Static Web Apps provides the hosting infrastructure with several key benefits:

  • Automated CI/CD Pipeline: GitHub Actions automatically build and deploy the application whenever code is pushed to the main branch
  • Global Content Delivery: The app is distributed across Microsoft's global CDN network, ensuring fast load times worldwide
  • Automatic HTTPS: Built-in SSL certificates provide secure connections without additional configuration
  • Custom Domain Support: Easy integration with custom domains and DNS management

The deployment process is fully automated - from code commit to live production deployment typically takes less than 3 minutes.

Progressive Web App Implementation

The PWA features are implemented through:

  • Service Worker: Handles caching strategies and offline functionality
  • Web App Manifest: Defines how the app appears when installed on devices
  • Cache Strategies: Intelligent caching of static assets and API responses
  • Background Sync: Ensures data consistency when connectivity is restored

I welcome contributions and feedback from the community to make Bingo-o-matic even better!

]]>
Developing & Working with .NET Containers on Windows ARM Devices https://montemagno.com/developing-working-with-net-containers-on-windows-arm-machines/ 65a9f8471270dd000126e7cb Fri, 19 Jan 2024 18:46:22 GMT

I am by no means a cloud-native developer, I am a client developer. I have spent some time though to get a general understanding about microservices, images, containers, docker, and all the buzz words that surround the topic. I recently got really into DevContainers and using them on GitHub Codespaces and Visual Studio Code to create an environment to run apps in. From there, I went down a rabbit hole of educating myself more by taking Microsoft Learn training courses, watching the new containers for beginner series, and actually trying to build something myself.

However, this is where I always seem to get into trouble as my main work machine every day where I am doing training is a Windows ARM device. I have been using the Project Volterra device for a long time and I simply love it. The ecosystem of what I can develop and my day to day is ALMOST identical to my normal x86 machine. One area as of time of writing this that always gets me is Docker and the lack of support Docker Desktop on Windows ARM (although it is coming in the future). DevContainers... needs Docker.... container development...needs Docker.... and there we have the issue.... or do we...

Enter WSL

Yes, the Window Subsystem for Linux to the rescue! We are creating Linux containers anyways so might as well run them in WSL! Just like the Windows Subsystem for Android enables me to develop Android apps easily, WSL does the same for container and dev containers. DevContainers was a simple solution, simply install Docker inside of Ubuntu on WSL and BOOM you are good to go. So elegant, so simple.

However, creating a container image with the dotnet publish -t:PublishContainer command is not so easy. In an ideal world I would just run this on my Windows machine for my .NET app, it would create the image, and then I could view it in Docker Desktop. Well.... no docker desktop. So, I could use WSL to run the command INSIDE of Ubuntu and then use Docker inside of WSL to run it! WHOA! That would work... if there was a distribution of Ubuntu out of the box that supported installing .NET 8 SDK for Ubuntu ARM easily. You technically need Ubuntu 23.10 to do this, and easy install of Ubuntu is only at 22.04. Now YES you can totally install any distro of Linux into WSL... but I like the "easy" button. Additionally, since I still don't have any Docker Desktop in this scenario, I would still have to use the Docker command line for management.

Enter Podman & Podman Desktop

But what if I told you don't need to have Docker to run images and containers. They are standard and other things can run them do such as Podman which is available on Windows, works by using WSL under the hood, and works on ARM devices!!!Oh, it also is super easy to install with just a normal installer!

Podman 4.8.3 Setup window reporting Installation Successfully Completed

After you have it installed, all you need to do is run podman machine init and start it up with podman machine start.

Now, since I love GUIs, it is time to install Podman Desktop that will easily help you boot podman, view images, create and run containers, and pretty much anything else you would want to do. It will also walk you through the complete set-up and installation process as well if you haven't done that yet or need to make changes in the future.

Podman Desktop home page

Let's create a .NET container!

Now, the fun part here is that I can use all my normal Windows tools including the .NET CLI, VS Code, or Visual Studio to create my images with the .NET SDK and run them in podman. Podman just sits in the background and handles the logistics. I don't have to worry about signing into WSL or anything.

Now, we can create a new .NET web app with dotnet new web -o HelloPodman.

Then it is time to publish it with dotnet publish -t:PublishContainer.

This should give you some output like this:

HelloPodman -> C:\Users\jamont\Desktop\HelloPodman\bin\Release\net8.0\HelloPodman.dll
  HelloPodman -> C:\Users\jamont\Desktop\HelloPodman\bin\Release\net8.0\publish\
  Building image 'hellopodman' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:8.0'.
  Pushed image 'hellopodman:latest' to local registry via 'podman'.

Now, let's run it! I could run it manually with podman commands in my normal Windows Terminal with any shell including normal cmd. Or I could open up Podman Desktop and create a new container from the image.

Podman Desktop Images list with the localhost/hellopodman latest image and its play button to run it

When you hit the play icon it will take you to the configuration screen where it will be pre-configured with the dotnet entry point and 8080 for port:

Create a container from image hellopodman form, prefilled with the dotnet HelloPodman.dll entrypoint and port 8080

Create the container and then you can launch it!

Container running on port 8080 in Podman Desktop and a browser at localhost:8080 showing Hello World from .NET

There you have it! Your Windows ARM machine is configured and ready to roll to develop containers and microservices with .NET!

]]>
Using Java SDK 11 on Xamarin Android Builds on Azure DevOps & GitHub Actions https://montemagno.com/java-sdk-11-azure-devops-github-actions/ 6578dab01270dd000126e748 Tue, 12 Dec 2023 22:45:42 GMT Everything is building great in your CI/CD pipeline and then BOOM your build fails for no reason. You didn't change anything in your code!?!?! That was me this morning as I was updating my InAppBilling Plugin for .NET. I got hit with this beauty:

error XA0031: Java SDK 11.0 or above is required when using $(TargetFrameworkVersion) v13.0

What is going on?!?!? Starting with Visual Studio 2022 17.8, which was just released, the Xamarin.Andriod tooling requires Java SDK 11.... but the default images on Azure DevOps still have the default set to an earlier version. No worries! as you can manually set this with the Java tool installer task as the first step in the process.

Azure DevOps pipeline with a Use Java 11 Java tool installer task first: JDK version 11, X64, Pre-installed source

Here is the YAML:

steps:
- task: JavaToolInstaller@0
  displayName: 'Use Java 11'
  inputs:
    versionSpec: 11
    jdkArchitectureOption: x64
    jdkSourceOption: PreInstalled

If you are using GitHub Actions, you can use the actions/setup-java action first: https://github.com/actions/setup-java

After that you are good to go!

]]>
Developing Android Apps on Windows Arm Devices https://montemagno.com/developing-android-apps-on-windows-arm/ 644961ecfaa091003df357e4 Thu, 27 Apr 2023 23:00:11 GMT

I have gone all in on Arm devices! My main MAC machine is a Mac Book Air M1 and now my main driver at work is a Windows Dev Kit 2023 (aka Project Volterra). One tricky thing for developers is getting all the software we use to work properly on Arm. Things have come a long way since the original release of Arm devices for Mac and Windows. I have a full setup guide for my M1 for Xamarin and now .NET MAUI development, but what about Windows?

Visual Studio 2022 recently released a full native Arm version and they have been adding more workloads for developers to use. .NET MAUI was recently introduced, which got me excited but I right away ran into an issue, which was that there are no compatible Android emulators for Windows Arm and apparently no plans from Google :(. So, what are our options? Two come to mind.

Deploy to a device

Well, it may be obvious, but you can just plug in a device and use something like Vysor to mirror your device. I am always a fan of deploying and testing on devices as you have full access to everything including Google Play services. As a bonus you don't drag down any resources on your main machine. If you don't have a device, you are still in luck!

Windows Subsystem for Android

That's right! I have blogged and talked about it a bunch, but the Windows Subsystem for Android(WSA) is a great way to deploy and debug applications on any Windows 11 machine. It has expanded into more regions since its first release, which is awesome. If you haven't heard about WSA, it is a mode that you can enable that allows you to run Android applications directly on your Windows machine. There are limitations of course, the largest being no access to Google Play services, but for a lot of developers this will get you off the ground running. Checkout my full video:

If you are using Visual Studio 2022 there is a great extension that will help you get up and running and automatically connect.

Once you have it all set up, it is ready and available!

Visual Studio debug target menu where Android Local Devices lists Microsoft Corporation Subsystem for Android, API 33

So, there you go! Two ways to get deploying Android apps if you are developing on a Windows Arm device.

]]>
Automate Posting to Mastodon via Web Requests https://montemagno.com/automate-posting-to-mastodon-via-web-requests/ 638ad2a6e86a03003db428f1 Sat, 03 Dec 2022 04:51:13 GMT One of my favorite applications that I use for automation is If This Then That aka IFTTT. I have been using it forever and it allows you to automate a plethora of things when something happens. For example, maybe you want ot create a spreadsheet of every favorited tweet you have, or maybe save them to a list somewhere, or maybe automatically turn on your lights at sunset. All of this is possible with IFTTT and is crazy easy to use with connectors. I often have automation setup on RSS feeds or my YouTube feed when I post something that it automatically Tweets it out and posts it to a Discord channel:

IFTTT applet: if I upload a new public YouTube video, then post a tweet and make two web requests

There are all sorts of built in connectors such as Twitter, which makes it really easy to setup automation. However, services such as Discord and Mastodon don't have these integrations.... they do however have an API that you can call through the web request feature of IFTTT. So, let's get started!

Get your API key

Head over to your Mastodon server, go to settings, and then Development. For example, this is what the URL looks like on mastodon.social: https://mastodon.social/settings/applications

Create a New application and set the following:

  • Application name: IFTTT
  • Application website: https://www.ifttt.com
  • Redirect URI: leave alone
  • Scopes: uncheck everything except - write:statuses
  • Save changes

Once create you will get 3 different keys. The one that you want is Your access token. Copy this as you will need this soon.

Create your webhook in IFTTT

When you setup your IFTTT, you will want to have it trigger by something, in my instance I posted on my rss. Then choose a "that" which is "Webhooks".

Enter the following:

Make sure to update mastodon.social with your server name and also put your API key in there ;)

Here is what my Body looks like for my blog

status=Just posted a new blog: {{EntryTitle}} - {{EntryUrl}}

Test it out!

You did it!!!! Mission successful!!!

IFTTT activity log: the RSS applet for this post ran, posting a tweet and making two webhook web requests

Mastodon post from @jamesmontemagno@mastodon.social sharing this blog post, posted via IFTTT

]]>
Enabling Users to Manage Subscriptions on iOS/Android https://montemagno.com/enabling-users-to-manage-subscriptions-on-ios-android/ 632f2485ae7288003d7930aa Sat, 24 Sep 2022 16:01:17 GMT

You know I love talking about in app purchases and subscriptions. Due to my InAppBilling library I get all the "fun" updates when Apple or Google force developers to make changes to stay compliant. Upcoming is a nice large change from Google to push Android developers to use Billing v4 (which my library does!) and another is that we must now give users a way to easily manage and cancel their subscriptions. You may have recently seen this message from google:

We’re updating our Subscriptions policy to state that users must be able to easily cancel their subscriptions from within the app. {#we%E2%80%99re-updating-our-subscriptions-policy-to-state-that-users-must-be-able-to-easily-cancel-their-subscriptions-from-within-the-app}

What does this mean? What do I need to do Google?!?!! Well if you dig through the documentation you will see that you have to add a button to your purchase screen and also create a deep link to launch the store management screen. Let's see what this looks like in code for Xamarin and .NET MAUI apps:

public async Task ManageSubscription(string sku)
{
    var url = string.Empty;
    if (DeviceInfo.Platform == DevicePlatform.iOS)
    {
        url = "https://support.apple.com/HT202039";
    }
    else if (DeviceInfo.Platform == DevicePlatform.Android)
    {
        if (string.IsNullOrWhiteSpace(sku))
        {
            url = "https://play.google.com/store/account/subscriptions";
        }
        else
        {
            url = $"https://play.google.com/store/account/subscriptions?sku={sku}&package={AppInfo.PackageName}";
        }
    }

    if (string.IsNullOrWhiteSpace(url))
        return;

    await Browser.OpenAsync(url);
}

For iOS there isn't really a requirement or even an API to give users an easy way to manage subscriptions, but I figure why not.

For Android, there are two cases here:

  1. They don't have any non-expired subscriptions and you just launch the generic page.
  2. Your user has a non-expired subscriptions for your app, where expiryTime is in the future or autoRenewing is set to true. In this case you would want to open the subscription page to the very specific subscription for them to manage.

And, that is about it... Well actually you could simplify this entire process by creating a Manage App Subscriptions page on your website and linking to that. Then your code becomes:

await Browser.OpenAsync("https://refractored.com/manage-app-subscriptions");

This should also adhere to the app store guidelines and also you can add additional information. Cheers!

Checkout my videos on YouTube

]]>
App Configuration Settings in .NET MAUI (appsettings.json) https://montemagno.com/dotnet-maui-appsettings-json-configuration/ 62195c853cfb01003d2c9dbd Fri, 25 Feb 2022 23:11:36 GMT

.NET MAUI is right around the corner and Preview 13 is out right now! There are so many great features packed into .NET MAUI and my favorite has to be all of the great Dependency Injection and configuration that goes into the MauiProgram startup. If you want to know more checkout my most recent YouTube video breaking down Preview 13 and all of the configuration that is built in including Essentials, Blazor, and more.

But, what if you want to do other things that aren't included out of the box... such as reading app configuration through a appsettings.json file?!?!? Well you can, and it is super easy! Thanks to the MauiAppBuilder we can use the ConfigurationManager that is built in to configure settings in our .NET MAUI app. First things first, let's add the appsettings.json file as an EmbeddedResource:

{
  "Settings": {
    "KeyOne": 1,
    "KeyTwo": true,
    "KeyThree": {
      "Message": "Oh, that's nice..."
    }
  }
}

Next, let's create a few classes that represent these configuration settings anywhere in the project:

public class Settings
{
	public int KeyOne { get; set; }
	public bool KeyTwo { get; set; }
	public NestedSettings KeyThree { get; set; } = null!;
}

public class NestedSettings
{
	public string Message { get; set; } = null!;
}

We will now need to configure our json file with a few Microsoft.Extensions.Configuration NuGet packages. Added the following into your .csproj:

<ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
</ItemGroup>

Now, let's read in our json file, and add it to our builder.Configuration in our CreateMauiApp method after we create the builder:

var a = Assembly.GetExecutingAssembly();
using var stream = a.GetManifestResourceStream("MauiApp27.appsettings.json");

var config = new ConfigurationBuilder()
			.AddJsonStream(stream)
			.Build();

builder.Configuration.AddConfiguration(config);

The code above reads in the manifest resource from the assembly into a stream. You will want to change the MauiApp27 to whatever your assembly name is for your project. After that we load it up and add our new configuration to the builder's main ConfigurationManager. We do this so we can dependency inject our IConfiguration anywhere we want.

Note here that there will be some perf here for reading it in from the file. You should consider if you really need to use appsettings.json as your json is never going to change once compiled up.

We will want to add our MainPage as a Transient into our IServiceCollection in our CreateMauiApp method:

 builder.Services.AddTransient<MainPage>();

Now we can adjust our App.xaml.cs to inject our MainPage:

	public App(MainPage page)
	{
		InitializeComponent();

		MainPage = page;
	}

Then over on our MainPage, we can inject our IConfiguration and then use it when our button is clicked to read in our Settings and bind them together:

	int count = 0;
	IConfiguration configuration;
	public MainPage(IConfiguration config)
	{
		InitializeComponent();

		configuration = config;

	}

	private async void OnCounterClicked(object sender, EventArgs e)
	{
		count++;
		CounterLabel.Text = $"Current count: {count}";

		SemanticScreenReader.Announce(CounterLabel.Text);

		var settings = configuration.GetRequiredSection("Settings").Get<Settings>();
		await DisplayAlert("Config", $"{nameof(settings.KeyOne)}: {settings.KeyOne}" +
            $"{nameof(settings.KeyTwo)}: {settings.KeyTwo}" +
            $"{nameof(settings.KeyThree.Message)}: {settings.KeyThree.Message}", "OK");
	}

Now, if you aren't using constructor injection don't worry as you can still access it manually by exposing the IServiceProvider in our MauiProgram.cs:

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        //normal builder stuff

        var app = builder.Build();
        Services = app.Services;

        return app;
    }

    public static IServiceProvider Services { get; private set; }
}

Then you can access it via: MauiProgram.Services.GetService();

Either way, you are good to go and you have access to these settings!!!

MainPage code reading the Settings section, and the app's Config alert showing KeyOne 1, KeyTwo True, and the Message value

Want to learn more? Be sure to read the full documentation on Configuration in .NET. Full source code example can be found on GitHub right here!

Want to go farther? Check out Allan Ritchie's awesome Shiny Extensions library at https://github.com/shinyorg/configurationextensions

Checkout my videos on YouTube

]]>
Implementing In-App Subscriptions in iOS & Android with no backend servers https://montemagno.com/ios-android-subscription-implemenation-strategies/ 62014fb35f3a88004810373c Mon, 07 Feb 2022 21:00:17 GMT

Over the years I have created several apps that I am super proud of, and for the most part, I have always put them out for free onto the app stores. More recently I have been experimenting with different monetization strategies for the apps so users can unlock features or just leave a tip. For the longest time I kept things simple with a "non-consumable" one-time purchase. The strategy is straight forward, they either purchased the item or they didn't. Based on this information you would unlock the features in the app. My nifty InAppBilling .NET plugin for iOS, Android, macOS, and Windows comes in super handy and makes it just a few lines of code to implement this logic. Over the holiday break I decided to take things a step further and dip my toes into the world of subscriptions, and what I found left my code in a complete state of madness! Before I go into details on how I decided to implement subscriptions, I recommend listening to our recent Merge Conflict episode all about subscriptions!

One of my goals of this was to not use any backend servers or accounts at all as I didn't want to add additional costs to management. That leads to even more complexity, but for me it was worth it, so let's get into it!

Setting up subscriptions

The first thing that you are going to need to do is setup your subscriptions in the App Store and Google Play. Each store has the concept of "Groups" that have multiple subscription options in them that users can upgrade/downgrade from. I am just offering a single subscription to keep things simple. The settings are straight forward:

  • How long is a subscription (1 week, 1 month, 2 months, 3 months, 6 months, 1 year)
  • How much is the subscription (as low as 0.49)
  • Name, Description, and Product ID

It is important you select the correct subscription length as you can't change this after it is approved. Additionally, you should set a unique Product ID across your apps and try to name them the same between iOS and Android for simplicity later. You can change the price later if you desire, but note that lowering it will affect all users, but if you increase you have the option to make it for all users or just for new subscriptions. I prefer to make it a bit higher to start and then adjust later as needed.

Apple and Google have great documentation if you need more information.

Purchasing subscriptions

Now that we have setup the subscription information, we can start our implementation. I mean technically you could skip the step ahead as well if you haven't prepped the app store yet, but of course you won't be able to test out things.

My Cadence Pro screen listing Pro features above Lifetime Pro, $0.99-a-month subscribe, and Restore Purchases buttons
Purchase screen for subscription

Purchasing is straight forward, and for this blog I am going to use my InAppBilling plugin that under the hood uses StoreKit and the Android Billing Library. Making the purchase is the same as any other purchase using the SDK, but the information that is returned is the most important part.

async Task PurchaseSubscription()
{
    if (IsBusy)
        return;

    IsBusy = true;
    try
    {

        // check internet first with Essentials
        if (Connectivity.NetworkAccess != NetworkAccess.Internet)
            return;

        // connect to the app store api
        var connected = await CrossInAppBilling.Current.ConnectAsync();
        if (!connected)
            return;

        var productIdSub = "mysubscriptionid";

        //try to make purchase, this will return a purchase, empty, or throw an exception
        var purchase = await CrossInAppBilling.Current.PurchaseAsync(productIdSub, ItemType.Subscription);

        if (purchase == null)
        {
            //nothing was purchased
            return;
        }

        if (purchase.State == PurchaseState.Purchased)
        {
            Settings.SubExpirationDate = DateTime.UtcNow.AddSubTime();
            Settings.HasPurchasedSub = true;
            Settings.CheckSubStatus = true;

            // Update UI if necessary if they have
            SetPro();

            try
            {
                // It is required to acknowledge the purchase, else it will be refunded
                if (DeviceInfo.Platform == DevicePlatform.Android)
                    await CrossInAppBilling.Current.AcknowledgePurchaseAsync(purchase.PurchaseToken);
            }
            catch (Exception ex)
            {
                Logger.AppendLine("Unable to acknowledge purcahse: " + ex);
            }
        }
        else
        {
            throw new InAppBillingPurchaseException(PurchaseError.GeneralError);
        }
    }
    catch (InAppBillingPurchaseException purchaseEx)
    {
        // Handle all the different error codes that can occure and do a pop up
    }
    catch (Exception ex)
    {
        // Handle a generic exception as something really went wrong
    }
    finally
    {
        await CrossInAppBilling.Current.DisconnectAsync();
        IsBusy = false;

    }
}

This looks like a lot of code, but it is only four calls to the billing service itself: Connect, buy, acknowledge (on Android), disconnect. And funny enough, on iOS you don't even need to really connect or disconnect as it doesn't do anything.

Now, the real thing to pay attention to is what we do when the user makes a successful purchase. I store 3 values that I store in preferences using Essentials:

   Settings.SubExpirationDate = DateTime.UtcNow.AddSubTime();
   Settings.HasPurchasedSub = true;
   Settings.CheckSubStatus = true;
  • SubExpirationDate: This value lets me check if the subscription is still valid and when to refresh the subscription status next.
  • HasPurchasedSub: A simple value to determine if they have EVER purchased a subscription. Just valuable information that I will use later.
  • CheckSubStatus: This value I use to determine if I have prompted the user to refresh the subscription status, it is true if the subscription is valid, and I have never prompted them.

That AddSubTime is a nice little extension method I made throughout my app to make it easier when checking if subscriptions are valid or not.

public static DateTime AddSubTime(this DateTime dateTime)
            => dateTime.AddMonths(1).AddDays(5);

I decided to add not only a month, but also five extra days just in case there is some weird billing issue. I figure what an extra five days anyway.

Now throughout my app I can run a single check to see if they have purchased a subscription and if it is valid:

public static bool IsSubValid => HasPurchasedSub && SubExpirationDate > DateTime.UtcNow;

Handling subscription renewals and cancelations

At this point, the user now has access to the subscription for a full month (and 5 days)! What happens when it is time to refresh that subscription status? I went with a simple approach on launch of the application that notifies the user that the subscription status needs to be refreshed. I could do this in the background, but I made it very transparent to the users.

if(Settings.CheckSubStatus && Settings.HasPurchasedSub && !Settings.IsSubValid)
{
    Settings.CheckSubStatus = false;
    if(await DisplayAlert("Subcription Status Refresh", "Looks like it is time to update your subscription status. If you canceled and resumed, you can always refresh your status on the settings page by restoring purchases.", "Refresh status", "Maybe Later"))
    {
        if (Connectivity.NetworkAccess != NetworkAccess.Internet)
        {
            Settings.CheckSubStatus = true;
            await DisplayAlert("No internet", "Please check internet connection and try restore purchases again in settings.", "OK");
            return;
        }
        //refresh status here
        await RestorePurchases();
    }
}

Here, I check to see if they ever purchased a subscription, if it is no longer valid, and if I should check the subcription status with a prompt. If they want to refresh the status, I call the RestorePurchase method.

Now, this is where things get FUNKY and weird because iOS and Android differ so much.

  • iOS: returns every single transaction including the original purchase and all renewals with proper transaction date time stamps!
  • Android: Only returns current valid subscriptions and the time stamp is always the original purchase date of the subscription, not the renewals.

I personally prefer the iOS mechanism for this as it makes the code much cleaner. All you must  do is order the purchases descending by transaction date and compare dates to see if a subscription is valid. On Android, if it returns a subscription then it is valid and you must figure out the expiration date manually by adding time over and over to the original transaction date, which seems problematic. However, after much testing, here is what I came up with to check to see if the subscription has been renewed:

async Task RestorePurchases()
{
    if (IsBusy)
        return;
    IsBusy = true;

    try
    {

        var connected = await CrossInAppBilling.Current.ConnectAsync();
        if (!connected)
            return;

        var foundStuff = false;

        var subs = await CrossInAppBilling.Current.GetPurchasesAsync(ItemType.Subscription);

        if (subs?.Any(p => p.ProductId == productIdSub) ?? false)
        {
            var sorted = subs.Where(p => p.ProductId == productIdSub).OrderByDescending(i => i.TransactionDateUtc).ToList();
            var recentSub = sorted[0];
            if (recentSub != null)
            {
                // On Android as long as you have one here then it is valid subscription
                if (DeviceInfo.Platform == DevicePlatform.Android)
                {
                    foundStuff = true;
                    Settings.HasTippedSub = true;
                    Settings.CheckSubStatus = true;

                    //loop through transactions and keep adding a month until valid
                    var date = recentSub.TransactionDateUtc;
                    while (date < DateTime.UtcNow)
                        date = date.AddMonths(1);

                    Settings.SubExpirationDate = date.AddDays(5);

                    SetPro();
                }
                else
                {
                    if (recentSub.TransactionDateUtc.AddSubTime() > DateTime.UtcNow)
                    {
                        foundStuff = true;
                        Settings.HasTippedSub = true;
                        Settings.CheckSubStatus = true;
                        Settings.SubExpirationDate = recentSub.TransactionDateUtc.AddSubTime();
                        SetPro();
                    }
                }

                if (DeviceInfo.Platform == DevicePlatform.Android && !recentSub.IsAcknowledged)
                {
                    try
                    {
                        await CrossInAppBilling.Current.AcknowledgePurchaseAsync(recentSub.PurchaseToken);
                    }
                    catch (Exception ex)
                    {
                        Logger.AppendLine("Unable to acknowledge purchase: " + ex);
                    }
                }
            }
        }

        if (!foundStuff)
        {
            await DisplayAlert("Hmmmm!", $"Looks like we couldn't find any subscription renewals, check your purchases and restore them in settings. Don't worry, all of your ride data will be saved.", "OK");
        }
        else
        {
            await DisplayAlert("Status Refreshed!", $"Thanks for being awesome and subscribing for another month of My Cadence Pro!", "OK");
        }

    }
    catch (Exception ex)
    {
        Debug.WriteLine("Issue connecting: " + ex);
        await DisplayAlert("Uh Oh!", $"Looks like something has gone wrong, please check connection and restore in the settings.  Code: {ex.Message}", "OK");
    }
    finally
    {
        IsBusy = false;
        SetPro();
        await CrossInAppBilling.Current.DisconnectAsync();
    }
}

It is not pretty, but totally working well. Note that for iOS I take the most recent subscription, add time, and compare it with the current UTC time: recentSub.TransactionDateUtc.AddSubTime() > DateTime.UtcNow and if it is still valid then I update the expiration date.

Verifying & restoring a subscription

One important feature that your app must implement is a "Restore Purchases" feature for in-app purchases and subscriptions. This is important if your user gets a new device, has multiple devices, or uninstalls and re-installs the app. I add a button directly on the settings/upgrade screen to do this. The application logic is actually the same as the renewal check above! Code re-use to the max!!! You may have other in-app purchases that you may want to put in there as well, so be sure to restore all your purchases!

Receipt validation

At this point we have finished all the in-app code for subscriptions, renewals, and restoration. See, it wasn't that bad :).... well except that we haven't done any receipt validation, which means the user could have cancelled, asked for a refund, paused a subscription, or who knows what else. Oh, and receipt validation also ensures that users haven't hacked your app in some way to intercept the calls to get free stuff. Look how easy this looks:

StoreKit purchase diagram with numbered steps between a Xamarin.iOS app, your server, iTunes servers, and iTunes Connect

Receipt validation is super important, and I recommend you checkout my documentation with tons of links to other important resources including how to use Azure Functions as a receipt validation service. For this blog, and in my apps currently I am not doing receipt validation. I haven't personally had any issues, but my apps are only downloaded a few thousand times, not millions of times. So, I am going to leave this up to you based on your application.

Testing subscriptions

Now is the fun part of testing!!!!!!!

Google Play test purchase sheet for the My Cadence Pro Subscription, renewing at $0.99 every 5 minutes on a test card

This is the worst part because when you test everything is different in a sandbox, and each platform is different. I ended up using TestFlight and Google Play Internal rings for testing with my normal account. When you subscribe on iOS and Android you aren't charged anything during testing, but they do renew differently for testing purposes. Android will just continue to renew every 5 minutes (for a 1 month sub, longer for others) and then you have to manually cancel the subscription. I like this approach a lot as it doesn't clutter up all my transactions and lets me do more realistic world testing. iOS is a bit of an interesting one as it also will renew the subscription every 5 minutes but will do it up to 12 times and then stop renewing. This is a fine approach, but I just wish they worked the same.

I could write for days on testing and all the complexity, but there is an amazing blog for iOS testing from RevenueCat that saved my life and outlines everything you need to know. Google actually has pretty good documenation for this and really helped me get through the process. Take your time on testing and make sure to test all of the different scenarios. One pro tip that I can offer is to change the amount of time for a valid subscription to 5 minutes (instead of my 1 month & 5 days) so you can test out the duration that Apple/Google renew on. Once you have validated that you can change it back and ship to the store.

In-App Subscriptions SaaS... RevenueCat

Implementing in-app subscriptions can be extremely complicated and can get even more complicated when you throw in user accounts, third party integrations, and subscriptions on the web. After reading the testing blog on RevenueCat, I realized that it was an entire SaaS solution for in-app subscriptions and is genius.

RevenueCat diagram: iOS, Android, and web apps feed into RevenueCat, which connects to services like Facebook and Slack

If you are really looking for a full solution this may be a good option for you to investigate. I have still decided to roll my own and use the raw APIs of the platform, but I have to say RevenueCat is pretty awesome and just shows how complex these APIs and testing matrixes are.

Other Considerations

In-app subscriptions are pretty neat as they offer a lot of flexibility over a single IAP or up-front app purchase. For example you can:

  • Offer a free trial for a month
  • Offer discounted rate for the first X months
  • Offer discounts for longer subscriptions
  • Promote them in the app store as another entry (iOS)

It is true that we are becoming a subscription based society and some people hate subscriptions. However, if you have great features and want to help your long-term development it is a great option to generate revenue. One option that I have in my apps is the option to do a "life time" subscription purchase. This is actually not a subscription, but a non-consumable in-app purchase. This adds a bit of complexity to the mix, but I think it is worth it at the end of the day. I hope that you found this blog a bit interesting and helpful on your IAP journey!

Checkout my videos on YouTube

]]>