diff --git a/README.md b/README.md index a79f704..832ccf6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ icp new --subfolder | [bitcoin-starter](./bitcoin-starter/) | Bitcoin integration with balance reading (Rust or Motoko) | | [proxy](./proxy/) | A pre-built proxy canister for use with `icp canister call --proxy` | | [static-website](./static-website/) | A static website deployed to an asset canister | +| [blazor-hello-world](./blazor-hello-world/) | Full-stack Blazor WebAssembly (.NET 10) frontend with a Motoko backend | ## Contributing diff --git a/blazor-hello-world/ICP-BLAZOR-HELLO-WORLD.bmp b/blazor-hello-world/ICP-BLAZOR-HELLO-WORLD.bmp new file mode 100644 index 0000000..9d98548 Binary files /dev/null and b/blazor-hello-world/ICP-BLAZOR-HELLO-WORLD.bmp differ diff --git a/blazor-hello-world/README.md b/blazor-hello-world/README.md new file mode 100644 index 0000000..4ba211b --- /dev/null +++ b/blazor-hello-world/README.md @@ -0,0 +1,169 @@ +# ICP Blazor Hello World +full medium article : +https://medium.com/@mitnick2012/your-net-blazor-skills-just-became-blockchain-skills-c41c14d87b44 + +A hello world template combining **Blazor WebAssembly (.NET 10)** on the frontend and **Motoko** on the backend, deployed fully on-chain on the **Internet Computer (ICP)**. + +Hello World Blazor Demo App: + +![Alt text](ICP-BLAZOR-HELLO-WORLD.bmp) + +> Scaffold a new project with [`icp new`](https://cli.internetcomputer.org/1.0/guides/creating-templates/): +> +> ```bash +> # from the team template collection +> icp new my-app --git https://github.com/mitnick2012/icp-cli-templates --subfolder blazor-hello-world +> +> # from a published git repo containing only this template +> icp new my-app --git https://github.com/mitnick2012/icp-blazor-wasm-template +> +> # or from a local checkout (the folder with cargo-generate.toml) +> icp new my-app --path ./icp-blazor-hello +> ``` +> +> `my-app` becomes the canister names `my-app-backend` / `my-app-frontend` and the +> .NET project name `MyApp`. + +## Stack + +| Layer | Technology | +|----------|-------------------------------------| +| Frontend | Blazor WebAssembly (.NET 10) | +| Backend | Motoko canister | +| Bridge | @icp-sdk/core (webpack bundle) | +| Platform | Internet Computer (ICP) | +| CLI | icp-cli | + +## Project Structure + +``` +icp-blazor-hello/ +├── cargo-generate.toml # icp-cli template config (cargo-generate) +├── icp.yaml # icp-cli project config +├── .gitignore +├── scripts/ +│ └── fix-wsl-keyring.sh # headless identity helper for WSL +├── backend/ +│ ├── canister.yaml # backend canister config (name: {{ project-name }}-backend) +│ ├── backend.did # Candid interface (semicolons required!) +│ └── src/ +│ └── main.mo # Motoko canister +└── frontend/ + ├── canister.yaml # frontend canister config (name: {{ project-name }}-frontend) + ├── vendor/ + │ └── assetstorage.wasm.gz # pinned asset canister WASM (no build-time download) + └── {{ project-name }}/ + ├── {{ project-name }}.csproj # .NET 10 Blazor WASM (AssemblyName = {{ project-name | pascal_case }}) + ├── Program.cs + ├── App.razor + ├── _Imports.razor # required — Blazor namespace imports + ├── package.json # webpack + @icp-sdk/core + ├── webpack.config.js # bundles icpAgent.ts → wwwroot/icpAgent.js + ├── tsconfig.json + ├── src/ + │ └── icpAgent.ts # TypeScript ICP agent bridge + ├── Layout/ + │ └── MainLayout.razor + ├── Pages/ + │ └── Home.razor # main UI with canister calls + ├── Services/ + │ └── IcpAgentService.cs # C# → JS interop service + └── wwwroot/ + ├── index.html + └── app.css +``` + +## How it works + +``` +Home.razor (C#) + → IcpAgentService.cs (IJSRuntime) + → window.IcpAgent.* (webpack bundle, defer loaded) + → @icp-sdk/core (Agent module: https://js.icp.build/core/latest/libs/agent) + → Motoko backend canister on ICP +``` + +The key insight: use **webpack** to bundle `@icp-sdk/core` into a plain JS file +loaded with `defer`, not as an ES module. This avoids the race condition between +the module loader and Blazor's JS interop system. + +## Prerequisites + +```bash +# icp-cli +npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm + +# Motoko toolchain +npm install -g ic-mops +``` + +### .NET 10 SDK + +**Method 1: Ubuntu 24.04 LTS or newer (APT)** +```bash +sudo apt update +sudo apt install -y dotnet-sdk-10.0 +``` + +> **Ubuntu 22.04 LTS?** Register the backports PPA first: +> ```bash +> sudo add-apt-repository ppa:dotnet/backports +> sudo apt update +> sudo apt install -y dotnet-sdk-10.0 +> ``` + +**Method 2: Official Microsoft script (recommended for non-Ubuntu or if APT fails)** +```bash +wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh +chmod +x dotnet-install.sh +./dotnet-install.sh --channel 10.0 +``` + +After installing, add .NET to your PATH: +```bash +echo 'export PATH="$HOME/.dotnet:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +### Blazor WASM workload (required) +```bash +dotnet workload install wasm-tools +``` + +### Remove conflicting `icp` binary (Ubuntu/Debian) +Ubuntu ships a package called `renameutils` that installs its own unrelated `icp` binary. +Remove it before installing icp-cli: +```bash +sudo apt remove renameutils -y +``` + +Verify the correct `icp` is active: +```bash +icp --version # should show icp-cli, not renameutils +``` + +## Run locally + +```bash +# 1. Start the local ICP network (gateway on the standard port 8000, +# see icp.yaml networks.gateway.port) +icp network start -d +icp network status # Gateway Url: http://localhost:8000/ + +# 2. Deploy both canisters +icp deploy + +# 3. Open the frontend URL printed by icp deploy (friendly domain), e.g. +# {{ project-name }}-frontend: http://{{ project-name }}-frontend.local.localhost:8000/ +# The canister-id form works too: http://.localhost:8000/ +``` + +## Deploy to mainnet + +```bash +icp deploy --network ic +``` + +## License + +MIT diff --git a/blazor-hello-world/backend/backend.did b/blazor-hello-world/backend/backend.did new file mode 100644 index 0000000..7f9052a --- /dev/null +++ b/blazor-hello-world/backend/backend.did @@ -0,0 +1,5 @@ +service : { + getGreeting : () -> (text) query; + setGreeting : (text) -> (text); + hello : (text) -> (text) query; +} diff --git a/blazor-hello-world/backend/canister.yaml b/blazor-hello-world/backend/canister.yaml new file mode 100644 index 0000000..b47dc80 --- /dev/null +++ b/blazor-hello-world/backend/canister.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v1.3.0/docs/schemas/canister-yaml-schema.json +name: "{{ project-name }}-backend" +recipe: + type: "@dfinity/motoko@v4.1.0" + configuration: + main: src/main.mo + candid: backend.did diff --git a/blazor-hello-world/backend/mops.toml b/blazor-hello-world/backend/mops.toml new file mode 100644 index 0000000..4363270 --- /dev/null +++ b/blazor-hello-world/backend/mops.toml @@ -0,0 +1,2 @@ +[toolchain] +moc = "1.3.0" diff --git a/blazor-hello-world/backend/src/main.mo b/blazor-hello-world/backend/src/main.mo new file mode 100644 index 0000000..f3771df --- /dev/null +++ b/blazor-hello-world/backend/src/main.mo @@ -0,0 +1,18 @@ +persistent actor { + + stable var greeting : Text = "Hello from {{ project-name }}!"; + + public query func getGreeting() : async Text { + return greeting; + }; + + public func setGreeting(name : Text) : async Text { + greeting := "Hello, " # name # "! Welcome to ICP + Blazor!"; + return greeting; + }; + + public query func hello(name : Text) : async Text { + return "Hello, " # name # "!"; + }; + +}; diff --git a/blazor-hello-world/cargo-generate.toml b/blazor-hello-world/cargo-generate.toml new file mode 100644 index 0000000..76cbca7 --- /dev/null +++ b/blazor-hello-world/cargo-generate.toml @@ -0,0 +1,26 @@ +[template] +name = "Hello World ICP app" +description = "Full-stack ICP app: Blazor WASM (.NET 10) frontend + Motoko backend, deployed fully on-chain" + +# Never run Liquid over binary assets (non-UTF8 files are copied verbatim anyway). +exclude = ["frontend/vendor/**"] + +# Never copy local build/test litter into generated projects. (cargo-generate +# does not read .gitignore, so anything git ignores is NOT automatically +# excluded here.) `ignore` matches whole files/folders, no wildcards, so every +# path below is spelled out. Missing paths are ignored. +ignore = [ + # ICP CLI runtime cache (can hold filenames with raw `{{ project-name }}` + # placeholders, e.g. cache/artifacts/{{ project-name }}-backend, which would + # otherwise make scaffolding fail with "invalid syntax"). + ".icp", + "frontend/{{project-name}}/bin", + "frontend/{{project-name}}/obj", + "frontend/{{project-name}}/node_modules", + "frontend/{{project-name}}/package-lock.json", + # Log files from manual test runs. + "frontend/{{project-name}}/npm-install.log", + # Webpack output is regenerated by `npm run build` during deploy. + "frontend/{{project-name}}/wwwroot/icpAgent.js", + "frontend/{{project-name}}/wwwroot/icpAgent.js.LICENSE.txt", +] diff --git a/blazor-hello-world/frontend/canister.yaml b/blazor-hello-world/frontend/canister.yaml new file mode 100644 index 0000000..32d541c --- /dev/null +++ b/blazor-hello-world/frontend/canister.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v1.3.0/docs/schemas/canister-yaml-schema.json +name: "{{ project-name }}-frontend" +build: + steps: + # 1. Build the TypeScript ICP agent bundle, then the Blazor WASM app. + - type: script + commands: + - cd {{project-name}} && npm install && npm run build + - dotnet publish {{project-name}}/{{project-name}}.csproj -c Release + # 2. Use the asset canister WASM vendored next to this file instead of + # fetching it from GitHub on every build. The `@dfinity/asset-canister` + # recipe downloaded it from + # https://github.com/dfinity/sdk/releases/latest/download/assetstorage.wasm.gz + # with no sha256, so icp-cli re-downloaded it on every deploy and any + # network hiccup failed the build ("failed to fetch wasm file"). + # See vendor/README.md for how to refresh this file. + - type: pre-built + path: vendor/assetstorage.wasm.gz + sha256: 04e565b3425fe7510ee16b02adcfe3f01abc9a2725c82a21cb08969241debd62 +sync: + steps: + # Upload the published wwwroot to the asset canister (same plugin step the + # @dfinity/asset-canister recipe emits). + - type: plugin + url: https://github.com/dfinity/certified-assets/releases/download/migration-v2.2.1-6b48585/sync_plugin.wasm + sha256: ca7cb5666c30d2875f8d5e10535f8a53f97a86c79c263f7d5bdac2fdd1bbf83c + dirs: + - "{{project-name}}/bin/Release/net10.0/publish/wwwroot" diff --git a/blazor-hello-world/frontend/vendor/README.md b/blazor-hello-world/frontend/vendor/README.md new file mode 100644 index 0000000..b1eeaf8 --- /dev/null +++ b/blazor-hello-world/frontend/vendor/README.md @@ -0,0 +1,45 @@ +# Vendored asset canister + +`assetstorage.wasm.gz` is the prebuilt **asset canister** module that serves the +Blazor frontend. It is checked in so `icp build` / `icp deploy` never has to +download it: + +| | | +|---|---| +| Source | `https://github.com/dfinity/sdk/releases/download/0.32.0/assetstorage.wasm.gz` (dfx release 0.32.0) | +| Size | 465,172 bytes | +| sha256 | `04e565b3425fe7510ee16b02adcfe3f01abc9a2725c82a21cb08969241debd62` | + +## Why it is vendored + +`frontend/canister.yaml` used to use the `@dfinity/asset-canister@v2.2.1` recipe. +That recipe's build step fetches the module from +`https://github.com/dfinity/sdk/releases/latest/download/assetstorage.wasm.gz` +and does **not** supply a `sha256`, and icp-cli only caches a remote WASM when a +checksum is given (`Remote without sha256: always downloads`). Every deploy +therefore re-downloaded ~465 KB through GitHub's `releases/latest` redirect. When +that request failed at the transport level (reset/TLS/DNS/timeout) the build +aborted with: + +``` +[frontend] ✘ Failed to build canister: failed to fetch wasm file +``` + +Keeping the module in the repository makes the build hermetic: the +`pre-built` step reads it from disk, verifies the checksum above, and no network +access is needed for the canister WASM. + +## Refreshing the module + +Pick a dfx release tag from , download +it and update `sha256` in `frontend/canister.yaml`: + +```bash +cd frontend +curl -sL -o vendor/assetstorage.wasm.gz \ + https://github.com/dfinity/sdk/releases/download//assetstorage.wasm.gz +sha256sum vendor/assetstorage.wasm.gz # copy this into canister.yaml +``` + +If the checksum in `canister.yaml` does not match the file, the build fails with +`checksum mismatch, expected: ..., actual: ...`. diff --git a/blazor-hello-world/frontend/vendor/assetstorage.wasm.gz b/blazor-hello-world/frontend/vendor/assetstorage.wasm.gz new file mode 100644 index 0000000..9a5396f Binary files /dev/null and b/blazor-hello-world/frontend/vendor/assetstorage.wasm.gz differ diff --git a/blazor-hello-world/frontend/{{project-name}}/App.razor b/blazor-hello-world/frontend/{{project-name}}/App.razor new file mode 100644 index 0000000..27923be --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/App.razor @@ -0,0 +1,12 @@ +@using Microsoft.AspNetCore.Components.Routing +@using {{ project-name | pascal_case }}.Layout + + + + + + + +

Page not found.

+
+
diff --git a/blazor-hello-world/frontend/{{project-name}}/Layout/MainLayout.razor b/blazor-hello-world/frontend/{{project-name}}/Layout/MainLayout.razor new file mode 100644 index 0000000..724fc91 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/Layout/MainLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + +
+ @Body +
diff --git a/blazor-hello-world/frontend/{{project-name}}/Pages/Home.razor b/blazor-hello-world/frontend/{{project-name}}/Pages/Home.razor new file mode 100644 index 0000000..199ae2d --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/Pages/Home.razor @@ -0,0 +1,97 @@ +@page "/" +@inject IcpAgentService IcpAgent + +
+

ICP + Blazor Hello World

+

{{ project-name }} · Blazor WebAssembly (.NET 10) · Motoko · Internet Computer

+ + +
+

Current Greeting

+ + @if (!string.IsNullOrEmpty(_greeting)) + { +

@_greeting

+ } +
+ + +
+

Set Custom Greeting

+ + + @if (!string.IsNullOrEmpty(_setResponse)) + { +

@_setResponse

+ } +
+ + +
+

Say Hello

+ + + @if (!string.IsNullOrEmpty(_helloResponse)) + { +

@_helloResponse

+ } +
+ + @if (!string.IsNullOrEmpty(_error)) + { +

Error: @_error

+ } +
+ +@code { + private string _greeting = ""; + private string _helloResponse = ""; + private string _setResponse = ""; + private string _name = ""; + private string _customName = ""; + private string _error = ""; + private bool _loading = false; + + private async Task FetchGreeting() + { + await Call(async () => _greeting = await IcpAgent.GetGreetingAsync()); + } + + private async Task SayHello() + { + if (string.IsNullOrWhiteSpace(_name)) return; + await Call(async () => _helloResponse = await IcpAgent.HelloAsync(_name)); + } + + private async Task SetGreeting() + { + if (string.IsNullOrWhiteSpace(_customName)) return; + await Call(async () => _setResponse = await IcpAgent.SetGreetingAsync(_customName)); + } + + private async Task Call(Func action) + { + _loading = true; + _error = ""; + StateHasChanged(); + try + { + await action(); + } + catch (JSException ex) + { + _error = $"JS Error: {ex.Message}"; + } + catch (Exception ex) + { + _error = $"Error: {ex.Message}"; + } + finally + { + _loading = false; + StateHasChanged(); + } + } +} diff --git a/blazor-hello-world/frontend/{{project-name}}/Program.cs b/blazor-hello-world/frontend/{{project-name}}/Program.cs new file mode 100644 index 0000000..bbc6531 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/Program.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using {{ project-name | pascal_case }}.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add<{{ project-name | pascal_case }}.App>("#app"); + +builder.Services.AddScoped(); + +await builder.Build().RunAsync(); diff --git a/blazor-hello-world/frontend/{{project-name}}/Services/IcpAgentService.cs b/blazor-hello-world/frontend/{{project-name}}/Services/IcpAgentService.cs new file mode 100644 index 0000000..5029913 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/Services/IcpAgentService.cs @@ -0,0 +1,26 @@ +using Microsoft.JSInterop; + +namespace {{ project-name | pascal_case }}.Services; + +/// +/// Calls the Motoko backend canister on ICP via JavaScript interop. +/// The IcpAgent global is defined in wwwroot/icpAgent.js (webpack bundle). +/// +public class IcpAgentService +{ + private readonly IJSRuntime _js; + + public IcpAgentService(IJSRuntime js) + { + _js = js; + } + + public async Task GetGreetingAsync() + => await _js.InvokeAsync("IcpAgent.getGreeting"); + + public async Task SetGreetingAsync(string name) + => await _js.InvokeAsync("IcpAgent.setGreeting", name); + + public async Task HelloAsync(string name) + => await _js.InvokeAsync("IcpAgent.hello", name); +} diff --git a/blazor-hello-world/frontend/{{project-name}}/_Imports.razor b/blazor-hello-world/frontend/{{project-name}}/_Imports.razor new file mode 100644 index 0000000..ee5d7bc --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/_Imports.razor @@ -0,0 +1,7 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using {{ project-name | pascal_case }} +@using {{ project-name | pascal_case }}.Layout +@using {{ project-name | pascal_case }}.Services diff --git a/blazor-hello-world/frontend/{{project-name}}/package.json b/blazor-hello-world/frontend/{{project-name}}/package.json new file mode 100644 index 0000000..5e199fd --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/package.json @@ -0,0 +1,16 @@ +{ + "name": "blazor-icp", + "version": "1.0.0", + "scripts": { + "build": "webpack" + }, + "dependencies": { + "@icp-sdk/core": "~6.1.0" + }, + "devDependencies": { + "webpack": "~5.107.0", + "webpack-cli": "~5.1.0", + "ts-loader": "~9.6.0", + "typescript": "~5.9.0" + } +} diff --git a/blazor-hello-world/frontend/{{project-name}}/src/icpAgent.ts b/blazor-hello-world/frontend/{{project-name}}/src/icpAgent.ts new file mode 100644 index 0000000..395de8c --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/src/icpAgent.ts @@ -0,0 +1,98 @@ +// Agent bridge for ICP canisters. +// +// SDK: @icp-sdk/core (see the Agent module docs: +// https://js.icp.build/core/latest/libs/agent) +import { Actor, HttpAgent } from "@icp-sdk/core/agent"; +import { Principal } from "@icp-sdk/core/principal"; + +const idlFactory = ({ IDL }: any) => + IDL.Service({ + getGreeting: IDL.Func([], [IDL.Text], ["query"]), + setGreeting: IDL.Func([IDL.Text], [IDL.Text], []), + hello: IDL.Func([IDL.Text], [IDL.Text], ["query"]), + }); + +function getCanisterId(): string { + const decoded = decodeURIComponent(document.cookie); + const match = decoded + .split("; ") + .find((c) => c.startsWith("ic_env=")); + + if (match) { + const params = new URLSearchParams(match.replace("ic_env=", "")); + const id = params.get("PUBLIC_CANISTER_ID:{{ project-name }}-backend"); + if (id) return id; + } + + throw new Error("Canister ID not found. Make sure the app is accessed via the canister subdomain."); +} + +let _actor: any = null; + +async function getActor() { + if (!_actor) { + const isLocal = + window.location.hostname === "localhost" || + window.location.hostname.endsWith(".localhost"); + + const canisterId = getCanisterId(); + + // Use the port the page was actually served from so the agent works + // regardless of which gateway port the local replica uses (8000, 8001, ...). + // Falls back to 8000, the standard local gateway port (see icp.yaml). + const localHost = `http://localhost:${window.location.port || "8000"}`; + const agent = await HttpAgent.create({ + host: isLocal ? localHost : "https://ic0.app", + // Local replica only: a skewed host clock (common with WSL/Windows, where + // the Windows clock and the WSL replica clock can differ by an hour) + // makes the replica's certificate look "signed in the future", and + // @icp-sdk/core only tolerates 5 minutes of skew: + // "Invalid certificate: Certificate is signed more than 5 minutes in + // the future" + // Query responses from a local replica are already trust-on-first-use via + // fetchRootKey(), so skip query signature verification locally. Mainnet + // (and any non-local host) keeps full verification enabled. + verifyQuerySignatures: !isLocal, + }); + + if (isLocal) { + await agent.fetchRootKey(); + + // The same clock skew also breaks update calls, because the agent derives + // the request's ingress_expiry from the browser clock: + // "Invalid request expiry: Specified ingress_expiry not within expected + // range ... Provided expiry: " + // syncTime() reads the replica's certified time and the agent then + // compensates ingress_expiry whenever the offset exceeds 30 seconds. + // Pass our own canister id explicitly: the default fallback is the ICP + // ledger (ryjl3-tyaaa-aaaaa-aaaba-cai), which does not exist on a freshly + // created local replica. + try { + await agent.syncTime({ canisterId: Principal.fromText(canisterId) }); + } catch (err) { + console.warn( + "[icpAgent] syncTime failed; update calls may be rejected if the host clock is skewed.", + err + ); + } + } + + _actor = Actor.createActor(idlFactory, { + agent, + canisterId, + }); + } + return _actor; +} + +export async function getGreeting(): Promise { + return (await getActor()).getGreeting(); +} + +export async function setGreeting(name: string): Promise { + return (await getActor()).setGreeting(name); +} + +export async function hello(name: string): Promise { + return (await getActor()).hello(name); +} diff --git a/blazor-hello-world/frontend/{{project-name}}/tsconfig.json b/blazor-hello-world/frontend/{{project-name}}/tsconfig.json new file mode 100644 index 0000000..5898c66 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "ESNext", + "moduleResolution": "bundler", // required: @icp-sdk/core exposes bare subpaths (@icp-sdk/core/agent) + // via a package "exports" map, which classic node10 resolution cannot see. + "strict": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/blazor-hello-world/frontend/{{project-name}}/webpack.config.js b/blazor-hello-world/frontend/{{project-name}}/webpack.config.js new file mode 100644 index 0000000..3246a97 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/webpack.config.js @@ -0,0 +1,21 @@ +const path = require("path"); + +module.exports = { + entry: "./src/icpAgent.ts", + output: { + path: path.resolve(__dirname, "wwwroot"), + filename: "icpAgent.js", + library: { + name: "IcpAgent", + type: "var", + }, + globalObject: "window", + }, + resolve: { + extensions: [".ts", ".js"], + }, + module: { + rules: [{ test: /\.ts$/, use: "ts-loader" }], + }, + mode: "production", +}; diff --git a/blazor-hello-world/frontend/{{project-name}}/wwwroot/app.css b/blazor-hello-world/frontend/{{project-name}}/wwwroot/app.css new file mode 100644 index 0000000..e4c14a3 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/wwwroot/app.css @@ -0,0 +1,94 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, -apple-system, sans-serif; + background: #0a0a1a; + color: #e0e0ff; + min-height: 100vh; + display: flex; + justify-content: center; + padding: 2rem 1rem; +} + +.container { + width: 100%; + max-width: 600px; +} + +h1 { + font-size: 2rem; + color: #a78bfa; + margin-bottom: 0.25rem; +} + +.subtitle { + color: #6b7280; + font-size: 0.9rem; + margin-bottom: 2rem; +} + +h2 { + font-size: 1.1rem; + color: #c4b5fd; + margin-bottom: 0.75rem; +} + +section { + background: #12122a; + border: 1px solid #2d2d5e; + border-radius: 10px; + padding: 1.25rem; + margin-bottom: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +input { + background: #1a1a35; + border: 1px solid #3d3d7a; + border-radius: 6px; + color: #e0e0ff; + font-size: 1rem; + padding: 0.5rem 0.75rem; + outline: none; + transition: border-color 0.2s; +} + +input:focus { border-color: #7c3aed; } + +button { + background: #7c3aed; + border: none; + border-radius: 6px; + color: white; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + padding: 0.5rem 1.25rem; + align-self: flex-start; + transition: background 0.2s; +} + +button:hover:not(:disabled) { background: #6d28d9; } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +.result { + background: #0d1f0d; + border: 1px solid #166534; + border-radius: 6px; + color: #86efac; + font-family: monospace; + font-size: 0.95rem; + padding: 0.5rem 0.75rem; +} + +.error { + background: #1f0d0d; + border: 1px solid #991b1b; + border-radius: 6px; + color: #fca5a5; + font-size: 0.9rem; + padding: 0.75rem; + margin-top: 0.5rem; +} diff --git a/blazor-hello-world/frontend/{{project-name}}/wwwroot/index.html b/blazor-hello-world/frontend/{{project-name}}/wwwroot/index.html new file mode 100644 index 0000000..f4f3904 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/wwwroot/index.html @@ -0,0 +1,21 @@ + + + + + + {{ project-name }} · ICP Blazor Hello World + + + + + +
Loading...
+ + + + + + diff --git a/blazor-hello-world/frontend/{{project-name}}/{{project-name}}.csproj b/blazor-hello-world/frontend/{{project-name}}/{{project-name}}.csproj new file mode 100644 index 0000000..e010705 --- /dev/null +++ b/blazor-hello-world/frontend/{{project-name}}/{{project-name}}.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + + {{ project-name | pascal_case }} + {{ project-name | pascal_case }} + + + + + true + true + + + + + false + false + + + + + + + diff --git a/blazor-hello-world/icp.yaml b/blazor-hello-world/icp.yaml new file mode 100644 index 0000000..a52705f --- /dev/null +++ b/blazor-hello-world/icp.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v1.3.0/docs/schemas/icp-yaml-schema.json +canisters: + - backend + - frontend +networks: + - name: local + mode: managed + gateway: + port: 8000 diff --git a/blazor-hello-world/scripts/fix-wsl-keyring.sh b/blazor-hello-world/scripts/fix-wsl-keyring.sh new file mode 100644 index 0000000..3a6de70 --- /dev/null +++ b/blazor-hello-world/scripts/fix-wsl-keyring.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Fix WSL "unlock keyring" freeze for icp-cli +# Symptom: +# Error: failed to load identity +# 0: failed to load password from keyring entry +# 1: Secret Service: unlock prompt was dismissed +# + frozen terminal titled "warn copy mode : unlock keyring (ubuntu)" +# +# Cause: icp-cli default --storage keyring uses Linux Secret Service (gnome-keyring). +# WSL has no GUI keyring daemon, so the unlock dialog freezes. +# +# This script creates a headless local identity that never touches the keyring. +# SAFE FOR LOCAL REPLICA ONLY (pocket-ic is ephemeral, no funds at risk). +# Keep your keyring/password identity for mainnet (ic) deploys. +# +# Usage: +# ./scripts/fix-wsl-keyring.sh # plaintext (recommended for local) +# ./scripts/fix-wsl-keyring.sh password # encrypted + password file +# ./scripts/fix-wsl-keyring.sh --help +set -euo pipefail + +MODE="${1:-plaintext}" + +usage() { + cat <<'EOF' +Usage: ./scripts/fix-wsl-keyring.sh [plaintext|password] + + plaintext Create/use `local` identity with --storage plaintext (default). + No prompt, headless, ideal for WSL + local replica. + password Create/use `local-pw` with --storage password + file + ~/.config/icp-cli/.icp-password (chmod 600). More secure. + +Examples: + ./scripts/fix-wsl-keyring.sh + ./scripts/fix-wsl-keyring.sh password + icp deploy --identity local + icp deploy --identity local-pw --identity-password-file ~/.config/icp-cli/.icp-password +EOF +} + +if [[ "$MODE" == "-h" || "$MODE" == "--help" ]]; then + usage + exit 0 +fi + +if ! command -v icp >/dev/null 2>&1; then + echo "error: icp not found in PATH (npm install -g @icp-sdk/icp-cli)" >&2 + exit 1 +fi + +echo "==> icp identity list (before)" +icp identity list || true +echo + +if [[ "$MODE" == "plaintext" ]]; then + if icp identity list 2>&1 | grep -qE '(^|\s)local(\s|$)'; then + echo "==> identity 'local' already exists, reusing" + else + echo "==> creating identity 'local' with --storage plaintext" + icp identity new local --storage plaintext + fi + echo "==> setting default identity to 'local'" + icp identity default local || true + echo + echo "==> verifying (should NOT prompt for keyring unlock)" + icp identity principal + echo + echo "==> done. Try:" + echo " icp network start -d" + echo " icp network status # expect gateway 127.0.0.1:8000 (see icp.yaml)" + echo " icp deploy # or: icp deploy --identity local" + echo + echo "Keep 'deployer'/'default' keyring identities for mainnet: icp deploy --network ic --identity deployer" + +elif [[ "$MODE" == "password" ]]; then + PW_FILE="${ICP_PASSWORD_FILE:-$HOME/.config/icp-cli/.icp-password}" + mkdir -p "$(dirname "$PW_FILE")" + if [[ ! -f "$PW_FILE" ]]; then + echo "==> generating password file $PW_FILE" + openssl rand -base64 24 > "$PW_FILE" + chmod 600 "$PW_FILE" + echo " chmod 600 $PW_FILE" + else + echo "==> password file exists: $PW_FILE (chmod 600)" + chmod 600 "$PW_FILE" + fi + if icp identity list 2>&1 | grep -qE '(^|\s)local-pw(\s|$)'; then + echo "==> identity 'local-pw' already exists" + else + echo "==> creating identity 'local-pw' with --storage password" + icp identity new local-pw --storage password --storage-password-file "$PW_FILE" + fi + echo "==> setting default to local-pw (optional)" + icp identity default local-pw || true + echo + echo "==> verifying" + icp identity principal + echo + echo "==> done. Deploy with:" + echo " icp deploy --identity local-pw --identity-password-file $PW_FILE" +else + echo "error: unknown mode '$MODE' (expected plaintext|password)" >&2 + usage + exit 2 +fi diff --git a/cargo-generate.toml b/cargo-generate.toml index b747dd0..061f2a9 100644 --- a/cargo-generate.toml +++ b/cargo-generate.toml @@ -6,4 +6,5 @@ sub_templates = [ "proxy", "rust", "static-website", + "blazor-hello-world", ] diff --git a/tests/matrix.yaml b/tests/matrix.yaml index fff5f4c..135b838 100644 --- a/tests/matrix.yaml +++ b/tests/matrix.yaml @@ -49,3 +49,16 @@ templates: expect: 'Hello, ICP!' - name: frontend curl: true + + blazor-hello-world: + permutations: + - {} + canisters: + - name: '{{project-name}}-backend' + call: + method: hello + args: '("ICP")' + expect: 'Hello, ICP!' + - name: '{{project-name}}-frontend' + curl: true +