21 min read

> "The best programs are written so that computing machines can perform them quickly and so that human beings can understand them clearly."

Learning Objectives

  • Access environment variables and command-line arguments
  • Perform advanced file system operations (directory traversal, file attributes, searching)
  • Launch external processes and capture their output using TProcess
  • Use conditional compilation to write cross-platform code
  • Call C libraries from Pascal using external declarations
  • Access Windows-specific APIs (registry, COM basics)
  • Access Linux-specific features (signals, /proc filesystem)
  • Add OS-specific path handling, auto-start registration, and system tray integration to PennyWise

Chapter 37: Interfacing with the Operating System: OS APIs, Processes, and Platform-Specific Code

"The best programs are written so that computing machines can perform them quickly and so that human beings can understand them clearly." — Donald Knuth


37.1 Accessing the Operating System from Pascal

Every program runs inside an operating system — Windows, Linux, macOS, or something else. The OS manages files, memory, processes, hardware, and user accounts. Until now, we have mostly worked within Pascal's own abstractions: ReadLn and WriteLn for I/O, AssignFile for files, TThread for concurrency. But these abstractions cover only a fraction of what the OS offers.

This chapter takes you through the OS interface layer: reading environment variables, navigating the filesystem, launching external programs, detecting the platform, calling C libraries, and accessing platform-specific features like the Windows registry and the Linux /proc filesystem. By the end, PennyWise will know which OS it is running on, store data in the right directory, register itself for auto-start, and display a system tray icon.

This is where Pascal shows one of its greatest strengths: it compiles to native code and runs directly on the OS, without a virtual machine or interpreter. When you call an OS function from Pascal, there is no abstraction layer, no runtime overhead, no "bridge." The compiled Pascal code calls the OS API directly, just like C does. This is why Free Pascal can target dozens of platforms — it speaks the native language of each one.

💡 Intuition: Your Program as an OS Citizen Think of the operating system as a city and your program as a business operating within it. So far, your business has operated mostly within its own walls. Now you are going to city hall (environment variables, filesystem), hiring contractors (launching external processes), getting a business license that works in multiple cities (cross-platform code), and accessing services specific to your city (Windows registry, Linux signals).


37.2 Environment Variables and Command-Line Arguments

Every process inherits a set of environment variables from its parent — key-value pairs that configure the system environment. When you open a terminal and type fpc MyProgram.pas, the resulting MyProgram executable inherits a copy of the terminal's environment — dozens or hundreds of variables that describe the system's configuration. Your program can read these variables to adapt its behavior without hard-coding paths, usernames, or preferences.

Common environment variables:

Variable Platform Purpose
PATH All Directories to search for executables
HOME Unix User's home directory (/home/rosa)
USERPROFILE Windows User's home directory (C:\Users\Rosa)
TEMP / TMP Windows Temporary file directory
TMPDIR Unix Temporary file directory
LANG Unix Locale setting (e.g., en_US.UTF-8)
XDG_CONFIG_HOME Linux User configuration directory
APPDATA Windows Per-user application data
USER / USERNAME Unix / Windows Current user's login name

Environment variables are not just for system configuration. Many applications use custom environment variables for deployment configuration: DATABASE_URL for database connections, API_KEY for service credentials, DEBUG=1 for verbose logging. This pattern is particularly common in server applications and follows the "Twelve-Factor App" methodology.

Reading Environment Variables

program EnvVarDemo;

{$mode objfpc}{$H+}

uses
  SysUtils;

begin
  WriteLn('HOME:     ', GetEnvironmentVariable('HOME'));
  WriteLn('PATH:     ', GetEnvironmentVariable('PATH'));
  WriteLn('USER:     ', GetEnvironmentVariable('USER'));
  WriteLn('TEMP:     ', GetEnvironmentVariable('TEMP'));
  WriteLn('LANG:     ', GetEnvironmentVariable('LANG'));

  { Check if a variable exists }
  if GetEnvironmentVariable('MY_APP_CONFIG') <> '' then
    WriteLn('Config: ', GetEnvironmentVariable('MY_APP_CONFIG'))
  else
    WriteLn('MY_APP_CONFIG not set — using defaults');
end.

GetEnvironmentVariable is in the SysUtils unit. It returns an empty string if the variable is not set.

Platform-Specific Application Directories

Different operating systems store application data in different places:

Platform App data location Example
Windows %APPDATA% C:\Users\Rosa\AppData\Roaming\PennyWise\
Linux $HOME/.config /home/rosa/.config/pennywise/
macOS ~/Library/Application Support /Users/rosa/Library/Application Support/PennyWise/

Here is a function that returns the correct directory for any platform:

function GetAppDataDir(const AppName: string): string;
begin
  {$IFDEF WINDOWS}
  Result := GetEnvironmentVariable('APPDATA');
  if Result = '' then
    Result := GetEnvironmentVariable('USERPROFILE') + '\AppData\Roaming';
  Result := IncludeTrailingPathDelimiter(Result) + AppName + PathDelim;
  {$ENDIF}

  {$IFDEF LINUX}
  Result := GetEnvironmentVariable('XDG_CONFIG_HOME');
  if Result = '' then
    Result := GetEnvironmentVariable('HOME') + '/.config';
  Result := IncludeTrailingPathDelimiter(Result) + LowerCase(AppName) + PathDelim;
  {$ENDIF}

  {$IFDEF DARWIN}
  Result := GetEnvironmentVariable('HOME') +
    '/Library/Application Support/' + AppName + PathDelim;
  {$ENDIF}

  { Create the directory if it does not exist }
  if not DirectoryExists(Result) then
    ForceDirectories(Result);
end;

Command-Line Arguments

Pascal provides ParamStr and ParamCount for accessing command-line arguments:

program ArgDemo;

{$mode objfpc}{$H+}

uses
  SysUtils;

var
  I: Integer;
begin
  WriteLn('Program: ', ParamStr(0));  { Full path to the executable }
  WriteLn('Argument count: ', ParamCount);

  for I := 1 to ParamCount do
    WriteLn('  Arg[', I, '] = "', ParamStr(I), '"');

  { Common pattern: check for flags }
  for I := 1 to ParamCount do
  begin
    if (ParamStr(I) = '--help') or (ParamStr(I) = '-h') then
    begin
      WriteLn('Usage: pennywise [options]');
      WriteLn('  --help, -h     Show this help');
      WriteLn('  --config FILE  Use specified config file');
      WriteLn('  --verbose      Enable verbose output');
      Halt(0);
    end;
  end;
end.

A Robust Command-Line Argument Parser

For PennyWise, we need structured argument parsing that handles flags, options with values, and help text:

type
  TCLIOptions = record
    ConfigFile: string;
    DBPath: string;
    Verbose: Boolean;
    NoSync: Boolean;
    ImportFile: string;
    ExportFormat: string;
    ShowHelp: Boolean;
  end;

function ParseCommandLine: TCLIOptions;
var
  I: Integer;
  Arg: string;
begin
  { Set defaults }
  Result.ConfigFile := '';
  Result.DBPath := '';
  Result.Verbose := False;
  Result.NoSync := False;
  Result.ImportFile := '';
  Result.ExportFormat := '';
  Result.ShowHelp := False;

  I := 1;
  while I <= ParamCount do
  begin
    Arg := ParamStr(I);

    if (Arg = '--help') or (Arg = '-h') then
      Result.ShowHelp := True
    else if (Arg = '--verbose') or (Arg = '-v') then
      Result.Verbose := True
    else if Arg = '--no-sync' then
      Result.NoSync := True
    else if (Arg = '--config') or (Arg = '-c') then
    begin
      Inc(I);
      if I <= ParamCount then
        Result.ConfigFile := ParamStr(I)
      else
        WriteLn('Error: --config requires a filename');
    end
    else if Arg = '--db-path' then
    begin
      Inc(I);
      if I <= ParamCount then
        Result.DBPath := ParamStr(I)
      else
        WriteLn('Error: --db-path requires a path');
    end
    else if Arg = '--import' then
    begin
      Inc(I);
      if I <= ParamCount then
        Result.ImportFile := ParamStr(I)
      else
        WriteLn('Error: --import requires a filename');
    end
    else if Arg = '--export' then
    begin
      Inc(I);
      if I <= ParamCount then
        Result.ExportFormat := ParamStr(I)
      else
        WriteLn('Error: --export requires a format (json/csv/xml)');
    end
    else
      WriteLn('Unknown option: ', Arg);

    Inc(I);
  end;
end;

procedure ShowUsage;
begin
  WriteLn('PennyWise Personal Finance Manager v3.4');
  WriteLn;
  WriteLn('Usage: pennywise [options]');
  WriteLn;
  WriteLn('Options:');
  WriteLn('  -h, --help          Show this help message');
  WriteLn('  -v, --verbose       Enable verbose logging');
  WriteLn('  -c, --config FILE   Use specified config file');
  WriteLn('  --db-path PATH      Override database file location');
  WriteLn('  --no-sync           Disable background sync');
  WriteLn('  --import FILE       Import expenses from CSV file');
  WriteLn('  --export FORMAT     Export expenses (json/csv/xml)');
  WriteLn;
  WriteLn('Examples:');
  WriteLn('  pennywise --import bank_statement.csv');
  WriteLn('  pennywise --export json --db-path /custom/path/expenses.db');
  WriteLn('  pennywise --config /etc/pennywise/config.ini --verbose');
end;

This argument parser follows Unix conventions: short flags (-v), long flags (--verbose), options with values (--config FILE), and a --help option. The ParseCommandLine function returns a record with all parsed options, and the main program uses this record to configure itself.

📊 Professional Practice Production applications typically use a command-line parsing library rather than manual ParamStr checking. Free Pascal includes CustApp with TCustomApplication that provides structured argument parsing with automatic help generation. For complex applications, consider the GetOpts unit which implements the POSIX getopt_long API. The manual approach shown above is appropriate for moderate complexity and has the advantage of zero external dependencies.

A Practical Pattern: Configuration Hierarchy

Well-designed applications use a configuration hierarchy where each level overrides the previous:

  1. Compiled defaults — hard-coded in the source
  2. System-wide config file/etc/pennywise.ini or C:\ProgramData\PennyWise\config.ini
  3. User config file~/.config/pennywise/config.ini or %APPDATA%\PennyWise\config.ini
  4. Environment variablesPENNYWISE_DB_PATH=/custom/path
  5. Command-line arguments--db-path /custom/path

Later levels override earlier ones. This means a user can override system defaults, and a command-line flag overrides everything. Here is how you might implement this:

function GetDatabasePath: string;
begin
  { Level 1: compiled default }
  Result := 'pennywise.db';

  { Level 3: user config (loaded from INI file) }
  if FConfig.ValueExists('Database', 'Path') then
    Result := FConfig.ReadString('Database', 'Path', Result);

  { Level 4: environment variable }
  if GetEnvironmentVariable('PENNYWISE_DB_PATH') <> '' then
    Result := GetEnvironmentVariable('PENNYWISE_DB_PATH');

  { Level 5: command-line argument }
  if FindCmdLineSwitch('db-path', Result) then
    { Result already set by FindCmdLineSwitch };
end;

This pattern is used by virtually every professional Unix tool — git, ssh, docker, and fpc itself all follow this hierarchy.


37.3 File System Operations

Chapter 13 covered basic file I/O. Here we go deeper: searching for files, traversing directories, reading file attributes, and performing bulk operations.

FindFirst / FindNext: Searching for Files

program FindFilesDemo;

{$mode objfpc}{$H+}

uses
  SysUtils;

procedure ListFiles(const Dir, Pattern: string);
var
  SR: TSearchRec;
  Count: Integer;
begin
  Count := 0;
  if FindFirst(IncludeTrailingPathDelimiter(Dir) + Pattern,
               faAnyFile, SR) = 0 then
  begin
    repeat
      if (SR.Name <> '.') and (SR.Name <> '..') then
      begin
        Inc(Count);
        if (SR.Attr and faDirectory) <> 0 then
          WriteLn('  [DIR]  ', SR.Name)
        else
          WriteLn(Format('  %8d  %s  %s',
            [SR.Size,
             FormatDateTime('yyyy-mm-dd hh:nn', FileDateToDateTime(SR.Time)),
             SR.Name]));
      end;
    until FindNext(SR) <> 0;
    FindClose(SR);
  end;
  WriteLn(Format('  --- %d items found ---', [Count]));
end;

begin
  WriteLn('Files in current directory:');
  ListFiles('.', '*');
  WriteLn;
  WriteLn('Pascal files:');
  ListFiles('.', '*.pas');
end.

Recursive Directory Traversal

procedure TraverseDirectory(const Dir: string; Depth: Integer = 0);
var
  SR: TSearchRec;
  Prefix: string;
begin
  Prefix := StringOfChar(' ', Depth * 2);

  if FindFirst(IncludeTrailingPathDelimiter(Dir) + '*', faAnyFile, SR) = 0 then
  begin
    repeat
      if (SR.Name <> '.') and (SR.Name <> '..') then
      begin
        if (SR.Attr and faDirectory) <> 0 then
        begin
          WriteLn(Prefix, '[', SR.Name, '/]');
          TraverseDirectory(
            IncludeTrailingPathDelimiter(Dir) + SR.Name, Depth + 1);
        end
        else
          WriteLn(Prefix, SR.Name, ' (', SR.Size, ' bytes)');
      end;
    until FindNext(SR) <> 0;
    FindClose(SR);
  end;
end;

File Attribute Operations

uses
  SysUtils, FileUtil;

{ Check if file is read-only }
function IsReadOnly(const Filename: string): Boolean;
begin
  Result := (FileGetAttr(Filename) and faReadOnly) <> 0;
end;

{ Get file size without opening it }
function GetFileSize(const Filename: string): Int64;
var
  SR: TSearchRec;
begin
  if FindFirst(Filename, faAnyFile, SR) = 0 then
  begin
    Result := SR.Size;
    FindClose(SR);
  end
  else
    Result := -1;
end;

{ Create directory tree }
procedure EnsureDirectoryExists(const Dir: string);
begin
  if not DirectoryExists(Dir) then
    ForceDirectories(Dir);
end;

{ Copy file with progress }
procedure CopyFileWithProgress(const Source, Dest: string);
var
  SrcStream, DstStream: TFileStream;
  Buffer: array[0..65535] of Byte;
  BytesRead, TotalRead: Int64;
  FileSize: Int64;
begin
  SrcStream := TFileStream.Create(Source, fmOpenRead or fmShareDenyWrite);
  try
    FileSize := SrcStream.Size;
    DstStream := TFileStream.Create(Dest, fmCreate);
    try
      TotalRead := 0;
      repeat
        BytesRead := SrcStream.Read(Buffer, SizeOf(Buffer));
        if BytesRead > 0 then
        begin
          DstStream.Write(Buffer, BytesRead);
          TotalRead := TotalRead + BytesRead;
          Write(#13, Format('Copying: %d%%', [TotalRead * 100 div FileSize]));
        end;
      until BytesRead = 0;
      WriteLn;
    finally
      DstStream.Free;
    end;
  finally
    SrcStream.Free;
  end;
end;

The PathDelim and IncludeTrailingPathDelimiter Functions

One of the most common cross-platform bugs is using the wrong path separator. Windows uses \, Unix uses /. Free Pascal provides two tools to avoid this:

  • PathDelim — a constant that is \ on Windows and / on Unix
  • IncludeTrailingPathDelimiter(Path) — ensures the path ends with the correct separator

Always use these instead of hard-coding separators:

{ Bad — breaks on the other platform }
Path := 'C:\Users\Rosa\AppData';
Path := '/home/rosa/.config';

{ Good — works everywhere }
Path := IncludeTrailingPathDelimiter(GetHomeDir) + 'Documents' + PathDelim + 'PennyWise';

Practical File Operations Summary

Task Function Unit
Check if file exists FileExists(Path) SysUtils
Check if directory exists DirectoryExists(Path) SysUtils
Create directory tree ForceDirectories(Path) SysUtils
Get file size FindFirst + SR.Size SysUtils
Get file age FileAge(Path) SysUtils
Delete file DeleteFile(Path) SysUtils
Rename/move file RenameFile(Old, New) SysUtils
Copy file CopyFile(Src, Dst) FileUtil
Extract filename ExtractFileName(Path) SysUtils
Extract directory ExtractFileDir(Path) SysUtils
Extract extension ExtractFileExt(Path) SysUtils
Change extension ChangeFileExt(Path, '.bak') SysUtils
Get temp filename GetTempFileName SysUtils

These functions are cross-platform — they work identically on Windows, Linux, and macOS. Use them instead of constructing paths manually with string concatenation.

Temporary Files and Atomic Writes

When writing important data (like PennyWise's expense database), a crash during the write can corrupt the file. The safe approach is atomic write: write to a temporary file, then rename it to the target name. Renaming is an atomic operation on all major operating systems — it either succeeds completely or fails completely, never leaving a half-written file:

procedure SafeWriteFile(const Filename, Content: string);
var
  TempFile: string;
  F: TextFile;
begin
  TempFile := Filename + '.tmp';
  AssignFile(F, TempFile);
  Rewrite(F);
  try
    Write(F, Content);
  finally
    CloseFile(F);
  end;

  { If target exists, delete it first (Windows requires this) }
  if FileExists(Filename) then
    DeleteFile(Filename);

  { Rename temp to target — atomic on all platforms }
  if not RenameFile(TempFile, Filename) then
    raise Exception.CreateFmt('Failed to rename %s to %s',
      [TempFile, Filename]);
end;

This pattern is used by text editors (Vim, Emacs), database engines (SQLite), and configuration tools. If the system crashes while writing the temp file, the original file is untouched. If the system crashes during the rename, the temp file contains the complete new data and can be recovered.


37.4 Running External Processes

Free Pascal's Process unit provides TProcess — a class for launching external programs, passing arguments, capturing their output, and checking their exit codes. This is the Pascal equivalent of Python's subprocess, C's fork/exec, or Go's os/exec. It turns your program from an isolated application into a coordinator that can leverage any tool installed on the system.

When to Use External Processes

External processes are appropriate when:

  • You need to use an existing command-line tool (git, curl, fpc, ffmpeg)
  • You want to open a file or URL in the user's default application
  • You need to invoke a system utility (ping, traceroute, systemctl)
  • You are integrating with a tool that has no library API

External processes are not appropriate when:

  • A native Pascal library exists (use fphttpclient instead of calling curl)
  • Performance matters (process creation has significant overhead)
  • You need fine-grained control (a library gives you more control than parsing stdout)

Simple Process Execution

program ProcessDemo;

{$mode objfpc}{$H+}

uses
  SysUtils, Classes, Process;

var
  AProcess: TProcess;
  Output: TStringList;
begin
  AProcess := TProcess.Create(nil);
  Output := TStringList.Create;
  try
    {$IFDEF WINDOWS}
    AProcess.Executable := 'cmd.exe';
    AProcess.Parameters.Add('/c');
    AProcess.Parameters.Add('dir');
    {$ELSE}
    AProcess.Executable := '/bin/ls';
    AProcess.Parameters.Add('-la');
    {$ENDIF}

    AProcess.Options := AProcess.Options + [poWaitOnExit, poUsePipes];
    AProcess.Execute;

    { Read stdout }
    Output.LoadFromStream(AProcess.Output);
    WriteLn('Exit code: ', AProcess.ExitCode);
    WriteLn('Output:');
    WriteLn(Output.Text);
  finally
    Output.Free;
    AProcess.Free;
  end;
end.

Capturing Output and Error Streams

function RunCommand(const Cmd: string; const Args: array of string;
  out StdOut, StdErr: string): Integer;
var
  P: TProcess;
  OutStream, ErrStream: TStringList;
  I: Integer;
begin
  P := TProcess.Create(nil);
  OutStream := TStringList.Create;
  ErrStream := TStringList.Create;
  try
    P.Executable := Cmd;
    for I := 0 to High(Args) do
      P.Parameters.Add(Args[I]);
    P.Options := [poWaitOnExit, poUsePipes, poStderrToOutPut];
    P.Execute;

    OutStream.LoadFromStream(P.Output);
    if P.Stderr <> nil then
      ErrStream.LoadFromStream(P.Stderr);

    StdOut := OutStream.Text;
    StdErr := ErrStream.Text;
    Result := P.ExitCode;
  finally
    ErrStream.Free;
    OutStream.Free;
    P.Free;
  end;
end;

Running Processes Asynchronously

For long-running processes, you can read output incrementally:

procedure RunWithLiveOutput(const Cmd: string; const Args: array of string);
var
  P: TProcess;
  Buffer: array[0..4095] of Byte;
  BytesRead: Integer;
  I: Integer;
begin
  P := TProcess.Create(nil);
  try
    P.Executable := Cmd;
    for I := 0 to High(Args) do
      P.Parameters.Add(Args[I]);
    P.Options := [poUsePipes];
    P.Execute;

    while P.Running do
    begin
      BytesRead := P.Output.Read(Buffer, SizeOf(Buffer));
      if BytesRead > 0 then
        Write(StringOf(Buffer, BytesRead));
      Sleep(10);
    end;

    { Read remaining output after process ends }
    repeat
      BytesRead := P.Output.Read(Buffer, SizeOf(Buffer));
      if BytesRead > 0 then
        Write(StringOf(Buffer, BytesRead));
    until BytesRead = 0;

    WriteLn;
    WriteLn('Exit code: ', P.ExitCode);
  finally
    P.Free;
  end;
end;

Capturing Output with Error Handling

Here is a robust helper function that runs a command and returns its output, error stream, and exit code:

function RunCommand(const Cmd: string; const Args: array of string;
  out StdOut, StdErr: string): Integer;
var
  P: TProcess;
  OutLines, ErrLines: TStringList;
  I: Integer;
begin
  P := TProcess.Create(nil);
  OutLines := TStringList.Create;
  ErrLines := TStringList.Create;
  try
    P.Executable := Cmd;
    for I := 0 to High(Args) do
      P.Parameters.Add(Args[I]);
    P.Options := [poWaitOnExit, poUsePipes];
    P.Execute;

    OutLines.LoadFromStream(P.Output);
    if (poStderrToOutPut in P.Options) then
      StdErr := ''
    else if P.Stderr <> nil then
      ErrLines.LoadFromStream(P.Stderr);

    StdOut := OutLines.Text;
    StdErr := ErrLines.Text;
    Result := P.ExitCode;
  finally
    ErrLines.Free;
    OutLines.Free;
    P.Free;
  end;
end;

Use this to integrate with system tools:

var
  Output, Errors: string;
  ExitCode: Integer;
begin
  { Check Free Pascal version }
  ExitCode := RunCommand('fpc', ['-iV'], Output, Errors);
  if ExitCode = 0 then
    WriteLn('FPC version: ', Trim(Output))
  else
    WriteLn('FPC not found');

  { Get system uptime on Linux }
  {$IFDEF LINUX}
  ExitCode := RunCommand('uptime', [], Output, Errors);
  if ExitCode = 0 then
    WriteLn('Uptime: ', Trim(Output));
  {$ENDIF}

  { Get IP configuration }
  {$IFDEF WINDOWS}
  ExitCode := RunCommand('cmd.exe', ['/c', 'ipconfig'], Output, Errors);
  {$ELSE}
  ExitCode := RunCommand('ip', ['addr', 'show'], Output, Errors);
  {$ENDIF}
  if ExitCode = 0 then
    WriteLn(Output);
end;

Running Processes Asynchronously

For long-running processes, you can read output incrementally rather than waiting for the process to finish:

procedure RunWithLiveOutput(const Cmd: string; const Args: array of string);
var
  P: TProcess;
  Buffer: array[0..4095] of Byte;
  BytesRead: Integer;
  I: Integer;
begin
  P := TProcess.Create(nil);
  try
    P.Executable := Cmd;
    for I := 0 to High(Args) do
      P.Parameters.Add(Args[I]);
    P.Options := [poUsePipes];
    P.Execute;

    while P.Running do
    begin
      BytesRead := P.Output.Read(Buffer, SizeOf(Buffer));
      if BytesRead > 0 then
        Write(StringOf(Buffer, BytesRead));
      Sleep(10);
    end;

    { Read remaining output after process ends }
    repeat
      BytesRead := P.Output.Read(Buffer, SizeOf(Buffer));
      if BytesRead > 0 then
        Write(StringOf(Buffer, BytesRead));
    until BytesRead = 0;

    WriteLn;
    WriteLn('Exit code: ', P.ExitCode);
  finally
    P.Free;
  end;
end;

This is particularly useful for compilers, build tools, and data processing scripts where you want to show progress as it happens rather than dumping everything at the end.

Sending Input to a Process

Some external programs expect input on stdin. You can write to a process's input stream:

procedure SendInputToProcess;
var
  P: TProcess;
  Input: string;
  Output: TStringList;
begin
  P := TProcess.Create(nil);
  Output := TStringList.Create;
  try
    {$IFDEF WINDOWS}
    P.Executable := 'cmd.exe';
    P.Parameters.Add('/c');
    P.Parameters.Add('sort');
    {$ELSE}
    P.Executable := '/usr/bin/sort';
    {$ENDIF}
    P.Options := [poWaitOnExit, poUsePipes];
    P.Execute;

    { Write data to the process's stdin }
    Input := 'banana' + LineEnding +
             'apple' + LineEnding +
             'cherry' + LineEnding +
             'date' + LineEnding;
    P.Input.Write(Input[1], Length(Input));
    P.CloseInput;  { Signal end of input }

    { Read sorted output }
    Output.LoadFromStream(P.Output);
    WriteLn('Sorted:');
    WriteLn(Output.Text);
  finally
    Output.Free;
    P.Free;
  end;
end;

The CloseInput call is crucial — it tells the external program that no more input is coming, which causes it to process the data and produce output. Without closing stdin, the external program will wait forever for more input.

💡 Use Case: Opening the User's Browser A common task is opening a URL in the user's default web browser:

procedure OpenURL(const URL: string);
var
  P: TProcess;
begin
  P := TProcess.Create(nil);
  try
    {$IFDEF WINDOWS}
    P.Executable := 'cmd.exe';
    P.Parameters.Add('/c');
    P.Parameters.Add('start');
    P.Parameters.Add(URL);
    {$ENDIF}
    {$IFDEF LINUX}
    P.Executable := 'xdg-open';
    P.Parameters.Add(URL);
    {$ENDIF}
    {$IFDEF DARWIN}
    P.Executable := 'open';
    P.Parameters.Add(URL);
    {$ENDIF}
    P.Options := [poNoConsole];
    P.Execute;
  finally
    P.Free;
  end;
end;

37.5 Platform Detection and Conditional Compilation

So far in this chapter, every platform-specific example has been wrapped in {$IFDEF}` blocks. This is **conditional compilation** — the compiler's preprocessor evaluates these directives at compile time and includes or excludes code based on the target platform. Code inside a false `{$IFDEF} block is not merely "skipped at runtime" — it is completely absent from the compiled executable. It might as well not exist.

This is fundamentally different from runtime platform detection (like Python's sys.platform or Java's System.getProperty("os.name")). Those approaches include all platform-specific code in the executable and branch at runtime. Conditional compilation includes only the code for the target platform, producing a smaller, faster, and cleaner executable.

The trade-off is that you must compile separately for each platform. You cannot produce a single "universal" executable from a conditionally compiled source. But this is the standard approach for natively compiled languages — C, C++, Rust, Go, and Pascal all use conditional compilation for platform-specific code.

Free Pascal defines compiler symbols for the target platform, allowing you to write platform-specific code within a single source file:

Compiler Defines

Symbol Platform
{$IFDEF WINDOWS} Windows (any version)
{$IFDEF LINUX} Linux
{$IFDEF DARWIN} macOS
{$IFDEF UNIX} Any Unix-like (Linux, macOS, FreeBSD, etc.)
{$IFDEF CPU32} 32-bit CPU
{$IFDEF CPU64} 64-bit CPU
{$IFDEF ENDIAN_LITTLE} Little-endian byte order
{$IFDEF ENDIAN_BIG} Big-endian byte order

Using Conditional Compilation

function GetPlatformName: string;
begin
  {$IFDEF WINDOWS}
  Result := 'Windows';
  {$ENDIF}
  {$IFDEF LINUX}
  Result := 'Linux';
  {$ENDIF}
  {$IFDEF DARWIN}
  Result := 'macOS';
  {$ENDIF}
  {$IFDEF CPU64}
  Result := Result + ' (64-bit)';
  {$ELSE}
  Result := Result + ' (32-bit)';
  {$ENDIF}
end;

function GetPathSeparator: Char;
begin
  {$IFDEF WINDOWS}
  Result := '\';
  {$ELSE}
  Result := '/';
  {$ENDIF}
end;

function GetConfigFilePath(const AppName: string): string;
begin
  {$IFDEF WINDOWS}
  Result := GetEnvironmentVariable('APPDATA') + '\' + AppName + '\config.ini';
  {$ENDIF}
  {$IFDEF UNIX}
  Result := GetEnvironmentVariable('HOME') + '/.' + LowerCase(AppName) + '/config.ini';
  {$ENDIF}
end;

The Cross-Platform Abstraction Pattern

For significant platform differences, the cleanest approach is to create a cross-platform abstraction unit with platform-specific implementations:

unit PlatformUtils;

{$mode objfpc}{$H+}

interface

function GetAppDataPath: string;
function GetTempPath: string;
function GetUserName: string;
procedure ShowNotification(const Title, Message: string);

implementation

uses
  SysUtils
  {$IFDEF WINDOWS}, Windows{$ENDIF}
  {$IFDEF UNIX}, BaseUnix{$ENDIF};

function GetAppDataPath: string;
begin
  {$IFDEF WINDOWS}
  Result := GetEnvironmentVariable('APPDATA');
  {$ENDIF}
  {$IFDEF UNIX}
  Result := GetEnvironmentVariable('XDG_CONFIG_HOME');
  if Result = '' then
    Result := GetEnvironmentVariable('HOME') + '/.config';
  {$ENDIF}
end;

function GetTempPath: string;
begin
  {$IFDEF WINDOWS}
  Result := GetEnvironmentVariable('TEMP');
  if Result = '' then Result := GetEnvironmentVariable('TMP');
  {$ENDIF}
  {$IFDEF UNIX}
  Result := GetEnvironmentVariable('TMPDIR');
  if Result = '' then Result := '/tmp';
  {$ENDIF}
end;

function GetUserName: string;
begin
  {$IFDEF WINDOWS}
  Result := GetEnvironmentVariable('USERNAME');
  {$ENDIF}
  {$IFDEF UNIX}
  Result := GetEnvironmentVariable('USER');
  {$ENDIF}
end;

procedure ShowNotification(const Title, Message: string);
begin
  {$IFDEF WINDOWS}
  { Would use Windows notification API or balloon tip }
  WriteLn('[Notification] ', Title, ': ', Message);
  {$ENDIF}
  {$IFDEF LINUX}
  { Would use notify-send via TProcess }
  WriteLn('[Notification] ', Title, ': ', Message);
  {$ENDIF}
  {$IFDEF DARWIN}
  { Would use osascript for macOS notification }
  WriteLn('[Notification] ', Title, ': ', Message);
  {$ENDIF}
end;

end.

Custom Compiler Defines

Beyond the built-in platform symbols, you can define your own symbols for conditional compilation:

{ Define in the project options or at the top of the file: }
{$DEFINE DEBUG}
{$DEFINE ENABLE_SYNC}

{ Use them in code: }
{$IFDEF DEBUG}
procedure DebugLog(const Msg: string);
begin
  WriteLn('[DEBUG] ', FormatDateTime('hh:nn:ss.zzz', Now), ' ', Msg);
end;
{$ELSE}
procedure DebugLog(const Msg: string);
begin
  { Do nothing in release builds }
end;
{$ENDIF}

{$IFDEF ENABLE_SYNC}
procedure StartBackgroundSync;
begin
  { Full sync implementation }
end;
{$ELSE}
procedure StartBackgroundSync;
begin
  WriteLn('Sync feature is disabled in this build.');
end;
{$ENDIF}

You can also use {$IF} with expressions for more complex conditions:

{$IF FPC_FULLVERSION >= 30200}
  { Use features available in FPC 3.2.0 and later }
{$ENDIF}

{$IF DEFINED(WINDOWS) AND DEFINED(CPU64)}
  { Windows 64-bit specific code }
{$ENDIF}

This is useful for maintaining compatibility across different Free Pascal versions or for building different editions of your application (free vs. professional, student vs. enterprise) from the same codebase.

Platform-Specific Unit Selection

For significantly different platform implementations, maintain separate unit files and select at compile time:

program PennyWise;

uses
  FinanceCore,
  FinanceDB,
  {$IFDEF WINDOWS}
  PlatformUtils_Windows,   { Windows-specific implementations }
  {$ENDIF}
  {$IFDEF LINUX}
  PlatformUtils_Linux,     { Linux-specific implementations }
  {$ENDIF}
  {$IFDEF DARWIN}
  PlatformUtils_macOS,     { macOS-specific implementations }
  {$ENDIF}
  FinanceUI;

Each platform unit exports the same set of functions (same interface), so the rest of the application does not need any {$IFDEF} blocks. This is the platform abstraction layer pattern — the same pattern used by Qt, SDL, and other cross-platform frameworks.

⚠️ Avoid #IFDEF Spaghetti If your conditional compilation produces deeply nested, hard-to-read code, consider splitting into separate platform-specific units (e.g., PlatformUtils_Win.pas, PlatformUtils_Linux.pas) and selecting the right one at compile time. This keeps each file clean and readable.


37.6 Calling C Libraries

Free Pascal can call functions from C libraries (DLLs on Windows, shared objects on Linux) using external declarations. This gives you access to the vast ecosystem of C libraries — from SQLite to OpenSSL, from zlib to libcurl, from image processing to machine learning frameworks. If a library has a C API, Pascal can call it.

This capability is one of the most powerful features of a natively compiled language. Python needs ctypes or C extension modules. Java needs JNI. JavaScript needs native add-ons. Pascal simply declares the function signature and calls it — the compiled code calls the C function directly, with zero overhead. The calling convention is the same, the data representations are compatible, and the linker resolves the references.

Type Mapping: C to Pascal

Before declaring C functions, you need to know how C types map to Pascal types:

C Type Pascal Type Notes
int Integer or cint Typically 32-bit
long LongInt or clong 32-bit on Windows, 64-bit on 64-bit Linux
char* PChar Pointer to null-terminated string
void* Pointer Generic pointer
double Double 64-bit IEEE float
float Single 32-bit IEEE float
size_t PtrUInt Pointer-sized unsigned integer
int32_t Int32 Fixed-width integer
uint8_t Byte Fixed-width unsigned byte
char** PPChar Pointer to pointer to char
struct record (with packed) Must match exact layout

The ctypes unit provides C-compatible type aliases (cint, clong, cuint32, etc.) for maximum portability.

External Function Declarations

{ Declare a C function from the math library }
function pow(base, exponent: Double): Double; cdecl; external 'libm';
function sqrt_c(x: Double): Double; cdecl; external 'libm' name 'sqrt';

{ Declare a C function from the C standard library }
function strlen(s: PChar): Integer; cdecl; external 'libc';
function getpid: Integer; cdecl; external 'libc';

The cdecl directive tells the compiler to use the C calling convention (arguments passed right-to-left on the stack, caller cleans up). The external clause specifies the library name.

Calling SQLite Directly

Here is a minimal example of calling SQLite's C API from Pascal:

const
  {$IFDEF WINDOWS}
  SQLITE_LIB = 'sqlite3.dll';
  {$ELSE}
  SQLITE_LIB = 'libsqlite3.so';
  {$ENDIF}

  SQLITE_OK = 0;
  SQLITE_ROW = 100;

type
  PSQLite3 = Pointer;
  PSQLite3Stmt = Pointer;

function sqlite3_open(filename: PChar; var db: PSQLite3): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_close(db: PSQLite3): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_exec(db: PSQLite3; sql: PChar; callback: Pointer;
  arg: Pointer; errmsg: PPChar): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_errmsg(db: PSQLite3): PChar;
  cdecl; external SQLITE_LIB;

Using SQLite from Pascal

Here is a more complete example that opens a database, creates a table, inserts data, and queries it — all through C API calls:

program SQLiteDemo;

{$mode objfpc}{$H+}

const
  {$IFDEF WINDOWS}
  SQLITE_LIB = 'sqlite3.dll';
  {$ELSE}
  SQLITE_LIB = 'libsqlite3.so';
  {$ENDIF}

  SQLITE_OK = 0;
  SQLITE_ROW = 100;
  SQLITE_DONE = 101;

type
  PSQLite3 = Pointer;
  PSQLite3Stmt = Pointer;

function sqlite3_open(filename: PChar; var db: PSQLite3): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_close(db: PSQLite3): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_exec(db: PSQLite3; sql: PChar; callback: Pointer;
  arg: Pointer; errmsg: PPChar): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_errmsg(db: PSQLite3): PChar;
  cdecl; external SQLITE_LIB;
function sqlite3_prepare_v2(db: PSQLite3; sql: PChar; nByte: Integer;
  var stmt: PSQLite3Stmt; pzTail: PPChar): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_step(stmt: PSQLite3Stmt): Integer;
  cdecl; external SQLITE_LIB;
function sqlite3_column_text(stmt: PSQLite3Stmt; col: Integer): PChar;
  cdecl; external SQLITE_LIB;
function sqlite3_column_double(stmt: PSQLite3Stmt; col: Integer): Double;
  cdecl; external SQLITE_LIB;
function sqlite3_finalize(stmt: PSQLite3Stmt): Integer;
  cdecl; external SQLITE_LIB;

var
  DB: PSQLite3;
  Stmt: PSQLite3Stmt;
  RC: Integer;
begin
  RC := sqlite3_open('test.db', DB);
  if RC <> SQLITE_OK then
  begin
    WriteLn('Cannot open database: ', sqlite3_errmsg(DB));
    Halt(1);
  end;

  { Create table }
  sqlite3_exec(DB, 'CREATE TABLE IF NOT EXISTS expenses (' +
    'id INTEGER PRIMARY KEY, description TEXT, amount REAL)',
    nil, nil, nil);

  { Insert data }
  sqlite3_exec(DB, 'INSERT INTO expenses VALUES (1, ''Groceries'', 85.50)',
    nil, nil, nil);
  sqlite3_exec(DB, 'INSERT INTO expenses VALUES (2, ''Bus pass'', 45.00)',
    nil, nil, nil);

  { Query data }
  RC := sqlite3_prepare_v2(DB, 'SELECT description, amount FROM expenses',
    -1, Stmt, nil);
  if RC = SQLITE_OK then
  begin
    while sqlite3_step(Stmt) = SQLITE_ROW do
      WriteLn(sqlite3_column_text(Stmt, 0), ': $',
              sqlite3_column_double(Stmt, 1):0:2);
    sqlite3_finalize(Stmt);
  end;

  sqlite3_close(DB);
end.

This example demonstrates the complete pattern for calling a C library: declare the types and functions with cdecl; external, call them like native Pascal functions, and handle errors using the library's error reporting mechanism (in SQLite's case, return codes and sqlite3_errmsg).

Calling Convention Details

The cdecl directive is critical. It tells the compiler to use the C calling convention:

  • Arguments are pushed right-to-left on the stack
  • The caller cleans up the stack after the call
  • Function names are not decorated (no name mangling)

Other calling conventions you might encounter:

Directive Convention Used By
cdecl C standard Most C libraries
stdcall Windows standard Windows API (Win32)
safecall COM safe call COM/OLE automation
register Pascal/Delphi Delphi internal

For Windows API calls, use stdcall. For everything else (including most cross-platform C libraries), use cdecl. Getting the calling convention wrong causes stack corruption and crashes — usually with an incomprehensible error message.

Dynamic Loading

If you do not want to require the library at compile time (it might not be installed), use dynamic loading:

uses
  DynLibs;

var
  LibHandle: TLibHandle;
  sqlite3_open: function(filename: PChar; var db: Pointer): Integer; cdecl;
begin
  LibHandle := LoadLibrary(SQLITE_LIB);
  if LibHandle = NilHandle then
  begin
    WriteLn('SQLite library not found');
    Halt(1);
  end;

  Pointer(sqlite3_open) := GetProcAddress(LibHandle, 'sqlite3_open');
  if sqlite3_open = nil then
  begin
    WriteLn('Function sqlite3_open not found');
    Halt(1);
  end;

  { Use the function... }

  UnloadLibrary(LibHandle);
end.

37.7 Windows-Specific: Registry, COM Basics

When you move beyond cross-platform code, each operating system has unique facilities that your program can leverage. Windows has two particularly important ones: the Registry and COM (Component Object Model).

The Windows Registry

The Registry is a hierarchical database built into Windows that stores configuration for the operating system, installed hardware, user preferences, and application settings. It is organized into a tree of keys (like folders) and values (like files). The top-level keys include:

Root Key Purpose
HKEY_CURRENT_USER (HKCU) Settings for the currently logged-in user
HKEY_LOCAL_MACHINE (HKLM) System-wide settings (requires admin)
HKEY_CLASSES_ROOT (HKCR) File associations and COM registrations

For PennyWise, the Registry is useful for two things: (1) registering the application for auto-start when Windows boots, and (2) storing user preferences that should persist across installations. The auto-start registration is particularly common — many applications (Slack, Steam, Spotify, Dropbox) register themselves in the Run key.

Pascal can read and write registry keys using the Registry unit. The TRegistry class provides a clean, object-oriented interface to the Windows Registry API.

Reading and Writing Application Settings

Beyond auto-start registration, the registry is useful for storing application preferences that should persist across installations. Unlike INI files (which are user-visible and hand-editable), registry values are hidden from casual users — appropriate for internal settings like window positions, last-opened file paths, and license keys.

{$IFDEF WINDOWS}
uses
  Registry;

procedure SaveWindowPosition(Left, Top, Width, Height: Integer);
var
  Reg: TRegistry;
begin
  Reg := TRegistry.Create;
  try
    Reg.RootKey := HKEY_CURRENT_USER;
    if Reg.OpenKey('\Software\PennyWise\Window', True) then
    begin
      Reg.WriteInteger('Left', Left);
      Reg.WriteInteger('Top', Top);
      Reg.WriteInteger('Width', Width);
      Reg.WriteInteger('Height', Height);
      Reg.CloseKey;
    end;
  finally
    Reg.Free;
  end;
end;

function LoadWindowPosition(out Left, Top, Width, Height: Integer): Boolean;
var
  Reg: TRegistry;
begin
  Result := False;
  Reg := TRegistry.Create;
  try
    Reg.RootKey := HKEY_CURRENT_USER;
    if Reg.OpenKeyReadOnly('\Software\PennyWise\Window') then
    begin
      if Reg.ValueExists('Left') then
      begin
        Left := Reg.ReadInteger('Left');
        Top := Reg.ReadInteger('Top');
        Width := Reg.ReadInteger('Width');
        Height := Reg.ReadInteger('Height');
        Result := True;
      end;
      Reg.CloseKey;
    end;
  finally
    Reg.Free;
  end;
end;

procedure CleanupRegistry;
var
  Reg: TRegistry;
begin
  Reg := TRegistry.Create;
  try
    Reg.RootKey := HKEY_CURRENT_USER;
    Reg.DeleteKey('\Software\PennyWise');
  finally
    Reg.Free;
  end;
end;
{$ENDIF}

⚠️ Registry Best Practices Always use HKEY_CURRENT_USER for per-user settings (no admin rights needed). Use HKEY_LOCAL_MACHINE only for system-wide settings that require administrator privileges. Always clean up your registry keys when the application is uninstalled — orphaned registry entries are the digital equivalent of litter.

Auto-Start Registration

{$IFDEF WINDOWS}
uses
  Registry;

procedure RegisterAutoStart(const AppName, ExePath: string);
var
  Reg: TRegistry;
begin
  Reg := TRegistry.Create;
  try
    Reg.RootKey := HKEY_CURRENT_USER;
    if Reg.OpenKey('\Software\Microsoft\Windows\CurrentVersion\Run', True) then
    begin
      Reg.WriteString(AppName, ExePath);
      Reg.CloseKey;
      WriteLn(AppName, ' registered for auto-start.');
    end;
  finally
    Reg.Free;
  end;
end;

procedure UnregisterAutoStart(const AppName: string);
var
  Reg: TRegistry;
begin
  Reg := TRegistry.Create;
  try
    Reg.RootKey := HKEY_CURRENT_USER;
    if Reg.OpenKey('\Software\Microsoft\Windows\CurrentVersion\Run', False) then
    begin
      if Reg.ValueExists(AppName) then
        Reg.DeleteValue(AppName);
      Reg.CloseKey;
      WriteLn(AppName, ' removed from auto-start.');
    end;
  finally
    Reg.Free;
  end;
end;
{$ENDIF}

37.8 Linux-Specific: Signals, /proc Filesystem

Linux (and other Unix-like systems) has its own unique facilities. Two of the most useful for application development are the /proc virtual filesystem and the signal system.

The /proc Filesystem

One of the most elegant design decisions in Unix is the principle that "everything is a file." The /proc directory extends this principle to the running kernel and its processes. The files in /proc are not real files on disk — they are virtual, generated on-the-fly by the kernel when you read them. But because they look like files, you can read them with standard file I/O — no special API needed.

This means your Pascal program can learn about the system (CPU, memory, network interfaces, loaded modules) and about other processes (name, state, memory usage, open files) simply by reading text files. No library is needed — just AssignFile, Reset, and ReadLn.

Key /proc files:

Path Contents
/proc/cpuinfo CPU model, speed, cores, features
/proc/meminfo Total, free, available, cached memory
/proc/uptime System uptime in seconds
/proc/loadavg System load averages (1, 5, 15 minutes)
/proc/[pid]/status Process name, state, memory usage
/proc/[pid]/cmdline Process command-line arguments
/proc/self/status Current process information

Linux exposes system information through the /proc virtual filesystem — text files that you can read to learn about the system.

{$IFDEF LINUX}
function GetLinuxMemoryInfo: string;
var
  F: TextFile;
  Line: string;
begin
  Result := '';
  AssignFile(F, '/proc/meminfo');
  Reset(F);
  try
    while not Eof(F) do
    begin
      ReadLn(F, Line);
      if (Pos('MemTotal', Line) = 1) or
         (Pos('MemFree', Line) = 1) or
         (Pos('MemAvailable', Line) = 1) then
        Result := Result + Line + LineEnding;
    end;
  finally
    CloseFile(F);
  end;
end;

function GetProcessInfo(PID: Integer): string;
var
  F: TextFile;
  Line: string;
begin
  Result := '';
  AssignFile(F, Format('/proc/%d/status', [PID]));
  {$I-}
  Reset(F);
  {$I+}
  if IOResult <> 0 then Exit('Process not found');
  try
    while not Eof(F) do
    begin
      ReadLn(F, Line);
      if (Pos('Name:', Line) = 1) or
         (Pos('State:', Line) = 1) or
         (Pos('VmRSS:', Line) = 1) then
        Result := Result + Line + LineEnding;
    end;
  finally
    CloseFile(F);
  end;
end;

function GetCPUInfo: string;
var
  F: TextFile;
  Line: string;
begin
  Result := '';
  AssignFile(F, '/proc/cpuinfo');
  Reset(F);
  try
    while not Eof(F) do
    begin
      ReadLn(F, Line);
      if Pos('model name', Line) = 1 then
      begin
        Result := Copy(Line, Pos(':', Line) + 2, Length(Line));
        Break;
      end;
    end;
  finally
    CloseFile(F);
  end;
end;
{$ENDIF}

Signal Handling

Linux uses signals for inter-process communication and process control. A signal is an asynchronous notification sent to a process, usually by the operating system or another process. Your program can install signal handlers — functions that execute when a specific signal is received.

Key signals:

Signal Number Source Default Action
SIGINT 2 User presses Ctrl+C Terminate
SIGTERM 15 kill command Terminate
SIGKILL 9 kill -9 (unblockable) Terminate immediately
SIGHUP 1 Terminal closed Terminate
SIGUSR1 10 User-defined None
SIGUSR2 12 User-defined None
SIGPIPE 13 Write to broken pipe Terminate

SIGKILL cannot be caught — it terminates the process immediately. All other signals can be intercepted with a handler. For PennyWise, we want to catch SIGTERM and SIGINT so we can save unsaved data before exiting.

Here is a complete signal handling example:

{$IFDEF UNIX}
uses
  BaseUnix;

var
  Running: Boolean = True;

procedure HandleSignal(Sig: cint); cdecl;
begin
  case Sig of
    SIGTERM: begin
      WriteLn('Received SIGTERM — shutting down gracefully');
      Running := False;
    end;
    SIGINT: begin
      WriteLn('Received SIGINT (Ctrl+C) — shutting down');
      Running := False;
    end;
  end;
end;

begin
  fpSignal(SIGTERM, @HandleSignal);
  fpSignal(SIGINT, @HandleSignal);

  WriteLn('Server running. Press Ctrl+C to stop.');
  while Running do
    Sleep(100);
  WriteLn('Shutdown complete.');
end.
{$ENDIF}

The Importance of Graceful Shutdown

Signal handling is not just a Linux feature — it represents a general principle that applies to all platforms: your application should handle termination requests gracefully.

On Linux, SIGTERM means "please stop." On Windows, the equivalent is WM_CLOSE (for GUI apps) or CTRL_CLOSE_EVENT (for console apps). In both cases, the application has a brief window to save state, close files, disconnect from servers, and clean up resources before exiting.

For PennyWise, graceful shutdown means: - Save any unsaved expenses to disk - Finish any pending sync operations (or mark them for retry) - Close the database connection - Write a final log entry

Without graceful shutdown, a killed PennyWise might lose the last few expenses Rosa entered, leave a database file in an inconsistent state, or leave a lock file that prevents the next launch.

Windows Console Event Handling

On Windows, console applications can handle shutdown events through the SetConsoleCtrlHandler API:

{$IFDEF WINDOWS}
uses
  Windows;

var
  Running: Boolean = True;

function ConsoleHandler(CtrlType: DWORD): BOOL; stdcall;
begin
  case CtrlType of
    CTRL_C_EVENT:
    begin
      WriteLn('Ctrl+C pressed — shutting down...');
      Running := False;
      Result := True;  { Signal handled }
    end;
    CTRL_CLOSE_EVENT:
    begin
      WriteLn('Console closing — saving data...');
      Running := False;
      Result := True;
    end;
    CTRL_SHUTDOWN_EVENT:
    begin
      WriteLn('System shutting down — emergency save...');
      Running := False;
      Result := True;
    end;
  else
    Result := False;  { Pass to next handler }
  end;
end;

begin
  SetConsoleCtrlHandler(@ConsoleHandler, True);
  WriteLn('Server running. Press Ctrl+C to stop.');
  while Running do
    Sleep(100);
  WriteLn('Cleanup complete. Goodbye.');
end.
{$ENDIF}

This is the Windows equivalent of Unix signal handling. The callback receives the event type and returns True if it handled the event. Note the stdcall calling convention — Windows API callbacks always use stdcall.

Lock Files: Preventing Multiple Instances

A common OS integration task is ensuring that only one instance of your application runs at a time. The simplest approach is a lock file:

function AcquireLockFile(const LockPath: string): Boolean;
var
  F: TextFile;
begin
  if FileExists(LockPath) then
  begin
    { Check if the process that created the lock is still running }
    { For simplicity, we just check file age }
    if (Now - FileDateToDateTime(FileAge(LockPath))) > 1.0 then
    begin
      { Lock file is over 24 hours old — stale lock }
      DeleteFile(LockPath);
    end
    else
    begin
      WriteLn('Another instance of PennyWise is already running.');
      Exit(False);
    end;
  end;

  AssignFile(F, LockPath);
  Rewrite(F);
  WriteLn(F, 'PID=', GetProcessID);
  WriteLn(F, 'Started=', DateTimeToStr(Now));
  CloseFile(F);
  Result := True;
end;

procedure ReleaseLockFile(const LockPath: string);
begin
  if FileExists(LockPath) then
    DeleteFile(LockPath);
end;

PennyWise creates a lock file in its data directory when it starts and deletes it when it exits. If a lock file already exists (and is not stale), the second instance warns the user and exits.


37.9 System Tray Icons and Notifications

For desktop applications, a system tray icon provides presence without a full window. The system tray (also called the notification area on Windows, the indicator area on Linux, or the menu bar on macOS) is a small area near the clock where applications can display icons, receive clicks, and show context menus. Users expect certain types of applications to live in the tray: chat programs, cloud sync, antivirus, media players, and — relevantly for us — personal finance managers that sync in the background. In Lazarus, the TrayIcon component handles this:

{ Lazarus GUI example }
type
  TMainForm = class(TForm)
    TrayIcon: TTrayIcon;
    PopupMenu: TPopupMenu;
    procedure FormCreate(Sender: TObject);
    procedure TrayIconClick(Sender: TObject);
    procedure MenuShowClick(Sender: TObject);
    procedure MenuQuitClick(Sender: TObject);
  end;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  TrayIcon.Icon := Application.Icon;
  TrayIcon.Hint := 'PennyWise - Personal Finance Manager';
  TrayIcon.PopUpMenu := PopupMenu;
  TrayIcon.Visible := True;
  TrayIcon.ShowBalloonHint;
end;

procedure TMainForm.TrayIconClick(Sender: TObject);
begin
  if Self.Visible then
    Self.Hide
  else
  begin
    Self.Show;
    Self.BringToFront;
  end;
end;

For console applications without Lazarus, creating a system tray icon requires calling the Windows API directly (Shell_NotifyIconW) or using a helper library. This is one area where using the Lazarus GUI framework significantly simplifies the code.


37.10 Project Checkpoint: PennyWise OS Integration

This is the final PennyWise checkpoint in Part VI, and it ties together everything from Chapters 33-37. After this checkpoint, PennyWise is not just a program — it is a well-behaved operating system citizen that stores data in the right place, starts automatically when the user logs in, and integrates with the desktop environment.

The three features we add are individually simple, but together they transform PennyWise from "a program the user runs" into "a system service that is always available." This is the difference between a homework assignment and a professional application.

PennyWise gains three OS integration features:

1. OS-Specific Data Paths

unit FinancePlatform;

{$mode objfpc}{$H+}

interface

function GetPennyWiseDataDir: string;
function GetPennyWiseConfigPath: string;
function GetPennyWiseDBPath: string;

implementation

uses
  SysUtils;

function GetPennyWiseDataDir: string;
begin
  {$IFDEF WINDOWS}
  Result := IncludeTrailingPathDelimiter(
    GetEnvironmentVariable('APPDATA')) + 'PennyWise' + PathDelim;
  {$ENDIF}
  {$IFDEF LINUX}
  Result := IncludeTrailingPathDelimiter(
    GetEnvironmentVariable('HOME')) + '.config/pennywise' + PathDelim;
  {$ENDIF}
  {$IFDEF DARWIN}
  Result := IncludeTrailingPathDelimiter(
    GetEnvironmentVariable('HOME')) +
    'Library/Application Support/PennyWise' + PathDelim;
  {$ENDIF}

  if not DirectoryExists(Result) then
    ForceDirectories(Result);
end;

function GetPennyWiseConfigPath: string;
begin
  Result := GetPennyWiseDataDir + 'config.ini';
end;

function GetPennyWiseDBPath: string;
begin
  Result := GetPennyWiseDataDir + 'expenses.db';
end;

2. Auto-Start Registration

procedure RegisterPennyWiseAutoStart;
begin
  {$IFDEF WINDOWS}
  RegisterAutoStart('PennyWise', ParamStr(0));
  {$ENDIF}

  {$IFDEF LINUX}
  { Create .desktop file in ~/.config/autostart/ }
  CreateDesktopAutoStart('PennyWise', ParamStr(0));
  {$ENDIF}
end;

{$IFDEF LINUX}
procedure CreateDesktopAutoStart(const Name, ExePath: string);
var
  AutoStartDir, DesktopFile: string;
  F: TextFile;
begin
  AutoStartDir := GetEnvironmentVariable('HOME') + '/.config/autostart/';
  if not DirectoryExists(AutoStartDir) then
    ForceDirectories(AutoStartDir);

  DesktopFile := AutoStartDir + LowerCase(Name) + '.desktop';
  AssignFile(F, DesktopFile);
  Rewrite(F);
  try
    WriteLn(F, '[Desktop Entry]');
    WriteLn(F, 'Type=Application');
    WriteLn(F, 'Name=', Name);
    WriteLn(F, 'Exec=', ExePath);
    WriteLn(F, 'Hidden=false');
    WriteLn(F, 'NoDisplay=false');
    WriteLn(F, 'X-GNOME-Autostart-enabled=true');
  finally
    CloseFile(F);
  end;
end;
{$ENDIF}

3. Opening the Help Page in the Default Browser

procedure OpenPennyWiseHelp;
begin
  OpenURL('https://pennywise.example.com/help');
end;

What Rosa Experienced

Rosa installs PennyWise on her Windows desktop at work and her Linux laptop at home. On both machines, PennyWise stores its data in the correct application directory — AppData\Roaming\PennyWise on Windows, ~/.config/pennywise on Linux. She enables auto-start on both. On Windows, PennyWise appears in the system tray with a notification balloon. On Linux, it creates a .desktop file in the autostart directory.

"Same program, different operating systems, and it just works?" Rosa asks.

"That is what cross-platform means," Tomás says. "One codebase, compiled for each platform, with conditional compilation for the parts that differ. Free Pascal makes this surprisingly clean."


What Rosa Experienced

Rosa installs PennyWise on her Windows desktop at work and her Linux laptop at home. On both machines, PennyWise stores its data in the correct application directory — AppData\Roaming\PennyWise on Windows, ~/.config/pennywise on Linux. She enables auto-start on both. On Windows, PennyWise registers in the Registry's Run key and starts silently when she logs in. On Linux, it creates a .desktop file in the autostart directory and appears after login. On both platforms, clicking "Help" opens her default web browser to the documentation page.

"Same program, different operating systems, and it just works?" Rosa asks.

"That is what cross-platform means," Tomás says. "One codebase, compiled for each platform, with conditional compilation for the parts that differ. The core logic — expense tracking, reporting, sync — is identical. Only the OS integration layer changes."

This is the culmination of Part VI's arc. PennyWise entered this part as a desktop application. It exits as a cross-platform system: modular (Chapter 33), interoperable (Chapter 34), networked (Chapter 35), concurrent (Chapter 36), and OS-aware (Chapter 37). Each chapter added a layer of capability without disrupting the layers below — because the modular architecture from Chapter 33 kept the changes isolated to their respective units.

The Part VI Architecture in Full

Let us step back and see the complete architecture that emerged across these five chapters:

PennyWise.pas (Main Program)
    |
    +-- FinanceCore.pas        (Ch 33: Domain model — types, calculations)
    |
    +-- FinanceDB.pas          (Ch 33: Data persistence — save/load)
    |
    +-- FinanceReports.pas     (Ch 33: Report generation)
    |
    +-- FinanceUI.pas          (Ch 33: User interface)
    |
    +-- FinanceExport.pas      (Ch 34: JSON/CSV/XML export)
    |
    +-- FinanceImport.pas      (Ch 34: Bank CSV import)
    |
    +-- FinanceSync.pas        (Ch 35: REST API client)
    |
    +-- FinanceSyncThread.pas  (Ch 36: Background sync thread)
    |
    +-- FinancePlatform.pas    (Ch 37: OS-specific paths, auto-start)
    |
    +-- ConfigManager.pas      (Ch 34: INI/JSON configuration)

Each unit has a clear responsibility. Each depends only on what it needs. The dependency graph is acyclic. Adding a new feature means creating a new unit or modifying an existing one — never a wholesale restructuring of the codebase.

This is what professional software architecture looks like. It is not glamorous. It does not involve exotic algorithms or cutting-edge frameworks. It is simple units with clear boundaries, doing one thing each, communicating through clean interfaces. And it scales: this architecture could grow to 50,000 lines of code and remain maintainable, because each unit is small enough to hold in your head, and the boundaries prevent changes from cascading.


37.11 Summary

This chapter covered the interface between Pascal programs and the operating system.

Environment variables and arguments provide runtime configuration. GetEnvironmentVariable reads system settings; ParamStr and ParamCount read command-line arguments. Different platforms store application data in different directories — write a platform-aware function to locate the right one.

File system operations go beyond basic I/O: FindFirst/FindNext search for files, FileGetAttr reads attributes, ForceDirectories creates directory trees, and recursive traversal explores entire directory hierarchies.

TProcess launches external programs, passes arguments, captures standard output and error streams, and checks exit codes. Practical uses include opening URLs in the default browser, running system commands, and integrating with external tools.

Conditional compilation ({$IFDEF WINDOWS}`, `{$IFDEF LINUX}, {$IFDEF DARWIN}) lets a single source file contain platform-specific code. For large platform differences, separate the code into platform-specific units.

Calling C libraries uses external declarations with the cdecl calling convention. Static linking requires the library at compile time; dynamic loading with DynLibs allows runtime library discovery.

Windows-specific features include the Registry (for auto-start registration, application settings) and COM automation. Linux-specific features include the /proc filesystem (for system information) and signal handling (for graceful shutdown).

System tray integration provides unobtrusive presence for background applications. Lazarus's TTrayIcon component makes this straightforward in GUI applications.

PennyWise gained three OS integration features: platform-aware data directories, auto-start registration, and browser-based help. The FinancePlatform unit encapsulates all platform-specific logic, keeping the rest of the codebase clean.

This chapter completes Part VI. PennyWise is now a modular (Chapter 33), interoperable (Chapter 34), networked (Chapter 35), concurrent (Chapter 36), cross-platform (Chapter 37) application. From a simple expense tracker in Chapter 3 to a full systems application in Chapter 37 — built in Pascal, compiled to native code, running on the operating system without an interpreter, a virtual machine, or a prayer.