Newer
Older
EARS-LINUX / program / Makefile
# ===================================================================== config
APP         := ears
VERSION     := 1.0.0
MAINTAINER  := Kazi <kazi.m@chiba-u.jp>
SUMMARY     := Auscultation training software
HOMEPAGE     = https://nlab.tms.chiba-u.jp/
YEAR        := $(shell date +%Y)
DATESTAMP   := $(shell date -R)

# literal '#', so make never eats shebangs or comment lines as its own comments
H           := \#

PREFIX      ?= /usr
DESTDIR     ?=
LIBDIR      := $(PREFIX)/lib/$(APP)
BINDIR      := $(PREFIX)/bin

MCS         := mcs
PKGS        := -pkg:gtk-sharp-3.0
MCSFLAGS    := -optimize+ -langversion:latest -warn:4

# ------------------------------------------------------------- source discovery
# Uses src/ and data/ when present, otherwise the project root.
SRCDIR      ?= $(if $(wildcard src/*.cs),src,.)
DATADIR     ?= $(if $(wildcard data/*.glade),data,.)

# .cs files in SRCDIR that must NOT be compiled (old WinForms code, scratch files)
EXCLUDE     ?= Form1.cs Form2.cs

SRC  := $(filter-out $(addprefix $(SRCDIR)/,$(EXCLUDE)),$(wildcard $(SRCDIR)/*.cs))
DATA := $(wildcard $(DATADIR)/*.glade) $(wildcard $(DATADIR)/*.css) \
        $(wildcard $(DATADIR)/*.csv)   $(wildcard $(DATADIR)/*.png)

# ---------------------------------------------------------------- output layout
OUT     := bin
LOUT    := $(OUT)/linux
LINST   := $(LOUT)/installer
LGEN    := $(LINST)/gen
LROOT   := $(LINST)/root
EXE     := $(LOUT)/$(APP).exe
DEB     := $(LINST)/$(APP)_$(VERSION)_all.deb

WOUT    := $(OUT)/windows
WBUNDLE := $(WOUT)/$(APP)
WINST   := $(WOUT)/installer
WGEN    := $(WINST)/gen
DBGEXE  := $(WBUNDLE)/$(APP)-debug.exe
WINZIP  := $(WOUT)/$(APP)-$(VERSION)-windows.zip
SETUP   := $(WINST)/$(APP)-$(VERSION)-setup.exe

# gtk-sharp managed assemblies + their dllmap .config files (for the Win bundle)
GTKSHARP := $(shell pkg-config --variable=Libraries gtk-sharp-3.0 2>/dev/null)
ifeq ($(strip $(GTKSHARP)),)
GTKSHARP := $(wildcard /usr/lib/cli/*/*.dll)
endif
GTKSHARP_CFG := $(wildcard $(addsuffix .config,$(GTKSHARP)))

MSYS2 ?= /c/msys64
MINGW := $(MSYS2)/mingw64
ISCC  ?= iscc

.DEFAULT_GOAL := all
.PHONY: all build run debug clean distclean dep-check show
.PHONY: gen install uninstall deb deb-install deb-remove deb-test lint
.PHONY: win win-gtk installer everything

all: deb
build: $(EXE)
everything: deb win

$(LOUT) $(LGEN) $(LROOT) $(WBUNDLE) $(WGEN):
	mkdir -p $@

# ================================================================== 1. compile
$(EXE): $(SRC) | $(LOUT)
	@test -n "$(strip $(SRC))" || { \
	  echo "ERROR: no .cs files found (SRCDIR=$(SRCDIR)). Run 'make show'."; exit 1; }
	$(MCS) -target:winexe $(PKGS) $(MCSFLAGS) -out:$@ $(SRC)

# console build: -target:exe keeps Console.WriteLine visible on Windows
$(DBGEXE): $(SRC) | $(WBUNDLE)
	@test -n "$(strip $(SRC))" || { \
	  echo "ERROR: no .cs files found (SRCDIR=$(SRCDIR)). Run 'make show'."; exit 1; }
	$(MCS) -target:exe -debug -define:DEBUG $(PKGS) -out:$@ $(SRC)

debug: $(DBGEXE)

run: build
	EARS_DATADIR=$(CURDIR)/$(DATADIR) mono $(EXE)

clean:
	rm -rf $(OUT)
distclean: clean

show:
	@echo "SRCDIR  = $(SRCDIR)"
	@echo "DATADIR = $(DATADIR)"
	@echo "SRC     = $(words $(SRC)) file(s)"
	@$(foreach f,$(SRC),echo "          $(f)";)
	@echo "DATA    = $(words $(DATA)) file(s)"
	@$(foreach f,$(DATA),echo "          $(f)";)
	@echo "EXE     = $(EXE)"
	@echo "DEB     = $(DEB)"
	@echo "GTK#    = $(words $(GTKSHARP)) managed assemblies"

dep-check:
	@command -v $(MCS) >/dev/null || { echo "missing: mono-devel (mcs)"; exit 1; }
	@pkg-config --exists gtk-sharp-3.0 || { echo "missing: gtk-sharp3"; exit 1; }
	@command -v dpkg-deb >/dev/null || { echo "missing: dpkg-dev"; exit 1; }
	@echo "toolchain OK"

# ========================================================== 2. generated files
define GEN_WRAPPER
$(H)!/bin/sh
$(H) $(APP) launcher - generated by make, do not edit
: "$${EARS_DATADIR:=$(LIBDIR)}"
export EARS_DATADIR
exec /usr/bin/mono "$(LIBDIR)/$(APP).exe" "$$@"
endef

define GEN_DESKTOP
[Desktop Entry]
Type=Application
Name=EARS
GenericName=Serial control panel
Comment=$(SUMMARY)
Exec=$(APP)
Icon=$(APP)
Terminal=false
Categories=Utility;Electronics;
Keywords=serial;esp32;arduino;
StartupNotify=true
endef

define GEN_ICON
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
  <rect x="8" y="8" width="112" height="112" rx="24" fill="rgb(31,111,235)"/>
  <g fill="none" stroke="rgb(255,255,255)" stroke-width="8" stroke-linecap="round">
    <path d="M40 82 V54 a24 24 0 0 1 48 0 V82"/>
    <path d="M34 82 h20 M74 82 h20"/>
  </g>
  <circle cx="64" cy="100" r="7" fill="rgb(255,255,255)"/>
</svg>
endef

define GEN_UDEV
$(H) generated by make - serial adapters used by $(APP)
$(H) TAG+="uaccess" gives the device to the active desktop user with no group.
$(H) GROUP="dialout" is the fallback for ssh / headless / WSL sessions.
ACTION!="add", GOTO="ears_end"
SUBSYSTEM!="tty", GOTO="ears_end"
ATTRS{idVendor}=="303a", MODE="0660", GROUP="dialout", TAG+="uaccess"
ATTRS{idVendor}=="10c4", MODE="0660", GROUP="dialout", TAG+="uaccess"
ATTRS{idVendor}=="1a86", MODE="0660", GROUP="dialout", TAG+="uaccess"
ATTRS{idVendor}=="0403", MODE="0660", GROUP="dialout", TAG+="uaccess"
ATTRS{idVendor}=="2341", MODE="0660", GROUP="dialout", TAG+="uaccess"
LABEL="ears_end"
endef

define GEN_CONTROL
Package: $(APP)
Version: $(VERSION)
Section: utils
Priority: optional
Architecture: all
Depends: mono-runtime (>= 6.0), gtk-sharp3, libgtk-3-0 | libgtk-3-0t64, libmono-system4.0-cil | mono-complete
Recommends: mono-complete
Installed-Size: @SIZE@
Maintainer: $(MAINTAINER)
Homepage: $(HOMEPAGE)
Description: $(SUMMARY)
 GTK$(H)3 front end that talks to an ESP32 over a serial port, with
 English/Japanese language switching and a live device log.
 .
 Architecture is "all" because the payload is architecture-neutral CIL
 executed by the Mono runtime.
endef

define GEN_POSTINST
$(H)!/bin/sh
set -e
case "$$1" in
configure)
    if command -v udevadm >/dev/null 2>&1; then
        udevadm control --reload-rules >/dev/null 2>&1 || true
        udevadm trigger --subsystem-match=tty >/dev/null 2>&1 || true
    fi

    if [ -n "$$SUDO_USER" ] && getent group dialout >/dev/null 2>&1; then
        if ! id -nG "$$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx dialout; then
            usermod -aG dialout "$$SUDO_USER" >/dev/null 2>&1 || true
            echo "$(APP): added '$$SUDO_USER' to group 'dialout'."
            echo "$(APP): log out and back in for serial access to take effect."
        fi
    fi

    if command -v update-desktop-database >/dev/null 2>&1; then
        update-desktop-database -q /usr/share/applications || true
    fi
    if command -v gtk-update-icon-cache >/dev/null 2>&1; then
        gtk-update-icon-cache -qtf /usr/share/icons/hicolor || true
    fi
    ;;
esac
exit 0
endef

define GEN_POSTRM
$(H)!/bin/sh
set -e
if command -v update-desktop-database >/dev/null 2>&1; then
    update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
    gtk-update-icon-cache -qtf /usr/share/icons/hicolor || true
fi
exit 0
endef

define GEN_CHANGELOG
$(APP) ($(VERSION)) stable; urgency=medium

  * Package generated by make.

 -- $(MAINTAINER)  $(DATESTAMP)
endef

define GEN_COPYRIGHT
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: $(APP)

Files: *
Copyright: $(YEAR) $(MAINTAINER)
License: proprietary
 All rights reserved.
endef

gen: | $(LGEN)
	@$(file >$(LGEN)/$(APP).sh,$(GEN_WRAPPER))
	@$(file >$(LGEN)/$(APP).desktop,$(GEN_DESKTOP))
	@$(file >$(LGEN)/$(APP).svg,$(GEN_ICON))
	@$(file >$(LGEN)/60-$(APP)-serial.rules,$(GEN_UDEV))
	@$(file >$(LGEN)/control,$(GEN_CONTROL))
	@$(file >$(LGEN)/postinst,$(GEN_POSTINST))
	@$(file >$(LGEN)/postrm,$(GEN_POSTRM))
	@$(file >$(LGEN)/changelog,$(GEN_CHANGELOG))
	@$(file >$(LGEN)/copyright,$(GEN_COPYRIGHT))
	@chmod 755 $(LGEN)/postinst $(LGEN)/postrm $(LGEN)/$(APP).sh
	@echo "generated packaging sources in $(LGEN)"

# =========================================================== 3. make install
install: build gen
	install -d $(DESTDIR)$(LIBDIR) $(DESTDIR)$(BINDIR)
	install -m 644 $(EXE) $(DESTDIR)$(LIBDIR)/$(APP).exe
	@if [ -n "$(strip $(DATA))" ]; then \
	  echo "install -m 644 <$(words $(DATA)) data files> $(DESTDIR)$(LIBDIR)/"; \
	  install -m 644 $(DATA) $(DESTDIR)$(LIBDIR)/; \
	else \
	  echo "WARNING: no data files found in '$(DATADIR)'"; \
	fi
	install -m 755 $(LGEN)/$(APP).sh $(DESTDIR)$(BINDIR)/$(APP)
	install -D -m 644 $(LGEN)/$(APP).desktop \
	  $(DESTDIR)$(PREFIX)/share/applications/$(APP).desktop
	install -D -m 644 $(LGEN)/$(APP).svg \
	  $(DESTDIR)$(PREFIX)/share/icons/hicolor/scalable/apps/$(APP).svg
	install -D -m 644 $(LGEN)/60-$(APP)-serial.rules \
	  $(DESTDIR)/lib/udev/rules.d/60-$(APP)-serial.rules
	install -D -m 644 $(LGEN)/copyright \
	  $(DESTDIR)$(PREFIX)/share/doc/$(APP)/copyright
	gzip -9nc $(LGEN)/changelog > $(LGEN)/changelog.gz
	install -D -m 644 $(LGEN)/changelog.gz \
	  $(DESTDIR)$(PREFIX)/share/doc/$(APP)/changelog.Debian.gz

uninstall:
	rm -rf $(DESTDIR)$(LIBDIR) $(DESTDIR)$(PREFIX)/share/doc/$(APP)
	rm -f  $(DESTDIR)$(BINDIR)/$(APP) \
	       $(DESTDIR)$(PREFIX)/share/applications/$(APP).desktop \
	       $(DESTDIR)$(PREFIX)/share/icons/hicolor/scalable/apps/$(APP).svg \
	       $(DESTDIR)/lib/udev/rules.d/60-$(APP)-serial.rules

# ==================================================================== 4. .deb
deb: build gen
	rm -rf $(LROOT)
	$(MAKE) --no-print-directory install DESTDIR=$(CURDIR)/$(LROOT) PREFIX=/usr
	install -d $(LROOT)/DEBIAN
	install -m 755 $(LGEN)/postinst $(LGEN)/postrm $(LROOT)/DEBIAN/
	sed "s|@SIZE@|$$(du -ks --exclude=DEBIAN $(LROOT) | cut -f1)|" \
	  $(LGEN)/control > $(LROOT)/DEBIAN/control
	cd $(LROOT) && find . -type f ! -path './DEBIAN/*' -printf '%P\0' \
	  | LC_ALL=C sort -z | xargs -0 md5sum > DEBIAN/md5sums
	find $(LROOT) -type d -exec chmod 755 {} +
	dpkg-deb --build --root-owner-group $(LROOT) $(DEB)
	@echo
	@echo "==> $(DEB)"
	@echo "    sudo apt install ./$(DEB)      # the ./ is required"

deb-install: deb
	sudo apt install -y ./$(DEB)

deb-remove:
	sudo apt remove -y $(APP)

lint: deb
	-lintian --no-tag-display-limit $(DEB)

deb-test: deb
	docker run --rm -v "$(CURDIR)/$(LINST)":/pkg:ro ubuntu:24.04 sh -c '\
	  set -e; \
	  apt-get update -qq; \
	  DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
	    /pkg/$(notdir $(DEB)) xvfb >/dev/null; \
	  echo "--- files:"; dpkg -L $(APP) | head -20; \
	  echo "--- runtime:"; mono --version | head -1; \
	  echo "--- smoke test:"; \
	  timeout 15 xvfb-run -a $(APP) || echo "exit=$$? (124 = window stayed open, OK)"'

# ================================================================= 5. windows
define GEN_WINBAT
@echo off
setlocal
set "HERE=%~dp0"
set "EARS_DATADIR=%HERE%"
if exist "%HERE%gtk\bin" set "PATH=%HERE%gtk\bin;%PATH%"
if exist "%HERE%gtk\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache" set "GDK_PIXBUF_MODULE_FILE=%HERE%gtk\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache"
if exist "%HERE%gtk\share\glib-2.0\schemas" set "GSETTINGS_SCHEMA_DIR=%HERE%gtk\share\glib-2.0\schemas"
if exist "%HERE%gtk\share" set "XDG_DATA_DIRS=%HERE%gtk\share;%XDG_DATA_DIRS%"
set "MONO=mono"
where mono >nul 2>&1 || set "MONO=%ProgramFiles%\Mono\bin\mono.exe"
if /i not "%MONO%"=="mono" if not exist "%MONO%" (
  echo Mono for Windows was not found.
  echo Install it from https://www.mono-project.com/download/stable/
  pause
  exit /b 1
)
start "" "%MONO%" "%HERE%$(APP).exe" %*
endef

define GEN_WINBATDBG
@echo off
setlocal
set "HERE=%~dp0"
set "EARS_DATADIR=%HERE%"
if exist "%HERE%gtk\bin" set "PATH=%HERE%gtk\bin;%PATH%"
if exist "%HERE%gtk\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache" set "GDK_PIXBUF_MODULE_FILE=%HERE%gtk\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache"
if exist "%HERE%gtk\share\glib-2.0\schemas" set "GSETTINGS_SCHEMA_DIR=%HERE%gtk\share\glib-2.0\schemas"
if exist "%HERE%gtk\share" set "XDG_DATA_DIRS=%HERE%gtk\share;%XDG_DATA_DIRS%"
set "MONO=mono"
where mono >nul 2>&1 || set "MONO=%ProgramFiles%\Mono\bin\mono.exe"
set "MONO_LOG_LEVEL=warning"
echo Running $(APP) with console output. Close this window to quit.
"%MONO%" "%HERE%$(APP)-debug.exe" %*
echo.
echo exit code %ERRORLEVEL%
pause
endef

win: build debug | $(WBUNDLE)
	cp $(EXE) $(WBUNDLE)/$(APP).exe
	@if [ -n "$(strip $(DATA))" ]; then cp $(DATA) $(WBUNDLE)/; \
	 else echo "WARNING: no data files found in '$(DATADIR)'"; fi
	@if [ -n "$(strip $(GTKSHARP))" ]; then \
	  cp $(GTKSHARP) $(WBUNDLE)/; \
	  echo "copied $(words $(GTKSHARP)) gtk-sharp assemblies"; \
	 else echo "WARNING: gtk-sharp managed assemblies not found"; fi
	@if [ -n "$(strip $(GTKSHARP_CFG))" ]; then cp $(GTKSHARP_CFG) $(WBUNDLE)/; fi
	@$(file >$(WBUNDLE)/$(APP).bat,$(GEN_WINBAT))
	@$(file >$(WBUNDLE)/$(APP)-debug.bat,$(GEN_WINBATDBG))
	-$(MAKE) --no-print-directory win-gtk MSYS2=$(MSYS2)
	cd $(WOUT) && rm -f $(notdir $(WINZIP)) && zip -qr $(notdir $(WINZIP)) $(APP)
	@echo "==> $(WINZIP)"

win-gtk: | $(WBUNDLE)
	@test -d $(MINGW)/bin || { \
	  echo "MSYS2 not found at $(MINGW) - shipping without native GTK."; \
	  echo "  pacman -S mingw-w64-x86_64-gtk3 mingw-w64-x86_64-librsvg mingw-w64-x86_64-ntldd"; \
	  echo "  then: make win MSYS2=/path/to/msys64"; exit 1; }
	rm -rf $(WBUNDLE)/gtk
	mkdir -p $(WBUNDLE)/gtk/bin $(WBUNDLE)/gtk/share $(WBUNDLE)/gtk/lib
	@echo "resolving native GTK dependencies..."
	@if command -v ntldd >/dev/null 2>&1; then \
	  for root in libgtk-3-0.dll librsvg-2-2.dll libgdk_pixbuf-2.0-0.dll; do \
	    [ -f $(MINGW)/bin/$$root ] || continue; \
	    ntldd -R $(MINGW)/bin/$$root \
	      | grep -io '[a-z]:[\\/][^ ]*mingw64[\\/]bin[\\/][^ ]*\.dll' \
	      | tr '\\' '/' | sort -u \
	      | while read -r dll; do cp -u "$$dll" $(WBUNDLE)/gtk/bin/ 2>/dev/null || true; done; \
	    cp -u $(MINGW)/bin/$$root $(WBUNDLE)/gtk/bin/; \
	  done; \
	else \
	  echo "ntldd missing - copying all of mingw64/bin (larger bundle)"; \
	  cp -u $(MINGW)/bin/*.dll $(WBUNDLE)/gtk/bin/; \
	fi
	-cp -r $(MINGW)/lib/gdk-pixbuf-2.0 $(WBUNDLE)/gtk/lib/
	-cp -r $(MINGW)/share/glib-2.0     $(WBUNDLE)/gtk/share/
	-cp -r $(MINGW)/share/icons        $(WBUNDLE)/gtk/share/
	-cp -r $(MINGW)/share/themes       $(WBUNDLE)/gtk/share/
	@echo "gtk runtime staged ($$(du -sh $(WBUNDLE)/gtk | cut -f1))"

define GEN_ISS
[Setup]
AppName=$(APP)
AppVersion=$(VERSION)
AppPublisher=$(MAINTAINER)
DefaultDirName={autopf}\$(APP)
DefaultGroupName=$(APP)
OutputBaseFilename=$(APP)-$(VERSION)-setup
OutputDir=.
Compression=lzma2/max
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
UninstallDisplayIcon={app}\$(APP).exe
WizardStyle=modern

[Files]
Source: "..\..\$(APP)\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion

[Icons]
Name: "{group}\$(APP)"; Filename: "{app}\$(APP).bat"; IconFilename: "{app}\$(APP).exe"
Name: "{group}\$(APP) (console)"; Filename: "{app}\$(APP)-debug.bat"; IconFilename: "{app}\$(APP).exe"
Name: "{autodesktop}\$(APP)"; Filename: "{app}\$(APP).bat"; IconFilename: "{app}\$(APP).exe"; Tasks: desktopicon

[Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"

[Run]
Filename: "{app}\$(APP).bat"; Description: "Launch $(APP)"; Flags: postinstall nowait skipifsilent shellexec

[Code]
function MonoPresent(): Boolean;
begin
  Result := FileExists(ExpandConstant('{commonpf}\Mono\bin\mono.exe')) or
            FileExists(ExpandConstant('{commonpf32}\Mono\bin\mono.exe')) or
            RegKeyExists(HKLM, 'SOFTWARE\Novell\Mono') or
            RegKeyExists(HKLM, 'SOFTWARE\WOW6432Node\Novell\Mono');
end;

function InitializeSetup(): Boolean;
var Code: Integer;
begin
  Result := True;
  if not MonoPresent() then
    if MsgBox('Mono for Windows was not detected. Open the download page now?',
              mbConfirmation, MB_YESNO) = IDYES then
      ShellExec('open', 'https://www.mono-project.com/download/stable/',
                '', '', SW_SHOW, ewNoWait, Code);
end;
endef

installer: win | $(WGEN)
	@$(file >$(WGEN)/$(APP).iss,$(GEN_ISS))
	@command -v $(ISCC) >/dev/null 2>&1 || { \
	  echo "Inno Setup compiler '$(ISCC)' not found."; \
	  echo "  Windows: make installer ISCC=\"/c/Program Files (x86)/Inno Setup 6/ISCC.exe\""; \
	  echo "  Linux:   make installer ISCC=\"wine /path/to/ISCC.exe\""; exit 1; }
	cd $(WGEN) && $(ISCC) $(APP).iss
	mv $(WGEN)/$(APP)-$(VERSION)-setup.exe $(SETUP)
	@echo "==> $(SETUP)"