<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Sebastian Jensen on Medium]]></title>
        <description><![CDATA[Stories by Sebastian Jensen on Medium]]></description>
        <link>https://medium.com/@tsjdevapps?source=rss-c8f6762e0e4b------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*NUgY8X4R_RXsjcagCua3Xw.png</url>
            <title>Stories by Sebastian Jensen on Medium</title>
            <link>https://medium.com/@tsjdevapps?source=rss-c8f6762e0e4b------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 04:04:51 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@tsjdevapps/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Getting Started with Maestro UI Tests for .NET MAUI]]></title>
            <link>https://medium.com/medialesson/getting-started-with-maestro-ui-tests-for-net-maui-3c8f714b7903?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/3c8f714b7903</guid>
            <category><![CDATA[maestros]]></category>
            <category><![CDATA[dotnet-maui]]></category>
            <category><![CDATA[ui-testing]]></category>
            <category><![CDATA[mobile-app-development]]></category>
            <category><![CDATA[test-automation]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Mon, 09 Nov 2026 10:13:43 GMT</pubDate>
            <atom:updated>2026-11-09T10:13:43.913Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>Understand the testing boundary, write your first YAML flow, and run it locally</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fTTAgKB8yAqmM3CtguPIjQ.png" /></figure><p>A ViewModel test can tell us that saving a valid task adds an item to a collection. But does the Save button execute the right command? Can the user see the validation message? Does navigation bring us back to a usable task list?</p><p>These questions sit between our application logic and the native UI.</p><p>For this sample, I wanted a small set of tests that interact with a .NET MAUI app in the same order as a user: open a page, enter a title, save it, and check the result.</p><p>In this post, we will look at the UI-testing concept, introduce Maestro, write a flow, and see how to run it locally on Android and iOS. The second part will take the same flows into GitHub Actions.</p><p>The local Android example uses Windows and an emulator. The iOS example needs a Mac and an iOS Simulator. Neither replaces a final check on real devices.</p><h3><strong>What Does a UI Test Actually Test?</strong></h3><p>Unit tests and UI tests answer different questions:</p><ul><li><strong>Unit Tests</strong>: Does the ViewModel reject a blank title? / Application logic and test doubles</li><li><strong>UI Tests</strong>: Does tapping Save show the validation message and keep the form open? / The installed app on a device or simulator</li></ul><p>A unit test can call a command directly. A UI test must find the button, tap it, and observe the screen afterwards. This exercises more of the application: native controls, bindings, input, and navigation.</p><p>That broader boundary is useful, but it costs more. UI tests need a running app and device, take longer, and can be affected by keyboard behavior, animations, or ambiguous selectors.</p><p>I would not replace unit tests with UI tests. Keep detailed business-rule checks close to the code, then use a focused set of UI tests for important user journeys.</p><p>The basic pattern stays familiar: <strong>arrange a known state, perform an action, and assert the result.</strong></p><h3><strong>Introducing Maestro</strong></h3><p><a href="https://docs.maestro.dev/">Maestro</a> describes UI interactions in YAML files called <strong>flows</strong>. A flow can launch an app, find an element in the native UI hierarchy, tap it, enter text, and check what becomes visible.</p><p>For .NET MAUI, that is an interesting boundary. Maestro does not know our ViewModel types. It interacts with the native controls that MAUI creates.</p><p>There is no Maestro NuGet package in this sample and no production-only test hook. The installed app remains the app we want to test.</p><p>This is not a pixel-diff test suite either. We check behavior through elements, identifiers, and accessibility descriptions. Screenshots can help us understand failures, but taking a screenshot does not automatically compare the UI against a visual baseline.</p><h3><strong>The Sample: MAUI Tasks</strong></h3><p>The app is deliberately small. It starts with three tasks:</p><ul><li>Learn .NET MAUI</li><li>Explore Maestro</li><li>Write UI tests</li></ul><p>We can add a task, mark it as completed, and delete it. Empty or whitespace-only titles are rejected with an inline message. Valid titles are trimmed before saving.</p><p>There is no backend, database, or authentication. An in-memory task service keeps the example focused on UI interaction rather than infrastructure.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/1*711OYk__KZLvC6t5EFOQ_g.png" /><figcaption><em>The starting task list and inline validation in the real app.</em></figcaption></figure><p>The repository contains five flows:</p><ul><li><strong>Smoke</strong>: Check the seeded task list and Add button</li><li><strong>Add</strong>: Save a new task and find it in the list</li><li><strong>Validate</strong>: Reject blank input, then recover by saving a valid title</li><li><strong>Complete</strong>: Complete a specific task and return it to “To do”</li><li><strong>Delete</strong>: Remove a specific task without removing the other tasks</li></ul><p>That is a useful starting point, not a claim of complete application coverage.</p><h3><strong>Keeping Unit Tests Separate</strong></h3><p>The solution separates the .NET MAUI app from a platform-independent Core library. Models, ViewModels, the task service, and a navigation contract live in Core; views and Shell integration remain in the app.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*h9lgeXqZl0GkJ7IIRjhj0A.png" /><figcaption><em>The app and xUnit tests reference Core. Maestro stays outside the .NET project graph and exercises the native UI.</em></figcaption></figure><p>Core targets net10.0 and does not reference .NET MAUI. We can run its unit tests directly with the matching .NET SDK:</p><pre>dotnet test `<br>    tests\MaestroMauiDemo.UnitTests\MaestroMauiDemo.UnitTests.csproj</pre><p>This project uses xUnit with VSTest, so the positional project path is intentional. No .NET MAUI workload or emulator is needed for this command.</p><p>The Maestro workspace is different. It is a directory of YAML files and runner scripts, not another .NET test project:</p><pre>tests/<br>  MaestroMauiDemo.UITests/<br>    config.yaml<br>    flows/<br>      smoke.yaml<br>      add-task.yaml<br>      validate-task.yaml<br>      complete-task.yaml<br>      delete-task.yaml<br>    scripts/<br>      run-android.ps1<br>      run-ios.sh</pre><h3><strong>Start with a Known State</strong></h3><p>To write a first flow, create a YAML file such as open-task-list.yaml inside the workspace’s flows directory. For a temporary exercise, remove it afterwards if you want to keep the repository’s original five-flow suite.</p><p>Here is its complete content:</p><pre>appId: ${APP_ID}<br>name: Open the task list<br>---<br>- launchApp:<br>    clearState: true<br>- assertVisible: &quot;MAUI Tasks&quot;<br>- assertVisible:<br>    id: &quot;TaskList&quot;</pre><p>The header identifies the app and names the flow. The --- separator starts the command list. Our workspace discovers files inside flows, so a new file there is included in suite runs.</p><p>APP_ID is supplied when we invoke Maestro. For this app, its value is de.tsjdevapps.maestromauidemo. Keeping it outside the flow avoids repeating the package identifier in every file.</p><p>launchApp starts the app with its data cleared. In this sample, the process restart also recreates the in-memory service and the three seeded tasks. Navigating between pages does not reset that service.</p><p>For an app backed by a server, this would not reset server-side data. We would need an explicit test-data strategy.</p><p>Finally, assertVisible checks the screen rather than assuming that launching the process means the app is usable. A launch with clearState is destructive to the selected app’s data, so use it only against the intended test app.</p><h3><strong>Give Controls Stable Selectors</strong></h3><p>Visible text can identify a screen, but it is not always the best way to identify a control. Labels change, and repeated list rows often contain identical buttons.</p><p>The sample assigns explicit .NET MAUI AutomationId values to important controls, including TaskList, TaskTitleEntry, and SaveTaskButton.</p><p>A unique button can then be selected like this:</p><pre>- tapOn:<br>    id: &quot;SaveTaskButton&quot;</pre><p>Rows require more detail. Every row has a Delete button. The relevant selector properties in its XAML are:</p><pre>&lt;Button<br>    AutomationId=&quot;DeleteTaskButton&quot;<br>    Text=&quot;Delete&quot;<br>    SemanticProperties.Description=&quot;{Binding Title, <br>        StringFormat=&#39;Delete task: {0}&#39;}&quot; /&gt;</pre><p>This excerpt omits the actual command binding and styling to focus on selection.</p><p>On Android, the native hierarchy exposes the description, allowing the flow to select one specific task:</p><pre>- tapOn:<br>    id: &quot;DeleteTaskButton&quot;<br>    text: &quot;Delete task: Write UI tests&quot;</pre><p>The completion checkbox uses the same idea with descriptions such as Completion for Explore Maestro.</p><p>Inspect the native hierarchy when a selector does not behave as expected. A XAML property alone is not proof of how it appears on every platform.</p><h3><strong>Write a Flow That Checks the Result</strong></h3><p>Here is the complete add-task flow from the repository:</p><pre>appId: ${APP_ID}<br>name: Add a task and return to the list<br>---<br>- launchApp:<br>    clearState: true<br>- assertVisible:<br>    id: &quot;TaskList&quot;<br>- assertNotVisible: &quot;Write Maestro UI tests&quot;<br>- tapOn:<br>    id: &quot;AddTaskButton&quot;<br>- assertVisible: &quot;A new task&quot;<br>- tapOn:<br>    id: &quot;TaskTitleEntry&quot;<br>- inputText: &quot;Write Maestro UI tests&quot;<br>- hideKeyboard<br>- tapOn:<br>    id: &quot;SaveTaskButton&quot;<br>- assertVisible:<br>    id: &quot;TaskList&quot;<br>- assertNotVisible:<br>    id: &quot;TaskTitleEntry&quot;<br>- scrollUntilVisible:<br>    element:<br>      id: &quot;TaskTitleLabel&quot;<br>      text: &quot;Write Maestro UI tests&quot;<br>- assertVisible:<br>    id: &quot;TaskTitleLabel&quot;<br>    text: &quot;Write Maestro UI tests&quot;</pre><p>We first verify the starting screen and make sure the new task is absent. Then we open the form, focus the entry, type the title, and hide the keyboard before saving.</p><p>The important part comes after the tap. We check that the list returns, the entry is no longer visible, and the new task appears.</p><p>scrollUntilVisible avoids depending on a particular screen height. The flow uses state-based checks instead of arbitrary fixed sleeps.</p><p>The repository’s other flows start independently too. None needs another flow to create its data first. That makes a single-flow run useful during development.</p><h3><strong>Do Not Forget the Rejection Path</strong></h3><p>A successful save does not prove that an invalid save behaves correctly.</p><p>After opening the add page, the validation flow contains:</p><pre>- tapOn:<br>    id: &quot;SaveTaskButton&quot;<br>- assertVisible:<br>    id: &quot;ValidationMessageLabel&quot;<br>    text: &#39;Enter a task title\.&#39;<br>- assertVisible:<br>    id: &quot;TaskTitleEntry&quot;<br>- assertNotVisible:<br>    id: &quot;TaskList&quot;</pre><p>This checks the message and the navigation result: the form stays open.</p><p>The full flow repeats the check with three spaces, then replaces them with a valid title and verifies the saved task. We test both rejection and recovery, not just the presence of red text.</p><h3><strong>Prepare the Local Android Environment</strong></h3><p>For the Windows runner in this repository, we need:</p><ul><li>.NET SDK 10.0.403</li><li>.NET MAUI Android workload and compatible Android SDK</li><li>Android Platform-Tools and Emulator</li><li>An existing Android AVD</li><li>Java 17+; use your vendor’s October 2026 security release or later, and point JAVA_HOME to that JDK</li><li>Native Maestro CLI</li><li>PowerShell 7</li></ul><p>Follow the <a href="https://docs.maestro.dev/maestro-cli/how-to-install-maestro-cli">official Maestro CLI installation guide</a>. On Windows, download the release ZIP, extract it to a stable directory, and add its bin directory to your user PATH. This uses the native CLI, without WSL.</p><p>Set JAVA_HOME to the JDK root containing bin\java.exe, not to bin itself. Configure ANDROID_HOME to point to the Android SDK. Open a new terminal after changing environment variables.</p><p>Check the tools before running a test:</p><pre>dotnet --version<br>&amp; &quot;$env:JAVA_HOME\bin\java.exe&quot; --version<br>maestro --version<br>&amp; &quot;$env:ANDROID_HOME\platform-tools\adb.exe&quot; devices -l<br>&amp; &quot;$env:ANDROID_HOME\emulator\emulator.exe&quot; -list-avds</pre><p>If no AVD exists, create one through Android Device Manager or Android Studio. The helper starts existing AVDs; it does not create them or install missing tools.</p><h3><strong>Run the Tests Locally on Android</strong></h3><p>From the repository root:</p><pre>.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1</pre><p>The helper reuses an eligible running emulator. If none is available, it selects the only existing AVD automatically, or offers a numbered choice when several exist.</p><p>For a repeatable command:</p><pre>.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1 `<br>    -AvdName &#39;pixel_5_-_api_33&#39;</pre><p>Replace the example with a name from your own AVD list. The screenshots and local verification in this article use API 33.</p><p>The script waits for boot, evaluates the project’s Android target and application ID, builds a fresh signed APK, installs it, and runs all five flows.</p><p>To work on just one flow:</p><pre>.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1 <br>    -Flow &quot;add-task.yaml&quot;</pre><p>The -Flow value is a filename inside the existing flow directory.</p><p>For YAML-only changes, we can avoid rebuilding:</p><pre>.\tests\MaestroMauiDemo.UITests\scripts\run-android.ps1 `<br>    -DeviceId &quot;emulator-5554&quot; -Flow &quot;add-task.yaml&quot; -SkipBuild</pre><p>-SkipBuild skips <strong>both build and installation</strong>. The selected emulator must already contain the app. Do not use this option to claim that freshly changed app code has been tested.</p><p>The helper shuts down an emulator only when it started that emulator itself. A pre-existing emulator stays running. Boot, installation, and test failures return nonzero rather than becoming a successful run after cleanup.</p><h3><strong>See What the Android Helper Runs</strong></h3><p>Once the app is installed and the emulator is booted, we can invoke Maestro directly:</p><pre>maestro --device=emulator-5554 --platform=android test `<br>    --env &quot;APP_ID=de.tsjdevapps.maestromauidemo&quot; `<br>    tests\MaestroMauiDemo.UITests\flows\add-task.yaml</pre><p>Replace the serial with the intended test emulator. This command runs the flow only: it does not build, install, or manage emulator shutdown.</p><p>For the whole workspace:</p><pre>maestro --device=emulator-5554 --platform=android test `<br>    --env &quot;APP_ID=de.tsjdevapps.maestromauidemo&quot; `<br>    tests\MaestroMauiDemo.UITests</pre><p>The workspace’s config.yaml limits discovery to the YAML flow directory:</p><pre>flows:<br>  - flows/*</pre><p>Maestro reports the commands and flow outcomes in the terminal. When a flow fails, check its first failed assertion and diagnostic output before changing the app or adding delays.</p><p>The five Android flows have passed locally, both as a suite and individually in a different order.</p><h3><strong>A Few Practical Pitfalls</strong></h3><p>An ADB-installed MAUI Debug APK needs its managed assemblies included. Visual Studio Fast Deployment can otherwise leave the APK depending on separately deployed files.</p><p>The helper builds with:</p><pre>AndroidPackageFormats=apk<br>EmbedAssembliesIntoApk=true</pre><p>An installation that succeeds is not enough if the app then aborts at startup.</p><p>For Java issues, check the actual output of java -version, not just the JDK folder name. The local Microsoft JDK reports 17.0.21.0.2, which produces an Android-tooling XA0033 parsing warning in this environment. Builds and flows passed, but the warning is not presented as resolved.</p><p>For element-selection failures, inspect the native hierarchy, the foreground screen, and the starting state. Stable IDs and row-specific descriptions are more useful than relying on “the second Delete button”.</p><p>The <a href="https://github.com/tsjdev-apps/maui-ui-testing-with-maestro/blob/main/tests/MaestroMauiDemo.UITests/README.md">UI-test guide</a> contains the full setup and troubleshooting details.</p><h3><strong>Run the Same Flows Locally on iOS</strong></h3><p>The YAML files do not need a separate iOS copy. The environment around them does change.</p><p>For an iOS Simulator run, we need:</p><ul><li>macOS and Xcode</li><li>A compatible .NET SDK and .NET MAUI iOS workload</li><li>An installed iOS Simulator runtime and an existing simulator</li><li>Java 17+; use your vendor’s October 2026 security release or later, and point JAVA_HOME to that JDK</li><li>Native Maestro CLI</li></ul><p>The repository’s hosted example uses Xcode 27.1, workload set 10.0.403, and Maestro 2.12.1. Maestro 2.12.0 added support for starting iOS simulators with Xcode 27; that support is included in 2.12.1. Keep Xcode and the installed .NET iOS workload compatible; changing one without checking the other can prevent the app from building.</p><p>An iOS Simulator runs <strong>only on macOS</strong>. A Windows or Linux machine cannot run it locally; a Windows developer needs access to a Mac for this part. The simulator does not require an Apple signing certificate or provisioning profile.</p><p>On the Mac, check the available simulators and run the Bash helper from the repository root:</p><pre>xcrun simctl list devices available<br>bash tests/MaestroMauiDemo.UITests/scripts/run-ios.sh</pre><p>The helper selects an available simulator, starts it when needed, builds a fresh simulator .app, checks its bundle identifier, installs it, and runs the same five flows. If several simulators are available, it asks us which one to use.</p><p>We can also select a simulator by its UDID and run one flow:</p><pre>bash tests/MaestroMauiDemo.UITests/scripts/run-ios.sh \<br>  --device-id &quot;YOUR-SIMULATOR-UDID&quot; --flow add-task.yaml</pre><p>Replace the placeholder with a UDID from simctl. Like the Android helper, --skip-build skips <strong>both</strong> the build and installation, so use it only when the intended app is already installed. The helper shuts down only a simulator it started itself.</p><p>If the simulator is already running and the app is installed, we can invoke the flow directly:</p><pre>maestro --device=&quot;YOUR-SIMULATOR-UDID&quot; --platform=ios test \<br>  --env &quot;APP_ID=de.tsjdevapps.maestromauidemo&quot; \<br>  tests/MaestroMauiDemo.UITests/flows/add-task.yaml</pre><p>This direct command does not build or install the app. It also does not manage simulator startup or shutdown.</p><p>Sharing a flow is useful, but it does not guarantee identical native behavior. Inspect the iOS accessibility hierarchy and verify selectors, scrolling, and keyboard dismissal on the simulator.</p><h3><strong>Do Not Skip Real-Device Testing</strong></h3><p>An emulator or simulator is a fast, repeatable place to catch many problems. It is not the final answer about how the app behaves in someone’s hand.</p><p>Before a release, run the app on <strong>a physical Android device and a physical iPhone</strong> as well. Check the important journeys, including navigation, text input, scrolling, and accessibility. Depending on the app, also check hardware features, permissions, notifications, performance, and real network conditions. Expand the device and OS coverage to match the people using the app.</p><p>An iOS simulator .app is not a device build. Running on an iPhone still requires macOS and the appropriate Apple signing and provisioning setup. Passing a simulator flow is useful evidence, not proof that the physical-device experience is ready.</p><h3><strong>Conclusion</strong></h3><p>A useful first UI test does not need to cover every screen. Start with one important journey and verify its outcome through the real app.</p><p>For this sample, that means starting from known data, finding controls through stable selectors, saving a task, and checking the resulting list. Add a rejection path next, and keep detailed business rules in unit tests. Run the flows on both platforms where their tooling is available, then check the app on real devices.</p><p>The sample and local runners are available on <a href="https://github.com/tsjdev-apps/maui-ui-testing-with-maestro">GitHub</a>.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3c8f714b7903" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/getting-started-with-maestro-ui-tests-for-net-maui-3c8f714b7903">Getting Started with Maestro UI Tests for .NET MAUI</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your Next Presentation Could Be a Git Repository]]></title>
            <link>https://medium.com/medialesson/your-next-presentation-could-be-a-git-repository-de4a81c7df54?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/de4a81c7df54</guid>
            <category><![CDATA[git]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[developer-tools]]></category>
            <category><![CDATA[presentations]]></category>
            <category><![CDATA[markdown]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 13:25:10 GMT</pubDate>
            <atom:updated>2026-11-02T13:25:10.471Z</atom:updated>
            <content:encoded><![CDATA[<h4><strong>Meet Slidev: Markdown slides, animated code, and a reusable developer brand</strong></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-hRHuUb3TC75KZe8FTJ3lQ.png" /></figure><p>I spend a lot of time in an editor. Moving to a presentation tool, dragging text boxes around, and pasting screenshots of code often feels like switching to a completely different workflow.</p><p><a href="https://sli.dev/">Slidev</a> takes another approach: write the presentation in Markdown, keep it in Git, and render it as an interactive web deck. When a slide needs more than Markdown, you can use Vue components, CSS, and layouts.</p><p>To explore it, I created English and German decks about my <a href="https://github.com/tsjdev-apps/maui-aspire-taskboard">.NET MAUI + Aspire TaskBoard</a>. They share a reusable tsjdevapps theme, including light/dark switching, and demonstrate both ordinary C# highlighting and Shiki Magic Move.</p><p>The complete example is available in <a href="https://github.com/tsjdev-apps/maui-aspire-taskboard-slides">maui-aspire-taskboard-slides</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W7fGLaf8aMlE8cnjubBStA.png" /><figcaption><em>The actual rendered deck: warm paper, teal accents, and developer typography.</em></figcaption></figure><h3><strong>Why this is interesting for developers</strong></h3><p>With Markdown as the source, a talk becomes something you can review:</p><ul><li>See what changed in a diff instead of comparing presentation files.</li><li>Keep the explanation near the implementation it describes.</li><li>Reuse a theme without copying yesterday’s slide deck.</li><li>Use real code fences rather than code screenshots.</li><li>Ask an AI assistant for an outline or a translation, then review its edits.</li></ul><p>There is a trade-off: building a custom visual identity still involves CSS and, occasionally, Vue. Markdown removes much of the content-editing friction; it does not remove the need to think about layout or rehearse.</p><h3><strong>Step 1: Run the example</strong></h3><p>Install <strong>Node.js 22.12 or newer</strong>; Node 24 LTS is a good choice. Then run:</p><pre>git clone https://github.com/tsjdev-apps/maui-aspire-taskboard-slides.git<br>cd maui-aspire-taskboard-slides<br>npm ci<br>npm run dev</pre><p>Open <a href="http://localhost:3030/en/">http://localhost:3030/en/</a> for English or <a href="http://localhost:3031/de/">http://localhost:3031/de/</a> for German. The command starts both development servers. Edit the English deck ../decks/en.md or the German deck ../decks/de.md and let Slidev reload it.</p><p>Use the top-right controls to change the language or appearance. Matching slide order and animation steps let the language switch retain your position. Keep both servers running when switching languages during development.</p><p>You do <strong>not</strong> need the .NET SDK, MAUI workloads, Docker, or an Azure account to run the slides. Those are separate concerns if you want to run the native <strong>TaskBoard</strong> application for the suggested live demo.</p><p>Starting an unrelated Slidev project from scratch is also straightforward: npm init slidev@latest. Follow its prompts, then edit the generated Markdown.</p><h3><strong>Step 2: Understand the Markdown</strong></h3><p>A minimal new deck in this repository can look like this:</p><pre>---<br>theme: ../themes/tsjdevapps<br>title: My next developer talk<br>layout: cover<br>themeConfig:<br>  language: en<br>---<br><br># Slides as code<br><br>One idea, one slide.<br><br>---<br><br># The next idea</pre><p>The opening YAML is called headmatter. It configures the deck and first slide. ---separates subsequent slides; per-slide frontmatter selects layouts.</p><p>Create this as decks/my-talk.md and start it with npx slidev decks/my-talk.md --port 3032. This is a standalone deck: its theme works even without the TaskBoard components or a second language.</p><p>HTML comments can hold speaker notes. Named slots such as ::right:: let the theme place content in a second column.</p><h3><strong>Step 3: Tell a story with code</strong></h3><p>TaskBoard is a .NET 10 sample with a native MAUI client, an ASP.NET Core Minimal API, SQLite persistence, and Aspire development orchestration. It deliberately avoids adding cloud services or Docker just to demonstrate orchestration.</p><p>For a talk, the AppHost makes a good story: begin with the host, register the API, and then connect the native client. Instead of showing three screenshots, use <a href="https://sli.dev/features/shiki-magic-move">Shiki Magic Move</a>.</p><p>The important syntax is <strong>four outer backticks</strong> around ordinary three-backtick code fences:</p><pre>````md magic-move<br>```csharp<br>var app = builder.Build();<br>app.Run();<br>```<br>```csharp<br>var app = builder.Build();<br>app.MapTaskEndpoints();<br>app.Run();<br>```<br>````</pre><p>This is a teaching example, not the full API startup. When you advance, matching tokens move into their new positions and the new line appears. The actual AppHost slide demonstrates three stages.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*T7V2JEJlJ47WIsRdKQ7kVQ.png" /><figcaption><em>The AppHost example in dark mode. The web deck animates between its code stages.</em></figcaption></figure><p>Ordinary line highlighting is useful too:</p><pre>```csharp {1|2|*}<br>options.Retry.MaxRetryAttempts = 2;<br>options.Retry.DisableFor(HttpMethod.Post, HttpMethod.Put,<br>    HttpMethod.Delete, HttpMethod.Patch);<br>```</pre><p>That example highlights the GET retry limit first, then the start of the write-method exclusions, then the complete block. The sample uses at most two retries for transient GET failures and no automatic retries for those write methods. These are application decisions, not universal recommendations.</p><p>The decks link project excerpts to source revision 9f678cd and label omissions or teaching steps explicitly. That matters when your slides explain a real project rather than an imaginary API.</p><h3><strong>Step 4: Make the design reusable</strong></h3><p>I wanted the decks to feel like <a href="https://www.tsjdev-apps.de/">my website</a>, not a generic template: warm off-white surfaces, teal accents, rounded cards, Roboto, and JetBrains Mono.</p><p>The theme lives under themes/tsjdevapps/:</p><pre>components/   Brand frame and appearance/language controls<br>layouts/      cover, default, two-cols, code<br>setup/        Light/dark Shiki themes<br>styles/       Brand tokens, typography, cards, local font imports</pre><p>Content belongs in the decks, not in the theme. The small TaskBoard illustration, for example, is a deck component and is clearly marked as an illustration rather than an app screenshot.</p><p>For another deck in this repository, reuse theme: ../themes/tsjdevapps. For another repository, run npm run theme:pack, install the resulting .tgz in that Slidev project, and use theme:tsjdevapps. You do not have to publish it to npm.</p><p>Light mode uses deep teal text on warm paper. Dark mode switches to midnight blue surfaces and mint accents. Both normal code blocks and Magic Move use Shiki’s matching light/dark themes. Fonts are bundled locally rather than loaded from a font CDN while presenting.</p><h3><strong>Step 5: Use AI as an editor, not as a source of truth</strong></h3><p>Markdown is convenient input for an AI-assisted workflow. I would start with the source files and a short outline, then ask for a draft:</p><pre>Create a Slidev talk from the provided TaskBoard source revision.<br>Use the tsjdevapps theme and matching English/German slide IDs.<br>Keep one idea per slide and link every project snippet to its source.<br>Include a three-stage AppHost magic-move animation and speaker notes.<br>Do not invent APIs, measurements, screenshots, or deployment features.<br>Mark all simplifications.</pre><p>Then review the result like code. Check the SDK methods against the source, review both languages, and inspect the actual rendered slides. A successful build does not prove that text fits the canvas or that a technical claim is true.</p><p>Share only code you are permitted to share. Remove secrets, personal data, and private URLs before giving material to any AI service.</p><h3><strong>Step 6: Build, share, and export</strong></h3><p>Build both interactive decks and preview them locally:</p><pre>npm run build<br>npm run preview</pre><p>Open <a href="http://localhost:4173/">http://localhost:4173</a>. Upload the <strong>whole</strong> `dist/` directory to a static host. For a repository subpath, build with:</p><pre>npm run build -- --base /maui-aspire-taskboard-slides/</pre><p>The host needs separate SPA fallbacks for en/* and de/*, pointing to each language’s index.html. The included preview server handles those routes and the configured base path. Public builds omit speaker notes.</p><p>For a handout, install Chromium once and export a PDF:</p><pre>npx playwright install chromium<br>npm run export:en<br>npm run export:de:dark</pre><p>Files go to exports/. Use npm run export:en --with-clicks to capture intermediate animation steps as separate pages. A PDF cannot preserve token transitions or switches; share the web build when interactivity matters.</p><p>For checks, run npm run typecheck, then npm test after building. The browser tests check both decks, appearance/language switching, source-code animation, and content boundaries.</p><h3><strong>The takeaway</strong></h3><p>Slidev is appealing because it lets a presentation join the rest of a developer’s workflow. Markdown handles the story. Git handles the history. Shiki handles the code. A reusable theme handles the visual identity.</p><p>And AI can help with the first draft, without taking over the responsibility for the final talk.</p><p>Less dragging. More storytelling.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><h3>Links</h3><ul><li><a href="https://sli.dev/guide/">Slidev getting started</a></li><li><a href="https://sli.dev/features/shiki-magic-move">Shiki Magic Move in Slidev</a></li><li><a href="https://sli.dev/guide/write-theme">Writing a Slidev theme</a></li><li><a href="https://github.com/tsjdev-apps/maui-aspire-taskboard">TaskBoard source</a></li><li><a href="https://github.com/tsjdev-apps/maui-aspire-taskboard-slides">Slides and reusable theme</a></li><li><a href="https://www.tsjdev-apps.de/">My developer blog</a></li><li><a href="https://medium.com/@tsjdevapps">Medium / @tsjdevapps</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=de4a81c7df54" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/your-next-presentation-could-be-a-git-repository-de4a81c7df54">Your Next Presentation Could Be a Git Repository</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Connecting .NET MAUI and ASP.NET Core with Aspire]]></title>
            <link>https://medium.com/medialesson/connecting-net-maui-and-asp-net-core-with-aspire-89c82577a19d?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/89c82577a19d</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[aspire]]></category>
            <category><![CDATA[aspnetcore]]></category>
            <category><![CDATA[dotnet-maui]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Mon, 12 Oct 2026 16:11:43 GMT</pubDate>
            <atom:updated>2026-10-12T16:11:43.180Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A practical TaskBoard example with local orchestration, service discovery, request diagnostics, and Android Dev Tunnels.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hfgj9d2xdTTSHoWm6qJDyQ.png" /></figure><p>You click Refresh in a native app, and the task list takes several seconds to update. The API is running, but that alone does not explain the delay. Was the request sent to the expected endpoint? Did the client retry? Was the server slow?</p><p><a href="https://aspire.dev/">Aspire</a> brings the client and backend into a shared development workflow, with resource management, endpoint configuration, and a dashboard for telemetry. A small task board gives us enough real behavior to explore that workflow: loading data, saving changes, handling failures, and running a native client alongside an ASP.NET Core API.</p><p>In this post, I will walk through <strong><em>TaskBoard</em></strong>, a .NET 10 sample built with .NET MAUI, MVVM, a Minimal API, and SQLite. We will look at the integration points, the mobile setup, and the tradeoffs of adding <a href="https://aspire.dev/">Aspire</a> to an existing application.</p><h3><strong>What Is .NET Aspire?</strong></h3><p><a href="https://aspire.dev/">Aspire</a> is an open-source, opinionated stack for building observable, cloud-ready distributed applications with .NET.</p><p>It brings an application’s related projects and resources — such as APIs, workers, and databases — into one development model, so they can be started and inspected together instead of configured as unrelated processes.</p><p>Three pieces are central to this workflow:</p><ul><li><strong>AppHost</strong> describes the projects, resources, and relationships in the application. Running it starts the development environment and supplies configuration such as service endpoints.</li><li><strong>Service Defaults</strong> provide shared infrastructure such as service discovery, health checks, and OpenTelemetry configuration. Each project opts in to the defaults it needs.</li><li><strong>Aspire Dashboard</strong> gives you one place to inspect resource state, logs, traces, and metrics exported by the application.</li></ul><p>In <strong><em>TaskBoard</em></strong>, AppHost coordinates the ASP.NET Core API and the native MAUI app. Service discovery gives the client the API address, and telemetry from both sides can be inspected together.</p><p>SQLite stays local to the API; it is not a separately orchestrated resource in this sample. Aspire is a development and diagnostics layer here, not a replacement for MAUI or ASP.NET Core, nor the production host for the app.</p><h3><strong>Introducing TaskBoard</strong></h3><p>The application has three screens:</p><ul><li><strong>Tasks:</strong> load, create, edit, complete, and delete tasks.</li><li><strong>Editor:</strong> validate a title and optional description, then save through the API.</li><li><strong>Diagnostics:</strong> send normal, delayed, failing, and timed-out requests.</li></ul><p>The UI uses native MAUI controls, Shell navigation, light/dark styling, and English/German resources. The API persists tasks in SQLite, so this sample does not require a database container or Docker. Two example tasks are inserted when a Development database is first created; deleting them does not bring them back on every restart.</p><p>The diagnostics screen makes the sample useful beyond CRUD. It gives us repeatable requests to inspect while learning how endpoint discovery, retries, and telemetry interact.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L0vOUDiuck0ILU28yTYZ9Q.png" /></figure><h3><strong>What You Need Before You Start</strong></h3><p>The sample pins its toolchain and package versions:</p><pre>|          Component         |      Sample Version      |<br>|:--------------------------:|:------------------------:|<br>| .NET SDK                   | 10.0.401                 |<br>| Aspire AppHost SDK         | 13.6.1                   |<br>| Aspire MAUI Hosting        | 13.6.1-preview.1.26506.6 |<br>| ASoure Dev Tunnels Hosting | 13.6.1                   |<br>| MAUI Controls/Core         | 10.0.110                 |<br>| CommunityToolkit.MVVM      | 8.4.2                    |<br>| EF Core SQLite             | 10.0.12                  |</pre><p>The SDK is selected in global.json, and ordinary NuGet versions live in Directory.Packages.props. The AppHost SDK version belongs in its project declaration.</p><p>From the repository root, install the MAUI workloads for the targets you want to build. On Windows, use the .NET MAUI workload in Visual Studio Installer or run dotnet workload restore TaskBoard.slnx. Trust the local HTTPS development certificate as well:</p><pre>dotnet dev-certs https --trust</pre><p>Windows can build the Windows and Android targets in this repository. Android also needs the Android SDK, a JDK, and a running emulator or device. iOS and Mac Catalyst require macOS, compatible Xcode, and the corresponding Apple workloads; start a simulator before deploying to it.</p><p>For mobile Aspire resources, install the <a href="https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started">Dev Tunnels CLI</a>, sign in with devtunnel user login, and have internet access. The API and telemetry tunnels are needed when you start a mobile resource; starting the Windows desktop app does not require Dev Tunnels. A separate Aspire CLI installation is not required for this project-based AppHost; the commands below use dotnet run.</p><p>The <a href="https://aspire.dev/integrations/dotnet/maui/">official Aspire MAUI integration</a> is currently a preview, with incomplete Visual Studio integration. The examples here use the pinned package combination above. Check compatibility before upgrading it.</p><h3><strong>Project Shape</strong></h3><p>The repository separates application behavior from platform and development infrastructure:</p><pre>TaskBoard.slnx<br>Directory.Packages.props<br>src/<br>  TaskBoard.Contracts/<br>  TaskBoard.Api/<br>  TaskBoard.ClientCore/<br>  TaskBoard.Maui/<br>  TaskBoard.ServiceDefaults/<br>  TaskBoard.MauiServiceDefaults/<br>  TaskBoard.AppHost/<br>tests/<br>  TaskBoard.ClientCore.Tests/<br>  TaskBoard.Api.Tests/<br>  TaskBoard.AppHost.Tests/</pre><pre>|       Project       |                        Responsibility                 |<br>|:-------------------:|:-----------------------------------------------------:|<br>| Contracts           | Task DTOs and shared validation limits                |<br>| Api                 | REST endpoints, EF Core, and SQLite                   |<br>| ClientCore          | HTTP client, ViewModels, resilience, and localization |<br>| Maui                | XAML pages, navigation, and native connectivity       |<br>| ServiceDefaults     | Backend health checks, discovery, and telemetry       |<br>| MauiServiceDefaults | Native discovery and telemetry initialization         |<br>| AppHost             | Development resource registrations and startup        |</pre><p>The request path stays simple: a MAUI ViewModel calls the typed HTTP client, the client calls taskapi, and the API reads or writes SQLite. Client and backend telemetry can be exported to the Aspire dashboard.</p><p>Contracts and ClientCore have no Aspire hosting dependency. That keeps the API contract and application behavior usable when the app starts independently.</p><h3><strong>Registering the API and Native App</strong></h3><p>The AppHost describes how the development resources fit together. Its project references the API and the hosting integrations:</p><pre>&lt;Project Sdk=&quot;Aspire.AppHost.Sdk/13.6.1&quot;&gt;<br>  &lt;PropertyGroup&gt;<br>    &lt;OutputType&gt;Exe&lt;/OutputType&gt;<br>    &lt;TargetFramework&gt;net10.0&lt;/TargetFramework&gt;<br>    &lt;AspireUseCliBundle&gt;true&lt;/AspireUseCliBundle&gt;<br>  &lt;/PropertyGroup&gt;<br>  &lt;ItemGroup&gt;<br>    &lt;PackageReference Include=&quot;Aspire.Hosting.Maui&quot; /&gt;<br>    &lt;PackageReference Include=&quot;Aspire.Hosting.DevTunnels&quot; /&gt;<br>    &lt;ProjectReference Include=&quot;../TaskBoard.Api/TaskBoard.Api.csproj&quot; /&gt;<br>  &lt;/ItemGroup&gt;<br>&lt;/Project&gt;</pre><p>This excerpt omits the sample’s user-secrets identifier and test assembly visibility. Package versions are supplied centrally.</p><p>The desktop portion of AppHost.cs looks like this, with the final Run call included for a minimal desktop configuration:</p><pre>var builder = DistributedApplication.CreateBuilder(args);<br><br>var api = builder.AddProject&lt;Projects.TaskBoard_Api&gt;(&quot;taskapi&quot;)<br>    .WithHttpHealthCheck(&quot;/health&quot;);<br><br>var maui = builder.AddMauiProject(<br>    &quot;taskboard&quot;, &quot;../TaskBoard.Maui/TaskBoard.Maui.csproj&quot;);<br><br>if (OperatingSystem.IsWindows())<br>{<br>    maui.AddWindowsDevice().WithReference(api);<br>}<br><br>if (OperatingSystem.IsMacOS())<br>{<br>    maui.AddMacCatalystDevice().WithReference(api);<br>}<br><br>builder.Build().Run();</pre><p>taskapi is the logical service name shared by the host and client. WithReference(api) supplies the API’s endpoint configuration to the native resource.</p><p>The MAUI project is registered through its .csproj path. The <a href="https://aspire.dev/integrations/dotnet/maui/#add-net-maui-project">integration documentation</a> explicitly advises against an AppHost-to-MAUI ProjectReference because their target frameworks are incompatible.</p><p>The backend references TaskBoard.ServiceDefaults and calls builder.AddServiceDefaults(). It also registers Problem Details, its DbContext, and a database health check. After building the application, it installs exception handling, initializes the database, maps task endpoints, and calls app.MapDefaultEndpoints(). The Development /health endpoint includes database readiness; /alive provides a liveness check.</p><h3><strong>Connecting MAUI to the Discovered API</strong></h3><p>A standalone client can start with a conventional base address:</p><pre>builder.Services.AddHttpClient&lt;ITaskApiClient, TaskApiClient&gt;(client =&gt;<br>    client.BaseAddress = new Uri(&quot;https://your-api.example/&quot;));</pre><p>TaskBoard loads its embedded settings and environment configuration, then selects the appropriate address. This excerpt comes from MauiProgram.BuildApplication():</p><pre>builder.AddServiceDefaults();<br><br>var hasDiscoveredApi = builder.Configuration<br>    .GetSection(&quot;services:taskapi&quot;)<br>    .GetChildren()<br>    .Any();<br><br>var baseAddress = hasDiscoveredApi<br>    ? new Uri(&quot;https+http://taskapi&quot;)<br>    : GetStandaloneAddress(builder.Configuration[&quot;TaskBoard:ApiBaseUrl&quot;]);<br><br>builder.Services.AddTaskBoardClient(baseAddress);</pre><p>Here, AddServiceDefaults() is the extension from <strong>MauiServiceDefaults</strong>. It registers discovery for outgoing HTTP clients and configures native telemetry. GetStandaloneAddress() is the sample’s helper that validates an absolute HTTP/HTTPS URL and normalizes its trailing slash.</p><p>When Aspire supplies service endpoints, the client uses the logical https+http://taskapi address. Discovery resolves it to the configured endpoint, preferring HTTPS. Outside that launch context, the embedded TaskBoard:ApiBaseUrl provides the conventional URL.</p><p>The typed client continues to use relative routes such as api/tasks. The ViewModels depend on ITaskApiClient in both modes, so switching startup configuration does not change the task operations.</p><h3><strong>Initializing Native Telemetry</strong></h3><p>Backend defaults register ASP.NET Core and HTTP instrumentation, runtime metrics, and OpenTelemetry logging. Native defaults register HTTP instrumentation, runtime metrics, and logging as well.</p><p>MAUI startup needs its own provider initialization. The sample’s OpenTelemetryInitializer implements IMauiInitializeService:</p><pre>public void Initialize(IServiceProvider services)<br>{<br>    _ = services.GetService&lt;MeterProvider&gt;();<br>    _ = services.GetService&lt;TracerProvider&gt;();<br>    _ = services.GetService&lt;LoggerProvider&gt;();<br>}</pre><p>Both defaults enable OTLP export when OTEL_EXPORTER_OTLP_ENDPOINT is configured. Launching through Aspire supplies the collector configuration; a standalone launch can supply a collector separately.</p><p>HTTP instrumentation propagates trace context between the client and API. In a dashboard session, refresh the board and look for the outgoing client request and corresponding server request under the same trace. That gives you a useful place to investigate server processing time and failed attempts.</p><p>The diagnostics screen displays the duration of the whole client action, including retry delays. Use that value alongside the trace spans when investigating a slow operation.</p><h3><strong>Making Retries and Timeouts Explicit</strong></h3><p>The task client owns one standard resilience handler. Native defaults register discovery globally and leave the task-specific resilience policy to ClientCore. The <a href="https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience">.NET HTTP resilience guidance</a> recommends using a single resilience handler rather than stacking handlers.</p><p>These are the relevant calls from ClientServiceCollectionExtensions.AddTaskBoardClient():</p><pre>var http = services.AddHttpClient&lt;ITaskApiClient, TaskApiClient&gt;(client =&gt;<br>{<br>    client.BaseAddress = baseAddress;<br>});<br><br>http.AddStandardResilienceHandler(options =&gt;<br>{<br>    options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(12);<br>    options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3);<br>    options.Retry.MaxRetryAttempts = 2;<br>    options.Retry.Delay = TimeSpan.FromMilliseconds(500);<br>    options.Retry.BackoffType = DelayBackoffType.Constant;<br>    options.Retry.UseJitter = false;<br>    options.Retry.DisableFor(<br>        HttpMethod.Post, HttpMethod.Put, <br>        HttpMethod.Delete, HttpMethod.Patch);<br>});<br><br>http.ConfigureHttpClient(client =&gt;<br>    client.Timeout = TimeSpan.FromSeconds(12));</pre><p>GET requests can make up to three attempts for transient failures. Writes receive no automatic retries. The standard circuit breaker and rate limiter remain enabled.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9oBb1Qkci9sPd2w0cUlRFw.png" /></figure><p>The final ConfigureHttpClient() call is intentional. The <a href="https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.StandardResilience.cs">standard handler implementation</a> disables HttpClient’s own timeout when it registers the pipeline. TaskBoard reapplies a 12-second limit afterward to cover response-body buffering outside the handler’s timeout scope. A regression test sends headers followed by a body that never completes and verifies that the actual configured client stops waiting.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Mn8zNdE3jpPKUsCZB-rPCA.png" /></figure><p>A failed or canceled write can already have been committed by the server. Refresh the board before manually repeating a write whose outcome is uncertain. This sample has no offline write queue or server-side idempotency keys.</p><h3><strong>Running the Sample on Windows</strong></h3><p>From a checkout of the <a href="https://github.com/tsjdev-apps/maui-aspire-taskboard">TaskBoard repository</a>, run these commands in its root directory:</p><pre>dotnet --version<br>dotnet restore TaskBoard.slnx<br>dotnet dev-certs https --trust<br>dotnet run --project src/TaskBoard.AppHost</pre><p>The AppHost prints its dashboard URL and starts the API. Open the dashboard and use <strong>Start</strong> on taskboard-windows to build and launch the native application. The native resource starts on demand.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kDJlRCpXrt5XdpXp4YFVUw.png" /></figure><p>The defaults use Development launch profiles, so taskboard-android is registered as well. Its tunnels stay stopped until a mobile resource is started. If you change resource registrations, rebuild and restart AppHost so the dashboard receives the new model.</p><p>The Windows client is configured as an unpackaged application for this local sample. Release packaging and signing belong to a separate distribution workflow.</p><h3><strong>Adding Android and Mobile Dev Tunnels</strong></h3><p>The mobile configuration uses one tunnel for the API and another for OTLP telemetry. TaskBoard marks both with explicit start behavior, then starts them before mobile deployment. Desktop resources connect directly to host endpoints.</p><p>The following additional block belongs before builder.Build().Run() in the desktop AppHost example. It uses the sample’s MobileTunnelStarter helper from the AppHost project, plus the TaskBoard.AppHost, Aspire.Hosting.DevTunnels, Microsoft.Extensions.Configuration, and Microsoft.Extensions.Hosting namespaces:</p><pre>var prepareMobileTunnels = builder.Configuration<br>    .GetValue&lt;bool&gt;(&quot;TaskBoard:EnableMobileTargets&quot;);<br><br>if (prepareMobileTunnels &amp;&amp; !builder.Environment.IsDevelopment())<br>{<br>    throw new InvalidOperationException(<br>        &quot;Mobile Dev Tunnels are only supported in Development.&quot;);<br>}<br><br>using var tunnelStarter = new MobileTunnelStarter();<br>if (builder.Environment.IsDevelopment())<br>{<br>    var tunnel = builder.AddDevTunnel(&quot;taskapi-tunnel&quot;)<br>        .WithAnonymousAccess()<br>        .WithReference(api.GetEndpoint(&quot;https&quot;));<br><br>    var emulatorId = builder.Configuration[&quot;TaskBoard:AndroidEmulatorId&quot;];<br>    var android = maui.AddAndroidEmulator(<br>            &quot;taskboard-android&quot;,<br>            string.IsNullOrWhiteSpace(emulatorId) ? null : emulatorId)<br>        .WithOtlpDevTunnel()<br>        .WithReference(api, tunnel);<br><br>    var mobileTunnels = builder.Resources<br>        .OfType&lt;DevTunnelResource&gt;().ToArray();<br><br>    if (!prepareMobileTunnels)<br>    {<br>        foreach (var mobileTunnel in mobileTunnels)<br>        {<br>            builder.CreateResourceBuilder(mobileTunnel)<br>                .WithExplicitStart();<br>        }<br>    }<br><br>    android.OnBeforeResourceStarted((_, e, ct) =&gt;<br>        tunnelStarter.StartAsync(mobileTunnels, e.Services, ct));<br>}</pre><p>The helper serializes mobile starts, reuses running tunnels, executes their Start commands, and waits for health/readiness. A failed command or unavailable tunnel fails the mobile start instead of leaving deployment waiting indefinitely. The complete AppHost also registers an iOS simulator target on macOS with the same API reference and OTLP tunnel.</p><p>Create and start an Android Virtual Device with the Android Device Manager. With a single running emulator, use <strong>Start</strong> on taskboard-android in the dashboard. The resource is configured to build, install, and launch the app on that emulator.</p><p><strong>The sample API has no authentication, and its API tunnel permits anonymous access. Starting a mobile resource exposes CRUD through that tunnel. Use disposable example data and stop the AppHost afterward.</strong></p><p>Signing in authenticates the tunnel host; it does not authenticate callers of this demonstration API.</p><h3><strong>When Aspire Is Worth Adding</strong></h3><p>For a client and API developed together, Aspire gives the team a repeatable starting point and a shared place to investigate requests. The diagnostics page makes that benefit easy to explore with predictable failures and delays.</p><p>The cost is additional configuration, a preview native integration, and account/network dependencies for mobile tunnels. A small app calling an already deployed API may have less to gain from coordinating local resources.</p><p>This repository uses Aspire for development orchestration. Publishing the API, signing native packages, and choosing production endpoints remain separate work. SQLite schema creation and the unauthenticated demo API also need additional design before supporting durable production data.</p><p>To add this pattern to your own application, begin with AppHost and the two defaults projects, then update client configuration and verify one desktop target. Keep the existing REST contract and MVVM behavior. Add mobile tunnels once the desktop path is working, and use a few controlled failure requests to check the resulting workflow.</p><p>You can explore the implementation in the <a href="https://github.com/tsjdev-apps/maui-aspire-taskboard">TaskBoard sample repository</a>.</p><h3><strong>Conclusion</strong></h3><p>Aspire gives a MAUI client and its API a shared development workflow for startup, endpoint discovery, and diagnostics without replacing either application. If you develop both sides and want to understand how requests move between them, the extra setup can be worthwhile; for a client that only calls an existing API, running it standalone may be enough.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zVvCPMmXiN6Sye5E25pHoQ.png" /></a></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=89c82577a19d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/connecting-net-maui-and-asp-net-core-with-aspire-89c82577a19d">Connecting .NET MAUI and ASP.NET Core with Aspire</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Seven-Segment Display Control with .NET MAUI and SkiaSharp]]></title>
            <link>https://medium.com/@tsjdevapps/building-a-seven-segment-display-control-with-net-maui-and-skiasharp-739adf1ca8d9?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/739adf1ca8d9</guid>
            <category><![CDATA[dotnet-maui]]></category>
            <category><![CDATA[ui-design]]></category>
            <category><![CDATA[skiasharp]]></category>
            <category><![CDATA[csharp]]></category>
            <category><![CDATA[cross-platform]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Fri, 10 Jul 2026 04:01:02 GMT</pubDate>
            <atom:updated>2026-07-10T04:01:02.003Z</atom:updated>
            <content:encoded><![CDATA[<h4>Learn how to build a fully customizable seven-segment display control for .NET MAUI using SkiaSharp, MVVM, and scalable vector rendering.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LaacnUOuW2LQNsI2DHI0Yg.png" /></figure><h3>What Is a Seven-Segment Display?</h3><p>A seven-segment display is one of the most recognizable digital UI patterns. It is the visual language of alarm clocks, calculators, kitchen timers, fuel pumps, lab equipment, and old arcade scoreboards.</p><p>Instead of drawing a character with curves and strokes like a normal font, a seven-segment display uses seven fixed bars:</p><pre> -- A --<br>|       |<br>F       B<br>|       |<br> -- G --<br>|       |<br>E       C<br>|       |<br> -- D --</pre><p>Each character is a combination of those bars. For example:</p><ul><li>8 lights every segment.</li><li>1 lights only the upper-right and lower-right segments.</li><li>0 lights every segment except the middle bar.</li><li>- lights only the middle bar.</li></ul><p>The charm comes from the constraint. You do not have a full alphabet in the traditional sense. You have a small set of physical shapes that can approximate numbers and a useful subset of letters. That limitation is exactly what makes the display feel mechanical, retro, and immediately readable.</p><h3>Why Build This as a Custom MAUI Control?</h3><p>The obvious shortcut would be to use a seven-segment font. That can work for simple demos, but a real reusable UI control gives you more control:</p><ul><li>You can draw active and inactive segments separately.</li><li>You can add glow without relying on font effects.</li><li>You can scale the display to any size.</li><li>You can expose colors, spacing, thickness, and animation as bindable properties.</li><li>You can make the control theme-aware through normal MAUI binding features.</li></ul><p>This sample builds the display manually with SkiaSharp, which keeps the rendering predictable across Android, iOS, Mac Catalyst, and Windows.</p><h3>Screenshots of the SevenSegmentDisplay Control</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/817/1*wqId6RQD8atxJyBonmwMTA.png" /><figcaption>The sample application showcases the SevenSegmentDisplay control with a digital clock, countdown timer, and status display, demonstrating customizable colors, glow effects, animations, and responsive scaling.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/817/1*JjG6ER6emoyp8otqYDCr6w.png" /><figcaption>The completed SevenSegmentDisplay control running in dark mode.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/531/1*ow8H-jArRFbIJOUaL8bp_w.png" /><figcaption>The control automatically scales to different screen sizes, delivering the same crisp rendering and visual style on mobile devices without any additional layout changes.</figcaption></figure><h3>Project Shape</h3><p>The sample keeps the repository layout intentionally clean:</p><pre>.editorconfig<br>Directory.Build.props<br>Directory.Packages.props<br>NuGet.config<br>MauiSevenSegmentDisplay.slnx<br>README.md<br>src/<br>└── MauiSevenSegmentDisplay/<br>    ├── Controls/<br>    │   ├── SevenSegmentDisplay.cs<br>    │   ├── SegmentCharacterMap.cs<br>    │   ├── SevenSegmentOptions.cs<br>    │   └── SevenSegmentParts.cs<br>    ├── Pages/<br>    │   ├── MainPage.xaml<br>    │   └── MainPage.xaml.cs<br>    ├── ViewModels/<br>    │   └── MainViewModel.cs<br>    └── MauiSevenSegmentDisplay.csproj<br>tests/<br>└── MauiSevenSegmentDisplay.Tests/<br>    ├── SegmentCharacterMapTests.cs<br>    ├── SevenSegmentOptionsTests.cs<br>    └── MauiSevenSegmentDisplay.Tests.csproj</pre><p>The platform, resource, and property folders generated by the MAUI template are still part of the app because they provide the real cross-platform project plumbing.</p><h3>Central Package Management</h3><p>The solution uses central package management through Directory.Packages.props. That keeps package versions out of the project file and makes future updates easier:</p><pre>&lt;PackageVersion Include=&quot;SkiaSharp&quot; Version=&quot;4.150.0&quot; /&gt;<br>&lt;PackageVersion Include=&quot;SkiaSharp.Views.Maui.Controls&quot; Version=&quot;4.150.0&quot; /&gt;<br>&lt;PackageVersion Include=&quot;CommunityToolkit.Mvvm&quot; Version=&quot;8.4.2&quot; /&gt;<br>&lt;PackageVersion Include=&quot;NUnit&quot; Version=&quot;4.6.1&quot; /&gt;<br>&lt;PackageVersion Include=&quot;NUnit3TestAdapter&quot; Version=&quot;6.2.0&quot; /&gt;<br>&lt;PackageVersion Include=&quot;Microsoft.NET.Test.Sdk&quot; Version=&quot;18.7.0&quot; /&gt;</pre><p>The project targets the installed stable .NET 10 MAUI workload and uses net10.0-android, net10.0-ios, net10.0-maccatalyst, and net10.0-windows10.0.19041.0.</p><h3>Character Mapping</h3><p>The mapping lives in SegmentCharacterMap. Each supported character maps to a SevenSegmentParts flag combination.</p><p>For example, 0 uses every segment except G, while 2 uses A, B, G, E, and D.</p><p>Unsupported characters map to None. That is important for a reusable control because a UI control should not throw just because a bound string contains an unexpected character. Rendering a blank is safer and easier to reason about.</p><p>The sample also treats : and . as special narrow symbols. They do not use the seven bars; they are drawn as round lights.</p><h3>Rendering with SkiaSharp</h3><p>The main control inherits from SKCanvasView. Every visual part is drawn manually in OnPaintSurface.</p><p>The renderer works in a design coordinate system first:</p><ul><li>Character width is based on segment thickness.</li><li>Character height is based on segment thickness.</li><li>Symbol width is narrower for colon and decimal point.</li><li>Character spacing is included in the measured design width.</li></ul><p>After measuring the string, the control calculates a scale factor that fits the design model inside the actual Skia canvas:</p><pre>float scale = MathF.Min(<br>    availableWidth / totalWidth, <br>    availableHeight / characterHeight);</pre><p>That means the same control can be used in a small card, a wide desktop layout, or a phone screen without hard-coded pixel sizes.</p><h3>Segment Geometry</h3><p>Each normal segment is a polygon, not a rectangle. Horizontal segments are six-point beveled shapes. Vertical segments use the same idea rotated into a vertical slot.</p><p>The points are then passed into a helper that rounds each corner by replacing sharp turns with short quadratic curves. This gives the display a more physical LED/LCD look without making the geometry hard to explain.</p><p>The renderer draws in layers:</p><ol><li>Inactive segment body</li><li>Optional glow for active segments</li><li>Active segment body</li></ol><p>That layering matters. The inactive segments make the whole display shape readable, while the active segments stay bright and crisp.</p><h3>Glow</h3><p>Glow is implemented by drawing the same active segment path with a blurred Skia paint before drawing the crisp active fill.</p><p>The control exposes:</p><ul><li>GlowColor</li><li>GlowIntensity</li><li>IsGlowEnabled</li></ul><p>This keeps the effect optional and styleable. A subtle blue countdown display can use a lower glow intensity, while a bright clock can look more like a classic LED sign.</p><h3>Animation</h3><p>When the Text property changes, the control runs a short pulse animation. The pulse slightly increases the glow and brightness impression while the new text is displayed.</p><p>The animation uses an IDispatcherTimer owned by the control. That avoids a startup issue where MAUI’s animation manager might not yet be available during early binding updates.</p><p>The timer stops when the animation completes and also stops when the control unloads.</p><h3>Bindable Properties</h3><p>The control exposes the common knobs a consuming app would expect:</p><pre>&lt;controls:SevenSegmentDisplay<br>    Text=&quot;{Binding CurrentTime}&quot;<br>    SegmentColor=&quot;Lime&quot;<br>    InactiveSegmentColor=&quot;DarkGreen&quot;<br>    DisplayBackgroundColor=&quot;Black&quot;<br>    GlowColor=&quot;Lime&quot;<br>    SegmentThickness=&quot;12&quot;<br>    CharacterSpacing=&quot;8&quot;<br>    SegmentCornerRadius=&quot;4&quot;<br>    InactiveSegmentOpacity=&quot;0.28&quot;<br>    GlowIntensity=&quot;0.7&quot;<br>    IsGlowEnabled=&quot;True&quot;<br>    IsAnimationEnabled=&quot;True&quot;<br>    AnimationDuration=&quot;250&quot; /&gt;</pre><p>The property is named DisplayBackgroundColor instead of BackgroundColor to avoid confusion with MAUI’s existing VisualElement.BackgroundColor.</p><h3>App Theming</h3><p>The demo page uses AppThemeBinding for light and dark themes:</p><pre>SegmentColor=&quot;{AppThemeBinding Light=#27D86A, Dark=#39FF72}&quot;<br>InactiveSegmentColor=&quot;{AppThemeBinding Light=#2B6640, Dark=#1E5531}&quot;<br>InactiveSegmentOpacity=&quot;{AppThemeBinding Light=0.34, Dark=0.3}&quot;</pre><p>The surrounding page becomes bright in light mode, while the display surfaces stay dark. That keeps the retro display contrast intact in both themes.</p><h3>Unit Tests</h3><p>The sample includes an NUnit project under tests/MauiSevenSegmentDisplay.Tests. The tests focus on behavior that is easy to validate without launching a MAUI UI:</p><ul><li>SegmentCharacterMapTests verifies digit mappings, demo status-letter mappings, blank symbol mappings, and unsupported-character behavior.</li><li>SevenSegmentOptionsTest verifies that the design-space constants stay sensible: regular characters are wider than punctuation symbols, digits are taller than they are wide, and segment thickness has a positive lower bound.</li></ul><p>These tests are intentionally small. They protect the most important non-visual contract of the control: given a character, the renderer receives the expected segment flags. Pixel-perfect rendering tests would require a separate screenshot or image-comparison workflow, which is useful for a production control but would distract from this article’s core idea.</p><p>The test project links the small UI-independent source files instead of referencing the MAUI app project directly. That keeps NUnit fast and avoids pulling MAUI Resizetizer targets into a plain unit-test build.</p><h3>Running the Sample</h3><p>From the solution folder:</p><pre>dotnet build MauiSevenSegmentDisplay.slnx<br>dotnet test MauiSevenSegmentDisplay.slnx</pre><p>The sample builds for Android, iOS, Mac Catalyst, and Windows when the matching MAUI workloads are installed.</p><h3>Closing Thoughts</h3><p>The interesting part of this control is not that it draws numbers. The interesting part is that a small, constrained visual system can become a polished, reusable UI component when it is modeled carefully:</p><ul><li>A clear segment map</li><li>Scalable geometry</li><li>Bindable styling</li><li>Theme-aware colors</li><li>Lightweight animation</li><li>MVVM-friendly sample state</li></ul><p>That is a good pattern for custom MAUI controls in general. Start with the visual model, expose the right customization points, keep platform behavior out of the control, and let SkiaSharp handle the pixels.</p><p>The complete source code for this project is available on <a href="https://github.com/tsjdev-apps/sevensegmentdisplay-maui">GitHub</a>. Feel free to explore the implementation, try it yourself, and use it as a starting point for your own custom .NET MAUI controls:</p><p><a href="https://github.com/tsjdev-apps/sevensegmentdisplay-maui">tsjdev-apps/sevensegmentdisplay-maui</a></p><blockquote>This article was created as part of the annual .NET MAUI UI July community event, where developers from around the world share blog posts and videos showcasing ideas, techniques, and projects built with .NET MAUI throughout July. You can learn more about the event here: <a href="https://goforgoldman.com/posts/mauiuijuly-26/">.NET MAUI UI July 2026</a>.</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=739adf1ca8d9" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Commit to MSIX: Automating .NET MAUI Windows Builds with GitHub Actions]]></title>
            <link>https://medium.com/medialesson/from-commit-to-msix-automating-net-maui-windows-builds-with-github-actions-9e1709a7e5dd?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/9e1709a7e5dd</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[ci-cd-pipeline]]></category>
            <category><![CDATA[mobile-development]]></category>
            <category><![CDATA[automation]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Mon, 30 Mar 2026 10:35:30 GMT</pubDate>
            <atom:updated>2026-03-30T10:35:30.008Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A practical guide to creating a test certificate, building a signed MSIX in CI, and distributing the resulting Windows test package.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*O2rRcIhYysbKMN6EQPgiXg.png" /></figure><p>Windows packaging is usually where a MAUI setup stops feeling “template simple.” The build command is not the hard part. The friction usually comes from everything around it:</p><ul><li>the package identity in Package.appxmanifest</li><li>the certificate subject and publisher name</li><li>the difference between a signed package and a trusted package</li><li>the extra files a test machine may need before installation works</li></ul><p>That is exactly why it helps to make the Windows pipeline explicit instead of treating it like “just another dotnet publish”.</p><p>In this post, I will walk through a practical GitHub Actions setup for a .NET MAUI Windows app. The goal is simple:</p><ul><li>align the Windows manifest with the signing identity</li><li>create a self-signed test certificate</li><li>store the signing material in GitHub Secrets</li><li>build a signed .msix in GitHub Actions</li><li>include the public certificate in the artifact</li><li>distribute a test package that can be installed on managed test machines</li></ul><p>The self-signed certificate described in this guide is intended only for local testing and controlled distribution to machines you manage. It is not suitable for publishing via the Microsoft Store or for public production use. If you plan to distribute your application through the Microsoft Store, Microsoft will handle the provisioning of the appropriate certificate.</p><p>This guide is based on a .NET 10 MAUI project and a Windows-based GitHub Actions runner.</p><p>If you are following the full cross-platform series, continue here as well:</p><ul><li><a href="https://medium.com/medialesson/from-commit-to-aab-apk-automating-net-maui-android-builds-with-github-actions-bc30468b2d08">From Commit to APK: Automating .NET MAUI Android Builds with GitHub Actions</a></li><li><a href="https://medium.com/medialesson/from-commit-to-ipa-automating-net-maui-ios-builds-with-github-actions-fd8d9d47240b">From Commit to IPA: Automating .NET MAUI iOS Builds with GitHub Actions</a></li></ul><h3><strong>🤖 Why Windows Packaging Needs More Than A Build Command</strong></h3><p>For Windows MSIX packaging, a successful CI run does not automatically mean the app will install on another machine.</p><p>That usually breaks for one of four reasons:</p><ul><li>the package is not signed at all</li><li>the certificate does not match the manifest publisher</li><li>the test machine does not trust the certificate</li><li>the target machine is missing a required dependency such as the Windows App Runtime package</li></ul><p>That makes the Windows pipeline more than a compile step. It is a packaging and distribution flow.</p><h3><strong>🧰 What You Need Before You Start</strong></h3><p>Before you begin, make sure you have:</p><ul><li>A .NET MAUI app targeting net10.0-windows10.0.19041.0</li><li>A planned publisher name, for example CN=Sebastian Jensen</li><li>Permission to create and export a test certificate</li><li>Access to add repository secrets in GitHub</li></ul><p>In the sample repository, the relevant files are:</p><pre>src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj<br>src/MyAwesomeMauiApp/Platforms/Windows/Package.appxmanifest<br>.github/workflows/windows-build.yml</pre><h3><strong>🧩 The Windows Signing Pieces In Plain Terms</strong></h3><p>Before changing anything, it helps to map the Windows terminology to concrete files and settings:</p><ul><li>Package.appxmanifest: defines the package identity and publisher</li><li>Publisher: the certificate subject that the package is expected to be signed with</li><li>.pfx: the certificate including the private key, used by GitHub Actions for signing</li><li>.cer: the public certificate only, used to trust the package on test machines</li><li>Trusted People: certificate store Windows checks for trusted app package signers</li></ul><p>The key rule is simple:</p><p>The Publisher in the manifest must exactly match the Subject of the certificate used for signing.</p><h3><strong>🎯 Step 1: Clean Up The Windows Package Identity</strong></h3><p>Open src/MyAwesomeMauiApp/Platforms/windows/Package.appxmanifest and review the Identity and Properties values carefully.</p><p>In a template project, the file often still contains placeholders such as:</p><pre>&lt;Identity Name=&quot;maui-package-name-placeholder&quot; <br>          Publisher=&quot;CN=User Name&quot; <br>          Version=&quot;0.0.0.0&quot; /&gt;</pre><p>For a real pipeline, replace these with real values. Example:</p><pre>&lt;Identity Name=&quot;SebastianJensen.MyAwesomeMauiApp&quot; <br>          Publisher=&quot;CN=Sebastian Jensen&quot; <br>          Version=&quot;0.0.0.0&quot; /&gt;</pre><p>You should also update the display values:</p><pre>&lt;Properties&gt;<br>  &lt;DisplayName&gt;MyAwesomeMauiApp&lt;/DisplayName&gt;<br>  &lt;PublisherDisplayName&gt;Sebastian Jensen&lt;/PublisherDisplayName&gt;<br>  &lt;Logo&gt;$placeholder$.png&lt;/Logo&gt;<br>&lt;/Properties&gt;</pre><p>The important part is not the exact wording. The important part is that the Publisher value matches the certificate subject exactly, character for character.</p><h3><strong>📦 Step 2: Understand WindowsPackageType</strong></h3><p>Many MAUI templates start with:</p><pre>&lt;WindowsPackageType&gt;None&lt;/WindowsPackageType&gt;</pre><p>That means the app is configured as unpackaged by default.</p><p>For the Windows pipeline in this post, that is fine. The workflow overrides it at publish time with:</p><pre>-p:WindowsPackageType=MSIX</pre><p>That keeps the project flexible while still allowing CI to produce a packaged Windows build.</p><h3><strong>🔐 Step 3: Create A Self-Signed Test Certificate</strong></h3><p>For local testing and controlled local distribution, a self-signed certificate is the simplest option.</p><p>Use this certificate only for local validation and managed test installs. Do not use it as a replacement for production or Store signing.</p><p>Open PowerShell and run:</p><pre>$publisher = &quot;CN=Sebastian Jensen&quot;<br><br>$cert = New-SelfSignedCertificate `<br>  -Type Custom `<br>  -Subject $publisher `<br>  -KeyUsage DigitalSignature `<br>  -CertStoreLocation &quot;Cert:\CurrentUser\My&quot; `<br>  -TextExtension @(&quot;2.5.29.37={text}1.3.6.1.5.5.7.3.3&quot;,&quot;2.5.29.19={text}&quot;) `<br>  -FriendlyName &quot;MyAwesomeMauiApp&quot;</pre><p>This creates a signing certificate in your personal certificate store.</p><p>The important details here are:</p><ul><li>the subject must match the package publisher</li><li>the certificate must include the Code Signing EKU</li><li>the certificate must be usable for digital signatures</li><li>after creation, inspect it:</li></ul><pre>Get-ChildItem &quot;Cert:\CurrentUser\My&quot; |<br>  Where-Object { $_.FriendlyName -eq &quot;MyAwesomeMauiApp&quot; } |<br>  Format-Table Subject, Thumbprint, HasPrivateKey</pre><p>HasPrivateKey must be True.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/784/1*Mc0s3goaJWMPFPfcMP4dbw.png" /></figure><h3><strong>📤 Step 4: Export The Certificate Files You Need</strong></h3><p>The pipeline needs the private key for signing, and the test machines need the public certificate for trust.</p><p>Export the public certificate:</p><pre>Export-Certificate `<br>  -Cert &quot;Cert:\CurrentUser\My\$($cert.Thumbprint)&quot; `<br>  -FilePath &quot;.\MyAwesomeMauiApp-Test.cer&quot;</pre><p>Then export the .pfx for GitHub Actions:</p><pre>$password = ConvertTo-SecureString &quot;EinSicheresTestPasswort123!&quot; `<br>  -AsPlainText -Force<br><br>Export-PfxCertificate `<br>  -Cert &quot;Cert:\CurrentUser\My\$($cert.Thumbprint)&quot; `<br>  -FilePath &quot;.\MyAwesomeMauiApp-Test.pfx&quot; `<br>  -Password $password</pre><p>The .cer is safe to distribute to testers. The .pfx is not. Keep the .pfx private and move it only into GitHub Secrets.</p><h3><strong>🔄 Step 5: Convert The .pfx To Base64</strong></h3><p>GitHub Secrets store text, so convert the exported .pfx into a Base64 string.</p><pre>$base64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes<br>  (&quot;.\MyAwesomeMauiApp-Test.pfx&quot;))<br><br>$base64 | Set-Content `<br>  -Path .\MyAwesomeMauiApp-Test.pfx.base64.txt `<br>  -NoNewline<br><br>Get-Content .\MyAwesomeMauiApp-Test.pfx.base64.txt</pre><p>At the end of this step, you have a text representation of the .pfx that can be stored safely as a GitHub repository secret for this test-signing flow.</p><h3><strong>🕵️ Step 6: Add The Required GitHub Secrets</strong></h3><p>For the sample Windows workflow, add these repository secrets:</p><ul><li>PFX_BASE64: the full Base64 content of the exported .pfx</li><li>PFX_PASSWORD: the password used when exporting the .pfx</li></ul><p>This is the complete signing input set for the sample workflow.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oHl1CXUYwqOeHT1WviwrCA.png" /></figure><h3><strong>⚙️ Step 7: Create The GitHub Actions Workflow</strong></h3><p>Create a file named .github/workflows/windows-build.yml.</p><p>Use this workflow:</p><pre>name: Build .NET MAUI Windows App (.msix)<br><br>on:<br>  # Allow manual execution from the GitHub Actions UI<br>  workflow_dispatch:<br><br>jobs:<br>  build-windows:<br>    runs-on: windows-latest<br><br>    env:<br>      # Path to your .NET MAUI project file<br>      PROJECT_PATH: src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj<br><br>    steps:<br>      - name: Checkout repository<br>        uses: actions/checkout@v6<br><br>      - name: Setup .NET from global.json<br>        uses: actions/setup-dotnet@v5<br>        with:<br>          global-json-file: global.json<br><br>      - name: Install MAUI Workload<br>        run: dotnet workload install maui<br><br>      - name: Set version variables<br>        run: |<br>          $buildNumber = [int]&quot;${{ github.run_number }}&quot;<br>          $packageBuild = [math]::Floor($buildNumber / 65535)<br>          $packageRevision = $buildNumber % 65535<br><br>          echo &quot;VERSION_DISPLAY=1.0.$buildNumber&quot; | Out-File -FilePath $env:GITHUB_ENV -Append<br>          echo &quot;VERSION_CODE=$((10000 + $buildNumber))&quot; | Out-File -FilePath $env:GITHUB_ENV -Append<br>          echo &quot;WINDOWS_PACKAGE_VERSION=1.0.$packageBuild.$packageRevision&quot; | Out-File -FilePath $env:GITHUB_ENV -Append<br><br>      - name: Restore NuGet packages<br>        run: dotnet restore &quot;$env:PROJECT_PATH&quot;<br><br>      - name: Decode and import certificate<br>        run: |<br>          $certPath = &quot;$env:USERPROFILE\MyMauiAppCert.pfx&quot;<br>          [System.IO.File]::WriteAllBytes($certPath, [System.Convert]::FromBase64String(&quot;${{ secrets.PFX_BASE64 }}&quot;))<br>  <br>          $password = ConvertTo-SecureString &quot;${{ secrets.PFX_PASSWORD }}&quot; -AsPlainText -Force<br>          $cert = Import-PfxCertificate -FilePath $certPath -Password $password -CertStoreLocation Cert:\CurrentUser\My<br><br>          [xml]$manifest = Get-Content &quot;src/MyAwesomeMauiApp/Platforms/Windows/Package.appxmanifest&quot;<br>          $publisher = $manifest.Package.Identity.Publisher<br>          if ($cert.Subject -ne $publisher) {<br>            throw &quot;Imported certificate subject &#39;$($cert.Subject)&#39; does not match manifest publisher &#39;$publisher&#39;.&quot;<br>          }<br><br>          $publicCertPath = &quot;$env:RUNNER_TEMP\MyAwesomeMauiApp-Test.cer&quot;<br>          Export-Certificate `<br>            -Cert &quot;Cert:\CurrentUser\My\$($cert.Thumbprint)&quot; `<br>            -FilePath $publicCertPath | Out-Null<br>  <br>          echo &quot;CERT_THUMBPRINT=$($cert.Thumbprint)&quot; | Out-File -FilePath $env:GITHUB_ENV -Append<br>          echo &quot;PUBLIC_CERT_PATH=$publicCertPath&quot; | Out-File -FilePath $env:GITHUB_ENV -Append<br><br>      - name: Build and Sign Windows App (Release)<br>        run: |<br>          dotnet publish &quot;$env:PROJECT_PATH&quot; `<br>            -f net10.0-windows10.0.19041.0 `<br>            -c Release `<br>            -p:RuntimeIdentifierOverride=win-x64 `<br>            -p:WindowsPackageType=MSIX `<br>            -p:AppxPackage=true `<br>            -p:AppxPackageSigningEnabled=true `<br>            -p:ApplicationVersion=$env:VERSION_CODE `<br>            -p:ApplicationDisplayVersion=$env:VERSION_DISPLAY `<br>            -p:AppxPackageVersion=$env:WINDOWS_PACKAGE_VERSION `<br>            -p:PackageCertificateThumbprint=$env:CERT_THUMBPRINT<br><br>      - name: Collect distributable files<br>        run: |<br>          $destination = &quot;$(pwd)\output&quot;<br>          New-Item -ItemType Directory -Force -Path $destination | Out-Null<br>          $files = Get-ChildItem -Recurse -Include *.msix<br>          foreach ($file in $files) {<br>            Copy-Item $file.FullName -Destination $destination -Force<br>          }<br><br>          Copy-Item $env:PUBLIC_CERT_PATH -Destination (Join-Path $destination &quot;MyAwesomeMauiApp-Test.cer&quot;) -Force<br><br>          @&#39;<br>          Install files in this artifact:<br>          - MyAwesomeMauiApp_*.msix: the app package<br>          - Microsoft.WindowsAppRuntime.*.msix: Windows App Runtime dependency package<br>          - MyAwesomeMauiApp-Test.cer: public test certificate to trust on the target machine<br><br>          Recommended installation flow on a test machine:<br>          1. Import MyAwesomeMauiApp-Test.cer into LocalMachine\TrustedPeople.<br>          2. Install Microsoft.WindowsAppRuntime.*.msix if Windows App Runtime is not already present.<br>          3. Install MyAwesomeMauiApp_*.msix.<br><br>          Notes:<br>          - The Windows App Runtime package may be unnecessary on machines where the required runtime is already installed.<br>          &#39;@ | Set-Content -Path (Join-Path $destination &quot;INSTALL.txt&quot;)<br><br>      - name: Upload Windows test package<br>        uses: actions/upload-artifact@v6<br>        with:<br>          name: windows-build<br>          path: output</pre><h3><strong>🧠 Step 8: Understand What The Workflow Does</strong></h3><p>At a high level, the workflow breaks down into a small number of predictable phases.</p><ol><li>Check Out The Repository</li><li>Install The Correct .NET SDK</li><li>Install The MAUI Workload</li><li>Set Version Variables For Upgradable MSIX Builds</li><li>Reconstruct And Import The Signing Certificate</li><li>Publish The Signed Windows Package</li><li>Collect The Files Testers Actually Need</li><li>Upload The Artifact</li></ol><p>After the workflow completes, you can download one artifact from the workflow run and hand that package set to a tester.</p><h3><strong>📁 Step 9: What Is Inside The Artifact?</strong></h3><p>With the sample workflow above, the artifact should contain at least these files:</p><ul><li>MyAwesomeMauiApp_*.msix</li><li>Microsoft.WindowsAppRuntime.1.7.msix or a similar Windows App Runtime package</li><li>MyAwesomeMauiApp-Test.cer</li><li>INSTALL.txt</li></ul><p>That distribution model solves two practical problems:</p><ul><li>the tester gets the public certificate that is needed to trust the package</li><li>the tester also gets the dependency package that may be required before the app package installs</li></ul><p>The recommended install flow on a test machine is:</p><ol><li>import MyAwesomeMauiApp-Test.cer into LocalMachine\TrustedPeople</li><li>install Microsoft.WindowsAppRuntime.*.msix if that runtime is not already present</li><li>install MyAwesomeMauiApp_*.msix</li></ol><p>On some machines, importing into TrustedPeople is enough. If Windows still reports that the chain ends in an untrusted root, also import the same public certificate into LocalMachine\Root.</p><p>The Windows App Runtime package may be unnecessary on machines where the matching runtime is already installed. Including it in the artifact still makes the test package more self-contained and easier to hand to others.</p><h3><strong>▶️ Step 10: Run The Workflow</strong></h3><p>Push the workflow file to your repository, open the Actions tab in GitHub, and run the workflow manually.</p><p>Because the sample uses workflow_dispatch, this is a safe way to validate the Windows packaging and test-distribution flow before attaching it to push, tags, or release branches.</p><p>If the run succeeds, you should see a windows-build artifact containing the final package set.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jVlojzYXn0DE-D5snpyglQ.png" /></figure><h3><strong>🚧 Common Issues To Watch For</strong></h3><p>Even when the commands are correct, a few Windows-specific mistakes show up repeatedly.</p><p><strong>Publisher Does Not Match The Certificate Subject</strong></p><p>If Package.appxmanifest says CN=Sebastian Jensen but the certificate was created with CN=User Name, signing will fail or the package will be unusable.</p><p><strong>The CI Certificate And The Manifest Publisher Do Not Match</strong></p><p>If the Publisher value in Package.appxmanifest differs from the subject of the certificate loaded from PFX_BASE64, the workflow may build an unusable package or fail during signing.</p><p><strong>WindowsPackageType Is Still Effectively Unpackaged</strong></p><p>If you publish with AppxPackage=true but do not request a packaged Windows build, the publish step can fail with an “Improper project configuration” error.</p><p><strong>The Artifact Does Not Include The Public Certificate</strong></p><p>If the tester receives only the .msix file, installation can fail even though CI succeeded. For self-signed test distribution, the public .cer should travel with the package.</p><p><strong>The Target Machine Does Not Trust The Certificate Yet</strong></p><p>If the package is signed but Windows reports trust or chain errors, import the public certificate into the appropriate certificate store on the target machine.</p><p><strong>The Windows App Runtime Dependency Is Missing</strong></p><p>If the app package references the Windows App Runtime and the target machine does not already have it, the app package may not install cleanly until Microsoft.WindowsAppRuntime.*.msix is installed.</p><p><strong>A Test Certificate Is Being Treated Like A Production Certificate</strong></p><p>This guide uses a self-signed certificate for local testing and controlled local distribution only. If your goal is Store publishing or broader production distribution, you need the signing approach that matches that release channel instead of reusing this test certificate.</p><p><strong>Wrong Runtime Identifier Parameter</strong></p><p>For .NET 10 MAUI Windows publishing, use:</p><pre>-p:RuntimeIdentifierOverride=win-x64</pre><p>not:</p><pre>-p:RuntimeIdentifier=win-x64</pre><p><strong>Template Values Were Never Replaced</strong></p><p>Values such as maui-package-name-placeholder, $placeholer$, or CN=User Name are fine for a template, but they should not survive into a real Windows packaging setup.</p><p><strong>MSIX Version Does Not Increase Between Builds</strong></p><p>If the package identity version stays constant, Windows treats the next package as the same or older build and blocks in-place upgrades.</p><p>Set AppxPackageVersion during publish and derive it from a monotonic CI value such as github.run_number.</p><h3><strong>🏁 Final Thoughts</strong></h3><p>The tricky part of Windows CI is not the dotnet publish command itself.</p><p>It is getting the identity, certificate, artifact contents, and installation story aligned.</p><p>Once those pieces are consistent, the workflow becomes much more predictable:</p><ol><li>define a real package identity</li><li>create a matching test certificate</li><li>store the signing material in GitHub Secrets</li><li>build a signed package in GitHub Actions</li><li>distribute the artifact together with the public certificate and required dependency package</li></ol><p>That is the point where Windows packaging stops feeling mysterious and starts feeling mechanical.</p><p>Just keep the scope in mind: this certificate is for local testing and controlled local distribution only. When you move to Store or production distribution, treat signing as a separate release concern with its own certificate and trust requirements.</p><p>Y️ou can find a sample repository containing a simple .NET MAUI application on <a href="https://github.com/tsjdev-apps/github-actions-dotnet-maui">GitHub</a>.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9e1709a7e5dd" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/from-commit-to-msix-automating-net-maui-windows-builds-with-github-actions-9e1709a7e5dd">From Commit to MSIX: Automating .NET MAUI Windows Builds with GitHub Actions</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Commit to IPA: Automating .NET MAUI iOS Builds with GitHub Actions]]></title>
            <link>https://medium.com/medialesson/from-commit-to-ipa-automating-net-maui-ios-builds-with-github-actions-fd8d9d47240b?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/fd8d9d47240b</guid>
            <category><![CDATA[ci-cd-pipeline]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[mobile-development]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Thu, 26 Mar 2026 17:54:26 GMT</pubDate>
            <atom:updated>2026-03-26T17:54:26.626Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A practical step-by-step guide of building and signing a .NET MAUI iOS app in CI once the Apple-side setup is already done.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Xm2mqglKKq-TqMni-DV2dg.png" /></figure><p>The hard part of iOS automation is usually not writing YAML.</p><p>It is getting the Apple-side prerequisites into a shape that CI can actually use: a valid .p12, the correct bundle identifier, an App Store Connect API key, and a provisioning profile that already exists in Apple Developer.</p><p>Once that preparation is done, the GitHub Actions part becomes much more predictable.</p><p>In this post, I will focus only on the GitHub Actions workflow and the repository secrets required to build a signed .ipa. The goal is simple:</p><ul><li>add the required iOS secrets to GitHub</li><li>understand what each secret is used for</li><li>run the iOS GitHub Actions workflow</li><li>build and sign an .ipa file</li><li>upload the final package as a workflow artifact</li></ul><p>This guide is based on a .NET 10 MAUI project and a macOS-based GitHub Actions runner.</p><p>If you still need to create the certificate, identifier, or provisioning profile first, start here: <a href="https://medium.com/medialesson/before-the-ipa-preparing-ios-signing-6e7861fcd84f"><strong>Preparation Post</strong></a></p><h3>🤖 Why Build iOS In GitHub Actions?</h3><p>For iOS, CI gives you a few immediate benefits:</p><ul><li>every build uses the same .NET SDK and Xcode version</li><li>signing is handled in a repeatable way instead of on one configured Mac</li><li>provisioning profiles can be downloaded automatically during the run</li><li>the final .ipa is attached to the workflow run</li><li>the release process becomes auditable and reproducible</li></ul><p>For .NET MAUI specifically, this matters because local Apple tooling differences are one of the easiest ways to get inconsistent iOS build results.</p><h3>🧰 What You Need Before You Start</h3><p>This post assumes these Apple-side prerequisites are already complete:</p><ul><li>A .NET MAUI app targeting net10.0-ios</li><li>A GitHub repository containing the app</li><li>A root global.json file pinned to the required .NET SDK</li><li>An Apple Distribution certificate exported as .p12</li><li>matching App ID / Bundle ID in Apple Developer</li><li>A valid App Store provisioning profile in Apple Developer</li><li>An App Store Connect API key</li></ul><p>In the sample repository, the MAUI project file is:</p><pre>src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj</pre><p>The repository also pins the SDK with:</p><pre>{<br>  &quot;sdk&quot;: {<br>    &quot;version&quot;: &quot;10.0.200&quot;,<br>    &quot;rollForward&quot;: &quot;latestFeature&quot;<br>  }<br>}</pre><h3>Step 0: Convert The .p12 For GitHub Secrets 🔄</h3><p>GitHub Secrets store text, not binary files, so the exported .p12 has to be converted to Base64.</p><pre>base64 -i ios_distribution.p12 | tr -d &#39;\n&#39; &gt; ios.p12.base64.txt</pre><p>The .p8 API key is already text, so that one does not need Base64 encoding. You store its full content directly as a GitHub secret later.</p><h3>🕵️ Step 1: Add The Required GitHub Secrets</h3><p>Before the workflow can run, the signing assets and App Store Connect credentials have to be available as repository secrets.</p><p>Add these secrets in your GitHub repository:</p><ul><li>IOS_P12_BASE64: the full Base64 content of your exported .p12 file</li><li>IOS_P12_PASSWORD: the password used when exporting the .p12</li><li>IOS_BUNDLE_ID: your bundle identifier, for example com.companyname.myawesomemauiapp</li><li>APP_STORE_CONNECT_ISSUER_ID: the issuer ID from App Store Connect</li><li>APP_STORE_CONNECT_KEY_ID: the key ID from App Store Connect</li><li>APP_STORE_CONNECT_PRIVATE_KEY: the full content of the downloaded .p8 API key file</li></ul><p>These six secrets are the complete input set for the sample iOS workflow in this repository.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TD3uXPDm3jhu066FUmQCiQ.png" /></figure><h3>🔍 Step 2: Understand What Each Secret Does</h3><p>It helps to be very explicit about why each secret exists.</p><h4>IOS_P12_BASE64</h4><p>This is the portable version of your Apple Distribution certificate and private key. The workflow uses it to reconstruct and import the signing identity into a temporary keychain on the macOS runner.</p><h4>IOS_P12_PASSWORD</h4><p>This is the export password for the .p12 file. Without it, the import action cannot unlock the certificate package.</p><h4>IOS_BUNDLE_ID</h4><p>This is the application identifier used during publish and profile download. It must match the App ID and provisioning profile you created in Apple Developer.</p><h4>APP_STORE_CONNECT_ISSUER_ID</h4><p>This identifies the App Store Connect API key issuer. The workflow needs it to authenticate against Apple when requesting provisioning profiles.</p><h4>APP_STORE_CONNECT_KEY_ID</h4><p>This is the identifier for the API key itself. Together with the issuer ID and private key, it lets the workflow call App Store Connect securely.</p><h4>APP_STORE_CONNECT_PRIVATE_KEY</h4><p>This is the full text content of the .p8 API key file. It is not Base64 encoded in this setup. You store the exact file content as the secret value.</p><h3>⚙️ Step 3: Create The GitHub Actions Workflow</h3><p>Create a file named .github/workflows/ios-build.yml.</p><p>Use this workflow:</p><pre>name: Build .NET MAUI iOS App (.ipa)<br><br>on:<br>  workflow_dispatch:<br><br>jobs:<br>  build-ios:<br>    runs-on: macos-26<br><br>    env:<br>      PROJECT_PATH: src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj<br><br>    steps:<br>      - name: Checkout repository<br>        uses: actions/checkout@v6<br><br>      - name: Setup .NET from global.json<br>        uses: actions/setup-dotnet@v5<br>        with:<br>          global-json-file: global.json<br><br>      - name: Select Xcode 26.2<br>        run: |<br>          sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer<br>          xcode-select -p<br><br>      - name: Install MAUI Workload<br>        run: dotnet workload install maui<br><br>      - name: Import P12 File<br>        uses: apple-actions/import-codesign-certs@v6<br>        with:<br>          p12-file-base64: ${{ secrets.IOS_P12_BASE64 }}<br>          p12-password: ${{ secrets.IOS_P12_PASSWORD }}<br>          keychain: build<br>          create-keychain: true<br>          keychain-password: buildpass<br><br>      - name: Activate build.keychain<br>        run: |<br>          security list-keychains -s ~/Library/Keychains/build.keychain-db<br>          security default-keychain -s ~/Library/Keychains/build.keychain-db<br>          security unlock-keychain -p buildpass ~/Library/Keychains/build.keychain-db<br><br>      - name: Extract signing identity<br>        run: |<br>          identity=$(security find-identity -v -p codesigning | grep &quot;Apple Distribution&quot; | head -n 1 | awk -F\&quot; &#39;{print $2}&#39;)<br>          echo &quot;CODE_SIGN_IDENTITY=$identity&quot; &gt;&gt; $GITHUB_ENV<br><br>      - name: Download Provisioning Profiles<br>        uses: apple-actions/download-provisioning-profiles@v5<br>        with:<br>          bundle-id: ${{ secrets.IOS_BUNDLE_ID }}<br>          profile-type: IOS_APP_STORE<br>          issuer-id: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}<br>          api-key-id: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}<br>          api-private-key: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}<br><br>      - name: Set version variables<br>        run: |<br>          buildNumber=&quot;${{ github.run_number }}&quot;<br>          echo &quot;VERSION_DISPLAY=1.0.$buildNumber&quot; &gt;&gt; $GITHUB_ENV<br>          echo &quot;VERSION_CODE=$((10000 + buildNumber))&quot; &gt;&gt; $GITHUB_ENV<br><br>      - name: Restore packages<br>        run: dotnet restore &quot;$PROJECT_PATH&quot;<br><br>      - name: Publish iOS app<br>        run: |<br>          dotnet publish &quot;$PROJECT_PATH&quot; -f net10.0-ios -c Release \<br>            -p:ApplicationVersion=&quot;$VERSION_CODE&quot; \<br>            -p:ApplicationDisplayVersion=&quot;$VERSION_DISPLAY&quot; \<br>            -p:ApplicationId=&quot;${{ secrets.IOS_BUNDLE_ID }}&quot; \<br>            -p:CodesignKey=&quot;$CODE_SIGN_IDENTITY&quot;<br><br>      - name: Copy .ipa to output<br>        run: |<br>          mkdir -p output<br>          find . -name &quot;*.ipa&quot; -exec cp {} output/ \;<br><br>      - name: Upload final IPA only<br>        uses: actions/upload-artifact@v6<br>        with:<br>          name: ios-build<br>          path: output</pre><h3>🧠 Step 4: Understand What The Workflow Does</h3><p>At a high level, the workflow breaks down into a few predictable phases.</p><ol><li>Check Out The Repository</li><li>Install The Correct .NET SDK</li><li>Select The Supported Xcode Version</li><li>Install The MAUI Workload</li><li>Import The Certificate From Secrets</li><li>Activate The Build Keychain</li><li>Resolve The Signing Identity Automatically</li><li>Download The Provisioning Profile From Apple</li><li>Generate Version Numbers From The Pipeline Run</li><li>Publish The iOS App</li><li>Copy And Upload The Final .ipa</li></ol><h3>▶️ Step 5: Run The Workflow</h3><p>Push the workflow file to your repository, open the Actions tab in GitHub, and run the workflow manually.</p><p>Because the sample uses workflow_dispatch, this is a safe way to validate the pipeline before attaching it to push, tags, or release branches.</p><p>If everything is configured correctly, the workflow run produces an ios-buid artifact containing the final signed .ipa.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uGrQdyERnhILDOXdRJx5pQ.png" /></figure><h3>🚧 Common Issues To Watch For</h3><p>Even when the YAML is correct, a few secret-related mistakes show up frequently.</p><h4>Wrong IOS_BUNDLE_ID</h4><p>If the bundle identifier does not match the App ID and provisioning profile in Apple Developer, signing will fail.</p><h4>Broken IOS_P12_BASE64 Value</h4><p>If the Base64 content is truncated or copied incorrectly, certificate import will fail.</p><h4>Wrong IOS_P12_PASSWORD</h4><p>If the export password does not match the .p12, the certificate import action cannot unlock it.</p><h4>Incorrect App Store Connect Values</h4><p>If the issuer ID, key ID, or .p8 content is wrong, the provisioning profile download step fails.</p><h4>Xcode Version Drift</h4><p>If the selected Xcode version is not supported by the installed .NET MAUI workload, publishing can fail even when signing assets are correct.</p><h3>🏁 Final Thoughts</h3><p>Once the Apple-side prerequisites exist, the iOS GitHub Actions workflow is mostly about supplying the right secrets to a predictable build process.</p><p>That is what makes this setup useful.</p><p>The secrets stay in GitHub, the provisioning profile stays in Apple Developer, and the workflow reconstructs only what it needs at runtime to produce a signed .ipa.</p><p>If you standardize those inputs, iOS releases become much less of a hand-crafted process and much more of a repeatable pipeline.</p><p>Y️ou can find a sample repository containing a simple .NET MAUI application on <a href="https://github.com/tsjdev-apps/github-actions-dotnet-maui">GitHub</a>.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><p>️</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fd8d9d47240b" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/from-commit-to-ipa-automating-net-maui-ios-builds-with-github-actions-fd8d9d47240b">From Commit to IPA: Automating .NET MAUI iOS Builds with GitHub Actions</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Before The IPA: Preparing iOS Signing]]></title>
            <link>https://medium.com/medialesson/before-the-ipa-preparing-ios-signing-6e7861fcd84f?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/6e7861fcd84f</guid>
            <category><![CDATA[ios-development]]></category>
            <category><![CDATA[cicd]]></category>
            <category><![CDATA[xcode]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[code-signing]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Wed, 25 Mar 2026 13:32:25 GMT</pubDate>
            <atom:updated>2026-03-25T13:32:25.054Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A practical guide of creating the Apple certificates, identifier, and provisioning profile you need before automating an iOS build.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zhJYhiiE-EA2Sh1UwHzpfQ.png" /></figure><p>If Android CI usually starts with a keystore, iOS CI starts much earlier. Before GitHub Actions can build a valid .ipa, Apple signing has to be prepared correctly. That means your app identifier has to exist, your distribution certificate has to be created and exported, your provisioning profile has to match, and your App Store Connect access has to be ready for automation.</p><p>This is the part that often feels confusing because several Apple systems are involved at the same time. The good news is that the setup is very manageable once you separate it into a few concrete pieces.</p><p>In this post, I will walk through the Apple-side preparation for an iOS build. The goal is simple:</p><ul><li>understand which signing assets are required</li><li>create the app identifier</li><li>generate the certificate request</li><li>create the Apple Distribution certificate</li><li>export the certificate as .p12</li><li>create the provisioning profile</li><li>create the App Store Connect API key for CI</li></ul><h3>🤖 Why iOS Preparation Matters</h3><p>For iOS, the CI workflow is only as reliable as the signing setup behind it.</p><p>If the certificate, identifier, or provisioning profile do not match, the pipeline does not fail randomly. It fails for a very specific reason that traces back to missing preparation.</p><p>Getting this right upfront gives you a few immediate benefits:</p><ul><li>your bundle identifier is defined once and used consistently</li><li>your signing certificate is portable to CI through a .p12 export</li><li>your provisioning profile matches the exact app you intend to ship</li><li>your GitHub Actions workflow can download the right profile automatically</li><li>future release builds stop depending on one specific Mac being configured correctly</li></ul><h3>🧰 What You Need Before You Start</h3><p>Before you begin, make sure you have:</p><ul><li>An Apple Developer membership</li><li>Access to App Store Connect</li><li>A Mac, or access to one, for Keychain Access and certificate export</li><li>A planned bundle identifier such as com.companyname.myawesomemauiapp</li></ul><h3>🧩 The iOS Signing Pieces In Plain Terms</h3><p>Before creating anything, it helps to map the Apple terminology to concrete files and decisions.</p><ul><li><strong>Bundle ID / App ID</strong>: the unique identifier for your app, for example com.companyname.myawesomemauiapp</li><li><strong>Certificate Signing Request (CSR)</strong>: the request file used to ask Apple for a signing certificate</li><li><strong>Apple Distribution certificate</strong>: the certificate used to sign release builds for distribution</li><li><strong>.p12 file</strong>: an export that contains the certificate and its private key for import into CI</li><li><strong>Provisioning profile</strong>: a file that ties your App ID, distribution method, and certificate together</li><li><strong>App Store Connect API key</strong>: the credential GitHub Actions can use to download provisioning profiles automatically</li></ul><p>The important part is not memorizing the names. The important part is that all of these assets point to the same app identity.</p><h3>Step 1: Confirm The MAUI Application ID 🎯</h3><p>Before creating anything in Apple Developer, confirm the identifier your app is supposed to use.</p><p>You can keep this value in the project file or override it during CI, but whichever route you choose, the Apple identifier and provisioning profile must use the same value.</p><p>If you change the bundle ID later, you will also need to update the Apple identifier and the provisioning profile.</p><h3>Step 2: Create The Identifier In Apple Developer 📱</h3><p>Now create the App ID that represents your app in <a href="https://developer.apple.com/">Apple Developer</a>.</p><p>In <a href="https://developer.apple.com/">Apple Developer</a>:</p><ul><li>Open <strong>Certificates, Identifiers &amp; Profiles</strong></li><li>Go to <strong>Identifiers</strong></li><li>Select <strong>Register an App ID</strong></li><li>Choose <strong>App</strong></li><li>Enter a description, for example MyAwesomeMauiApp</li><li>Enter the bundle ID, for example com.companyname.myawesomemauiapp</li><li>7. Enable any capabilities your app needs</li><li>8. Save the identifier</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ilET-5DixU3Ufk4uiZMoSg.png" /></figure><p>If your app uses capabilities such as push notifications, associated domains, sign in with Apple, or keychain sharing, enable them here. The provisioning profile created later inherits from this identifier configuration.</p><p>If your app does not need extra capabilities yet, keep the setup simple and add them later only when the feature exists. Adding capabilities will not change your identifier.</p><h3>Step 3: Create A Certificate Signing Request 📝</h3><p>To create the Apple Distribution certificate, Apple needs a Certificate Signing Request, called CSR.</p><p>The most common approach is through Keychain Access on macOS.</p><ul><li>Open <strong>Keychain Access</strong></li><li>Select <strong>Keychain Access &gt; Certificate Assistant &gt; Request a Certificate From a Certificate Authority</strong></li><li>Enter your email address</li><li>Enter your name</li><li>Choose <strong>Saved to disk</strong></li><li>Save the generated .csr or .certSigningRequest file</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/728/1*IzKlwZzttq0zHHOzwwg84w.png" /></figure><p>This creates the request file Apple uses to issue the certificate and also creates the private key on your Mac.</p><h3>Step 4: Create The Apple Distribution Certificate 🍎</h3><p>With the CSR ready, create the release signing certificate.</p><p>In Apple Developer:</p><ul><li>Open <strong>Certificates, Identifiers &amp; Profiles</strong></li><li>Go to <strong>Certificates</strong></li><li>Select <strong>Add</strong></li><li>Choose <strong>Apple Distribution</strong></li><li>Upload the CSR from the previous step</li><li>Download the generated certificate file, typically .cer</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uY1-VGZI6H91mYSDyXZFWA.png" /></figure><p>This certificate is the release signing identity you need for App Store or distribution builds.</p><p>The downloaded .cer file by itself is not what GitHub Actions will use directly. CI needs a .p12 export that includes both the certificate and its private key.</p><h3>Step 5: Export The Certificate As .p12 🔐</h3><p>Now convert the certificate into a portable format that CI can import.</p><p>On your Mac:</p><ul><li>Double-click the downloaded .cer file to import it into Keychain Access</li><li>Verify the certificate appears together with its private key under <strong>My Certificates</strong></li><li>Right-click the certificate entry</li><li>Choose <strong>Export</strong></li><li>Export it as ios_distribution.p12</li><li>Set a strong export password</li></ul><p>The .p12 file is the artifact your GitHub Actions workflow will import into a temporary keychain.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/989/1*NmPzxvnv2ofqKPGXdvHG6w.png" /></figure><p>If the private key does not appear together with the certificate, the CSR and certificate were not created on the same key material, and the export will not work correctly. That is one of the most common iOS signing mistakes.</p><h3>Step 6: Create The Provisioning Profile 📦</h3><p>With the identifier and certificate in place, create the provisioning profile that binds them together.</p><p>In <a href="https://developer.apple.com/">Apple Developer</a>:</p><ul><li>Open <strong>Certificates, Identifiers &amp; Profiles</strong></li><li>Go to <strong>Profiles</strong></li><li>Select <strong>Add</strong></li><li>Choose the distribution profile type that matches your release target</li><li>Select the App ID you created earlier</li><li>Select the Apple Distribution certificate</li><li>Name the profile clearly, for example MyAwesomeMauiApp App Store</li><li>Generate and save the profile</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SxVro6QkLsxiRH49WZsDTQ.png" /></figure><h3>Step 7: Create An App Store Connect API Key 🔑</h3><p>Our workflow later downloads provisioning profiles automatically instead of storing them in the repository. For that, GitHub Actions needs an App Store Connect API key.</p><p>In <a href="https://appstoreconnect.apple.com/login">App Store Connect</a>:</p><ul><li>Open <strong>Users and Access</strong></li><li>Go to <strong>Integrations</strong></li><li>Open <strong>App Store Connect API</strong></li><li>Create a new API key</li><li>Give it a clear name</li><li>Choose the appropriate access level</li><li>Download the .p8 key file</li><li>Record the <strong>Key ID</strong></li><li>Record the <strong>Issuer ID</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3co6IaqKatseQVljs1BABw.png" /></figure><p>The .p8 file is only downloadable once, so store it securely.</p><p>This key is not used to sign the app. It is used to authenticate the GitHub Action that fetches the provisioning profile from Apple.</p><h3>Step 9: Verify Everything Matches ✅</h3><p>Before moving to the GitHub Actions workflow, verify the following alignment:</p><ul><li>The Apple identifier bundle ID matches your application</li><li>The provisioning profile was created for that same identifier</li><li>The provisioning profile includes the Apple Distribution certificate</li><li>The .p12 export comes from the same certificate and private key pair</li><li>The App Store Connect API key has enough permission to download profiles</li></ul><p>If these pieces are aligned, the CI workflow becomes much simpler because it is mostly importing assets and calling dotnet publish.</p><h3>Final Thoughts 🏁</h3><p>The hardest part of iOS CI is usually not the YAML file. It is preparing the Apple-side assets correctly.</p><p>Once the identifier, distribution certificate, .p12 export, provisioning profile, and App Store Connect API key are in place, the rest of the automation becomes much more mechanical.</p><p>That is the real milestone.</p><p>You move from <em>“can one configured Mac produce a release build?”</em> to <em>“can the pipeline produce the same signed output every time?”</em></p><p>The next step is to wire these assets into GitHub repository secrets and let GitHub Actions build the .ipa. So keep an eye on my Medium account to see how we can setup a complete GitHub Action to create the .ipa file using the files and data created in this blog post.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6e7861fcd84f" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/before-the-ipa-preparing-ios-signing-6e7861fcd84f">Before The IPA: Preparing iOS Signing</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Commit to AAB/APK: Automating .NET MAUI Android Builds with GitHub Actions]]></title>
            <link>https://medium.com/medialesson/from-commit-to-aab-apk-automating-net-maui-android-builds-with-github-actions-bc30468b2d08?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/bc30468b2d08</guid>
            <category><![CDATA[automation]]></category>
            <category><![CDATA[mobile-development]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[ci-cd-pipeline]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Fri, 20 Mar 2026 09:18:34 GMT</pubDate>
            <atom:updated>2026-03-20T09:18:34.062Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A practical step-by-step guide to building, signing, and publishing Android artifacts in CI.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-K3ahv7NkNsRWKgrTyMPGg.png" /></figure><p>Shipping an Android app is easy to postpone and surprisingly hard to standardize. A local build works on one machine, someone manually signs an .aab, someone else exports an .apk, and six weeks later nobody remembers which password, keystore, or command produced the store-ready package. That is exactly where GitHub Actions helps.</p><p>In this post, I will walk through a practical setup for a .NET MAUI Android app that produces both .aab and .apk artifacts in CI. The goal is simple:</p><ul><li>build a .NET MAUI Android app in GitHub Actions</li><li>sign it with an Android keystore</li><li>generate both .aab and .apk</li><li>upload the outputs as downloadable workflow artifacts</li></ul><p>This guide is based on a .NET 10 MAUI project and a Windows-based GitHub Actions runner.</p><h3><strong>🤖 Why Build Android In GitHub Actions?</strong></h3><p>For Android, CI gives you a few immediate benefits:</p><ul><li>every build uses the same SDK and workload versions</li><li>signing is automated and repeatable</li><li>artifacts are stored with the workflow run</li><li>version numbers can be derived automatically from the pipeline run</li><li>you reduce the number of local machine differences that break release builds</li></ul><p>For .NET MAUI specifically, Android builds are a good first platform to automate because the workflow is straightforward compared to iOS signing.</p><h3><strong>🧰 What You Need Before You Start?</strong></h3><p>Before creating the workflow, make sure you have these pieces ready:</p><ul><li>A .NET MAUI app targeting net10.0-android</li><li>A GitHub repository containing the app</li><li>A .NET SDK pinned with global.json</li><li>An Android keystore for signing</li><li>GitHub repository secrets for the keystore and passwords</li></ul><p>In the <a href="https://github.com/tsjdev-apps/github-actions-dotnet-maui">sample repository</a>, the Android project file is:</p><pre>src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj</pre><h3><strong>🎯 Step 1: Target .NET 10 In Your MAUI Project</strong></h3><p>Your MAUI project should explicitly target Android on .NET 10.</p><p>Example:</p><pre>&lt;TargetFrameworks&gt;net10.0-android&lt;/TargetFrameworks&gt;</pre><p>If your project is still targeting net9.0-android, update it first. The workflow should match the framework your project actually targets.</p><h3><strong>📌 Step 2: Pin The SDK With global.json</strong></h3><p>Your CI pipeline should not depend on whichever SDK version happens to be installed on the runner image that day.</p><p>Create a global.json file in the repository root:</p><pre>{<br>  &quot;sdk&quot;: {<br>    &quot;version&quot;: &quot;10.0.200&quot;,<br>    &quot;rollForward&quot;: &quot;latestFeature&quot;<br>  }<br>}</pre><p>This does two things:</p><ul><li>it keeps local builds and CI builds aligned</li><li>it makes SDK upgrades explicit instead of accidental</li></ul><p>In GitHub Actions, actions/setup-dotnet can read this file directly.</p><h3>🔐<strong> Step 3: Create An Android Keystore</strong></h3><p>To produce signed Android packages, you need a keystore. If you already have one for your app, use that. If you are setting this up for the first time, create one with keytool.</p><p>Example command:</p><pre>keytool -genkeypair \<br>  -v \<br>  -storetype PKCS12 \<br>  -keystore myapp.keystore \<br>  -alias myapp \<br>  -keyalg RSA \<br>  -keysize 2048 \<br>  -validity 10000</pre><p>What each parameter means:</p><ul><li>-genkeypair: creates a new key pair and stores it in the keystore</li><li>-v: prints verbose output</li><li>-storetype PKCS12: uses the PKCS#12 keystore format</li><li>-keystore myapp.keystore: writes the keystore to myapp.keystore</li><li>-alias myapp: names the key entry inside the keystore</li><li>-keyalg RSA: uses RSA as the key algorithm</li><li>-keysize 2048: generates a 2048-bit key</li><li>-validity 10000: makes the certificate valid for 10,000 days</li></ul><p><strong>Why </strong><strong>10000?</strong></p><p>Because Android app signing certificates should remain valid for a very long time. 10000 days is a little over 27 years. That is a common practical choice because an expired signing certificate can complicate long-term app updates.</p><p>If you want a non-interactive version, use this:</p><pre>keytool -genkeypair \<br>  -v \<br>  -storetype PKCS12 \<br>  -keystore myapp.keystore \<br>  -alias myapp \<br>  -keyalg RSA \<br>  -keysize 2048 \<br>  -validity 10000 \<br>  -storepass &quot;YOUR_KEYSTORE_PASSWORD&quot; \<br>  -keypass &quot;YOUR_KEY_PASSWORD&quot; \<br>  -dname &quot;CN=Name, OU=Mobile, O=Company, L=City, S=State, C=US&quot;</pre><p>Here you can see the command in action:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9KssfqhhdrjhWLQPRiUFbA.png" /></figure><h3>🔄<strong> Step 4: Convert The Keystore To Base64</strong></h3><p>GitHub Secrets store text, not raw binary files. That means your .keystore file needs to be converted to a Base64 string before you save it as a secret.</p><p><strong>PowerShell</strong></p><pre>$base64 = <br>  [Convert]::ToBase64String([IO.File]::ReadAllBytes(&quot;.\myapp.keystore&quot;))<br>$base64 | Set-Content -Path .\myapp.keystore.base64.txt -NoNewline<br>Get-Content .\myapp.keystore.base64.txt</pre><p><strong>Bash</strong></p><pre>base64 -w 0 myapp.keystore &gt; myapp.keystore.base64.txt<br>cat myapp.keystore.base64.txt</pre><p>At the end of this step, you have a text representation of the keystore that can safely live in a GitHub secret.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b-fEsUtAnMBUI2Uh_ZUJUA.png" /></figure><h3>🕵️<strong> Step 5: Add The Required GitHub Secrets</strong></h3><p>In your <a href="https://github.com/tsjdev-apps/github-actions-dotnet-maui">GitHub repository</a>, add these secrets:</p><ul><li>ANDROID_KEYSTORE: the full Base64 content of the keystore</li><li>ANDROID_KEY_ALIAS: the alias inside the keystore, for example myapp</li><li>ANDROID_KEY_PASSWORD: the password for the key entry</li><li>ANDROID_KEYSTORE_PASSWORD: the password for the keystore itself</li></ul><p>This is the minimum signing data required by the Android workflow.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/911/1*_6j4We3Rva6MHWbCY6cGgA.png" /></figure><h3>️⚙️<strong>Step 6: Create The GitHub Actions Workflow</strong></h3><p>Create a file named .github/workflows/android-build.yml.</p><p>Use this workflow:</p><pre>name: Build .NET MAUI Android App (.aab, .apk)<br><br>on:<br>  workflow_dispatch:<br><br>jobs:<br>  build-android:<br>    runs-on: windows-latest<br><br>    env:<br>      PROJECT_PATH: src/MyAwesomeMauiApp/MyAwesomeMauiApp.csproj<br><br>    steps:<br>      - name: Checkout repository<br>        uses: actions/checkout@v4<br><br>      - name: Setup .NET from global.json<br>        uses: actions/setup-dotnet@v4<br>        with:<br>          global-json-file: global.json<br><br>      - name: Install MAUI Workload<br>        run: dotnet workload install maui<br><br>      - name: Decode Keystore<br>        run: |<br>          echo &quot;${{ secrets.ANDROID_KEYSTORE }}&quot; | Out-File -Encoding ascii -FilePath myapp.keystore.b64<br>          certutil -decode myapp.keystore.b64 myapp.keystore<br><br>      - name: Set version variables<br>        run: |<br>          $buildNumber = &quot;${{ github.run_number }}&quot;<br>          echo &quot;VERSION_DISPLAY=1.0.$buildNumber&quot; &gt;&gt; $env:GITHUB_ENV<br>          echo &quot;VERSION_CODE=$((10000 + $buildNumber))&quot; &gt;&gt; $env:GITHUB_ENV<br><br>      - name: Restore NuGet packages<br>        run: dotnet restore &quot;$env:PROJECT_PATH&quot;<br><br>      - name: Build (Release)<br>        run: |<br>          dotnet publish &quot;$env:PROJECT_PATH&quot; -f net10.0-android -c Release `<br>            -p:ApplicationVersion=&quot;$env:VERSION_CODE&quot; `<br>            -p:ApplicationDisplayVersion=&quot;$env:VERSION_DISPLAY&quot; `<br>            -p:AndroidSigningKeyAlias=&quot;${{ secrets.ANDROID_KEY_ALIAS }}&quot; `<br>            -p:AndroidSigningKeyPass=&quot;${{ secrets.ANDROID_KEY_PASSWORD }}&quot; `<br>            -p:AndroidSigningStorePass=&quot;${{ secrets.ANDROID_KEYSTORE_PASSWORD }}&quot; `<br>            -p:AndroidSigningStore=myapp.keystore<br><br>      - name: Copy .aab and .apk to output<br>        run: |<br>          $destination = &quot;$(pwd)\output&quot;<br>          mkdir $destination<br>          $files = Get-ChildItem -Recurse -Include *.aab,*.apk<br>          foreach ($file in $files) {<br>            Copy-Item $file.FullName -Destination $destination -Force<br>          }<br><br>      - name: Upload final AAB and APK only<br>        uses: actions/upload-artifact@v4<br>        with:<br>          name: android-build<br>          path: output</pre><h3><strong>🧠 Step 7: Understand What The Workflow Does</strong></h3><p>At a high level, the workflow breaks down into a small number of important phases.</p><ol><li>Check out the repository</li><li>Install the correct .NET SDK</li><li>Install the MAUI workload️</li><li>Reconstruct the keystore from GitHub Secrets</li><li>Generate version numbers from the pipeline run</li><li>Publish the Android app</li><li>Collect the artifacts</li><li>Upload the build outputs</li></ol><p>After the workflow completes, you can download the generated .aab and .apk directly from the GitHub Actions run.</p><h3><strong>➡️ Step 8: Run The Workflow</strong></h3><p>Push the workflow file to your repository, open the Actions tab in GitHub, and run the workflow manually.</p><p>Because the sample uses workflow_dispatch, this is a safe way to validate the pipeline before you attach it to push, pull_request, tags, or releases.</p><p>If the run succeeds, you should see an android-build artifact containing the final .aab and .apk files.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0VUueBJvEIRxJjTkpr5CCw.png" /></figure><h3><strong>🚧 Common Issues To Watch For</strong></h3><p>Even a straightforward Android pipeline can fail in a few predictable ways.</p><h4><strong>Wrong Project Path</strong></h4><p>If PROJECT_PATH points to the wrong .csproj, restore or publish will fail immediately.</p><h4><strong>Incorrect Secret Values</strong></h4><p>If the keystore password, key password, or alias do not match the actual keystore, signing will fail during publish.</p><h4><strong>Missing MAUI Workload</strong></h4><p>If dotnet workload install maui is missing, the runner will not be able to publish the MAUI Android app correctly.</p><h4><strong>SDK Drift</strong></h4><p>If you do not pin the SDK through global.json, a runner image update can change your build behavior unexpectedly.</p><h3>📱<strong>AAB Vs APK: Why Generate Both?</strong></h3><p>These outputs serve different use cases:</p><ul><li>.aab: the preferred format for Google Play publishing</li><li>.apk: useful for direct installation, internal testing, and quick validation on devices</li></ul><p>Generating both in CI gives you store-ready output and test-ready output from the same pipeline run.</p><h3><strong>🏁 Final Thoughts</strong></h3><p>The Android part of a .NET MAUI release pipeline does not need to be fragile. Once you standardize the keystore handling, GitHub secrets, SDK versioning, and dotnet publish command, the process becomes predictable and repeatable.</p><p>That is the real win. Not just automation, but confidence that every Android release is built the same way every time.</p><p>Y️ou can find a sample repository containing a simple .NET MAUI application on <a href="https://github.com/tsjdev-apps/github-actions-dotnet-maui">GitHub</a>.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bc30468b2d08" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/from-commit-to-aab-apk-automating-net-maui-android-builds-with-github-actions-bc30468b2d08">From Commit to AAB/APK: Automating .NET MAUI Android Builds with GitHub Actions</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Multi-Model Image Generation Console App with Azure OpenAI and OpenAI]]></title>
            <link>https://medium.com/medialesson/building-a-multi-model-image-generation-console-app-with-azure-openai-and-openai-fa5b9b790e7e?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/fa5b9b790e7e</guid>
            <category><![CDATA[ai-image-generator]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[azure]]></category>
            <category><![CDATA[image-generation]]></category>
            <category><![CDATA[openai]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Wed, 17 Dec 2025 13:41:54 GMT</pubDate>
            <atom:updated>2025-12-17T13:41:54.307Z</atom:updated>
            <content:encoded><![CDATA[<h4>Learn how to build a flexible .NET 10 console app supporting multiple AI image generation models with a rich terminal UI.</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w_jkXKZ-NpGXFr1HACiLfQ.png" /></figure><p>The world of AI-powered image generation is evolving rapidly. With OpenAI announcing the deprecation of <strong>DALL·E 3</strong> and the release of newer, more capable models like <strong>gpt-image-1</strong>, <strong>gpt-image-1-mini</strong>, and the newly introduced <strong>gpt-image-1.5</strong>, now is the perfect time to build a flexible and future-proof image generation solution.</p><p>In this blog post, I’ll walk you through building an interactive console application using <strong>.NET 10</strong> that seamlessly integrates with both <strong>Azure OpenAI</strong> and <strong>OpenAI</strong> services. The application supports multiple image generation models — including <strong>gpt-image-1.5</strong> — and allows customizable configurations so you can easily switch between providers and models as the ecosystem continues to evolve.</p><h3>🚨 Important: DALL-E 3 Deprecation</h3><p>Before we dive in, it’s crucial to understand that <strong>DALL-E 3 is now part of OpenAI’s deprecated models list</strong>. According to OpenAI’s deprecation timeline, DALL-E 3 will be phased out in the near future, with the recommended migration path being:</p><ul><li><strong>gpt-image-1</strong>: The successor to DALL-E 3, offering high-quality image generation</li><li><strong>gpt-image-1-mini</strong>: A faster, more cost-effective alternative for less complex use cases</li><li><strong>gpt-image-1.5</strong>: The new flagship image generation model</li></ul><p>This application is built with this transition in mind, supporting all three models and making it easy to adapt as the ecosystem evolves.</p><h3>Project Overview</h3><p>The Image Generation Console application is a terminal-based tool that:</p><ul><li>✅ Supports both <strong>Azure OpenAI</strong> and <strong>OpenAI</strong> providers</li><li>✅ Works with <strong>DALL-E 3</strong> (legacy), <strong>gpt-image-1</strong>, <strong>gpt-image-1-mini</strong>, and <strong>gpt-image-1.5</strong></li><li>✅ Provides a beautiful, interactive UI using <a href="https://www.nuget.org/packages/spectre.console"><strong>Spectre.Console</strong></a></li><li>✅ Automatically saves generated images with timestamps</li><li>✅ Tracks generation performance for each model</li><li>✅ Handles errors gracefully with detailed feedback</li></ul><h3>Key Features Breakdown</h3><h4>Multi-Provider Support</h4><p>One of the core features is the ability to seamlessly switch between Azure OpenAI and OpenAI:</p><pre>string provider = ConsoleHelper.SelectFromOptions(<br>    [Providers.AzureOpenAI, Providers.OpenAI],<br>    Prompts.SelectProvider);</pre><p><strong>Azure OpenAI</strong> requires:</p><ul><li>Endpoint URL</li><li>API Key</li><li>Custom deployment names mapped to model types</li></ul><p><strong>OpenAI</strong> requires:</p><ul><li>API Key only</li><li>Direct model selection from available options</li></ul><h4>Model-Specific Configuration</h4><p>⚠️ <strong>Important Note</strong>: The model configurations in this application have been customized based on specific requirements and may differ from default SDK settings.</p><p>Here’s how different models are configured:</p><pre>// DALL-E 3 (Deprecated)<br>ModelTypes.DallE3 =&gt; new()<br>{<br>  Size = GeneratedImageSize.W1792xH1024,<br>  Quality = GeneratedImageQuality.Standard,<br>  Style = GeneratedImageStyle.Vivid,<br>  ResponseFormat = GeneratedImageFormat.Uri<br>}<br><br>// gpt-image-1 (Recommended)<br>ModelTypes.GptImage1 =&gt; new()<br>{<br>  Size = GeneratedImageSize.W1536xH1024<br>}<br><br>// gpt-image-1-mini (Cost-Effective)<br>ModelTypes.GptImage1Mini =&gt; new()<br>{<br>  Size = GeneratedImageSize.W1536xH1024,<br>  Quality = GeneratedImageQuality.Medium<br>}<br><br>// gpt-image-1.5 (New Flagship)<br>ModelTypes.GptImage15 =&gt; new()<br>{<br>  Size = GeneratedImageSize.W1536xH1024<br>}</pre><p><strong>Note</strong>: These configurations have been modified from the default SDK settings to optimize for specific use cases. You may need to adjust them based on your requirements, budget, and quality expectations.</p><h4>Enhanced User Experience</h4><p>The application leverages <strong>Spectre.Console</strong> to provide:</p><ul><li>🎨 <strong>Rich Colors and Markup</strong>: Yellow prompts, green success messages, red errors</li><li>⏳ <strong>Animated Spinners</strong>: Visual feedback during image generation</li><li>🔒 <strong>Masked Input</strong>: Secure API key entry with asterisks</li><li>✅ <strong>Multi-Selection</strong>: Choose multiple models with space bar</li><li>🔗 <strong>Clickable Links</strong>: Generated file paths are clickable in supported terminals</li></ul><h4>Robust Error Handling</h4><p>The application handles various error scenarios gracefully:</p><pre>catch (ClientResultException ex)<br>{<br>    ConsoleHelper.WriteError(<br>      string.Format(ErrorMessages.ApiError, modelKey, ex.Message));<br>    <br>    if (ex.Status == 401)<br>    {<br>        ConsoleHelper.WriteError(<br>          ErrorMessages.AuthenticationFailed);<br>    }<br>    else if (ex.Status == 429)<br>    {<br>        ConsoleHelper.WriteError(<br>          ErrorMessages.RateLimitExceeded);<br>    }<br>    else if (ex.Status == 404)<br>    {<br>        ConsoleHelper.WriteError(<br>          string.Format(ErrorMessages.ModelNotFound, deploymentName));<br>    }<br>}</pre><p>Common error scenarios covered:</p><ul><li>🔑 <strong>401 Unauthorized</strong>: Invalid API credentials</li><li>⏱️ <strong>429 Rate Limit</strong>: Too many requests</li><li>🔍 <strong>404 Not Found</strong>: Invalid model or deployment name</li><li>🌐 <strong>Network Errors</strong>: Connection failures during download</li><li>💾 <strong>File System Errors</strong>: Permission or I/O issues</li></ul><h4>Performance Tracking</h4><p>Each image generation is timed using Stopwatch:</p><pre>var stopwatch = Stopwatch.StartNew();<br><br>ClientResult&lt;GeneratedImage&gt; imageResult =<br>    await imageClient.GenerateImageAsync(imagePrompt, options);<br><br>stopwatch.Stop();<br><br>ConsoleHelper.WriteMessage(<br>    string.Format(Messages.GenerationCompleted, <br>    stopwatch.ElapsedMilliseconds, <br>    stopwatch.Elapsed.TotalSeconds));</pre><p>This helps users understand the performance characteristics of different models.</p><h4>Working with Azure OpenAI Deployments</h4><p><strong>Azure OpenAI</strong> requires a unique approach with custom deployment names. The application handles this elegantly:</p><pre>// User input format: &quot;deploymentName:modelType&quot;<br>// Example: &quot;myDallE:dall-e-3,myGptImage:gpt-image-1&quot;<br><br>string deploymentInput = ConsoleHelper.GetStringFromConsole(<br>    Prompts.EnterDeployments,<br>    validateLength: false,<br>    showHeader: false);<br><br>var deployments = <br>  deploymentInput.Split(&#39;,&#39;, <br>  StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);</pre><p>This allows users to:</p><ul><li>Map their custom Azure deployment names to standard model types</li><li>Use multiple deployments in a single session</li><li>Maintain consistent configuration patterns</li></ul><h4>Image Output and Storage</h4><p>Generated images are automatically saved to the system’s temporary directory with a timestamp-based naming convention:</p><pre>var tempPath = Path.Combine(<br>    Path.GetTempPath(), <br>    $&quot;{modelName}_{DateTime.Now:yyyyMMdd_HHmmss}.png&quot;);</pre><p>The application supports both:</p><ul><li><strong>URL-based images</strong>: Downloaded via HTTP and saved locally</li><li><strong>Byte-based images</strong>: Direct binary data from the API</li></ul><h3>Console Application in Action</h3><h4>Main Menu</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VQ5ry0hgVZ0RhaVDF1qsRA.png" /></figure><h4>Azure OpenAI Configuration</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vtxnJ9N3zt3sFhEQdU9nDw.png" /></figure><h4>Model Selection (OpenAI)</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bsXGAmQ8Pw8Vvqjyg9ZJFw.png" /></figure><h4>Image Generation Progress</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Z9Dgmx-KfZkWjSY8F1rWiQ.png" /></figure><h4>Generation Results</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tAZlRQF1Tx6zOxgQEv6esQ.png" /><figcaption>DALL-E 3</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*99oHcnqpJ3DKwKkJhMuOdA.png" /><figcaption>GPT-Image-1.5</figcaption></figure><h3>Migration Strategy: From DALL-E 3 to New Models</h3><h4>Why Migrate?</h4><ol><li><strong>Deprecation Timeline</strong>: DALL-E 3 will be phased out in May 2026</li><li><strong>Better Performance</strong>: gpt-image-1 offers improved quality and consistency</li><li><strong>Cost Efficiency</strong>: gpt-image-1-mini provides cheaper generations</li><li><strong>Future-Proofing</strong>: Stay ahead of API changes</li></ol><h4>Migration Checklist</h4><ul><li>✅ Update your code to support <strong>gpt-image-1</strong>, <strong>gpt-image-1-mini</strong>, and <strong>gpt-image-1.5</strong></li><li>✅ Test image quality across different models</li><li>✅ Adjust size and quality parameters as needed</li><li>✅ Update Azure OpenAI deployments to use new models</li><li>✅ Monitor costs and performance metrics</li><li>✅ Plan for eventual DALL-E 3 removal</li></ul><h3><strong>Conclusion</strong></h3><p>Building a multi-model image generation console application is more than just integrating APIs — it’s about creating a robust, user-friendly tool that adapts to the evolving AI landscape. With DALL-E 3’s deprecation on the horizon, applications like this demonstrate how to build flexibility and future-proofing into your codebase.</p><p>Key takeaways:</p><ul><li>🚀 <strong>Embrace Change</strong>: The AI ecosystem evolves quickly — build for adaptability</li><li>💡 <strong>User Experience Matters</strong>: Invest in good UX, even for console applications</li><li>⚠️ <strong>Error Handling is Critical</strong>: Graceful failure handling keeps users informed</li><li>🔧 <strong>Configuration Flexibility</strong>: Allow customization for different use cases</li><li>📈 <strong>Performance Monitoring</strong>: Track and display generation metrics</li></ul><p>The complete source code is available on <a href="https://github.com/tsjdev-apps/ai-image-generation-console">GitHub</a>, and I encourage you to try it out, customize it for your needs, and contribute improvements!</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fa5b9b790e7e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/building-a-multi-model-image-generation-console-app-with-azure-openai-and-openai-fa5b9b790e7e">Building a Multi-Model Image Generation Console App with Azure OpenAI and OpenAI</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Modern, Cross-Platform Weather App with Avalonia UI]]></title>
            <link>https://medium.com/medialesson/building-a-modern-cross-platform-weather-app-with-avalonia-ui-b580328d1cfb?source=rss-c8f6762e0e4b------2</link>
            <guid isPermaLink="false">https://medium.com/p/b580328d1cfb</guid>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[avalonia]]></category>
            <category><![CDATA[dependency-injection]]></category>
            <category><![CDATA[openweathermap-api]]></category>
            <category><![CDATA[clean-architecture]]></category>
            <dc:creator><![CDATA[Sebastian Jensen]]></dc:creator>
            <pubDate>Fri, 12 Dec 2025 06:46:56 GMT</pubDate>
            <atom:updated>2025-12-12T06:46:56.860Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>A modern cross-platform weather app showcasing the power of Avalonia UI, .NET 10, and clean architecture.</em></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r40VrhDNweRG_mYv4RCfLg.png" /></figure><p>Creating beautiful, responsive, cross-platform desktop applications has become easier than ever — thanks to frameworks like <strong>Avalonia UI</strong>. In this post, I’ll walk you through my latest project, <strong>AvaloniaWeather</strong>, a modern weather and air-quality desktop app built with <strong>.NET 10</strong>, <strong>MVVM</strong>, <strong>Dependency Injection</strong>, <strong>Localization</strong>, and my own NuGet package <a href="https://www.nuget.org/packages/OpenWeatherMapSharp"><strong>OpenWeatherMapSharp</strong></a>.</p><p>Whether you’re a .NET developer curious about Avalonia or someone looking for inspiration for a cross-platform desktop app, this article will give you a complete end-to-end overview of the architecture, technology choices, and features.</p><h3>🖥️ What Is Avalonia UI, and Why Use It?</h3><p>If you’ve ever wished for a true <strong>cross-platform WPF-like framework</strong>, <a href="https://avaloniaui.net/">Avalonia UI</a> is the answer. Avalonia is an open-source, XAML-based UI framework that allows you to target: Windows, macOS, and Linux.</p><p>Avalonia uses a WPF-inspired layout system but is far more flexible and modern, offering responsive UI capabilities, GPU acceleration, and a clean MVVM-first development experience. With Avalonia, developers enjoy:</p><ul><li>A rich control set</li><li>XAML-based layouts</li><li>Full styling and theming support</li><li>Strong community ecosystem</li><li>Excellent tooling, including hot reload</li></ul><p>For developers already familiar with WPF, MAUI, or UWP, Avalonia feels natural — while delivering true multi-platform desktop capability.</p><h3>🎨 Modern UI Styling with Semi.Avalonia</h3><p>To give <a href="https://github.com/tsjdev-apps/weather-avalonia"><em>AvaloniaWeather</em></a> a modern, fluent, and polished appearance, the application uses <a href="https://docs.irihi.tech/semi/en/docs/introduction/"><strong>Semi.Avalonia</strong></a>, a UI theme inspired by Fluent Design and Semi-UI.</p><p><strong>Semi.Avalonia</strong> provides:</p><ul><li>Clean, modern controls</li><li>Fluent-inspired transitions</li><li>Better typography and spacing</li><li>Dark and light themes</li><li>Highly customizable styling layers</li></ul><p>Combined with the <strong>Inter font</strong> and a shared style library, the result is a crisp and cohesive user experience that feels like a native desktop app, regardless of the operating system.</p><h3>🌦️ Introducing <em>AvaloniaWeather</em></h3><p><a href="https://github.com/tsjdev-apps/weather-avalonia"><strong>AvaloniaWeather</strong></a> is a modern desktop application that displays:</p><ul><li>Current weather conditions</li><li>5-day / 3-hour interval forecast</li><li>Current air quality (AQI + pollutant metrics)</li><li>Air pollution forecast</li><li>Unit selection (metric, imperial, standard)</li><li>Localization (UI &amp; data language)</li><li>Configurable API key</li></ul><p>Built on Avalonia and .NET 10, the app serves as both a real-world weather tool and a clean architectural template for creating modular, maintainable desktop applications.</p><h3>🌐 Powered by OpenWeatherMapSharp</h3><p>To retrieve weather and air-quality data, <em>AvaloniaWeather </em>uses <a href="https://www.nuget.org/packages/OpenWeatherMapSharp"><strong>OpenWeatherMapSharp</strong></a>, a NuGet package I created to simplify interacting with the <a href="https://openweathermap.org/api">OpenWeatherMap API</a>.</p><p>It provides:</p><ul><li>Strongly typed DTOs</li><li>Async methods</li><li>Predictable error handling</li><li>Modular service abstractions</li></ul><p>This helps keep the service layer clean, testable, and easy to extend — not only for weather APIs but for any future provider you may want to plug in.</p><h3>⚙️ Architecture Overview</h3><p>A key goal of AvaloniaWeather is <strong>clean architecture</strong>, making it easy to understand, scale, and maintain. The project is split into logical layers:</p><h4>🧩 Views</h4><p>XAML-based UI definitions (Views/*.axaml), designed with Avalonia’s styling system and Semi.Avalonia theme controls.</p><h4>🎛️ ViewModels</h4><p>Implemented using CommunityToolkit.Mvvm, leveraging:</p><ul><li>Source-generated properties</li><li>Relay commands</li><li>Dependency injection support</li></ul><p>This reduces boilerplate and keeps ViewModels expressive and lightweight.</p><h4>🔌 Services &amp; Interfaces</h4><p>A dedicated service layer handles API calls and produces aggregated results (WeatherResult), with abstractions that allow future plug-in providers.</p><h4>📦 Models</h4><p>Lightweight data structures representing UI-ready weather and air-quality information.</p><h4>🔄 Value Converters</h4><p>Used for formatting, unit suffixes, color transformations (e.g., AQI → color), and other UI logic.</p><h4>🌍 Localization</h4><p>Based on .resx resources:</p><ul><li>Resources.resx (default)</li><li>Resources.de-DE.resx (German)</li></ul><p>UI strings and weather language selection work independently, allowing full flexibility for multilingual users.</p><h3>🔑 Dependency Injection &amp; .NET 10</h3><p>Although the app keeps DI optional and minimal by default, the architecture supports expansion using: <strong>Microsoft.Extensions.DepenendyInjection</strong></p><p>This makes AvaloniaWeather an excellent learning example for:</p><ul><li>.NET 10 SDK setup (via global.json)</li><li>DI patterns</li><li>MVVM workflows</li><li>High-performance async operations</li><li>Cross-platform desktop execution</li></ul><h3>🌍 Multi-Language Support (UI + Weather Data)</h3><p><em>AvaloniaWeather </em>supports:</p><ul><li>Localized UI strings</li><li>Multi-language weather data</li></ul><p>You can add new languages simply by:</p><ol><li>Duplicating Resources.resx</li><li>Naming it Resources.&lt;culture&gt;.resx</li><li>Adding translations</li><li>Rebuilding the project</li></ol><h3>🚀 Getting Started (Developers)</h3><h4>1️⃣ Install .NET 10</h4><p>Make sure your system matches the pinned version in global.json.</p><h4>2️⃣ Clone the repository</h4><pre>git clone https://github.com/tsjdev-apps/weather-avalonia.git<br>cd weather-avalonia/src/AvaloniaWeather<br>dotnet restore</pre><h4>3️⃣ Build</h4><pre>dotnet build -c Release -f net10.0</pre><h4>4️⃣ Run</h4><pre>dotnet run -f net10.0</pre><p>If your SDK doesn’t match, install the correct version from the <a href="https://dotnet.microsoft.com/en-us/download">official .NET download page</a>.</p><h3>📸 UI Highlights</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/902/1*CbP5FpBR3kmPCXFL4p9iVQ.png" /><figcaption><strong>Unit and language selection UI</strong></figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/902/1*thCTW6cVHqoT6sgRaCRfLA.png" /><figcaption><strong>Current weather view</strong></figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/902/1*1-CxaejEFguU-IfprPj2YQ.png" /><figcaption><strong>Forecast with 3-hour intervals</strong></figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/902/1*03MKpjhnNg3JzLsZOwU1gQ.png" /><figcaption><strong>Current AQI with pollutant visibility</strong></figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/902/1*keKLvw-kuJwSPrwIsgXc4A.png" /><figcaption><strong>Air pollution forecast</strong></figcaption></figure><h3>🎉 Final Thoughts</h3><p><em>AvaloniaWeather </em>is more than a weather app — it’s a demonstration of what a <strong>modern, cross-platform .NET desktop application</strong> can look like when combining:</p><ul><li>Avalonia UI</li><li>Semi.Avalonia</li><li>MVVM + DI</li><li>.NET 10</li><li>Localization</li><li>Strong service abstractions</li><li>Clean UI architecture</li><li>OpenWeatherMapSharp</li></ul><p>If you want to explore Avalonia, build a cross-platform desktop tool, or create a real-world app integrating clean architecture principles, <em>AvaloniaWeather </em>is a great starting point. You can find the source code in my <a href="https://github.com/tsjdev-apps/weather-avalonia">GitHub repository</a>.</p><figure><a href="https://go.tsjdev-apps.de/medium-contact"><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*awu08j-PnzGLDaptofKVbg.png" /></a></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b580328d1cfb" width="1" height="1" alt=""><hr><p><a href="https://medium.com/medialesson/building-a-modern-cross-platform-weather-app-with-avalonia-ui-b580328d1cfb">Building a Modern, Cross-Platform Weather App with Avalonia UI</a> was originally published in <a href="https://medium.com/medialesson">Medialesson</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>