Skip to content

Commit c9304ec

Browse files
Merge windows-support: Windows installers, CI job, platform fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents 283b5a9 + 18f0d74 commit c9304ec

11 files changed

Lines changed: 177 additions & 37 deletions

File tree

.github/workflows/release.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
22
# Build installable binaries and attach them to a GitHub Release on every v* tag.
33
#
4-
# macOS → universal .dmg (Apple Silicon + Intel). Signs + notarizes automatically
5-
# IF the APPLE_* secrets are set; otherwise the .dmg is unsigned
6-
# (users right-click → Open the first time).
7-
# Linux → .AppImage / .deb / .rpm.
4+
# macOS → universal .dmg (Apple Silicon + Intel). Signs + notarizes automatically
5+
# IF the APPLE_* secrets are set; otherwise the .dmg is unsigned
6+
# (users right-click → Open the first time).
7+
# Linux → .AppImage / .deb / .rpm.
8+
# Windows → .exe (NSIS) / .msi (WiX). Unsigned, so SmartScreen shows
9+
# "More info → Run anyway" on first launch.
810
name: Release
911

1012
on:
@@ -26,6 +28,9 @@ jobs:
2628
- platform: ubuntu-22.04
2729
args: ""
2830
rust-targets: ""
31+
- platform: windows-latest
32+
args: ""
33+
rust-targets: ""
2934
runs-on: ${{ matrix.platform }}
3035
steps:
3136
- uses: actions/checkout@v4
@@ -41,6 +46,12 @@ jobs:
4146
libayatana-appindicator3-dev \
4247
librsvg2-dev patchelf
4348
49+
- name: Install NASM (Windows)
50+
# The vendored OpenSSL that backs SQLCipher assembles with NASM on MSVC.
51+
# Strawberry Perl is already on the runner; NASM is not.
52+
if: matrix.platform == 'windows-latest'
53+
uses: ilammy/setup-nasm@v1
54+
4455
- name: Set up Node
4556
uses: actions/setup-node@v4
4657
with:
@@ -95,6 +106,9 @@ jobs:
95106
4. Scroll to the **Security** section — you'll see a message that the app was blocked.
96107
5. Click **Open Anyway**, enter your Mac's login password, and click **OK**.
97108
109+
**Windows:** download the `-setup.exe` installer. It isn't code-signed yet, so
110+
SmartScreen may warn on first launch — click **More info → Run anyway**.
111+
98112
**Linux:** use the .AppImage (portable), or the .deb / .rpm for your distro.
99113
100114
The Companion and text-import features run a model locally — the app guides
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2+
# Build the Windows installers (NSIS .exe + WiX .msi) WITHOUT releasing anything.
3+
# Run manually from the Actions tab to prove the Windows build before tagging a
4+
# release. Artifacts are attached to the run for download/smoke-testing.
5+
name: Windows build (no release)
6+
7+
on:
8+
workflow_dispatch:
9+
10+
jobs:
11+
build:
12+
runs-on: windows-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Install NASM
17+
# The vendored OpenSSL that backs SQLCipher assembles with NASM on MSVC.
18+
# Strawberry Perl is already on the runner; NASM is not.
19+
uses: ilammy/setup-nasm@v1
20+
21+
- name: Set up Node
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: 20
25+
26+
- name: Set up Rust
27+
uses: dtolnay/rust-toolchain@stable
28+
29+
- name: Cache Rust build
30+
uses: swatinem/rust-cache@v2
31+
with:
32+
workspaces: ./src-tauri -> target
33+
34+
- name: Install frontend dependencies
35+
run: npm ci
36+
37+
# No `cargo test` here: Windows test binaries die at launch with
38+
# STATUS_ENTRYPOINT_NOT_FOUND because tauri-build links the app manifest
39+
# only to the main binary (tauri-apps/tauri#13419). The shipped app is
40+
# unaffected; the suite runs on macOS/Linux where the logic is identical.
41+
42+
- name: Build installers
43+
# No tagName/releaseId → tauri-action builds but does not touch releases.
44+
uses: tauri-apps/tauri-action@v0
45+
env:
46+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+
# createUpdaterArtifacts is on in tauri.conf.json, so `tauri build`
48+
# refuses to run without the updater signing key even here.
49+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
50+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
51+
52+
- name: Upload installers
53+
uses: actions/upload-artifact@v4
54+
with:
55+
name: field-notes-windows
56+
path: |
57+
src-tauri/target/release/bundle/nsis/*.exe
58+
src-tauri/target/release/bundle/msi/*.msi
59+
if-no-files-found: error

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ anywhere.
8383

8484
## Architecture
8585

86-
- **Tauri 2 + Svelte (TypeScript)** desktop app (macOS + Linux).
86+
- **Tauri 2 + Svelte (TypeScript)** desktop app (macOS + Linux + Windows).
8787
- **Local SQLite** (`rusqlite`, bundled) at the app data dir — `substances`,
8888
`experiences`, `doses`, `timeline_events`. No network, no accounts.
8989
- The safety-critical layers are **deterministic Rust, independent of any model**
@@ -110,12 +110,16 @@ Download an installer for your platform from the
110110
so on first launch **right-click → Open** (or `xattr -dr com.apple.quarantine
111111
"/Applications/Field Notes.app"`).
112112
- **Linux** — the `.AppImage` (make it executable and run), or the `.deb` / `.rpm`.
113+
- **Windows** — run the `-setup.exe` installer (or the `.msi`). Not code-signed yet,
114+
so SmartScreen may warn on first launch — click **More info → Run anyway**.
113115

114116
**To update:** grab the latest release and install it over the old version
115-
(replace the app in Applications, or the AppImage; reinstall the `.deb`/`.rpm`).
116-
**Your journal is safe** — all data lives in the OS app-data directory
117+
(replace the app in Applications, or the AppImage; reinstall the `.deb`/`.rpm`;
118+
re-run the Windows installer). **Your journal is safe** — all data lives in the
119+
OS app-data directory
117120
(`~/Library/Application Support/com.fieldnotes.journal` on macOS,
118-
`~/.local/share/com.fieldnotes.journal` on Linux), separate from the app bundle,
121+
`~/.local/share/com.fieldnotes.journal` on Linux,
122+
`%APPDATA%\com.fieldnotes.journal` on Windows), separate from the app bundle,
119123
so updating never touches it. **From v0.2.0 on, the app checks for updates on
120124
launch** and offers to install them in place ("Install & restart") — signed and
121125
verified, fully in-app. (v0.1.0 predates the updater, so update to v0.2.0 manually

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "field-notes",
3-
"version": "0.4.1",
3+
"version": "0.5.0",
44
"description": "Offline harm-reduction journal & trip-sitting workstation.",
55
"type": "module",
66
"private": true,

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "field_notes"
3-
version = "0.4.1"
3+
version = "0.5.0"
44
description = "Offline harm-reduction journal & trip-sitting workstation"
55
authors = ["sparkly-quasar"]
66
# Licensed under the PolyForm Noncommercial License 1.0.0 — see ../LICENSE.

src-tauri/src/commands.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -884,7 +884,11 @@ pub fn reveal_data_dir(db: State<'_, Db>) -> Result<(), String> {
884884
{
885885
std::process::Command::new("xdg-open").arg(dir).spawn().map_err(err)?;
886886
}
887-
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
887+
#[cfg(target_os = "windows")]
888+
{
889+
std::process::Command::new("explorer").arg(dir).spawn().map_err(err)?;
890+
}
891+
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
888892
{
889893
let _ = dir;
890894
return Err("Opening the data folder isn't supported on this platform.".to_string());
@@ -980,21 +984,30 @@ pub struct TailscaleStatus {
980984

981985
/// Where Tailscale's CLI actually lives. The Mac App Store build hides it inside
982986
/// the .app, which is why `which tailscale` finds nothing on plenty of machines.
987+
/// The Windows installer puts it in Program Files without touching PATH either.
983988
fn tailscale_bin() -> Option<String> {
989+
#[cfg(not(windows))]
984990
const PATHS: &[&str] = &[
985991
"/usr/local/bin/tailscale",
986992
"/opt/homebrew/bin/tailscale",
987993
"/usr/bin/tailscale",
988994
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
989995
];
996+
#[cfg(windows)]
997+
const PATHS: &[&str] = &[
998+
r"C:\Program Files\Tailscale\tailscale.exe",
999+
r"C:\Program Files (x86)\Tailscale IPN\tailscale.exe",
1000+
];
9901001
PATHS.iter().find(|p| Path::new(p).exists()).map(|p| p.to_string())
9911002
}
9921003

9931004
/// Run a `tailscale` subcommand, returning its stderr as the error. Tailscale's own
9941005
/// messages are the useful ones here ("HTTPS must be enabled in the admin console",
9951006
/// "not logged in"), and a generic "failed to publish" would throw them away.
9961007
fn tailscale_run(bin: &str, args: &[&str]) -> Result<String, String> {
997-
let out = std::process::Command::new(bin)
1008+
let mut cmd = std::process::Command::new(bin);
1009+
crate::ollama::hide_console(&mut cmd);
1010+
let out = cmd
9981011
.args(args)
9991012
.output()
10001013
.map_err(|e| format!("Couldn't run Tailscale: {e}"))?;
@@ -1017,11 +1030,9 @@ pub fn portal_tailscale(portal: State<'_, Portal>) -> TailscaleStatus {
10171030
};
10181031
};
10191032

1020-
let host = std::process::Command::new(&bin)
1021-
.args(["status", "--json"])
1022-
.output()
1033+
let host = tailscale_run(&bin, &["status", "--json"])
10231034
.ok()
1024-
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
1035+
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
10251036
.and_then(|v| {
10261037
let dns = v["Self"]["DNSName"].as_str()?.trim_end_matches('.').to_string();
10271038
(!dns.is_empty()).then_some(dns)
@@ -1052,7 +1063,7 @@ pub fn portal_tailscale(portal: State<'_, Portal>) -> TailscaleStatus {
10521063
/// refuses if the portal isn't actually running, rather than serving a dead port.
10531064
#[tauri::command]
10541065
pub fn portal_serve(portal: State<'_, Portal>) -> Result<TailscaleStatus, String> {
1055-
let bin = tailscale_bin().ok_or("Tailscale isn't installed on this Mac.")?;
1066+
let bin = tailscale_bin().ok_or("Tailscale isn't installed on this computer.")?;
10561067
let port = portal.status().port.ok_or("Turn on phone access first.")?;
10571068
tailscale_run(&bin, &["serve", "--bg", &port.to_string()])?;
10581069
Ok(portal_tailscale(portal))
@@ -1062,7 +1073,7 @@ pub fn portal_serve(portal: State<'_, Portal>) -> Result<TailscaleStatus, String
10621073
/// the tailnet's route to it.
10631074
#[tauri::command]
10641075
pub fn portal_unserve(portal: State<'_, Portal>) -> Result<TailscaleStatus, String> {
1065-
let bin = tailscale_bin().ok_or("Tailscale isn't installed on this Mac.")?;
1076+
let bin = tailscale_bin().ok_or("Tailscale isn't installed on this computer.")?;
10661077
tailscale_run(&bin, &["serve", "--https=443", "off"])?;
10671078
Ok(portal_tailscale(portal))
10681079
}

src-tauri/src/ollama.rs

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const BASE: &str = "http://127.0.0.1:11434";
1717
/// install locations. A macOS app launched from Finder/Dock inherits only a
1818
/// minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) — *not* `/opt/homebrew/bin` —
1919
/// so without this, `brew` and `ollama` look "missing" even when installed.
20+
/// On Windows the equivalent gap is a PATH captured before Ollama's installer
21+
/// appended to the *user* PATH, so its default install dir is added explicitly.
2022
fn search_dirs() -> Vec<PathBuf> {
2123
let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
2224
.map(|p| std::env::split_paths(&p).collect())
@@ -26,30 +28,57 @@ fn search_dirs() -> Vec<PathBuf> {
2628
dirs.push(d);
2729
}
2830
};
29-
for d in [
30-
"/opt/homebrew/bin", // Apple Silicon Homebrew
31-
"/opt/homebrew/sbin",
32-
"/usr/local/bin", // Intel Homebrew + Ollama.app CLI symlink
33-
"/usr/local/sbin",
34-
] {
35-
push(&mut dirs, PathBuf::from(d));
31+
#[cfg(unix)]
32+
{
33+
for d in [
34+
"/opt/homebrew/bin", // Apple Silicon Homebrew
35+
"/opt/homebrew/sbin",
36+
"/usr/local/bin", // Intel Homebrew + Ollama.app CLI symlink
37+
"/usr/local/sbin",
38+
] {
39+
push(&mut dirs, PathBuf::from(d));
40+
}
41+
if let Some(home) = std::env::var_os("HOME") {
42+
for sub in [".local/bin", ".ollama/bin"] {
43+
push(&mut dirs, PathBuf::from(&home).join(sub));
44+
}
45+
}
3646
}
37-
if let Some(home) = std::env::var_os("HOME") {
38-
for sub in [".local/bin", ".ollama/bin"] {
39-
push(&mut dirs, PathBuf::from(&home).join(sub));
47+
#[cfg(windows)]
48+
{
49+
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
50+
// OllamaSetup.exe's per-user default.
51+
push(&mut dirs, PathBuf::from(&local).join("Programs").join("Ollama"));
52+
}
53+
if let Some(pf) = std::env::var_os("ProgramFiles") {
54+
push(&mut dirs, PathBuf::from(&pf).join("Ollama"));
4055
}
4156
}
4257
dirs
4358
}
4459

4560
/// Absolute path of an executable found across [`search_dirs`], if any.
4661
fn find_bin(name: &str) -> Option<PathBuf> {
62+
let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
4763
search_dirs()
4864
.into_iter()
49-
.map(|d| d.join(name))
65+
.map(|d| d.join(&file))
5066
.find(|p| p.is_file())
5167
}
5268

69+
/// Keep a spawned console tool from flashing a terminal window on Windows
70+
/// (CREATE_NO_WINDOW). The app itself is `windows_subsystem = "windows"`, so
71+
/// every `Command` would otherwise pop one open. No-op elsewhere.
72+
pub fn hide_console(cmd: &mut Command) {
73+
#[cfg(windows)]
74+
{
75+
use std::os::windows::process::CommandExt;
76+
cmd.creation_flags(0x0800_0000);
77+
}
78+
#[cfg(not(windows))]
79+
let _ = cmd;
80+
}
81+
5382
/// A `Command` for a CLI tool, resolved to its absolute path and with an
5483
/// augmented PATH exported so any subprocess it spawns resolves too. Falls back
5584
/// to the bare name (letting the OS search) when the binary isn't found.
@@ -60,6 +89,7 @@ fn command(name: &str) -> Command {
6089
None => Command::new(name),
6190
};
6291
cmd.env("PATH", path);
92+
hide_console(&mut cmd);
6393
cmd
6494
}
6595

@@ -129,7 +159,8 @@ fn run_streamed(app: &AppHandle, event: &str, mut cmd: Command) -> Result<(), St
129159
}
130160
}
131161

132-
/// Install Ollama (macOS: Homebrew; Linux: official script), streaming progress.
162+
/// Install Ollama (macOS: Homebrew; Linux: official script; Windows: WinGet),
163+
/// streaming progress.
133164
pub fn install(app: &AppHandle) -> Result<(), String> {
134165
let _ = app.emit("ai-progress", "Installing Ollama…".to_string());
135166

@@ -149,7 +180,21 @@ pub fn install(app: &AppHandle) -> Result<(), String> {
149180
cmd.args(["-c", "curl -fsSL https://ollama.com/install.sh | sh"]);
150181
run_streamed(app, "ai-progress", cmd)?;
151182
}
152-
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
183+
#[cfg(target_os = "windows")]
184+
{
185+
let has_winget = command("winget").arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false);
186+
if !has_winget {
187+
return Err("WinGet isn't available. Install Ollama from https://ollama.com/download, then reopen this.".into());
188+
}
189+
let mut cmd = command("winget");
190+
cmd.args([
191+
"install", "--exact", "--id", "Ollama.Ollama",
192+
"--silent", "--accept-source-agreements", "--accept-package-agreements",
193+
"--disable-interactivity",
194+
]);
195+
run_streamed(app, "ai-progress", cmd)?;
196+
}
197+
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
153198
{
154199
return Err("Automatic install isn't supported on this platform. Install Ollama from https://ollama.com/download.".into());
155200
}

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Field Notes",
4-
"version": "0.4.1",
4+
"version": "0.5.0",
55
"identifier": "com.fieldnotes.journal",
66
"build": {
77
"beforeDevCommand": "npm run dev",

0 commit comments

Comments
 (0)