> "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
In This Chapter
- 37.1 Accessing the Operating System from Pascal
- 37.2 Environment Variables and Command-Line Arguments
- 37.3 File System Operations
- 37.4 Running External Processes
- 37.5 Platform Detection and Conditional Compilation
- 37.6 Calling C Libraries
- 37.7 Windows-Specific: Registry, COM Basics
- 37.8 Linux-Specific: Signals, /proc Filesystem
- 37.9 System Tray Icons and Notifications
- 37.10 Project Checkpoint: PennyWise OS Integration
- 37.11 Summary
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:
- Compiled defaults — hard-coded in the source
- System-wide config file —
/etc/pennywise.iniorC:\ProgramData\PennyWise\config.ini - User config file —
~/.config/pennywise/config.inior%APPDATA%\PennyWise\config.ini - Environment variables —
PENNYWISE_DB_PATH=/custom/path - 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 UnixIncludeTrailingPathDelimiter(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
fphttpclientinstead of callingcurl) - 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.